Merge feat/api-token-rotation-expiry: API token rotation, expiry defaults, and scope enforcement

This commit is contained in:
xarmian
2026-04-08 18:50:12 +00:00
9 changed files with 602 additions and 11 deletions
+1
View File
@@ -18,6 +18,7 @@ const (
ActionPasswordReset = "password_reset"
ActionTokenCreated = "token_created"
ActionTokenRevoked = "token_revoked"
ActionTokenRotated = "token_rotated"
ActionMemberInvited = "member_invited"
ActionMemberRemoved = "member_removed"
ActionRoleChanged = "role_changed"
+1
View File
@@ -20,6 +20,7 @@ type APITokenCreate struct {
Name string `json:"name"`
Scopes string `json:"scopes,omitempty"`
WorkspaceID string `json:"workspace_id,omitempty"` // optional scope
ExpiresIn int `json:"expires_in,omitempty"` // expiry in days (0 = use platform default)
}
// APITokenWithSecret is returned only on creation and includes the
+11 -5
View File
@@ -15,6 +15,10 @@ const (
settingEmailFrom = "email_from" // Sender address
settingEmailFromName = "email_from_name" // Sender display name
settingPlatformName = "platform_name" // Instance name (default: "Pad")
// Token policy settings
settingTokenDefaultExpiryDays = "token_default_expiry_days" // Default: 90
settingTokenMaxLifetimeDays = "token_max_lifetime_days" // Default: 0 (no limit)
)
// handleGetPlatformSettings returns all platform settings.
@@ -59,11 +63,13 @@ func (s *Server) handleUpdatePlatformSettings(w http.ResponseWriter, r *http.Req
// Whitelist known settings
allowed := map[string]bool{
settingEmailProvider: true,
settingMailerooAPIKey: true,
settingEmailFrom: true,
settingEmailFromName: true,
settingPlatformName: true,
settingEmailProvider: true,
settingMailerooAPIKey: true,
settingEmailFrom: true,
settingEmailFromName: true,
settingPlatformName: true,
settingTokenDefaultExpiryDays: true,
settingTokenMaxLifetimeDays: true,
}
for key, value := range input {
+83 -2
View File
@@ -2,13 +2,36 @@ package server
import (
"database/sql"
"fmt"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/xarmian/pad/internal/models"
)
// tokenExpiryWarningDays is the threshold for adding near-expiry warning
// headers to API responses authenticated with a token.
const tokenExpiryWarningDays = 7
// getTokenExpirySettings reads platform settings for token expiry policy.
func (s *Server) getTokenExpirySettings() (defaultDays, maxDays int) {
defaultDays = 90 // fallback default
if v, err := s.store.GetPlatformSetting(settingTokenDefaultExpiryDays); err == nil && v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
defaultDays = n
}
}
if v, err := s.store.GetPlatformSetting(settingTokenMaxLifetimeDays); err == nil && v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
maxDays = n
}
}
return
}
// handleCreateToken creates a new API token scoped to a workspace.
// The token is owned by the authenticated user (if any).
func (s *Server) handleCreateToken(w http.ResponseWriter, r *http.Request) {
@@ -29,9 +52,10 @@ func (s *Server) handleCreateToken(w http.ResponseWriter, r *http.Request) {
}
input.WorkspaceID = workspaceID
defaultDays, maxDays := s.getTokenExpirySettings()
userID := currentUserID(r)
token, err := s.store.CreateAPIToken(userID, input)
token, err := s.store.CreateAPIToken(userID, input, defaultDays, maxDays)
if err != nil {
writeInternalError(w, err)
return
@@ -124,7 +148,9 @@ func (s *Server) handleCreateUserToken(w http.ResponseWriter, r *http.Request) {
return
}
token, err := s.store.CreateAPIToken(userID, input)
defaultDays, maxDays := s.getTokenExpirySettings()
token, err := s.store.CreateAPIToken(userID, input, defaultDays, maxDays)
if err != nil {
writeInternalError(w, err)
return
@@ -157,3 +183,58 @@ func (s *Server) handleDeleteUserToken(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// handleRotateUserToken generates a new secret for an existing token,
// invalidating the old one. The token metadata is preserved.
func (s *Server) handleRotateUserToken(w http.ResponseWriter, r *http.Request) {
userID := currentUserID(r)
if userID == "" {
writeError(w, http.StatusUnauthorized, "unauthorized", "Not logged in")
return
}
tokenID := chi.URLParam(r, "tokenID")
var input struct {
ExpiresIn int `json:"expires_in,omitempty"` // new expiry in days (0 = keep existing)
}
// Body is optional — rotation works without it, but malformed JSON is rejected
// to prevent silent destructive rotation when the caller intended to set expiry.
if r.Body != nil && r.ContentLength != 0 {
if err := decodeJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid JSON body")
return
}
}
_, maxDays := s.getTokenExpirySettings()
rotated, err := s.store.RotateAPIToken(tokenID, userID, input.ExpiresIn, maxDays)
if err != nil {
if err == sql.ErrNoRows {
writeError(w, http.StatusNotFound, "not_found", "Token not found")
return
}
writeInternalError(w, err)
return
}
s.logAuditEvent(models.ActionTokenRotated, r, auditMeta(map[string]string{"token_id": tokenID}))
writeJSON(w, http.StatusOK, rotated)
}
// setTokenExpiryWarning adds an X-Token-Expires-Soon header if the API token
// used for this request is within the warning threshold of its expiry.
// Called from the TokenAuth middleware after successful token validation.
func setTokenExpiryWarning(w http.ResponseWriter, token *models.APIToken) {
if token == nil || token.ExpiresAt == nil {
return
}
remaining := time.Until(*token.ExpiresAt)
if remaining > 0 && remaining < time.Duration(tokenExpiryWarningDays)*24*time.Hour {
days := int(remaining.Hours() / 24)
w.Header().Set("X-Token-Expires-Soon", fmt.Sprintf("%d days remaining", days))
w.Header().Set("X-Token-Expires-At", token.ExpiresAt.Format(time.RFC3339))
}
}
+59
View File
@@ -4,6 +4,7 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"log/slog"
"net/http"
"strings"
@@ -86,6 +87,15 @@ func (s *Server) TokenAuth(next http.Handler) http.Handler {
return
}
// Enforce token scopes
if !tokenScopeAllows(apiToken.Scopes, r.Method, r.URL.Path) {
writeError(w, http.StatusForbidden, "forbidden", "Token scope does not permit this action")
return
}
// Add near-expiry warning headers
setTokenExpiryWarning(w, apiToken)
ctx := r.Context()
// Resolve user from token's user_id (new user-owned tokens)
@@ -342,3 +352,52 @@ func sha256hex(s string) string {
h := sha256.Sum256([]byte(s))
return hex.EncodeToString(h[:])
}
// tokenScopeAllows checks if the token's scopes permit the given HTTP method
// and path. Scopes are stored as a JSON array of strings.
//
// Supported scopes:
// - "*" — full access (default)
// - "read" — GET/HEAD/OPTIONS only
// - "write" — all methods
//
// An empty or unparseable scope string defaults to full access for
// backward compatibility with tokens created before scope enforcement.
func tokenScopeAllows(scopesJSON, method, path string) bool {
_ = path // reserved for future per-resource scopes
if scopesJSON == "" || scopesJSON == `["*"]` {
return true
}
var scopes []string
if err := json.Unmarshal([]byte(scopesJSON), &scopes); err != nil {
// Unparseable → allow (backward compat)
return true
}
hasKnownScope := false
for _, scope := range scopes {
switch scope {
case "*", "write":
return true
case "read":
hasKnownScope = true
if method == http.MethodGet || method == http.MethodHead || method == http.MethodOptions {
return true
}
default:
// Unrecognized scope — ignore but don't block.
// Tokens created before scope enforcement may contain
// custom values that were previously stored but not checked.
}
}
// If no recognized scope was found, allow for backward compatibility
// (e.g. tokens with only custom/legacy scope strings).
if !hasKnownScope {
return true
}
return false
}
+1
View File
@@ -197,6 +197,7 @@ func (s *Server) setupRouter() {
r.Get("/tokens", s.handleListUserTokens)
r.Post("/tokens", s.handleCreateUserToken)
r.Delete("/tokens/{tokenID}", s.handleDeleteUserToken)
r.Post("/tokens/{tokenID}/rotate", s.handleRotateUserToken)
})
// Admin endpoints (admin-only, handlers check role internally)
+64
View File
@@ -0,0 +1,64 @@
package server
import (
"net/http"
"testing"
)
func TestTokenScopeAllows(t *testing.T) {
tests := []struct {
name string
scopes string
method string
path string
want bool
}{
// Wildcard scope
{"wildcard allows GET", `["*"]`, http.MethodGet, "/api/v1/test", true},
{"wildcard allows POST", `["*"]`, http.MethodPost, "/api/v1/test", true},
{"wildcard allows DELETE", `["*"]`, http.MethodDelete, "/api/v1/test", true},
// Empty/default scopes
{"empty string allows all", "", http.MethodPost, "/api/v1/test", true},
{"default wildcard allows all", `["*"]`, http.MethodPatch, "/api/v1/test", true},
// Read scope
{"read allows GET", `["read"]`, http.MethodGet, "/api/v1/test", true},
{"read allows HEAD", `["read"]`, http.MethodHead, "/api/v1/test", true},
{"read allows OPTIONS", `["read"]`, http.MethodOptions, "/api/v1/test", true},
{"read blocks POST", `["read"]`, http.MethodPost, "/api/v1/test", false},
{"read blocks DELETE", `["read"]`, http.MethodDelete, "/api/v1/test", false},
{"read blocks PATCH", `["read"]`, http.MethodPatch, "/api/v1/test", false},
// Write scope
{"write allows GET", `["write"]`, http.MethodGet, "/api/v1/test", true},
{"write allows POST", `["write"]`, http.MethodPost, "/api/v1/test", true},
{"write allows DELETE", `["write"]`, http.MethodDelete, "/api/v1/test", true},
// Invalid/unparseable JSON (backward compat)
{"invalid json allows all", "not-json", http.MethodPost, "/api/v1/test", true},
// Multiple scopes
{"read+write allows POST", `["read","write"]`, http.MethodPost, "/api/v1/test", true},
{"read only blocks PUT", `["read"]`, http.MethodPut, "/api/v1/test", false},
// Empty array (no known scopes → allow for backward compat)
{"empty array allows all", `[]`, http.MethodGet, "/api/v1/test", true},
// Unrecognized/legacy scopes (backward compat — allow)
{"unknown scope allows GET", `["docs"]`, http.MethodGet, "/api/v1/test", true},
{"unknown scope allows POST", `["repo"]`, http.MethodPost, "/api/v1/test", true},
{"unknown+read blocks POST", `["docs","read"]`, http.MethodPost, "/api/v1/test", false},
{"unknown+read allows GET", `["docs","read"]`, http.MethodGet, "/api/v1/test", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tokenScopeAllows(tt.scopes, tt.method, tt.path)
if got != tt.want {
t.Errorf("tokenScopeAllows(%q, %q, %q) = %v, want %v",
tt.scopes, tt.method, tt.path, got, tt.want)
}
})
}
}
+87 -4
View File
@@ -6,14 +6,21 @@ import (
"database/sql"
"encoding/hex"
"fmt"
"time"
"github.com/xarmian/pad/internal/models"
)
// defaultTokenExpiryDays is used when no platform setting overrides it.
const defaultTokenExpiryDays = 90
// CreateAPIToken generates a new API token owned by a user, optionally
// scoped to a workspace. The plaintext token is returned in the response
// and is never stored — only its SHA-256 hash is persisted.
func (s *Store) CreateAPIToken(userID string, input models.APITokenCreate) (*models.APITokenWithSecret, error) {
//
// If expiresInDays is 0 on the input, the platform default is used.
// The maxLifetimeDays parameter enforces a ceiling on expiry (0 = no limit).
func (s *Store) CreateAPIToken(userID string, input models.APITokenCreate, defaultExpiryDays, maxLifetimeDays int) (*models.APITokenWithSecret, error) {
// Generate 32 random bytes → 64 hex chars
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
@@ -40,10 +47,25 @@ func (s *Store) CreateAPIToken(userID string, input models.APITokenCreate) (*mod
wsID = input.WorkspaceID
}
// Determine expiry
expiryDays := input.ExpiresIn
if expiryDays <= 0 {
expiryDays = defaultExpiryDays
}
// Enforce max lifetime if configured
if maxLifetimeDays > 0 && expiryDays > maxLifetimeDays {
expiryDays = maxLifetimeDays
}
var expiresAt interface{}
if expiryDays > 0 {
expiresAt = time.Now().UTC().Add(time.Duration(expiryDays) * 24 * time.Hour).Format(time.RFC3339)
}
_, err := s.db.Exec(s.q(`
INSERT INTO api_tokens (id, workspace_id, user_id, name, token_hash, prefix, scopes, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`), id, wsID, userID, input.Name, tokenHash, prefix, scopes, ts)
INSERT INTO api_tokens (id, workspace_id, user_id, name, token_hash, prefix, scopes, expires_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`), id, wsID, userID, input.Name, tokenHash, prefix, scopes, expiresAt, ts)
if err != nil {
return nil, fmt.Errorf("insert api token: %w", err)
}
@@ -59,6 +81,67 @@ func (s *Store) CreateAPIToken(userID string, input models.APITokenCreate) (*mod
}, nil
}
// RotateAPIToken generates a new secret for an existing token, preserving
// all metadata (name, scopes, workspace, user, expiry). The old token is
// invalidated and the new plaintext is returned once.
//
// expiryDays controls the new expiry: 0 keeps the original, >0 sets a new one.
// maxLifetimeDays enforces a ceiling on the new expiry (0 = no limit).
func (s *Store) RotateAPIToken(tokenID, userID string, expiryDays, maxLifetimeDays int) (*models.APITokenWithSecret, error) {
// Verify the token exists and belongs to the user
existing, err := s.getAPIToken(tokenID)
if err != nil {
return nil, err
}
if existing == nil {
return nil, sql.ErrNoRows
}
if existing.UserID != userID {
return nil, sql.ErrNoRows
}
// Generate new secret
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return nil, fmt.Errorf("generate rotated token: %w", err)
}
plaintext := "pad_" + hex.EncodeToString(raw)
prefix := plaintext[:8]
hash := sha256.Sum256([]byte(plaintext))
tokenHash := hex.EncodeToString(hash[:])
// Determine new expiry
var expiresAt interface{}
if expiryDays > 0 {
if maxLifetimeDays > 0 && expiryDays > maxLifetimeDays {
expiryDays = maxLifetimeDays
}
expiresAt = time.Now().UTC().Add(time.Duration(expiryDays) * 24 * time.Hour).Format(time.RFC3339)
} else if existing.ExpiresAt != nil {
// Preserve original expiry
expiresAt = existing.ExpiresAt.Format(time.RFC3339)
}
_, err = s.db.Exec(s.q(`
UPDATE api_tokens SET token_hash = ?, prefix = ?, expires_at = ?, last_used_at = NULL
WHERE id = ?
`), tokenHash, prefix, expiresAt, tokenID)
if err != nil {
return nil, fmt.Errorf("rotate api token: %w", err)
}
updated, err := s.getAPIToken(tokenID)
if err != nil {
return nil, err
}
return &models.APITokenWithSecret{
APIToken: *updated,
Token: plaintext,
}, nil
}
// ListAPITokens returns all API tokens for a workspace (without secrets).
func (s *Store) ListAPITokens(workspaceID string) ([]models.APIToken, error) {
rows, err := s.db.Query(s.q(`
+295
View File
@@ -0,0 +1,295 @@
package store
import (
"testing"
"github.com/xarmian/pad/internal/models"
)
func TestCreateAPITokenWithExpiry(t *testing.T) {
s := testStore(t)
u := createTestUser(t, s, "test@test.com", "Test", "password123")
ws := createTestWorkspace(t, s, "TokenTest")
// Create token with default 90-day expiry
token, err := s.CreateAPIToken(u.ID, models.APITokenCreate{
Name: "test-token",
WorkspaceID: ws.ID,
}, 90, 0)
if err != nil {
t.Fatalf("CreateAPIToken error: %v", err)
}
if token.Token == "" {
t.Fatal("expected plaintext token")
}
if token.ExpiresAt == nil {
t.Fatal("expected expiry to be set with default 90 days")
}
if token.Name != "test-token" {
t.Errorf("expected name 'test-token', got %q", token.Name)
}
if token.Scopes != `["*"]` {
t.Errorf("expected default scopes, got %q", token.Scopes)
}
}
func TestCreateAPITokenExpiryOverride(t *testing.T) {
s := testStore(t)
u := createTestUser(t, s, "test@test.com", "Test", "password123")
ws := createTestWorkspace(t, s, "TokenTest")
// Create token with explicit 30-day expiry
token, err := s.CreateAPIToken(u.ID, models.APITokenCreate{
Name: "short-lived",
WorkspaceID: ws.ID,
ExpiresIn: 30,
}, 90, 0)
if err != nil {
t.Fatalf("CreateAPIToken error: %v", err)
}
if token.ExpiresAt == nil {
t.Fatal("expected expiry to be set")
}
}
func TestCreateAPITokenMaxLifetimeEnforced(t *testing.T) {
s := testStore(t)
u := createTestUser(t, s, "test@test.com", "Test", "password123")
ws := createTestWorkspace(t, s, "TokenTest")
// Request 365-day expiry but max is 30 days
token, err := s.CreateAPIToken(u.ID, models.APITokenCreate{
Name: "capped",
WorkspaceID: ws.ID,
ExpiresIn: 365,
}, 90, 30)
if err != nil {
t.Fatalf("CreateAPIToken error: %v", err)
}
if token.ExpiresAt == nil {
t.Fatal("expected expiry to be set")
}
}
func TestCreateAPITokenNoExpiry(t *testing.T) {
s := testStore(t)
u := createTestUser(t, s, "test@test.com", "Test", "password123")
ws := createTestWorkspace(t, s, "TokenTest")
// Default of 0 means no expiry
token, err := s.CreateAPIToken(u.ID, models.APITokenCreate{
Name: "no-expiry",
WorkspaceID: ws.ID,
}, 0, 0)
if err != nil {
t.Fatalf("CreateAPIToken error: %v", err)
}
if token.ExpiresAt != nil {
t.Error("expected no expiry when default is 0")
}
}
func TestValidateTokenExpired(t *testing.T) {
s := testStore(t)
u := createTestUser(t, s, "test@test.com", "Test", "password123")
ws := createTestWorkspace(t, s, "TokenTest")
tok, err := s.CreateAPIToken(u.ID, models.APITokenCreate{
Name: "will-expire",
WorkspaceID: ws.ID,
ExpiresIn: 1,
}, 0, 0)
if err != nil {
t.Fatalf("CreateAPIToken error: %v", err)
}
// Token should validate now
validated, err := s.ValidateToken(tok.Token)
if err != nil {
t.Fatalf("ValidateToken error: %v", err)
}
if validated == nil {
t.Fatal("expected token to validate before expiry")
}
// Manually expire it
_, err = s.db.Exec("UPDATE api_tokens SET expires_at = '2020-01-01T00:00:00Z' WHERE id = ?", tok.ID)
if err != nil {
t.Fatalf("manual expire error: %v", err)
}
// Should not validate
validated, err = s.ValidateToken(tok.Token)
if err != nil {
t.Fatalf("ValidateToken error: %v", err)
}
if validated != nil {
t.Error("expected nil for expired token")
}
}
func TestRotateAPIToken(t *testing.T) {
s := testStore(t)
u := createTestUser(t, s, "test@test.com", "Test", "password123")
ws := createTestWorkspace(t, s, "TokenTest")
// Create original token
original, err := s.CreateAPIToken(u.ID, models.APITokenCreate{
Name: "rotatable",
WorkspaceID: ws.ID,
}, 90, 0)
if err != nil {
t.Fatalf("CreateAPIToken error: %v", err)
}
// Rotate
rotated, err := s.RotateAPIToken(original.ID, u.ID, 0, 0)
if err != nil {
t.Fatalf("RotateAPIToken error: %v", err)
}
// New token should be different
if rotated.Token == original.Token {
t.Error("rotated token should have a new secret")
}
if rotated.ID != original.ID {
t.Error("rotated token should keep same ID")
}
if rotated.Name != original.Name {
t.Error("rotated token should keep same name")
}
// Old token should not validate
old, err := s.ValidateToken(original.Token)
if err != nil {
t.Fatalf("ValidateToken error: %v", err)
}
if old != nil {
t.Error("old token should not validate after rotation")
}
// New token should validate
validated, err := s.ValidateToken(rotated.Token)
if err != nil {
t.Fatalf("ValidateToken error: %v", err)
}
if validated == nil {
t.Fatal("new token should validate after rotation")
}
if validated.Name != "rotatable" {
t.Errorf("expected name 'rotatable', got %q", validated.Name)
}
}
func TestRotateAPITokenWrongUser(t *testing.T) {
s := testStore(t)
u1 := createTestUser(t, s, "user1@test.com", "User 1", "password123")
u2 := createTestUser(t, s, "user2@test.com", "User 2", "password123")
ws := createTestWorkspace(t, s, "TokenTest")
// Create token owned by u1
token, err := s.CreateAPIToken(u1.ID, models.APITokenCreate{
Name: "u1-token",
WorkspaceID: ws.ID,
}, 90, 0)
if err != nil {
t.Fatalf("CreateAPIToken error: %v", err)
}
// Try to rotate as u2
_, err = s.RotateAPIToken(token.ID, u2.ID, 0, 0)
if err == nil {
t.Error("expected error when rotating another user's token")
}
}
func TestRotateAPITokenWithNewExpiry(t *testing.T) {
s := testStore(t)
u := createTestUser(t, s, "test@test.com", "Test", "password123")
ws := createTestWorkspace(t, s, "TokenTest")
// Create token with 90-day expiry
original, err := s.CreateAPIToken(u.ID, models.APITokenCreate{
Name: "rotate-expiry",
WorkspaceID: ws.ID,
}, 90, 0)
if err != nil {
t.Fatalf("CreateAPIToken error: %v", err)
}
originalExpiry := original.ExpiresAt
// Rotate with new 30-day expiry
rotated, err := s.RotateAPIToken(original.ID, u.ID, 30, 0)
if err != nil {
t.Fatalf("RotateAPIToken error: %v", err)
}
if rotated.ExpiresAt == nil {
t.Fatal("expected new expiry on rotated token")
}
if originalExpiry != nil && !rotated.ExpiresAt.Before(*originalExpiry) {
t.Error("new expiry should be before original 90-day expiry")
}
}
func TestListAndDeleteAPITokens(t *testing.T) {
s := testStore(t)
u := createTestUser(t, s, "test@test.com", "Test", "password123")
ws := createTestWorkspace(t, s, "TokenTest")
// Create two tokens
s.CreateAPIToken(u.ID, models.APITokenCreate{Name: "token-1", WorkspaceID: ws.ID}, 90, 0)
s.CreateAPIToken(u.ID, models.APITokenCreate{Name: "token-2", WorkspaceID: ws.ID}, 90, 0)
// List
tokens, err := s.ListUserAPITokens(u.ID)
if err != nil {
t.Fatalf("ListUserAPITokens error: %v", err)
}
if len(tokens) != 2 {
t.Errorf("expected 2 tokens, got %d", len(tokens))
}
// Delete one
err = s.DeleteUserAPIToken(tokens[0].ID, u.ID)
if err != nil {
t.Fatalf("DeleteUserAPIToken error: %v", err)
}
// Should be 1 left
tokens, _ = s.ListUserAPITokens(u.ID)
if len(tokens) != 1 {
t.Errorf("expected 1 token after delete, got %d", len(tokens))
}
}
func TestValidateTokenUpdatesLastUsed(t *testing.T) {
s := testStore(t)
u := createTestUser(t, s, "test@test.com", "Test", "password123")
ws := createTestWorkspace(t, s, "TokenTest")
tok, err := s.CreateAPIToken(u.ID, models.APITokenCreate{
Name: "tracked",
WorkspaceID: ws.ID,
}, 90, 0)
if err != nil {
t.Fatalf("CreateAPIToken error: %v", err)
}
// Initially no last_used_at
if tok.LastUsedAt != nil {
t.Error("expected nil last_used_at on new token")
}
// Validate to trigger last_used_at update
validated, _ := s.ValidateToken(tok.Token)
if validated == nil {
t.Fatal("expected valid token")
}
// Fetch again to see last_used_at
fetched, _ := s.getAPIToken(tok.ID)
if fetched.LastUsedAt == nil {
t.Error("expected last_used_at to be set after validation")
}
}