Ctrl K
ComunidadGuías…

internal/oauth/service_integration_test.go

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

Archivo original ↗Preguntar a KiBot
package oauth

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

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

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

var schemaSeq atomic.Int64

// sam is the user newTestPool seeds, rendered "sam@stump.rocks".
const sam = "00000000-0000-4000-8000-000000000101"

// newTestPool connects to CAIRN_TEST_DATABASE_URL (skipping otherwise) and
// applies the embedded migrations inside a private, per-test schema — the same
// isolation discipline as the other packages' integration suites.
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 oauth integration tests")
	}
	ctx := context.Background()
	schema := fmt.Sprintf("oauth_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)
	}
	// Grants 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')`, sam); err != nil {
		t.Fatalf("seed users: %v", err)
	}
	return pool
}

// issueGrant walks a full code issue + redemption for tests, returning the set.
func issueGrant(t *testing.T, svc *Service, userID string) (*Client, *TokenSet, string) {
	t.Helper()
	ctx := context.Background()
	client, err := svc.RegisterClient(ctx, "Test Agent", []string{"https://client.example/cb"})
	if err != nil {
		t.Fatalf("register client: %v", err)
	}
	verifier := strings.Repeat("v", 64)
	code, err := svc.CreateAuthCode(ctx, client.ID, userID, "https://client.example/cb", AllScopes(), S256Challenge(verifier))
	if err != nil {
		t.Fatalf("create code: %v", err)
	}
	set, err := svc.RedeemCode(ctx, code, client.ID, "https://client.example/cb", verifier)
	if err != nil {
		t.Fatalf("redeem code: %v", err)
	}
	return client, set, verifier
}

// TestIntegrationAudienceBinding proves an access token minted for one
// audience is rejected by a resource server validating a different audience
// (SPEC-0007 scenario "Access token bound to audience", RFC 8707).
func TestIntegrationAudienceBinding(t *testing.T) {
	pool := newTestPool(t)
	ctx := context.Background()
	cairn := NewService(pool, "https://cairn.test", Options{})
	other := NewService(pool, "https://other.example", Options{})

	_, set, _ := issueGrant(t, cairn, sam)

	ident, err := cairn.AuthenticateAccess(ctx, set.AccessToken)
	if err != nil {
		t.Fatalf("own-audience authenticate: %v", err)
	}
	if ident.UserID != sam || ident.ActorID != "sam@stump.rocks" {
		t.Fatalf("subject = %q (%q), want the human", ident.UserID, ident.ActorID)
	}
	if _, err := other.AuthenticateAccess(ctx, set.AccessToken); !errors.Is(err, ErrInvalidGrant) {
		t.Fatalf("foreign-audience authenticate = %v, want ErrInvalidGrant", err)
	}
}

// TestIntegrationRefreshRotationAtomicity proves rotation invalidates the old
// refresh token and issues exactly one new pair, and that reusing the
// rotated-out token revokes the whole family (SPEC-0007 scenarios "Atomic
// refresh rotation", "Refresh rotation and reuse detection").
func TestIntegrationRefreshRotationAndReuseRevokesFamily(t *testing.T) {
	pool := newTestPool(t)
	ctx := context.Background()
	svc := NewService(pool, "https://cairn.test", Options{})

	client, set, _ := issueGrant(t, svc, sam)

	rotated, err := svc.Refresh(ctx, set.RefreshToken, client.ID)
	if err != nil {
		t.Fatalf("refresh: %v", err)
	}
	if rotated.RefreshToken == set.RefreshToken {
		t.Fatal("refresh did not rotate the token")
	}
	if rotated.GrantID != set.GrantID {
		t.Fatalf("rotation changed the grant: %q → %q", set.GrantID, rotated.GrantID)
	}
	// Exactly one live (unrotated, unrevoked) refresh token exists.
	var live int
	if err := pool.QueryRow(ctx, `
		SELECT count(*) FROM oauth_tokens
		WHERE grant_id = $1 AND kind = 'refresh' AND rotated_at IS NULL AND revoked_at IS NULL`,
		set.GrantID,
	).Scan(&live); err != nil {
		t.Fatalf("count refresh tokens: %v", err)
	}
	if live != 1 {
		t.Fatalf("live refresh tokens = %d, want exactly 1", live)
	}

	// Reusing the rotated-out token is theft: the family dies.
	if _, err := svc.Refresh(ctx, set.RefreshToken, client.ID); !errors.Is(err, ErrInvalidGrant) {
		t.Fatalf("reuse = %v, want ErrInvalidGrant", err)
	}
	if _, err := svc.AuthenticateAccess(ctx, rotated.AccessToken); !errors.Is(err, ErrInvalidGrant) {
		t.Fatalf("post-reuse access = %v, want ErrInvalidGrant (family revoked)", err)
	}
	if _, err := svc.Refresh(ctx, rotated.RefreshToken, client.ID); !errors.Is(err, ErrInvalidGrant) {
		t.Fatalf("post-reuse refresh = %v, want ErrInvalidGrant (family revoked)", err)
	}
}

// TestIntegrationRevokeIsolation proves revoking one grant leaves a sibling
// grant for the same human untouched (SPEC-0007 scenario "Revoke one
// connection").
func TestIntegrationRevokeIsolation(t *testing.T) {
	pool := newTestPool(t)
	ctx := context.Background()
	svc := NewService(pool, "https://cairn.test", Options{})

	clientA, setA, _ := issueGrant(t, svc, sam)
	_, setB, _ := issueGrant(t, svc, sam)

	if err := svc.Revoke(ctx, setA.RefreshToken, clientA.ID); err != nil {
		t.Fatalf("revoke: %v", err)
	}
	if _, err := svc.AuthenticateAccess(ctx, setA.AccessToken); !errors.Is(err, ErrInvalidGrant) {
		t.Fatalf("revoked grant's access token = %v, want ErrInvalidGrant", err)
	}
	if _, err := svc.Refresh(ctx, setA.RefreshToken, clientA.ID); !errors.Is(err, ErrInvalidGrant) {
		t.Fatalf("revoked grant's refresh token = %v, want ErrInvalidGrant", err)
	}
	if _, err := svc.AuthenticateAccess(ctx, setB.AccessToken); err != nil {
		t.Fatalf("sibling grant's access token = %v, want live", err)
	}
}

// TestIntegrationRevokeUnknownAndForeignTokens proves RFC 7009 semantics: an
// unknown token and another client's token both return success and revoke
// nothing.
func TestIntegrationRevokeUnknownAndForeignTokens(t *testing.T) {
	pool := newTestPool(t)
	ctx := context.Background()
	svc := NewService(pool, "https://cairn.test", Options{})

	clientA, setA, _ := issueGrant(t, svc, sam)
	clientB, _, _ := issueGrant(t, svc, sam)

	if err := svc.Revoke(ctx, "cairn_rt_unknown", clientA.ID); err != nil {
		t.Fatalf("revoke unknown token: %v, want nil per RFC 7009", err)
	}
	// Client B presenting client A's token: treated as unknown, revokes nothing.
	if err := svc.Revoke(ctx, setA.RefreshToken, clientB.ID); err != nil {
		t.Fatalf("revoke foreign token: %v, want nil", err)
	}
	if _, err := svc.AuthenticateAccess(ctx, setA.AccessToken); err != nil {
		t.Fatalf("grant A access token = %v, want still live after foreign revoke", err)
	}
}

// TestIntegrationCodeBurnsOnFailedExchange proves a failed PKCE attempt spends
// the single-use code: the correct verifier no longer redeems it afterwards
// (SPEC-0007 REQ "OAuth 2.1 Authorization-Code + PKCE": codes are single-use).
func TestIntegrationCodeBurnsOnFailedExchange(t *testing.T) {
	pool := newTestPool(t)
	ctx := context.Background()
	svc := NewService(pool, "https://cairn.test", Options{})

	client, err := svc.RegisterClient(ctx, "Test Agent", []string{"https://client.example/cb"})
	if err != nil {
		t.Fatalf("register client: %v", err)
	}
	verifier := strings.Repeat("v", 64)
	code, err := svc.CreateAuthCode(ctx, client.ID, sam, "https://client.example/cb", AllScopes(), S256Challenge(verifier))
	if err != nil {
		t.Fatalf("create code: %v", err)
	}
	if _, err := svc.RedeemCode(ctx, code, client.ID, "https://client.example/cb", strings.Repeat("x", 64)); !errors.Is(err, ErrInvalidGrant) {
		t.Fatalf("bad verifier = %v, want ErrInvalidGrant", err)
	}
	if _, err := svc.RedeemCode(ctx, code, client.ID, "https://client.example/cb", verifier); !errors.Is(err, ErrInvalidGrant) {
		t.Fatalf("burned code with good verifier = %v, want ErrInvalidGrant", err)
	}
}