mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-07 08:57:12 +00:00
235 lines
8.8 KiB
Go
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")
|
|
}
|
|
}
|