package authn import ( "context" "errors" "net/http" "net/http/httptest" "reflect" "testing" ) func newTestResolver(t *testing.T, store TokenStore) *Resolver { t.Helper() rs, err := NewResolver("bigbes", store) if err != nil { t.Fatalf("NewResolver: %v", err) } return rs } func TestNewResolver_RejectsBadWiring(t *testing.T) { if _, err := NewResolver("bigbes", nil); err == nil { t.Fatal("nil store must be rejected") } if _, err := NewResolver("", newStubStore()); err == nil { t.Fatal("empty owner must be rejected") } if _, err := NewResolver("Not A Name", newStubStore()); err == nil { t.Fatal("unusable owner must be rejected") } // The canonical "~user" spelling is accepted and normalised. rs, err := NewResolver("~bigbes", newStubStore()) if err != nil { t.Fatalf("NewResolver(~bigbes): %v", err) } if rs.Owner() != "bigbes" { t.Fatalf("Owner() = %q, want %q", rs.Owner(), "bigbes") } } func TestResolve_OwnerCookie(t *testing.T) { store := newStubStore() rs := newTestResolver(t, store) p, err := rs.Resolve(context.Background(), request(sealCookie(t, "bigbes"), nil)) if err != nil { t.Fatalf("Resolve: %v", err) } if !p.IsOwner() { t.Fatalf("principal = %+v, want the owner", p) } if p.IsAnonymous() || p.IsAgent() { t.Fatalf("owner classified as anonymous/agent: %+v", p) } if p.Owner != "bigbes" { t.Fatalf("Owner = %q, want %q", p.Owner, "bigbes") } if store.calls != 0 { t.Fatalf("cookie path consulted the token store %d times, want 0", store.calls) } } func TestResolve_AbsentCookieIsAnonymousNotAnError(t *testing.T) { rs := newTestResolver(t, newStubStore()) p, err := rs.Resolve(context.Background(), request("", nil)) if err != nil { t.Fatalf("an anonymous request must never error: %v", err) } if !p.IsAnonymous() { t.Fatalf("principal = %+v, want anonymous", p) } } func TestResolve_BrokenCookiesAreAnonymousNotErrors(t *testing.T) { rs := newTestResolver(t, newStubStore()) for name, value := range map[string]string{ "tampered": tamper(t, sealCookie(t, "bigbes")), "garbage": "not-a-valid-fernet-token", "foreign key": sealCookieWithKey(t, &rotatedKey, "bigbes"), } { t.Run(name, func(t *testing.T) { p, err := rs.Resolve(context.Background(), request(value, nil)) if err != nil { t.Fatalf("a broken cookie must never error: %v", err) } if !p.IsAnonymous() { t.Fatalf("principal = %+v, want anonymous", p) } }) } } // Single-user: a real user who is not bigbes has nothing granted to them, so // they read exactly as an anonymous viewer does. The name survives for logs // only, and must not be mistaken for authority. func TestResolve_NonOwnerCookieIsAnonymous(t *testing.T) { rs := newTestResolver(t, newStubStore()) p, err := rs.Resolve(context.Background(), request(sealCookie(t, "someone"), nil)) if err != nil { t.Fatalf("Resolve: %v", err) } if !p.IsAnonymous() { t.Fatalf("principal = %+v, want anonymous", p) } if p.IsOwner() { t.Fatal("a non-owner cookie must not yield the owner") } if p.Owner != "" { t.Fatalf("Owner = %q, want empty for a non-owner", p.Owner) } if p.CookieUser != "someone" { t.Fatalf("CookieUser = %q, want %q", p.CookieUser, "someone") } } func TestResolve_AgentTokenAccepted(t *testing.T) { store := newStubStore() store.add("live-token", "laptop") rs := newTestResolver(t, store) p, err := rs.Resolve(context.Background(), request("", map[string]string{ "Authorization": "Bearer live-token", HeaderAgent: "claude-code/spec-writer", HeaderAgentSession: "8fb9c9a4-b078-4af1-89eb-d97c522f9921", })) if err != nil { t.Fatalf("Resolve: %v", err) } if !p.IsAgent() { t.Fatalf("principal = %+v, want an agent", p) } if p.IsOwner() || p.IsAnonymous() { t.Fatalf("agent classified as owner/anonymous: %+v", p) } if p.Agent != "claude-code/spec-writer" || p.Session != "8fb9c9a4-b078-4af1-89eb-d97c522f9921" { t.Fatalf("provenance not carried onto the principal: %+v", p) } if p.Owner != "bigbes" { t.Fatalf("agent acts for %q, want %q", p.Owner, "bigbes") } if p.TokenName != "laptop" { t.Fatalf("TokenName = %q, want %q", p.TokenName, "laptop") } } func TestResolve_AgentTokenRevokedAndUnknown(t *testing.T) { store := newStubStore() store.add("live-token", "laptop") store.revoke("dead-token", "cron") rs := newTestResolver(t, store) cases := map[string]struct { token string want error }{ "revoked": {"dead-token", ErrRevokedToken}, "unknown": {"never-issued", ErrUnknownToken}, } for name, c := range cases { t.Run(name, func(t *testing.T) { p, err := rs.Resolve(context.Background(), request("", map[string]string{"Authorization": "Bearer " + c.token})) if !errors.Is(err, c.want) { t.Fatalf("error = %v, want %v", err, c.want) } if !p.IsAnonymous() { t.Fatalf("a refused token must yield no authority: %+v", p) } }) } } // A bearer token wins over a cookie. Letting a stale browser cookie promote a // token-bearing request to the owner would hand an agent the approved branch. func TestResolve_BearerBeatsCookie(t *testing.T) { store := newStubStore() store.add("live-token", "laptop") rs := newTestResolver(t, store) p, err := rs.Resolve(context.Background(), request(sealCookie(t, "bigbes"), map[string]string{"Authorization": "Bearer live-token"})) if err != nil { t.Fatalf("Resolve: %v", err) } if !p.IsAgent() { t.Fatalf("principal = %+v, want an agent", p) } if p.IsOwner() { t.Fatal("an owner cookie must not promote a token-bearing request") } } // A bad token is a hard failure even when a valid owner cookie is present: an // agent silently downgraded to a reader fails confusingly at its first write // instead of clearly at the door. func TestResolve_BadTokenFailsEvenWithOwnerCookie(t *testing.T) { store := newStubStore() rs := newTestResolver(t, store) _, err := rs.Resolve(context.Background(), request(sealCookie(t, "bigbes"), map[string]string{"Authorization": "Bearer never-issued"})) if !errors.Is(err, ErrUnknownToken) { t.Fatalf("error = %v, want ErrUnknownToken", err) } } func runMiddleware(t *testing.T, rs *Resolver, r *http.Request) (Principal, int, bool) { t.Helper() var got Principal var reached bool h := rs.Middleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reached = true got = PrincipalFromContext(r.Context()) })) rec := httptest.NewRecorder() h.ServeHTTP(rec, r) return got, rec.Code, reached } func TestMiddleware_AttachesPrincipal(t *testing.T) { store := newStubStore() store.add("live-token", "laptop") rs := newTestResolver(t, store) t.Run("owner", func(t *testing.T) { p, code, reached := runMiddleware(t, rs, request(sealCookie(t, "bigbes"), nil)) if !reached || code != http.StatusOK { t.Fatalf("reached=%v code=%d, want true/200", reached, code) } if !p.IsOwner() { t.Fatalf("principal = %+v, want the owner", p) } }) t.Run("anonymous", func(t *testing.T) { p, code, reached := runMiddleware(t, rs, request("", nil)) if !reached || code != http.StatusOK { t.Fatalf("reached=%v code=%d, want true/200", reached, code) } if !p.IsAnonymous() { t.Fatalf("principal = %+v, want anonymous", p) } }) t.Run("agent", func(t *testing.T) { p, code, reached := runMiddleware(t, rs, request("", map[string]string{ "Authorization": "Bearer live-token", HeaderAgent: "claude-code/spec-writer", HeaderAgentSession: "sess-1", })) if !reached || code != http.StatusOK { t.Fatalf("reached=%v code=%d, want true/200", reached, code) } if !p.IsAgent() || p.Agent != "claude-code/spec-writer" { t.Fatalf("principal = %+v, want the agent", p) } }) } func TestMiddleware_RejectsBadToken(t *testing.T) { rs := newTestResolver(t, newStubStore()) _, code, reached := runMiddleware(t, rs, request("", map[string]string{"Authorization": "Bearer never-issued"})) if reached { t.Fatal("a refused token must not reach the handler") } if code != http.StatusUnauthorized { t.Fatalf("code = %d, want 401", code) } } func TestMiddleware_StoreOutageIs503(t *testing.T) { store := newStubStore() store.err = errors.New("connection refused") rs := newTestResolver(t, store) _, code, reached := runMiddleware(t, rs, request("", map[string]string{"Authorization": "Bearer live-token"})) if reached { t.Fatal("a store outage must fail closed, not reach the handler") } if code != http.StatusServiceUnavailable { t.Fatalf("code = %d, want 503", code) } } // A handler reached without the middleware must degrade to less authority, not // more. func TestPrincipalFromContext_BareContextIsAnonymous(t *testing.T) { p := PrincipalFromContext(context.Background()) if !p.IsAnonymous() || p.IsOwner() || p.IsAgent() { t.Fatalf("bare context yielded %+v, want anonymous", p) } if p.Kind != KindAnonymous { t.Fatalf("Kind = %q, want %q", p.Kind, KindAnonymous) } } // Principal is not comparable with == — it carries a grant set — so the round // trip is asserted with reflect.DeepEqual, which also covers the grants. func TestPrincipalFromContext_RoundTrip(t *testing.T) { want := Principal{ Kind: KindAgent, Owner: "bigbes", Agent: "a", Session: "s", Plane: PlaneInstance, Grants: mustGrants(t, "spec:read spec:propose"), UserID: 3, } got := PrincipalFromContext(WithPrincipal(context.Background(), want)) if !reflect.DeepEqual(got, want) { t.Fatalf("round trip = %+v, want %+v", got, want) } } // The zero Principal must be the anonymous one — an unrecognised Kind is denied // rather than accidentally admitted. func TestPrincipal_ZeroValueIsAnonymous(t *testing.T) { var p Principal if !p.IsAnonymous() || p.IsOwner() || p.IsAgent() { t.Fatalf("zero Principal = %+v, want anonymous", p) } if p2 := (Principal{Kind: Kind("nonsense")}); !p2.IsAnonymous() { t.Fatal("an unrecognised Kind must be anonymous") } } func TestPrincipal_String(t *testing.T) { // A slice and not a map keyed by Principal: it carries a grant set and is // therefore not comparable. cases := []struct { p Principal want string }{ {Anonymous(), "anonymous"}, {Principal{Kind: KindAnonymous, CookieUser: "someone"}, "anonymous (cookie user ~someone)"}, {Principal{Kind: KindOwner, Owner: "bigbes"}, "owner ~bigbes"}, { Principal{Kind: KindAgent, Owner: "bigbes", Agent: "claude-code/spec-writer", Session: "s-1"}, "agent claude-code/spec-writer session s-1 for ~bigbes", }, {Principal{Kind: KindAgent, Owner: "bigbes"}, "agent (unnamed) session (no session) for ~bigbes"}, // The local plane renders exactly as it did before the instance plane // existed: the annotation appears only where there is something to say. { Principal{Kind: KindAgent, Owner: "bigbes", Agent: "a", Session: "s-1", Plane: PlaneLocal}, "agent a session s-1 for ~bigbes", }, { Principal{ Kind: KindAgent, Owner: "bigbes", Agent: "a", Session: "s-1", Plane: PlaneInstance, Grants: mustGrants(t, "spec:propose"), }, "agent a session s-1 for ~bigbes (tokens.sr.ht: spec:propose)", }, } for _, c := range cases { if got := c.p.String(); got != c.want { t.Fatalf("String() = %q, want %q", got, c.want) } } }