package serviceaccount import ( "context" "errors" "os" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" ) func setupTest(t *testing.T) (*Store, *pgxpool.Pool, func()) { t.Helper() adminDSN := os.Getenv("TEST_ADMIN_DSN") if adminDSN == "" { t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen") } ctx := context.Background() pool, err := pgxpool.New(ctx, adminDSN) if err != nil { t.Fatalf("pool: %v", err) } if _, err := pool.Exec(ctx, ` CREATE TABLE IF NOT EXISTS service_accounts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS api_tokens ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), service_account_id UUID NOT NULL REFERENCES service_accounts(id), token_hash BYTEA NOT NULL UNIQUE, scopes TEXT[] NOT NULL DEFAULT '{}', expires_at TIMESTAMPTZ, revoked_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); `); err != nil { t.Fatalf("schema: %v", err) } cleanup := func() { pool.Close() } return NewStore(pool), pool, cleanup } // Akzeptanzkriterium 1: Service-Account anlegen, eigenes gehashtes Token erhalten. // Akzeptanzkriterium 1 + Pruefung 1: Token ausschliesslich gehasht gespeichert. func TestIssueToken_StoresOnlyHash(t *testing.T) { store, pool, cleanup := setupTest(t) defer cleanup() ctx := context.Background() sa, err := store.CreateServiceAccount(ctx, "ci-pipeline") if err != nil { t.Fatalf("create service account: %v", err) } tokenID, plaintext, err := store.IssueToken(ctx, sa.ID, []string{"read"}, nil) if err != nil { t.Fatalf("issue token: %v", err) } if plaintext == "" { t.Fatal("erwartet nicht-leeren klartext-token") } var storedHash []byte if err := pool.QueryRow(ctx, `SELECT token_hash FROM api_tokens WHERE id = $1`, tokenID).Scan(&storedHash); err != nil { t.Fatalf("hash lesen: %v", err) } if string(storedHash) == plaintext { t.Fatal("token_hash enthaelt den klartext-token — verstoss gegen akzeptanzkriterium 1") } if len(storedHash) != 32 { // sha256 t.Fatalf("erwartet 32-byte sha256-hash, habe %d bytes", len(storedHash)) } tok, err := store.Verify(ctx, plaintext, "") if err != nil { t.Fatalf("verify: %v", err) } if tok.ServiceAccountID != sa.ID { t.Fatalf("service account id = %q, want %q", tok.ServiceAccountID, sa.ID) } } // Akzeptanzkriterium 2 + Pruefung 3: Scope-Verletzung wird abgewiesen. func TestVerify_RejectsInsufficientScope(t *testing.T) { store, _, cleanup := setupTest(t) defer cleanup() ctx := context.Background() sa, err := store.CreateServiceAccount(ctx, "readonly-bot") if err != nil { t.Fatalf("create service account: %v", err) } _, plaintext, err := store.IssueToken(ctx, sa.ID, []string{"read"}, nil) if err != nil { t.Fatalf("issue token: %v", err) } if _, err := store.Verify(ctx, plaintext, "read"); err != nil { t.Fatalf("erwartet erfolg fuer passenden scope: %v", err) } if _, err := store.Verify(ctx, plaintext, "write"); !errors.Is(err, ErrScopeInsufficient) { t.Fatalf("erwartet ErrScopeInsufficient fuer fehlenden scope, habe %v", err) } } // Akzeptanzkriterium 2: optionale zeitliche Befristung wird durchgesetzt. func TestVerify_RejectsExpiredToken(t *testing.T) { store, _, cleanup := setupTest(t) defer cleanup() ctx := context.Background() sa, err := store.CreateServiceAccount(ctx, "temp-bot") if err != nil { t.Fatalf("create service account: %v", err) } ttl := -1 * time.Second // sofort abgelaufen _, plaintext, err := store.IssueToken(ctx, sa.ID, nil, &ttl) if err != nil { t.Fatalf("issue token: %v", err) } if _, err := store.Verify(ctx, plaintext, ""); !errors.Is(err, ErrTokenInvalid) { t.Fatalf("erwartet ErrTokenInvalid bei abgelaufenem token, habe %v", err) } } // Akzeptanzkriterium 3 + Pruefung 2: widerrufenes Token wird sofort abgewiesen. func TestRevoke_TakesEffectImmediately(t *testing.T) { store, _, cleanup := setupTest(t) defer cleanup() ctx := context.Background() sa, err := store.CreateServiceAccount(ctx, "revoke-bot") if err != nil { t.Fatalf("create service account: %v", err) } tokenID, plaintext, err := store.IssueToken(ctx, sa.ID, nil, nil) if err != nil { t.Fatalf("issue token: %v", err) } if _, err := store.Verify(ctx, plaintext, ""); err != nil { t.Fatalf("verify vor widerruf: %v", err) } if err := store.Revoke(ctx, tokenID); err != nil { t.Fatalf("revoke: %v", err) } if _, err := store.Verify(ctx, plaintext, ""); !errors.Is(err, ErrTokenInvalid) { t.Fatalf("erwartet ErrTokenInvalid sofort nach widerruf, habe %v", err) } } func TestVerify_RejectsUnknownToken(t *testing.T) { store, _, cleanup := setupTest(t) defer cleanup() ctx := context.Background() if _, err := store.Verify(ctx, "niemals-ausgestelltes-token", ""); !errors.Is(err, ErrTokenInvalid) { t.Fatalf("erwartet ErrTokenInvalid, habe %v", err) } }