diff --git a/modules/bouncer/auth_time_test.go b/modules/bouncer/auth_time_test.go new file mode 100644 index 00000000..73b8d107 --- /dev/null +++ b/modules/bouncer/auth_time_test.go @@ -0,0 +1,182 @@ +package bouncer + +import ( + "context" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// TestAuthTime pins the auth_time claim of plan 07 (P-29, C-CR-02's +// framework half): every sign-in mint stamps it equal to iat for both +// audiences, every refresh path copies it unchanged across rotations, a +// token without it refreshes without it, and VerifyAuthTime keeps the +// checks of VerifyClaims and VerifyClaimsAudience. +func TestAuthTime(t *testing.T) { + const iss = "https://app.test/_user/api/v1/login" + const refreshIss = "https://app.test/_user/api/v1/refresh" + + if ClaimAuthTime != "auth_time" { + t.Fatalf("ClaimAuthTime = %q, want auth_time", ClaimAuthTime) + } + + t.Run("mint stamps auth_time equal to iat for both audiences", func(t *testing.T) { + for _, aud := range []string{AudienceUser, AudienceBackend} { + tok, _, err := MintAudience(secret, "42", iss, time.Hour, aud) + if err != nil { + t.Fatal(err) + } + claims := decodeClaims(t, tok, secret) + at := claimUnix(t, claims, ClaimAuthTime) + iat := claimUnix(t, claims, "iat") + if at.Unix() != iat.Unix() { + t.Fatalf("%s: auth_time %d != iat %d", aud, at.Unix(), iat.Unix()) + } + _, got, ok, err := VerifyAuthTime(tok, secret, aud) + if err != nil || !ok || got.Unix() != iat.Unix() { + t.Fatalf("%s: VerifyAuthTime = %v, %v, %v", aud, got, ok, err) + } + } + tok, _, err := Mint(secret, "42", iss, time.Hour) + if err != nil { + t.Fatal(err) + } + if _, _, ok, err := VerifyAuthTime(tok, secret, AudienceUser); err != nil || !ok { + t.Fatalf("Mint: ok=%v err=%v", ok, err) + } + }) + + signedAt := time.Now().Add(-90 * time.Minute).Truncate(time.Second) + users := memUsers{byID: map[uint]*Principal{42: {ID: 42, Backend: true}}} + refreshers := []struct { + name string + audience string + refresh func(tok string) (string, error) + }{ + {"Refresh", AudienceUser, func(tok string) (string, error) { + return Refresh(secret, tok, 4*time.Hour, NewMemoryBlacklist(), 0, refreshIss) + }}, + {"RefreshAudience", AudienceBackend, func(tok string) (string, error) { + return RefreshAudience(secret, tok, AudienceBackend, 4*time.Hour, NewMemoryBlacklist(), 0, refreshIss) + }}, + {"RefreshAudienceFor", AudienceBackend, func(tok string) (string, error) { + return RefreshAudienceFor(context.Background(), users, secret, tok, AudienceBackend, 4*time.Hour, NewMemoryBlacklist(), 0, refreshIss) + }}, + } + for _, r := range refreshers { + t.Run(r.name+" carries auth_time unchanged across two rotations", func(t *testing.T) { + tok, _, err := mintAudience(secret, "42", iss, 30*time.Minute, r.audience, signedAt, signedAt) + if err != nil { + t.Fatal(err) + } + prevIat := signedAt + for i := 1; i <= 2; i++ { + next, err := r.refresh(tok) + if err != nil { + t.Fatalf("rotation %d: %v", i, err) + } + _, at, ok, err := VerifyAuthTime(next, secret, r.audience) + if err != nil || !ok { + t.Fatalf("rotation %d: ok=%v err=%v", i, ok, err) + } + if at.Unix() != signedAt.Unix() { + t.Fatalf("rotation %d: auth_time %d, want %d", i, at.Unix(), signedAt.Unix()) + } + iat := claimUnix(t, decodeClaims(t, next, secret), "iat") + if !iat.After(prevIat) && i == 1 { + t.Fatalf("rotation %d: iat %s did not move past %s", i, iat, prevIat) + } + prevIat = iat + tok = next + } + }) + } + + t.Run("a legacy token without auth_time refreshes without one", func(t *testing.T) { + legacy := sign(t, jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": "42", "aud": AudienceBackend, "jti": "legacy-backend", + "iat": time.Now().Add(-10 * time.Minute).Unix(), + "exp": time.Now().Add(time.Minute).Unix(), + }, []byte(secret)) + for name, refresh := range map[string]func(string) (string, error){ + "RefreshAudience": func(tok string) (string, error) { + return RefreshAudience(secret, tok, AudienceBackend, time.Hour, NewMemoryBlacklist(), 0, refreshIss) + }, + "RefreshAudienceFor": func(tok string) (string, error) { + return RefreshAudienceFor(context.Background(), users, secret, tok, AudienceBackend, time.Hour, NewMemoryBlacklist(), 0, refreshIss) + }, + } { + next, err := refresh(legacy) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if _, has := decodeClaims(t, next, secret)[ClaimAuthTime]; has { + t.Fatalf("%s stamped auth_time on a legacy token", name) + } + if _, _, ok, err := VerifyAuthTime(next, secret, AudienceBackend); err != nil || ok { + t.Fatalf("%s: VerifyAuthTime ok=%v err=%v, want ok=false and no error", name, ok, err) + } + } + user := sign(t, jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": "42", "jti": "legacy-user", + "iat": time.Now().Add(-10 * time.Minute).Unix(), + "exp": time.Now().Add(time.Minute).Unix(), + }, []byte(secret)) + next, err := Refresh(secret, user, time.Hour, NewMemoryBlacklist(), 0, refreshIss) + if err != nil { + t.Fatal(err) + } + if _, has := decodeClaims(t, next, secret)[ClaimAuthTime]; has { + t.Fatal("Refresh stamped auth_time on a legacy token") + } + }) + + t.Run("VerifyAuthTime refuses what VerifyClaims refuses", func(t *testing.T) { + good, _, err := MintAudience(secret, "42", iss, time.Hour, AudienceUser) + if err != nil { + t.Fatal(err) + } + if _, _, _, err := VerifyAuthTime(good, "another-secret", AudienceUser); err == nil { + t.Fatal("bad signature accepted") + } + if _, _, _, err := VerifyAuthTime(good, secret, AudienceBackend); err == nil { + t.Fatal("wrong audience accepted") + } + backend, _, err := MintAudience(secret, "42", iss, time.Hour, AudienceBackend) + if err != nil { + t.Fatal(err) + } + if _, _, _, err := VerifyAuthTime(backend, secret, AudienceUser); err == nil { + t.Fatal("a backend token accepted for the frontend audience") + } + past := time.Now().Add(-time.Hour) + expired, _, err := mintAudience(secret, "42", iss, time.Minute, AudienceUser, past, past) + if err != nil { + t.Fatal(err) + } + if _, _, _, err := VerifyAuthTime(expired, secret, AudienceUser); err == nil { + t.Fatal("expired token accepted") + } + if _, _, _, err := VerifyAuthTime(good, secret, " "); err == nil { + t.Fatal("empty audience accepted") + } + }) + + t.Run("a frontend token without aud is accepted as VerifyClaims does", func(t *testing.T) { + at := time.Now().Add(-5 * time.Minute).Unix() + noAud := sign(t, jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": "42", "jti": "no-aud", + "iat": time.Now().Unix(), + "exp": time.Now().Add(time.Hour).Unix(), + ClaimAuthTime: at, + }, []byte(secret)) + if _, _, _, _, err := VerifyClaims(noAud, secret); err != nil { + t.Fatalf("VerifyClaims refused the no-aud token: %v", err) + } + sub, got, ok, err := VerifyAuthTime(noAud, secret, AudienceUser) + if err != nil || !ok || sub != "42" || got.Unix() != at { + t.Fatalf("VerifyAuthTime(no aud) = %q, %v, %v, %v", sub, got, ok, err) + } + }) +} diff --git a/modules/pier/export_test.go b/modules/pier/export_test.go index 98f51e15..43ff2f14 100644 --- a/modules/pier/export_test.go +++ b/modules/pier/export_test.go @@ -67,3 +67,21 @@ func (p *Pier) StopReaper() { p.stopReap() } } + +// SessionDone answers a channel closed once the local session's SDK server +// session has ended (ss.Wait returned), and false when the session is not +// connected on this instance. +func (p *Pier) SessionDone(sid string) (<-chan struct{}, bool) { + p.sessions.mu.Lock() + ls := p.sessions.local[sid] + p.sessions.mu.Unlock() + if ls == nil || ls.ss == nil { + return nil, false + } + done := make(chan struct{}) + go func() { + _ = ls.ss.Wait() + close(done) + }() + return done, true +} diff --git a/modules/pier/reaper_test.go b/modules/pier/reaper_test.go new file mode 100644 index 00000000..597babcb --- /dev/null +++ b/modules/pier/reaper_test.go @@ -0,0 +1,249 @@ +package pier_test + +import ( + "bytes" + "context" + "io" + "log/slog" + "net/http" + "strings" + "sync" + "testing" + "time" + + "git.golem15.com/golem15/summercms/modules/pact" +) + +// logBuffer is a goroutine-safe slog destination for audit-line checks. +type logBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *logBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *logBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +// captureLogs routes the process logger (and so diary's pier component) +// into a buffer for the rest of the test. +func captureLogs(t *testing.T) *logBuffer { + t.Helper() + buf := &logBuffer{} + previous := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + t.Cleanup(func() { slog.SetDefault(previous) }) + return buf +} + +// TestSessionReaper pins the session reaper of plan 06 (A-CR-01, A-IN-01, +// A-IN-06): a stateful session whose store record expired is closed on the +// instance holding it (its SDK session ends, the id answers 404, the audit +// line says expired); on a shared Redis store an instance keeps a copy only +// while the record lives; a store error never closes a copy; a GET stream +// whose record vanished ends through the re-check; and a tool call served +// without a session logs the session as ephemeral. +func TestSessionReaper(t *testing.T) { + plugin := acmePlugin{tools: []pact.MCPTool{echoTool()}} + + t.Run("memory store reaps an idle session past the TTL", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + in := startInstance(t, newApp(t, appSpec{ + mcp: "enabled: false\nsessions:\n store: memory\n idle_ttl: 30m\n", + guards: []pact.MCPGuard{&acmeGuard{}}, + }), plugin) + p := in.pier() + t.Cleanup(p.StopReaper) + logs := captureLogs(t) + + sid := openSession(t, ctx, in, "oas_at_test") + done, ok := p.SessionDone(sid) + if !ok { + t.Fatal("the session is not connected on the instance that opened it") + } + if n := p.ReapNow(ctx); n != 0 { + t.Fatalf("a pass within the TTL reaped %d sessions", n) + } + if !p.AdvanceStoreClock(31 * time.Minute) { + t.Fatal("the instance has no memory store") + } + if n := p.ReapNow(ctx); n != 1 { + t.Fatalf("reaped = %d, want 1", n) + } + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("ss.Wait did not return after the reap") + } + if n := p.LocalSessions(); n != 0 { + t.Fatalf("local sessions after the reap = %d, want 0", n) + } + if gone := rpc(t, ctx, in.url(), "oas_at_test", sid, callBody); gone.status != http.StatusNotFound { + t.Fatalf("call on the reaped session = %d %s", gone.status, gone.body) + } + logged := logs.String() + if !strings.Contains(logged, "step=expired") || !strings.Contains(logged, "session="+sid[:8]) { + t.Fatalf("no expired audit line for %s: %s", sid[:8], logged) + } + if strings.Contains(logged, "oas_at_test") { + t.Fatalf("the audit lines carry the token: %s", logged) + } + }) + + t.Run("redis instance keeps its copy only while the record lives", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + a, b, mr := twoInstances(t, appSpec{ + mcp: "enabled: false\nsessions:\n store: redis\n idle_ttl: 30m\n", + guards: []pact.MCPGuard{&acmeGuard{}}, + }, plugin) + t.Cleanup(a.pier().StopReaper) + t.Cleanup(b.pier().StopReaper) + + sid := openSession(t, ctx, a, "oas_at_test") + // B serves one request and holds a rehydrated copy; A keeps serving. + if call := rpc(t, ctx, b.url(), "oas_at_test", sid, callBody); call.status != http.StatusOK { + t.Fatalf("call on B = %d %s", call.status, call.body) + } + if n := b.pier().LocalSessions(); n != 1 { + t.Fatalf("B local sessions = %d, want 1", n) + } + mr.FastForward(20 * time.Minute) + if call := rpc(t, ctx, a.url(), "oas_at_test", sid, callBody); call.status != http.StatusOK { + t.Fatalf("call on A = %d %s", call.status, call.body) + } + mr.FastForward(20 * time.Minute) + if n := b.pier().ReapNow(ctx); n != 0 { + t.Fatalf("B reaped a session A kept alive (%d closed)", n) + } + if n := b.pier().LocalSessions(); n != 1 { + t.Fatalf("B local sessions while the record lives = %d, want 1", n) + } + mr.FastForward(31 * time.Minute) + if n := b.pier().ReapNow(ctx); n != 1 { + t.Fatalf("B reaped %d sessions after the record expired, want 1", n) + } + if n := a.pier().ReapNow(ctx); n != 1 { + t.Fatalf("A reaped %d sessions after the record expired, want 1", n) + } + if a.pier().LocalSessions() != 0 || b.pier().LocalSessions() != 0 { + t.Fatalf("copies left: A %d, B %d", a.pier().LocalSessions(), b.pier().LocalSessions()) + } + }) + + t.Run("a store error keeps the copy", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + a, _, mr := twoInstances(t, appSpec{ + mcp: "enabled: false\nsessions:\n store: redis\n idle_ttl: 30m\n", + guards: []pact.MCPGuard{&acmeGuard{}}, + }, plugin) + t.Cleanup(a.pier().StopReaper) + logs := captureLogs(t) + openSession(t, ctx, a, "oas_at_test") + openSession(t, ctx, a, "oas_at_test") + mr.SetError("ERR the store is down") + n := a.pier().ReapNow(ctx) + mr.SetError("") + if n != 0 { + t.Fatalf("a store error closed %d sessions", n) + } + if got := a.pier().LocalSessions(); got != 2 { + t.Fatalf("local sessions after a failing pass = %d, want 2", got) + } + if c := strings.Count(logs.String(), "session reaper could not read the store"); c != 1 { + t.Fatalf("store error logged %d times in one pass, want once: %s", c, logs.String()) + } + }) + + t.Run("a GET stream whose record vanished ends through the re-check", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + guard := &acmeGuard{} + in := startInstance(t, newApp(t, appSpec{ + mcp: "enabled: false\nsessions:\n store: memory\n idle_ttl: 30m\n", + guards: []pact.MCPGuard{guard}, + }), plugin) + p := in.pier() + t.Cleanup(p.StopReaper) + p.SetRecheck(50 * time.Millisecond) + logs := captureLogs(t) + + sid := openSession(t, ctx, in, "oas_at_test") + req, err := http.NewRequestWithContext(ctx, http.MethodGet, in.url(), nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer oas_at_test") + req.Header.Set("Accept", "text/event-stream") + req.Header.Set("Mcp-Session-Id", sid) + req.Header.Set("Mcp-Protocol-Version", "2025-11-25") + res, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + t.Fatalf("GET stream = %d", res.StatusCode) + } + ended := make(chan []byte, 1) + go func() { + raw, _ := io.ReadAll(res.Body) + ended <- raw + }() + select { + case <-ended: + t.Fatal("the stream ended while its record lived") + case <-time.After(300 * time.Millisecond): + } + // The record expires; no event is published, so only the re-check + // can notice. + if !p.AdvanceStoreClock(31 * time.Minute) { + t.Fatal("the instance has no memory store") + } + select { + case raw := <-ended: + if strings.Contains(string(raw), "ended") { + t.Fatalf("the stream delivered an event before closing: %s", raw) + } + case <-time.After(5 * time.Second): + t.Fatal("the stream outlived its record") + } + if n := p.LocalSessions(); n != 0 { + t.Fatalf("local sessions after the stream closed = %d, want 0", n) + } + if !strings.Contains(logs.String(), "step=stream_closed") { + t.Fatalf("no stream_closed audit line: %s", logs.String()) + } + }) + + t.Run("a call without a session logs ephemeral", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + in := startInstance(t, newApp(t, appSpec{ + mcp: "enabled: false\nsessions:\n store: memory\n", + guards: []pact.MCPGuard{&acmeGuard{}}, + }), plugin) + t.Cleanup(in.pier().StopReaper) + logs := captureLogs(t) + call := rpc(t, ctx, in.url(), "oas_at_test", "", callBody) + if call.status != http.StatusOK || !strings.Contains(call.body, `\"principal\":7`) { + t.Fatalf("call without a session = %d %s", call.status, call.body) + } + logged := logs.String() + if !strings.Contains(logged, "pier: tool call") || !strings.Contains(logged, "session=ephemeral") { + t.Fatalf("the tool call is not logged as ephemeral: %s", logged) + } + if n := in.pier().LocalSessions(); n != 0 { + t.Fatalf("an ephemeral call left %d local sessions", n) + } + }) +} diff --git a/scripts/check-phase19.7.sh b/scripts/check-phase19.7.sh index 0c919d98..97a1d08d 100755 --- a/scripts/check-phase19.7.sh +++ b/scripts/check-phase19.7.sh @@ -15,14 +15,17 @@ # the Docker-free framework tests by required name (GO_TESTS, # and GO_TAGGED_TESTS with -tags summercms_mcp). # plugins in the user plugin (GOWORK=off), the OAuth server plugin and -# the blog plugin: go vet, go test -short, the Docker-free tests +# the blog plugin: the workspace check, go vet, go test -short, +# the Docker-free tests # by required name and the additive check (no exported func, # method or type removed from a non-test .go file since the # plugin's last commit before the phase). -# consumers the applications at their bumped pins (D-31): go build ./... -# and go test -short ./... in each, the demo also with -tags -# summercms_mcp, its pin check and its deploy check (the nginx -# locations of the MCP endpoint). +# consumers the applications at their bumped pins (D-31): the workspace +# check, go build ./... and go test -short ./... in each, the +# demo also with -tags summercms_mcp, its pin check and its +# deploy check (the nginx locations of the MCP endpoint); the +# applications that use the user plugin are also built against +# USER_PLUGIN through a scratch copy of their go.work. # docker Docker host only: the Postgres suites by required name in the # framework (cabana), the three plugins and the demo (tagged). # admin npm --prefix admin run typecheck, the vitest run (the phase's @@ -48,11 +51,24 @@ # docker when a Docker daemon answers (NOT RUN docker otherwise, a failure # with PHASE197_REQUIRE_DOCKER=1). # +# The workspace check: before a repository's commands run, it must resolve +# the framework to this checkout (go list -m, through its go.work or its +# go.mod replace), and the OAuth server plugin must resolve the user plugin +# to USER_PLUGIN. A mismatch is a refuse: line naming both paths. +# +# The demo's pin check: a plugin submodule ahead of its pin by test-only +# commits is reported; one ahead by other commits is accepted only when its +# HEAD descends from the pin and is the HEAD of, or an ancestor of, the +# canonical checkout (USER_PLUGIN, OAUTHSERVER_PLUGIN or JOURNAL_PLUGIN), +# and is reported as unreleased commits; anything else fails. +# # Environment overrides (default: the sibling checkouts next to this # repository): PHASE197_ROOT (this repository), USER_PLUGIN, # OAUTHSERVER_PLUGIN, JOURNAL_PLUGIN, DEMO_APP, FONOTEKA, BM_APP, the # additive bases PHASE197_USER_BASE, PHASE197_OAS_BASE, -# PHASE197_JOURNAL_BASE, and PHASE197_REQUIRE_DOCKER. +# PHASE197_JOURNAL_BASE, PHASE197_REQUIRE_DOCKER, and PHASE197_RELEASED +# (1 after the plugin tags and the application bumps: the demo's pin check +# then refuses any drift, test-only or unreleased). set -euo pipefail unset FORCE_COLOR @@ -102,6 +118,9 @@ GO_TESTS=( ./modules/wristband:TestMCPScopes ./modules/cabana:TestAccountContributions ./modules/cabana:TestRecordActionPrompt ./modules/cabana:TestScopeLimitForMethods + # Gap closure, plans 06 and 07: the smokes and the named tests of plan 09. + ./modules/pier:TestPierReaperSmoke ./modules/bouncer:TestAuthTimeSmoke + ./modules/pier:TestSessionReaper ./modules/bouncer:TestAuthTime ) # go stage: framework tests that also run in a binary built with the MCP @@ -111,6 +130,7 @@ GO_TAGGED_TESTS=( ./modules/pier:TestGuardRules ./modules/pier:TestSessions ./modules/pier:TestFanOut ./modules/pier:TestPierTracerSmoke ./modules/pier:TestPierStatelessSmoke ./modules/pier:TestEndpointGuards ./modules/surf:TestPierMount ./modules/surf:TestStreamingRoute ./modules/surf:TestDispatchSmoke + ./modules/pier:TestSessionReaper ) # docker stage: Postgres tests in the framework. @@ -159,6 +179,8 @@ PHASE_TEST_FILES=( modules/surf/pier_mount_test.go modules/surf/pier_mount_smoke_test.go modules/surf/dispatch_smoke_test.go modules/wristband/mcp_scopes_test.go modules/wristband/mcp_scopes_smoke_test.go admin/tests/account/AccountContributions.test.ts admin/tests/form/RecordActionsPrompt.test.ts + modules/pier/reaper_test.go modules/pier/reaper_smoke_test.go + modules/bouncer/auth_time_test.go modules/bouncer/auth_time_smoke_test.go ) STACK_NAMES=("github.com/modelcontextprotocol/go-sdk" "github.com/google/jsonschema-go") @@ -357,6 +379,112 @@ require_dir() { } } +# workspace_resolve DIR MODULE prints the directory MODULE resolves to in +# DIR (its replacement's directory, else its own); GO_ENV is prepended to +# the go command's environment, for GOWORK=off. +workspace_resolve() { + # shellcheck disable=SC2086 + (cd "$1" && env ${GO_ENV:-} go list -m -f '{{if .Replace}}{{.Replace.Dir}}{{else}}{{.Dir}}{{end}}' "$2" 2>/dev/null) || true +} + +# workspace_verdict LABEL MODULE GOT WANT compares a resolved directory with +# the expected one (both through realpath) and refuses a mismatch, naming +# both paths. +workspace_verdict() { + local label="$1" module="$2" got="$3" want="$4" rgot rwant + rgot="$(realpath -e -- "$got" 2>/dev/null || true)" + rwant="$(realpath -e -- "$want" 2>/dev/null || true)" + if [[ -z "$got" || -z "$rgot" || -z "$rwant" || "$rgot" != "$rwant" ]]; then + echo "refuse: $label resolves $module to ${got:-}, not $want" >&2 + return 1 + fi + echo "workspace: $label resolves $module to $rwant" +} + +# workspace_check DIR [EXPECT_USER_PLUGIN] refuses a repository that does +# not resolve the framework to ROOT and, with a second argument, the user +# plugin to it (P-40: no trust in local go.work files). +workspace_check() { + local dir="$1" user="${2:-}" + workspace_verdict "$dir" git.golem15.com/golem15/summercms "$(workspace_resolve "$dir" git.golem15.com/golem15/summercms)" "$ROOT" + if [[ -n "$user" ]]; then + workspace_verdict "$dir" git.golem15.com/golem15/sm-user-plugin "$(workspace_resolve "$dir" git.golem15.com/golem15/sm-user-plugin)" "$user" + fi +} + +# gowork_with_user_plugin APP USER FRAMEWORK prints APP's go.work rewritten +# for a scratch workspace: every use and replace path absolute, APP's own +# plugins/golem15/user swapped for USER, and the framework replaced by +# FRAMEWORK (any framework replace of the original is dropped). +gowork_with_user_plugin() { + python3 - "$1" "$2" "$3" <<'PY' +import os, re, sys +app, user, root = (os.path.realpath(a) for a in sys.argv[1:4]) +own_user = os.path.realpath(os.path.join(app, "plugins", "golem15", "user")) +framework = "git.golem15.com/golem15/summercms" + +def absolute(path): + full = path if path.startswith("/") else os.path.normpath(os.path.join(app, path)) + return user if os.path.realpath(full) == own_user else full + +def replace(spec): + left, right = (part.strip() for part in spec.split("=>", 1)) + if left.split()[0] == framework: + return None + target = right.split() + if len(target) == 1 and target[0].startswith((".", "/")): + right = absolute(target[0]) + return f"{left} => {right}" + +out, block = [], None +for line in open(os.path.join(app, "go.work"), encoding="utf-8").read().splitlines(): + code = line.split("//", 1)[0].strip() + if block is not None: + if code == ")": + block = None + out.append(")") + elif code and block == "use": + out.append("\t" + absolute(code)) + elif code: + rewritten = replace(code) + if rewritten is not None: + out.append("\t" + rewritten) + continue + if code in ("use (", "replace ("): + block = code.split()[0] + out.append(code) + continue + m = re.fullmatch(r"use\s+(\S+)", code) + if m: + out.append("use " + absolute(m.group(1))) + continue + m = re.fullmatch(r"replace\s+(.+)", code) + if m: + rewritten = replace(m.group(1)) + if rewritten is not None: + out.append("replace " + rewritten) + continue + out.append(line) +out += ["", f"replace {framework} => {root}"] +print("\n".join(out)) +PY +} + +# consumer_with_user_plugin APP builds APP against USER_PLUGIN through a +# scratch copy of its go.work (APP's own go.work is never edited), after +# checking that the scratch workspace resolves the user plugin there. +consumer_with_user_plugin() { + local app="$1" + ( + work="$(mktemp -d)" + trap 'rm -rf "$work"' EXIT + gowork_with_user_plugin "$app" "$USER_PLUGIN" "$ROOT" >"$work/go.work" + GO_ENV="GOWORK=$work/go.work" workspace_check "$app" "$USER_PLUGIN" >/dev/null + (cd "$app" && GOWORK="$work/go.work" go build ./...) + ) + echo "build: $app against $USER_PLUGIN (scratch go.work)" +} + expect_detector() { local detector="$1" name="$2" want="$3" payload="$4" log dc=0 log="$(mktemp)" @@ -367,6 +495,7 @@ expect_detector() { echo "refuse: self-test $detector $name: exit $dc, want $want" >&2 return 1 fi + echo "PASS self-test $detector $name" } # expect_mode NAME WANT_EXIT WANT_TEXT MODE ENV... runs this script in MODE @@ -381,6 +510,7 @@ expect_mode() { echo "refuse: self-test $name: exit $rc (want $want), output must carry \"$text\"" >&2 return 1 fi + echo "PASS self-test $name" } # marker_callers DIR prints every non-test .go file under DIR/modules, @@ -457,6 +587,7 @@ run_self_test() { echo "refuse: self-test marker_callers missed a caller outside pier" >&2 return 1 fi + echo "PASS self-test marker_callers" # The docker NOT RUN rule: a docker that does not answer. mkdir -p "$tmp/bin" @@ -485,6 +616,7 @@ run_self_test() { return 1 fi done + echo "PASS self-test removed_exports" # additive_check end to end on a planted repository: an added export # passes, a removed one fails, an unknown base fails. @@ -513,6 +645,101 @@ run_self_test() { return 1 fi rm -rf "$repo" + echo "PASS self-test additive_check" + + # The workspace check on planted go list answers: this checkout (also + # through a symlink) passes, another checkout or no answer is refused + # with both paths named. + local ws out + ws="$(mktemp -d)" + mkdir -p "$ws/framework" "$ws/other" + ln -s "$ws/framework" "$ws/link" + if ! workspace_verdict planted git.golem15.com/golem15/summercms "$ws/link" "$ws/framework" >/dev/null 2>&1; then + rm -rf "$ws" + echo "refuse: self-test workspace_verdict refused a matching answer" >&2 + return 1 + fi + out="$(workspace_verdict planted git.golem15.com/golem15/summercms "$ws/other" "$ws/framework" 2>&1)" && { + rm -rf "$ws" + echo "refuse: self-test workspace_verdict accepted another checkout" >&2 + return 1 + } + if ! grep -qF "refuse: planted resolves git.golem15.com/golem15/summercms to $ws/other, not $ws/framework" <<<"$out"; then + rm -rf "$ws" + echo "refuse: self-test workspace_verdict did not name both paths: $out" >&2 + return 1 + fi + if workspace_verdict planted git.golem15.com/golem15/summercms "" "$ws/framework" >/dev/null 2>&1; then + rm -rf "$ws" + echo "refuse: self-test workspace_verdict accepted an empty answer" >&2 + return 1 + fi + echo "PASS self-test workspace_verdict (match, symlinked match, mismatch naming both paths, empty answer)" + + # The scratch go.work of consumer_with_user_plugin on a planted + # application: absolute use and replace paths, the application's user + # checkout swapped for the given one, one framework replace. + local gw + mkdir -p "$ws/app/plugins/golem15/user" "$ws/user" + printf '%s\n' 'go 1.27.0' '' 'use (' ' .' ' ./plugins/golem15/user' ' ./plugins/golem15/feedback // a comment' ')' '' \ + 'use ./tools' '' 'replace git.golem15.com/golem15/summercms => ../summercms.go' '' \ + 'replace (' ' git.golem15.com/golem15/sm-golem-plugin => ./plugins/golem15/golem' ' git.golem15.com/golem15/sm-user-plugin => ./plugins/golem15/user' ' example.com/x v1.0.0 => example.com/y v1.1.0' ')' >"$ws/app/go.work" + gw="$(gowork_with_user_plugin "$ws/app" "$ws/user" "$ws/framework")" + local want_line + for want_line in " $ws/app" " $ws/user" " $ws/app/plugins/golem15/feedback" "use $ws/app/tools" \ + " git.golem15.com/golem15/sm-golem-plugin => $ws/app/plugins/golem15/golem" \ + " git.golem15.com/golem15/sm-user-plugin => $ws/user" \ + " example.com/x v1.0.0 => example.com/y v1.1.0" \ + "replace git.golem15.com/golem15/summercms => $ws/framework"; do + if ! grep -qxF -- "$want_line" <<<"$gw"; then + rm -rf "$ws" + echo "refuse: self-test gowork_with_user_plugin lacks the line \"$want_line\": $gw" >&2 + return 1 + fi + done + if [[ "$(grep -c 'golem15/summercms =>' <<<"$gw")" != 1 ]] || grep -qE '(^|[[:space:]])\./|\.\./' <<<"$gw"; then + rm -rf "$ws" + echo "refuse: self-test gowork_with_user_plugin left a relative path or a second framework replace: $gw" >&2 + return 1 + fi + rm -rf "$ws" + echo "PASS self-test gowork_with_user_plugin" + + # The demo pin policy on a planted demo whose check-pins.sh prints one + # mismatch: test-only drift is reported, a non-test commit the canonical + # checkout holds is accepted as unreleased, one it does not hold is + # refused, and PHASE197_RELEASED=1 refuses any drift. + local demo sub pin canon + demo="$(mktemp -d)" + sub="$demo/plugins/golem15/user" + mkdir -p "$demo/scripts" "$sub" + git -C "$sub" init -q + printf 'package user\n' >"$sub/user.go" + git -C "$sub" add user.go + git -C "$sub" -c user.name=t -c user.email=t@example.test commit -q -m pin + pin="$(git -C "$sub" rev-parse HEAD)" + printf 'package user\n' >"$sub/user_test.go" + git -C "$sub" add user_test.go + git -C "$sub" -c user.name=t -c user.email=t@example.test commit -q -m test-only + planted_pins() { + printf '#!/usr/bin/env bash\necho "check-pins: plugins/golem15/user is at %s, go.mod requires git.golem15.com/golem15/sm-user-plugin v0.1.10 (%s)" >&2\nexit 1\n' \ + "$(git -C "$sub" rev-parse --short=12 HEAD)" "${pin:0:12}" >"$demo/scripts/check-pins.sh" + } + planted_pins + expect_mode pins-test-only 0 "by test-only commits" --demo-pins "DEMO_APP=$demo" "USER_PLUGIN=$sub" PHASE197_RELEASED=0 + expect_mode pins-released-test-only 1 "refuse: PHASE197_RELEASED=1" --demo-pins "DEMO_APP=$demo" "USER_PLUGIN=$sub" PHASE197_RELEASED=1 + printf 'package user\n\nfunc Added() {}\n' >"$sub/user.go" + git -C "$sub" -c user.name=t -c user.email=t@example.test commit -q -am feature + planted_pins + canon="$demo/canonical" + git clone -q "$sub" "$canon" + expect_mode pins-unreleased-accepted 0 "is ahead of its pin by unreleased commits" --demo-pins "DEMO_APP=$demo" "USER_PLUGIN=$canon" PHASE197_RELEASED=0 + git -C "$canon" checkout -q "$pin" + expect_mode pins-unreleased-refused 1 "does not hold" --demo-pins "DEMO_APP=$demo" "USER_PLUGIN=$canon" PHASE197_RELEASED=0 + git -C "$canon" checkout -q - + expect_mode pins-released-unreleased 1 "refuse: PHASE197_RELEASED=1" --demo-pins "DEMO_APP=$demo" "USER_PLUGIN=$canon" PHASE197_RELEASED=1 + unset -f planted_pins + rm -rf "$demo" local mode for mode in self-test go plugins consumers docker admin docs marker all; do @@ -604,6 +831,9 @@ run_plugins() { require_dir "$JOURNAL_PLUGIN" "blog plugin" # The user plugin is checked against its own go.mod (GOWORK=off), the # way a consuming application resolves it. + GO_ENV="GOWORK=off" workspace_check "$USER_PLUGIN" + workspace_check "$OAUTHSERVER_PLUGIN" "$USER_PLUGIN" + workspace_check "$JOURNAL_PLUGIN" GO_ENV="GOWORK=off" go_vet "$USER_PLUGIN" ./... GO_ENV="GOWORK=off" go_short "$USER_PLUGIN" ./... additive_check user "$USER_PLUGIN" "$USER_BASE" @@ -624,10 +854,17 @@ run_consumers() { local app for app in "$FONOTEKA" "$BM_APP" "$DEMO_APP"; do require_dir "$app" "application" + workspace_check "$app" + done + for app in "$FONOTEKA" "$BM_APP" "$DEMO_APP"; do (cd "$app" && go build ./...) echo "build: $app" go_short "$app" ./... done + # The unreleased user plugin compiles with the applications that use it + # (P-40: additive for its consumers before any tag). + consumer_with_user_plugin "$FONOTEKA" + consumer_with_user_plugin "$BM_APP" (cd "$DEMO_APP" && go build -tags "$MCP_TAG" ./...) echo "build: $DEMO_APP (-tags $MCP_TAG)" GO_FLAGS="-tags $MCP_TAG" go_short "$DEMO_APP" ./... @@ -637,13 +874,26 @@ run_consumers() { echo "phase19.7 consumers passed" } +# canonical_checkout NAME maps a demo plugin submodule to the checkout its +# commits are developed in. +canonical_checkout() { + case "$1" in + plugins/golem15/user) echo "$USER_PLUGIN" ;; + plugins/golem15/oauthserver) echo "$OAUTHSERVER_PLUGIN" ;; + plugins/golem15/journal) echo "$JOURNAL_PLUGIN" ;; + *) echo "" ;; + esac +} + # demo_pins runs the demo's pin check (every plugin submodule on the commit -# go.mod requires). A submodule ahead of its pin only by commits that touch -# *_test.go files (this phase's plugin tests, committed but not released) -# ships the same code and is reported, not refused; any other mismatch -# fails. +# go.mod requires). With PHASE197_RELEASED=1 any mismatch fails. Otherwise +# a submodule that descends from its pin is accepted when the commits past +# the pin touch only *_test.go files (the same shipped code, reported as +# test-only), or when its HEAD is the HEAD of, or an ancestor of, the +# plugin's canonical checkout (reported as unreleased commits, released +# after verification); any other mismatch fails. demo_pins() { - local out rc=0 line name want head changed + local out rc=0 line name want head full changed canonical [[ -f "$DEMO_APP/scripts/check-pins.sh" ]] || { echo "refuse: $DEMO_APP/scripts/check-pins.sh not found" >&2 return 1 @@ -653,6 +903,11 @@ demo_pins() { printf '%s\n' "$out" return 0 fi + if [[ "${PHASE197_RELEASED:-0}" == "1" ]]; then + printf '%s\n' "$out" >&2 + echo "refuse: PHASE197_RELEASED=1 and a demo plugin submodule is not on its pin (bump the pins after the release)" >&2 + return 1 + fi while IFS= read -r line; do [[ -z "$line" ]] && continue if [[ "$line" =~ ^check-pins:\ (plugins/[^ ]+)\ is\ at\ ([0-9a-f]+),\ go\.mod\ requires\ .*\(([0-9a-f]+)\)$ ]]; then @@ -664,13 +919,19 @@ demo_pins() { return 1 fi changed="$(git -C "$DEMO_APP/$name" diff --name-only "$want" HEAD | grep -vE '_test\.go$' || true)" - if [[ -n "$changed" ]]; then - printf '%s\n' "$changed" >&2 - echo "refuse: $name is ahead of its pin $want with non-test changes" >&2 - return 1 + if [[ -z "$changed" ]]; then + echo "pins: $name is ahead of its pin $want by test-only commits (release not needed for shipped code)" + continue fi - echo "pins: $name is ahead of its pin $want by test-only commits (release not needed for shipped code)" - continue + canonical="$(canonical_checkout "$name")" + full="$(git -C "$DEMO_APP/$name" rev-parse HEAD)" + if [[ -n "$canonical" ]] && git -C "$canonical" merge-base --is-ancestor "$full" HEAD 2>/dev/null; then + echo "pins: $name is ahead of its pin by unreleased commits (release after verification)" + continue + fi + printf '%s\n' "$changed" >&2 + echo "refuse: $name is ahead of its pin $want with non-test changes its canonical checkout ${canonical:-} does not hold" >&2 + return 1 fi printf '%s\n' "$out" >&2 echo "refuse: the demo's pin check failed" >&2 @@ -690,6 +951,9 @@ run_docker() { require_dir "$USER_PLUGIN" "user plugin" require_dir "$OAUTHSERVER_PLUGIN" "OAuth server plugin" require_dir "$DEMO_APP" "demo application" + GO_ENV="GOWORK=off" workspace_check "$USER_PLUGIN" + workspace_check "$OAUTHSERVER_PLUGIN" "$USER_PLUGIN" + workspace_check "$DEMO_APP" go_pkg_named "$ROOT" "-timeout 20m" "${DOCKER_TESTS[@]}" GO_ENV="GOWORK=off" go_named "$USER_PLUGIN" . "-timeout 20m" "${USER_TESTS[@]}" go_named "$OAUTHSERVER_PLUGIN" . "-timeout 20m" "${OAS_TESTS[@]}" @@ -829,6 +1093,7 @@ case "${1:---all}" in --consumers | consumers) run_consumers ;; --docker | docker) run_docker ;; --docker-gate) docker_gate ;; + --demo-pins) demo_pins ;; --admin | admin) run_admin ;; --docs | docs) run_docs ;; --marker | marker) run_marker ;;