Pulse/internal/api/security_tokens_lifecycle_test.go
2026-03-18 16:06:30 +00:00

235 lines
8.8 KiB
Go

package api
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
// TestSecurityTokens_DeleteFailsAuthImmediately verifies that after deleting an API token,
// subsequent requests using that token receive 401 Unauthorized.
// Covers acceptance test checklist item 2.4: "Delete token — verify it no longer authenticates".
func TestSecurityTokens_DeleteFailsAuthImmediately(t *testing.T) {
rawToken := "lifecycle-delete-token.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
// Keep a second token so the system stays in API-token mode after
// deleting the first (otherwise HasAPITokens() returns false and
// auth falls through to session/password or anonymous).
keepToken := "lifecycle-keep-token.87654321"
keepRecord := newTokenRecord(t, keepToken, []string{config.ScopeMonitoringRead}, nil)
cfg := newTestConfigWithTokens(t, record, keepRecord)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
handler := router.Handler()
// 1. Token works before deletion.
req := httptest.NewRequest(http.MethodGet, "/simple-stats", nil)
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 before deletion, got %d", rec.Code)
}
// 2. Delete the target token from config (simulates handler delete).
removed := cfg.RemoveAPIToken(record.ID)
if removed == nil {
t.Fatal("expected token to be removed from config")
}
// 3. Deleted token now fails with 401 via X-API-Token.
req = httptest.NewRequest(http.MethodGet, "/simple-stats", nil)
req.Header.Set("X-API-Token", rawToken)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 after deletion, got %d", rec.Code)
}
// 4. Deleted token also fails via Bearer.
req = httptest.NewRequest(http.MethodGet, "/simple-stats", nil)
req.Header.Set("Authorization", "Bearer "+rawToken)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for Bearer after deletion, got %d", rec.Code)
}
// 5. The kept token still works (proving auth itself isn't broken).
req = httptest.NewRequest(http.MethodGet, "/simple-stats", nil)
req.Header.Set("X-API-Token", keepToken)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 for kept token, got %d", rec.Code)
}
}
// TestSecurityTokens_ExpiredTokenRejectedAtHTTPLayer verifies that an API token whose
// ExpiresAt is in the past is rejected at the HTTP authentication layer
// with 401 Unauthorized.
// Covers acceptance test checklist item 2.4: "Expired token is rejected".
func TestSecurityTokens_ExpiredTokenRejectedAtHTTPLayer(t *testing.T) {
rawToken := "lifecycle-expired-token.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
// Set expiration 1 second in the past so the token is already expired.
past := time.Now().UTC().Add(-1 * time.Second)
record.ExpiresAt = &past
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
handler := router.Handler()
// X-API-Token header path.
req := httptest.NewRequest(http.MethodGet, "/simple-stats", nil)
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for expired token via X-API-Token, got %d", rec.Code)
}
// Bearer token path.
req = httptest.NewRequest(http.MethodGet, "/simple-stats", nil)
req.Header.Set("Authorization", "Bearer "+rawToken)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for expired token via Bearer, got %d", rec.Code)
}
// Verify LastUsedAt was NOT updated (expired tokens must not update stats).
for _, storedRecord := range cfg.APITokens {
if storedRecord.ID == record.ID && storedRecord.LastUsedAt != nil {
t.Fatal("expired token should not have LastUsedAt updated in config")
}
}
}
// TestSecurityTokens_ValidTokenUpdatesLastUsedAt verifies that making an API call with a
// valid token updates the token's LastUsedAt timestamp in the config.
// Covers acceptance test checklist item 2.4: "lastUsedAt updates after token is used".
func TestSecurityTokens_ValidTokenUpdatesLastUsedAt(t *testing.T) {
rawToken := "lifecycle-lastused-token.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
handler := router.Handler()
// Confirm LastUsedAt is nil before first use.
if cfg.APITokens[0].LastUsedAt != nil {
t.Fatal("expected LastUsedAt to be nil before first use")
}
before := time.Now().UTC()
// Make a request with the token.
req := httptest.NewRequest(http.MethodGet, "/simple-stats", nil)
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
after := time.Now().UTC()
// Verify LastUsedAt was set and is recent.
if cfg.APITokens[0].LastUsedAt == nil {
t.Fatal("expected LastUsedAt to be set after token use")
}
lastUsed := *cfg.APITokens[0].LastUsedAt
if lastUsed.Before(before) || lastUsed.After(after.Add(time.Second)) {
t.Fatalf("LastUsedAt %v not in expected range [%v, %v]", lastUsed, before, after)
}
// Second request should update the timestamp again.
time.Sleep(2 * time.Millisecond) // ensure measurable time difference
beforeSecond := time.Now().UTC()
req = httptest.NewRequest(http.MethodGet, "/simple-stats", nil)
req.Header.Set("X-API-Token", rawToken)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 on second call, got %d", rec.Code)
}
secondLastUsed := *cfg.APITokens[0].LastUsedAt
if secondLastUsed.Before(beforeSecond) {
t.Fatalf("LastUsedAt did not advance after second request: first=%v second=%v", lastUsed, secondLastUsed)
}
}
// TestSecurityTokens_NonExpiredTokenAllowedThenExpiredRejected verifies the lifecycle
// transition: a token with a future expiration works, and once the
// expiration passes, the same token is rejected.
// Uses direct ExpiresAt mutation instead of time.Sleep to avoid slow/flaky tests.
func TestSecurityTokens_NonExpiredTokenAllowedThenExpiredRejected(t *testing.T) {
rawToken := "lifecycle-expiry-window.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
// Set expiration far enough in the future that it won't expire during the test.
future := time.Now().UTC().Add(1 * time.Hour)
record.ExpiresAt = &future
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
handler := router.Handler()
// Token works while not expired.
req := httptest.NewRequest(http.MethodGet, "/simple-stats", nil)
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 while token is valid, got %d", rec.Code)
}
// Move expiration into the past to simulate natural expiry.
past := time.Now().UTC().Add(-1 * time.Second)
cfg.APITokens[0].ExpiresAt = &past
// Same token now rejected.
req = httptest.NewRequest(http.MethodGet, "/simple-stats", nil)
req.Header.Set("X-API-Token", rawToken)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 after expiration, got %d", rec.Code)
}
}
// TestSecurityTokens_InvalidTokenHeaderReturns401 verifies that a completely wrong token
// value in the X-API-Token header returns 401 with the correct error message.
// Covers acceptance test checklist item 2.4: "API call with wrong token returns 401".
func TestSecurityTokens_InvalidTokenHeaderReturns401(t *testing.T) {
rawToken := "lifecycle-valid-token.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeMonitoringRead}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
handler := router.Handler()
// Wrong X-API-Token header.
req := httptest.NewRequest(http.MethodGet, "/simple-stats", nil)
req.Header.Set("X-API-Token", "completely-wrong-token")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for wrong X-API-Token, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Invalid API token") {
t.Fatalf("expected 'Invalid API token' body, got %q", rec.Body.String())
}
// Verify LastUsedAt NOT updated for invalid tokens.
if cfg.APITokens[0].LastUsedAt != nil {
t.Fatal("invalid token should not update LastUsedAt")
}
}