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)
}
}
}