mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
420 lines
17 KiB
Go
420 lines
17 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/api/agenttokens"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
)
|
|
|
|
func TestSelfRevokeActionRunnerCredentialRejectsNonRunnerExecBearer(t *testing.T) {
|
|
router, cfg, hostID := newActionRunnerCredentialTestRouter(t)
|
|
raw := "legacy-exec-token-1234567890.12345678"
|
|
record, err := config.NewAPITokenRecord(raw, "legacy", []string{config.ScopeAgentExec})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
record.OrgID = "default"
|
|
record.Metadata = map[string]string{
|
|
agenttokens.RuntimeRoleMetadataKey: agenttokens.CredentialKindLegacyFullTrust,
|
|
"bound_agent_id": hostID,
|
|
"bound_hostname": "host-1.local",
|
|
}
|
|
cfg.APITokens = append(cfg.APITokens, *record)
|
|
body, _ := json.Marshal(actionRunnerCredentialSelfRevokeRequest{AgentID: hostID, Hostname: "host-1.local"})
|
|
req := httptest.NewRequest(http.MethodDelete, "/api/agents/action-runner/credential", bytes.NewReader(body))
|
|
req.Header.Set("Authorization", "Bearer "+raw)
|
|
req = req.WithContext(context.WithValue(req.Context(), OrgIDContextKey, "default"))
|
|
rec := httptest.NewRecorder()
|
|
actionRunnerCredentialRoute(cfg, router.handleIssueActionRunnerCredential, router.handleActivateActionRunnerCredential, router.handleSelfRevokeActionRunnerCredential)(rec, req)
|
|
if rec.Code != http.StatusForbidden || len(cfg.APITokens) != 1 {
|
|
t.Fatalf("legacy self revoke = status %d tokens %#v body=%s", rec.Code, cfg.APITokens, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSecurityTokensCreateRollsBackCompleteInventoryWhenPersistenceFails(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
tokens := []config.APITokenRecord{
|
|
{ID: "newest", Name: "newest", Hash: "hash-newest", CreatedAt: now, Scopes: []string{config.ScopeWildcard}},
|
|
{ID: "oldest", Name: "oldest", Hash: "hash-oldest", CreatedAt: now.Add(-time.Minute), Scopes: []string{config.ScopeWildcard}},
|
|
}
|
|
cfg := &config.Config{APITokens: append([]config.APITokenRecord(nil), tokens...)}
|
|
cfg.SortAPITokens()
|
|
|
|
stateDir := filepath.Join(t.TempDir(), "state")
|
|
persistence := config.NewConfigPersistence(stateDir)
|
|
if err := os.RemoveAll(stateDir); err != nil {
|
|
t.Fatalf("remove persistence directory: %v", err)
|
|
}
|
|
if err := os.WriteFile(stateDir, []byte("not a directory"), 0o600); err != nil {
|
|
t.Fatalf("create persistence blocker: %v", err)
|
|
}
|
|
router := &Router{config: cfg, persistence: persistence}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/security/tokens", bytes.NewBufferString(`{"name":"must-not-survive"}`))
|
|
rec := httptest.NewRecorder()
|
|
router.handleCreateAPIToken(rec, req)
|
|
|
|
if rec.Code != http.StatusInternalServerError {
|
|
t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusInternalServerError, rec.Body.String())
|
|
}
|
|
assertLifecycleAPITokenIDs(t, cfg.APITokens, "newest", "oldest")
|
|
if cfg.APIToken != "hash-newest" {
|
|
t.Fatalf("legacy primary token = %q, want rollback to %q", cfg.APIToken, "hash-newest")
|
|
}
|
|
for _, token := range cfg.APITokens {
|
|
if token.Name == "must-not-survive" {
|
|
t.Fatalf("failed creation left generated token active: %+v", token)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAgentInstallCommandReturnsOnlyDurablyCommittedCredential(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
tokens := []config.APITokenRecord{
|
|
{ID: "newest", Name: "newest", Hash: "hash-newest", CreatedAt: now, Scopes: []string{config.ScopeWildcard}},
|
|
{ID: "oldest", Name: "oldest", Hash: "hash-oldest", CreatedAt: now.Add(-time.Minute), Scopes: []string{config.ScopeWildcard}},
|
|
}
|
|
stateDir := filepath.Join(t.TempDir(), "state")
|
|
cfg := &config.Config{
|
|
DataPath: stateDir,
|
|
AuthUser: "admin",
|
|
AuthPass: "hashed-password",
|
|
APITokens: append([]config.APITokenRecord(nil), tokens...),
|
|
}
|
|
cfg.SortAPITokens()
|
|
handler := newTestConfigHandlers(t, cfg)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/agent-install-command", bytes.NewBufferString(`{"type":"host","name":"must-not-survive"}`))
|
|
persistence := handler.Persistence(req.Context())
|
|
if err := persistence.SaveAPITokens(tokens); err != nil {
|
|
t.Fatalf("save initial tokens: %v", err)
|
|
}
|
|
if err := os.RemoveAll(stateDir); err != nil {
|
|
t.Fatalf("remove persistence directory: %v", err)
|
|
}
|
|
if err := os.WriteFile(stateDir, []byte("not a directory"), 0o600); err != nil {
|
|
t.Fatalf("create persistence blocker: %v", err)
|
|
}
|
|
|
|
rec := httptest.NewRecorder()
|
|
handler.HandleAgentInstallCommand(rec, req)
|
|
|
|
if rec.Code != http.StatusInternalServerError {
|
|
t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusInternalServerError, rec.Body.String())
|
|
}
|
|
if strings.Contains(rec.Body.String(), "must-not-survive") {
|
|
t.Fatalf("failed install-token response disclosed generated record: %q", rec.Body.String())
|
|
}
|
|
assertLifecycleAPITokenIDs(t, cfg.APITokens, "newest", "oldest")
|
|
if cfg.APIToken != "hash-newest" {
|
|
t.Fatalf("legacy primary token = %q, want rollback to %q", cfg.APIToken, "hash-newest")
|
|
}
|
|
}
|
|
|
|
func TestSecurityTokensDeletePersistsOnlyRequestedRemoval(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
tokens := []config.APITokenRecord{
|
|
{ID: "newest", Name: "newest", Hash: "hash-newest", CreatedAt: now, Scopes: []string{config.ScopeWildcard}},
|
|
{ID: "target", Name: "target", Hash: "hash-target", CreatedAt: now.Add(-time.Minute), Scopes: []string{config.ScopeWildcard}},
|
|
{ID: "oldest", Name: "oldest", Hash: "hash-oldest", CreatedAt: now.Add(-2 * time.Minute), Scopes: []string{config.ScopeWildcard}},
|
|
}
|
|
persistence := config.NewConfigPersistence(t.TempDir())
|
|
if err := persistence.SaveAPITokens(tokens); err != nil {
|
|
t.Fatalf("save initial tokens: %v", err)
|
|
}
|
|
cfg := &config.Config{APITokens: append([]config.APITokenRecord(nil), tokens...)}
|
|
router := &Router{config: cfg, persistence: persistence}
|
|
|
|
req := httptest.NewRequest(http.MethodDelete, "/api/security/tokens/target", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.handleDeleteAPIToken(rec, req)
|
|
|
|
if rec.Code != http.StatusNoContent {
|
|
t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusNoContent, rec.Body.String())
|
|
}
|
|
assertLifecycleAPITokenIDs(t, cfg.APITokens, "newest", "oldest")
|
|
persisted, err := persistence.LoadAPITokens()
|
|
if err != nil {
|
|
t.Fatalf("load persisted tokens: %v", err)
|
|
}
|
|
assertLifecycleAPITokenIDs(t, persisted, "newest", "oldest")
|
|
}
|
|
|
|
func TestSecurityTokensDeleteRollsBackWhenPersistenceFails(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
tokens := []config.APITokenRecord{
|
|
{ID: "keep", Name: "keep", Hash: "hash-keep", CreatedAt: now, Scopes: []string{config.ScopeWildcard}},
|
|
{ID: "target", Name: "target", Hash: "hash-target", CreatedAt: now.Add(-time.Minute), Scopes: []string{config.ScopeWildcard}},
|
|
}
|
|
cfg := &config.Config{APITokens: append([]config.APITokenRecord(nil), tokens...)}
|
|
cfg.SortAPITokens()
|
|
|
|
stateDir := filepath.Join(t.TempDir(), "state")
|
|
persistence := config.NewConfigPersistence(stateDir)
|
|
if err := os.RemoveAll(stateDir); err != nil {
|
|
t.Fatalf("remove persistence directory: %v", err)
|
|
}
|
|
if err := os.WriteFile(stateDir, []byte("not a directory"), 0o600); err != nil {
|
|
t.Fatalf("create persistence blocker: %v", err)
|
|
}
|
|
router := &Router{config: cfg, persistence: persistence}
|
|
|
|
req := httptest.NewRequest(http.MethodDelete, "/api/security/tokens/target", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.handleDeleteAPIToken(rec, req)
|
|
|
|
if rec.Code != http.StatusInternalServerError {
|
|
t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusInternalServerError, rec.Body.String())
|
|
}
|
|
assertLifecycleAPITokenIDs(t, cfg.APITokens, "keep", "target")
|
|
if cfg.APIToken != "hash-keep" {
|
|
t.Fatalf("legacy primary token = %q, want rollback to %q", cfg.APIToken, "hash-keep")
|
|
}
|
|
}
|
|
|
|
func assertLifecycleAPITokenIDs(t *testing.T, tokens []config.APITokenRecord, want ...string) {
|
|
t.Helper()
|
|
if len(tokens) != len(want) {
|
|
t.Fatalf("token count = %d, want %d: %+v", len(tokens), len(want), tokens)
|
|
}
|
|
for index, id := range want {
|
|
if tokens[index].ID != id {
|
|
t.Fatalf("token[%d].ID = %q, want %q", index, tokens[index].ID, id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
}
|