Ctrl K
ComunidadGuías…

internal/mcpsession/service_integration_test.go

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

Archivo original ↗Preguntar a KiBot
package mcpsession

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

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

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

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/pat's and internal/oauth's integration
// suites use.
// sam, alice and bob are the users newTestPool seeds.
const (
	sam   = "00000000-0000-4000-8000-0000000000f1"
	alice = "00000000-0000-4000-8000-0000000000f2"
	bob   = "00000000-0000-4000-8000-0000000000f3"
)

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 mcpsession integration tests")
	}
	ctx := context.Background()
	schema := fmt.Sprintf("mcpsession_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 and sessions 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
}

// seedGrant walks a real OAuth authorization-code + PKCE redemption
// (mirroring internal/oauth's own issueGrant test helper) so mcp_sessions'
// grant_id foreign key has a genuine oauth_grants row to reference — a
// session is never recorded against a grant that doesn't exist.
func seedGrant(t *testing.T, pool *pgxpool.Pool, userID string) (clientID, grantID string) {
	t.Helper()
	ctx := context.Background()
	oauthSvc := oauth.NewService(pool, "https://cairn.test", oauth.Options{})
	client, err := oauthSvc.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 := oauthSvc.CreateAuthCode(ctx, client.ID, userID, "https://client.example/cb", oauth.AllScopes(), oauth.S256Challenge(verifier))
	if err != nil {
		t.Fatalf("create code: %v", err)
	}
	set, err := oauthSvc.RedeemCode(ctx, code, client.ID, "https://client.example/cb", verifier)
	if err != nil {
		t.Fatalf("redeem code: %v", err)
	}
	return client.ID, set.GrantID
}

// TestIntegrationRecordTouchList proves the headline round trip (issue #76:
// "a session row created/incremented by initialize + tool calls"): Record
// opens a session tied to its grant, Touch increments both the always-on
// tool-call count and the create/annotate-specific counters, and List
// surfaces the accumulated state, most recently active first.
func TestIntegrationRecordTouchList(t *testing.T) {
	pool := newTestPool(t)
	ctx := context.Background()
	clientID, grantID := seedGrant(t, pool, sam)
	svc := NewService(pool)

	sess, err := svc.Record(ctx, RecordInput{
		ID: "sess-1", UserID: sam, GrantID: grantID, ClientID: clientID,
		ClientName: "claude-code", ClientVersion: "1.2.3",
	})
	if err != nil {
		t.Fatalf("record: %v", err)
	}
	if sess.ClientName != "claude-code" || sess.ClientVersion != "1.2.3" {
		t.Fatalf("session client identity = %+v, want claude-code/1.2.3", sess)
	}

	if err := svc.Touch(ctx, "sess-1", ActivityToolCall); err != nil {
		t.Fatalf("touch tool call: %v", err)
	}
	if err := svc.Touch(ctx, "sess-1", ActivityArtifactCreated); err != nil {
		t.Fatalf("touch artifact created: %v", err)
	}
	if err := svc.Touch(ctx, "sess-1", ActivityAnnotationPosted); err != nil {
		t.Fatalf("touch annotation posted: %v", err)
	}

	list, err := svc.List(ctx, sam, 0)
	if err != nil {
		t.Fatalf("list: %v", err)
	}
	if len(list) != 1 {
		t.Fatalf("list len = %d, want 1", len(list))
	}
	got := list[0]
	if got.ToolCalls != 3 {
		t.Fatalf("tool_calls = %d, want 3 (every Touch counts a call)", got.ToolCalls)
	}
	if got.ArtifactsCreated != 1 {
		t.Fatalf("artifacts_created = %d, want 1", got.ArtifactsCreated)
	}
	if got.AnnotationsPosted != 1 {
		t.Fatalf("annotations_posted = %d, want 1", got.AnnotationsPosted)
	}
	if got.Ended() {
		t.Fatal("a session on a live grant must not report Ended")
	}
	if !got.LastActivityAt.After(got.ConnectedAt) && !got.LastActivityAt.Equal(got.ConnectedAt) {
		t.Fatalf("last_activity_at (%v) should be at or after connected_at (%v)", got.LastActivityAt, got.ConnectedAt)
	}
}

// TestIntegrationOwnerIsolation proves one owner's session list never
// surfaces another owner's sessions, and Get is owner-scoped-uniform-404 —
// the same discipline pat.Service and oauth.Service's grant isolation use
// (SPEC-0007 acceptance: "owner isolation").
func TestIntegrationOwnerIsolation(t *testing.T) {
	pool := newTestPool(t)
	ctx := context.Background()
	_, grantA := seedGrant(t, pool, alice)
	clientB, grantB := seedGrant(t, pool, bob)
	svc := NewService(pool)

	if _, err := svc.Record(ctx, RecordInput{ID: "sess-a", UserID: alice, GrantID: grantA, ClientID: "client-a"}); err != nil {
		t.Fatalf("record alice: %v", err)
	}
	if _, err := svc.Record(ctx, RecordInput{ID: "sess-b", UserID: bob, GrantID: grantB, ClientID: clientB}); err != nil {
		t.Fatalf("record bob: %v", err)
	}

	aliceList, err := svc.List(ctx, alice, 0)
	if err != nil {
		t.Fatalf("list alice: %v", err)
	}
	if len(aliceList) != 1 || aliceList[0].ID != "sess-a" {
		t.Fatalf("alice's list = %+v, want exactly her own session", aliceList)
	}

	// Bob cannot Get Alice's session by id.
	if _, err := svc.Get(ctx, bob, "sess-a"); !errors.Is(err, errs.ErrNotFound) {
		t.Fatalf("cross-owner get = %v, want errs.ErrNotFound", err)
	}
	// Bob's own session is still reachable.
	if _, err := svc.Get(ctx, bob, "sess-b"); err != nil {
		t.Fatalf("bob's own get: %v", err)
	}
}

// TestIntegrationEndedReflectsRevokedGrant proves a session's Ended() flips
// true once its tied OAuth grant is revoked — "tie to the OAuth grant so
// revoking the grant ends the session" (issue #76) — with no separate
// revoked flag of this package's own to fall out of sync.
func TestIntegrationEndedReflectsRevokedGrant(t *testing.T) {
	pool := newTestPool(t)
	ctx := context.Background()
	clientID, grantID := seedGrant(t, pool, sam)
	svc := NewService(pool)
	oauthSvc := oauth.NewService(pool, "https://cairn.test", oauth.Options{})

	if _, err := svc.Record(ctx, RecordInput{ID: "sess-1", UserID: sam, GrantID: grantID, ClientID: clientID}); err != nil {
		t.Fatalf("record: %v", err)
	}
	before, err := svc.Get(ctx, sam, "sess-1")
	if err != nil {
		t.Fatalf("get before revoke: %v", err)
	}
	if before.Ended() {
		t.Fatal("session should not be ended before the grant is revoked")
	}

	if err := oauthSvc.RevokeGrant(ctx, grantID); err != nil {
		t.Fatalf("revoke grant: %v", err)
	}

	after, err := svc.Get(ctx, sam, "sess-1")
	if err != nil {
		t.Fatalf("get after revoke: %v", err)
	}
	if !after.Ended() {
		t.Fatal("session should report Ended once its grant is revoked")
	}
}

// TestIntegrationTouchUnknownSessionIsNoop proves Touch never fails a tool
// call over an unrecorded session id (e.g. a session this process never saw
// `initialize` for, after a restart) — activity counters are a best-effort
// presentation aid, not a correctness-critical ledger.
func TestIntegrationTouchUnknownSessionIsNoop(t *testing.T) {
	pool := newTestPool(t)
	ctx := context.Background()
	svc := NewService(pool)

	if err := svc.Touch(ctx, "sess-does-not-exist", ActivityToolCall); err != nil {
		t.Fatalf("touch unknown session should be a silent no-op, got: %v", err)
	}
}

// TestIntegrationGetUnknownIsNotFound proves Get on a never-recorded id is
// the same uniform not-found as a cross-owner lookup.
func TestIntegrationGetUnknownIsNotFound(t *testing.T) {
	pool := newTestPool(t)
	ctx := context.Background()
	svc := NewService(pool)

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