Ctrl K
ComunidadGuías…

internal/pat/service_integration_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package pat

import (
	"context"
	"errors"
	"fmt"
	"os"
	"sync/atomic"
	"testing"
	"time"

	"github.com/jackc/pgx/v5/pgxpool"

	"github.com/stump-wtf/cairn/internal/db"
	"github.com/stump-wtf/cairn/internal/errs"
)

var schemaSeq atomic.Int64

// newTestPool connects to CAIRN_TEST_DATABASE_URL (skipping otherwise) and
// applies the embedded migrations inside a private, per-test schema — the
// same isolation discipline internal/oauth's integration suite uses.
// sam, alice and bob are the users newTestPool seeds.
const (
	sam   = "00000000-0000-4000-8000-0000000000e1"
	alice = "00000000-0000-4000-8000-0000000000e2"
	bob   = "00000000-0000-4000-8000-0000000000e3"
)

func newTestPool(t *testing.T) *pgxpool.Pool {
	t.Helper()
	dsn := os.Getenv("CAIRN_TEST_DATABASE_URL")
	if dsn == "" {
		t.Skip("set CAIRN_TEST_DATABASE_URL to run pat integration tests")
	}
	ctx := context.Background()
	schema := fmt.Sprintf("pat_test_%d_%d", time.Now().UnixNano(), schemaSeq.Add(1))

	admin, err := db.Connect(ctx, dsn)
	if err != nil {
		t.Fatalf("connect: %v", err)
	}
	if _, err := admin.Exec(ctx, fmt.Sprintf(`CREATE SCHEMA %q`, schema)); err != nil {
		admin.Close()
		t.Fatalf("create schema: %v", err)
	}
	t.Cleanup(func() {
		if _, err := admin.Exec(context.Background(), fmt.Sprintf(`DROP SCHEMA %q CASCADE`, schema)); err != nil {
			t.Errorf("drop schema: %v", err)
		}
		admin.Close()
	})

	cfg, err := pgxpool.ParseConfig(dsn)
	if err != nil {
		t.Fatalf("parse dsn: %v", err)
	}
	cfg.ConnConfig.RuntimeParams["search_path"] = schema
	pool, err := pgxpool.NewWithConfig(ctx, cfg)
	if err != nil {
		t.Fatalf("open pool: %v", err)
	}
	t.Cleanup(pool.Close)

	if err := db.Migrate(ctx, pool); err != nil {
		t.Fatalf("migrate: %v", err)
	}
	// Tokens belong to users (SPEC-0023 REQ "Owner Model").
	if _, err := pool.Exec(ctx, `INSERT INTO users (id, actor_key, display_handle) VALUES
		($1, 'sam@stump.rocks', 'sam'), ($2, 'alice@stump.rocks', 'alice'), ($3, 'bob@stump.rocks', 'bob')`,
		sam, alice, bob); err != nil {
		t.Fatalf("seed users: %v", err)
	}
	return pool
}

// TestIntegrationCreateAuthenticateRevoke walks the full lifecycle: mint a
// token, authenticate with its plaintext secret, revoke it, and prove the
// revoked secret is rejected — the round trip issue #74 requires ("create →
// use as bearer → list → revoke → rejected").
func TestIntegrationCreateAuthenticateRevoke(t *testing.T) {
	pool := newTestPool(t)
	ctx := context.Background()
	svc := NewService(pool)

	secret, tok, err := svc.Create(ctx, sam, "laptop agent", []string{ScopeArtifactsRead, ScopeArtifactsWrite}, true)
	if err != nil {
		t.Fatalf("create: %v", err)
	}
	if secret == "" {
		t.Fatal("create returned an empty secret")
	}
	if tok.RevokedAt != nil {
		t.Fatal("freshly minted token must not be revoked")
	}

	got, err := svc.Authenticate(ctx, secret)
	if err != nil {
		t.Fatalf("authenticate: %v", err)
	}
	if got.UserID != sam || got.Actor != "sam@stump.rocks" {
		t.Fatalf("owner = %q (%q), want sam@stump.rocks", got.UserID, got.Actor)
	}
	if !got.IsAgent {
		t.Fatal("is_agent flag did not round-trip")
	}
	if got.LastUsedAt == nil {
		t.Fatal("authenticate must set last_used_at")
	}
	if len(got.Scopes) != 2 {
		t.Fatalf("scopes = %v, want exactly the granted two", got.Scopes)
	}

	if err := svc.Revoke(ctx, sam, tok.ID); err != nil {
		t.Fatalf("revoke: %v", err)
	}
	if _, err := svc.Authenticate(ctx, secret); !errors.Is(err, errs.ErrUnauthorized) {
		t.Fatalf("post-revoke authenticate = %v, want errs.ErrUnauthorized", err)
	}

	toks, err := svc.List(ctx, sam)
	if err != nil {
		t.Fatalf("list: %v", err)
	}
	if len(toks) != 1 {
		t.Fatalf("list len = %d, want 1", len(toks))
	}
	if toks[0].RevokedAt == nil {
		t.Fatal("listed token should show revoked_at set")
	}
}

// TestIntegrationOwnerIsolation proves one owner's revoke cannot touch
// another owner's token, and List never crosses owners (issue #74
// acceptance: "owner isolation").
func TestIntegrationOwnerIsolation(t *testing.T) {
	pool := newTestPool(t)
	ctx := context.Background()
	svc := NewService(pool)

	_, tokA, err := svc.Create(ctx, alice, "alice's token", AllScopes(), false)
	if err != nil {
		t.Fatalf("create A: %v", err)
	}
	secretB, tokB, err := svc.Create(ctx, bob, "bob's token", AllScopes(), false)
	if err != nil {
		t.Fatalf("create B: %v", err)
	}

	// Bob cannot revoke Alice's token by id.
	if err := svc.Revoke(ctx, bob, tokA.ID); !errors.Is(err, errs.ErrNotFound) {
		t.Fatalf("cross-owner revoke = %v, want errs.ErrNotFound", err)
	}
	// Bob's own token still authenticates — Alice's failed revoke attempt did
	// nothing to Bob's token, and Bob's token was never touched either.
	if _, err := svc.Authenticate(ctx, secretB); err != nil {
		t.Fatalf("bob's token should still authenticate: %v", err)
	}

	aliceList, err := svc.List(ctx, alice)
	if err != nil {
		t.Fatalf("list alice: %v", err)
	}
	if len(aliceList) != 1 || aliceList[0].ID != tokA.ID {
		t.Fatalf("alice's list = %+v, want exactly her own token", aliceList)
	}
	bobList, err := svc.List(ctx, bob)
	if err != nil {
		t.Fatalf("list bob: %v", err)
	}
	if len(bobList) != 1 || bobList[0].ID != tokB.ID {
		t.Fatalf("bob's list = %+v, want exactly his own token", bobList)
	}
}

// TestIntegrationRevokeUnknownIsNotFound proves revoking an id that never
// existed is the same uniform not-found as revoking someone else's token —
// no existence oracle.
func TestIntegrationRevokeUnknownIsNotFound(t *testing.T) {
	pool := newTestPool(t)
	ctx := context.Background()
	svc := NewService(pool)

	if err := svc.Revoke(ctx, sam, "pat-does-not-exist"); !errors.Is(err, errs.ErrNotFound) {
		t.Fatalf("revoke unknown id = %v, want errs.ErrNotFound", err)
	}
}

// TestIntegrationRevokeIsIdempotentFailure proves revoking an already-revoked
// token a second time also fails not-found (it is no longer live), rather
// than silently succeeding twice.
func TestIntegrationRevokeIsIdempotentFailure(t *testing.T) {
	pool := newTestPool(t)
	ctx := context.Background()
	svc := NewService(pool)

	_, tok, err := svc.Create(ctx, sam, "one-shot", AllScopes(), false)
	if err != nil {
		t.Fatalf("create: %v", err)
	}
	if err := svc.Revoke(ctx, sam, tok.ID); err != nil {
		t.Fatalf("first revoke: %v", err)
	}
	if err := svc.Revoke(ctx, sam, tok.ID); !errors.Is(err, errs.ErrNotFound) {
		t.Fatalf("second revoke = %v, want errs.ErrNotFound", err)
	}
}

// TestIntegrationAuthenticateRejectsUnknownSecret proves an unknown secret
// (never minted) fails closed.
func TestIntegrationAuthenticateRejectsUnknownSecret(t *testing.T) {
	pool := newTestPool(t)
	ctx := context.Background()
	svc := NewService(pool)

	if _, err := svc.Authenticate(ctx, "cairn_pat_totally-unknown"); !errors.Is(err, errs.ErrUnauthorized) {
		t.Fatalf("unknown secret = %v, want errs.ErrUnauthorized", err)
	}
}

// TestIntegrationCreateRejectsEmptyScopes proves the service enforces the
// domain invariant regardless of what a caller passes (defense in depth
// behind the transport's own ParseScope validation).
func TestIntegrationCreateRejectsEmptyScopes(t *testing.T) {
	pool := newTestPool(t)
	ctx := context.Background()
	svc := NewService(pool)

	if _, _, err := svc.Create(ctx, sam, "empty", nil, false); !errors.Is(err, errs.ErrValidation) {
		t.Fatalf("create with no scopes = %v, want errs.ErrValidation", err)
	}
}