diff --git a/internal/models/activity.go b/internal/models/activity.go index cd7af44a..0c0fe7de 100644 --- a/internal/models/activity.go +++ b/internal/models/activity.go @@ -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" diff --git a/internal/models/api_token.go b/internal/models/api_token.go index 6fff1881..7191f0f6 100644 --- a/internal/models/api_token.go +++ b/internal/models/api_token.go @@ -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 diff --git a/internal/server/handlers_admin.go b/internal/server/handlers_admin.go index d462b637..de83e9a0 100644 --- a/internal/server/handlers_admin.go +++ b/internal/server/handlers_admin.go @@ -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 { diff --git a/internal/server/handlers_tokens.go b/internal/server/handlers_tokens.go index fc6edfbc..158de150 100644 --- a/internal/server/handlers_tokens.go +++ b/internal/server/handlers_tokens.go @@ -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)) + } +} diff --git a/internal/server/middleware_auth.go b/internal/server/middleware_auth.go index 588f4c4d..4b4c0e2b 100644 --- a/internal/server/middleware_auth.go +++ b/internal/server/middleware_auth.go @@ -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 +} diff --git a/internal/server/server.go b/internal/server/server.go index 874c95e6..6e9604f9 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) diff --git a/internal/server/token_scopes_test.go b/internal/server/token_scopes_test.go new file mode 100644 index 00000000..46ef6575 --- /dev/null +++ b/internal/server/token_scopes_test.go @@ -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) + } + }) + } +} diff --git a/internal/store/api_tokens.go b/internal/store/api_tokens.go index 89f9d653..f3be57ba 100644 --- a/internal/store/api_tokens.go +++ b/internal/store/api_tokens.go @@ -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(` diff --git a/internal/store/api_tokens_test.go b/internal/store/api_tokens_test.go new file mode 100644 index 00000000..a04d673d --- /dev/null +++ b/internal/store/api_tokens_test.go @@ -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") + } +}