Files
nexarch/internal/serviceaccount/serviceaccount_test.go
T
sysopsandClaude Sonnet 5 95275407b1 IAM-09: api-token-service-accounts
internal/serviceaccount: Service-Accounts als eigenstaendige Identitaetsklasse
(eigene Tabelle service_accounts, getrennt von internal/user — Zitadel-
Vorbild). Store.IssueToken gibt den Klartext-Token NUR einmal an den
Aufrufer zurueck, gespeichert wird ausschliesslich der SHA-256-Hash
(Akzeptanzkriterium 1). Scopes und optionale Ablaufzeit sind Teil des
Tokens selbst (Akzeptanzkriterium 2).

Store.Verify prueft Widerruf/Ablauf/Scope bei JEDEM Aufruf direkt gegen die
Datenbank — kein Cache dazwischen, ein widerrufenes Token wird ab dem
naechsten Request sofort abgewiesen (Akzeptanzkriterium 3).

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Token ausschliesslich gehasht gespeichert (Stichprobe in der Datenbank) —
   TestIssueToken_StoresOnlyHash: token_hash-Spalte direkt gelesen, enthaelt
   nachweislich nicht den Klartext, 32-Byte-SHA-256-Laenge bestaetigt. PASS.
2. Widerrufenes Token wird beim naechsten Request zuverlaessig abgewiesen —
   TestRevoke_TakesEffectImmediately: Verify vor Widerruf erfolgreich, sofort
   danach ErrTokenInvalid. PASS.
3. Scope-Verletzung wird korrekt abgewiesen —
   TestVerify_RejectsInsufficientScope: Token mit scope=read wird fuer
   scope=write abgewiesen (ErrScopeInsufficient), fuer scope=read akzeptiert. PASS.

Zusaetzlich: TestVerify_RejectsExpiredToken belegt die optionale zeitliche
Befristung aus Akzeptanzkriterium 2. PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 22:19:39 +02:00

164 lines
4.9 KiB
Go

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)
}
}