package authn import ( "errors" "strings" "testing" "github.com/vaughan0/go-ini" ) const testBase = "1f0c1d1a1e2b3c4d5e6f708192a3b4c5d6e7f809" func TestInstanceFromConfig(t *testing.T) { inst := testInstance(t) if inst.OwnerName != "bigbes" { t.Fatalf("OwnerName = %q, want %q", inst.OwnerName, "bigbes") } if inst.OwnerEmail != "bigbes@gmail.com" { t.Fatalf("OwnerEmail = %q, want %q", inst.OwnerEmail, "bigbes@gmail.com") } // Derived from [spec.sr.ht] origin, so there is no second key to forget. if inst.AgentEmail != "agent@spec.srht.bigb.es" { t.Fatalf("AgentEmail = %q, want %q", inst.AgentEmail, "agent@spec.srht.bigb.es") } if got := inst.OwnerSignature().String(); got != "bigbes " { t.Fatalf("OwnerSignature = %q", got) } } func TestInstanceFromConfig_MissingKeysAreStartupErrors(t *testing.T) { full := func() ini.File { return ini.File{ "sr.ht": ini.Section{"owner-name": "bigbes", "owner-email": "bigbes@gmail.com"}, ConfigSection: ini.Section{"origin": "https://spec.srht.bigb.es"}, } } cases := map[string]func(ini.File){ "no owner-name": func(c ini.File) { delete(c["sr.ht"], "owner-name") }, "no owner-email": func(c ini.File) { delete(c["sr.ht"], "owner-email") }, "no origin": func(c ini.File) { delete(c[ConfigSection], "origin") }, "hostless origin": func(c ini.File) { c[ConfigSection]["origin"] = "not-a-url" }, "unusable owner": func(c ini.File) { c["sr.ht"]["owner-name"] = "Not A Name" }, "unparseable origin": func(c ini.File) { c[ConfigSection]["origin"] = "https://[::1" }, "unusable owner-email": func(c ini.File) { c["sr.ht"]["owner-email"] = "bigbes @gmail.com" }, "host that cannot be a mailbox": func(c ini.File) { c[ConfigSection]["origin"] = "https://" + strings.Repeat("h", MaxAgentLen) }, } for name, mutate := range cases { t.Run(name, func(t *testing.T) { conf := full() mutate(conf) if _, err := InstanceFromConfig(conf); !errors.Is(err, ErrMissingConfig) { t.Fatalf("error = %v, want ErrMissingConfig", err) } }) } } // An Instance assembled by hand rather than from config is held to the same // standard, and Provenance refuses to build a commit on top of a broken one. func TestInstance_ValidateRejectsHandBuiltGarbage(t *testing.T) { good := AgentWrite{Agent: "a", Session: "s-1", Base: testBase} cases := map[string]Instance{ "no owner name": {OwnerName: "", OwnerEmail: "b@example.com", AgentEmail: "agent@example.com"}, "unusable owner name": {OwnerName: "Not A Name", OwnerEmail: "b@example.com", AgentEmail: "agent@example.com"}, "no owner email": {OwnerName: "bigbes", OwnerEmail: "", AgentEmail: "agent@example.com"}, "owner email injects": {OwnerName: "bigbes", OwnerEmail: "b@example.com>\nAuthor: root", AgentEmail: "agent@example.com"}, "no agent email": {OwnerName: "bigbes", OwnerEmail: "b@example.com", AgentEmail: ""}, "agent email injects": {OwnerName: "bigbes", OwnerEmail: "b@example.com", AgentEmail: "agent@example.com>x"}, } for name, inst := range cases { t.Run(name, func(t *testing.T) { if err := inst.Validate(); !errors.Is(err, ErrMissingConfig) { t.Fatalf("Validate error = %v, want ErrMissingConfig", err) } if _, err := inst.Provenance(good); !errors.Is(err, ErrMissingConfig) { t.Fatalf("Provenance error = %v, want ErrMissingConfig", err) } }) } } // The worked example from the design, byte for byte apart from the agent // mailbox, which is derived from our own origin. func TestProvenance_MatchesTheDesign(t *testing.T) { inst := testInstance(t) prov, err := inst.Provenance(AgentWrite{ Agent: "claude-code/spec-writer", Session: "8fb9c9a4-b078-4af1-89eb-d97c522f9921", Base: testBase, }) if err != nil { t.Fatalf("Provenance: %v", err) } wantAuthor := "claude-code/spec-writer (for bigbes) " if got := prov.Author.String(); got != wantAuthor { t.Fatalf("Author = %q, want %q", got, wantAuthor) } wantCommitter := "bigbes " if got := prov.Committer.String(); got != wantCommitter { t.Fatalf("Committer = %q, want %q", got, wantCommitter) } wantTrailers := "X-Agent-Session: 8fb9c9a4-b078-4af1-89eb-d97c522f9921\n" + "X-Agent-Base: " + testBase + "\n" if got := prov.TrailerBlock(); got != wantTrailers { t.Fatalf("TrailerBlock = %q, want %q", got, wantTrailers) } msg, err := prov.CommitMessage("Add storage model section") if err != nil { t.Fatalf("CommitMessage: %v", err) } want := "Add storage model section\n\n" + wantTrailers if msg != want { t.Fatalf("CommitMessage = %q, want %q", msg, want) } } // Agent identity and session are mandatory on every agent write. A write that // omits either is rejected, never defaulted — a commit stamped with a // synthesised session launders unattributable output as attributed. func TestProvenance_RejectsMissingAgentIdentity(t *testing.T) { inst := testInstance(t) full := AgentWrite{ Agent: "claude-code/spec-writer", Session: "8fb9c9a4-b078-4af1-89eb-d97c522f9921", Base: testBase, } cases := map[string]AgentWrite{ "no agent": {Agent: "", Session: full.Session, Base: full.Base}, "no session": {Agent: full.Agent, Session: "", Base: full.Base}, "no base": {Agent: full.Agent, Session: full.Session, Base: ""}, "nothing": {}, } for name, w := range cases { t.Run(name, func(t *testing.T) { if err := w.Validate(); !errors.Is(err, ErrMissingProvenance) { t.Fatalf("Validate error = %v, want ErrMissingProvenance", err) } if _, err := inst.Provenance(w); !errors.Is(err, ErrMissingProvenance) { t.Fatalf("Provenance error = %v, want ErrMissingProvenance", err) } }) } // Control: the complete write is accepted, so the cases above are failing // on the missing field and not on something incidental. if err := full.Validate(); err != nil { t.Fatalf("complete write rejected: %v", err) } } // A newline in the agent identity would split the git author line; a newline in // the session would inject an arbitrary extra trailer. Both are refused // outright rather than escaped. func TestProvenance_RejectsInjection(t *testing.T) { inst := testInstance(t) cases := map[string]AgentWrite{ "newline in agent": {Agent: "evil\nAuthor: root", Session: "s-1", Base: testBase}, "newline in session": {Agent: "a", Session: "s\nX-Agent-Base: deadbeef", Base: testBase}, "cr in session": {Agent: "a", Session: "s\rX-Agent-Base: deadbeef", Base: testBase}, "angle in agent": {Agent: "a ", Session: "s-1", Base: testBase}, "angle in session": {Agent: "a", Session: "", Base: testBase}, "padded agent": {Agent: " a ", Session: "s-1", Base: testBase}, "nul in agent": {Agent: "a\x00b", Session: "s-1", Base: testBase}, "over-long agent": {Agent: strings.Repeat("a", MaxAgentLen+1), Session: "s-1", Base: testBase}, "over-long session": {Agent: "a", Session: strings.Repeat("s", MaxSessionLen+1), Base: testBase}, "base with a space": {Agent: "a", Session: "s-1", Base: "dead beef"}, "base not hex": {Agent: "a", Session: "s-1", Base: "proposals/42"}, "base uppercase hex": {Agent: "a", Session: "s-1", Base: strings.ToUpper(testBase)}, "base too short": {Agent: "a", Session: "s-1", Base: "abc"}, "base too long": {Agent: "a", Session: "s-1", Base: strings.Repeat("a", maxRevLen+1)}, "base with a newline": {Agent: "a", Session: "s-1", Base: testBase + "\nX-Agent-Session: forged"}, "invalid utf-8 agent": {Agent: "a\xff", Session: "s-1", Base: testBase}, "invalid utf-8 sessid": {Agent: "a", Session: "s\xff", Base: testBase}, } for name, w := range cases { t.Run(name, func(t *testing.T) { if err := w.Validate(); !errors.Is(err, ErrInvalidProvenance) { t.Fatalf("Validate error = %v, want ErrInvalidProvenance", err) } if _, err := inst.Provenance(w); !errors.Is(err, ErrInvalidProvenance) { t.Fatalf("Provenance error = %v, want ErrInvalidProvenance", err) } }) } } // An abbreviated object name is accepted; the trailer stays auditable and // nothing can hide in it. func TestProvenance_AcceptsAbbreviatedBase(t *testing.T) { inst := testInstance(t) for _, base := range []string{"1f0c1d1", testBase, strings.Repeat("a", 64)} { if _, err := inst.Provenance(AgentWrite{Agent: "a", Session: "s-1", Base: base}); err != nil { t.Fatalf("base %q rejected: %v", base, err) } } } func TestCommitMessage_RejectsEmpty(t *testing.T) { inst := testInstance(t) prov, err := inst.Provenance(AgentWrite{Agent: "a", Session: "s-1", Base: testBase}) if err != nil { t.Fatal(err) } for _, msg := range []string{"", " ", "\n\n\t"} { if _, err := prov.CommitMessage(msg); !errors.Is(err, ErrInvalidProvenance) { t.Fatalf("CommitMessage(%q) error = %v, want ErrInvalidProvenance", msg, err) } } } // Trailer-shaped text inside the agent's own message must stay in the body: // our block is the last paragraph, which is the one git parses as trailers. func TestCommitMessage_OurTrailersAreTheLastParagraph(t *testing.T) { inst := testInstance(t) prov, err := inst.Provenance(AgentWrite{Agent: "a", Session: "real-session", Base: testBase}) if err != nil { t.Fatal(err) } msg, err := prov.CommitMessage("Subject\n\nX-Agent-Session: forged\n") if err != nil { t.Fatal(err) } paras := strings.Split(strings.TrimRight(msg, "\n"), "\n\n") last := paras[len(paras)-1] if !strings.HasPrefix(last, "X-Agent-Session: real-session\n") { t.Fatalf("last paragraph = %q, want it to start with the real session", last) } if strings.Contains(last, "forged") { t.Fatalf("forged trailer leaked into the trailer paragraph: %q", last) } } // Provenance for a non-agent principal is an error: the human write path goes // through native receive-pack and builds no commit here. func TestAgentWriteFor_RejectsNonAgents(t *testing.T) { for _, p := range []Principal{ Anonymous(), {Kind: KindOwner, Owner: "bigbes"}, } { if _, err := p.AgentWriteFor(testBase); !errors.Is(err, ErrNotAgent) { t.Fatalf("%s: error = %v, want ErrNotAgent", p, err) } } } func TestAgentWriteFor_Agent(t *testing.T) { p := Principal{ Kind: KindAgent, Owner: "bigbes", Agent: "claude-code/spec-writer", Session: "8fb9c9a4-b078-4af1-89eb-d97c522f9921", } w, err := p.AgentWriteFor(testBase) if err != nil { t.Fatalf("AgentWriteFor: %v", err) } if w.Agent != p.Agent || w.Session != p.Session || w.Base != testBase { t.Fatalf("AgentWrite = %+v", w) } // An agent that authenticated but sent no provenance headers reads fine and // is refused at the write, which is where the design requires the fields. bare := Principal{Kind: KindAgent, Owner: "bigbes"} if _, err := bare.AgentWriteFor(testBase); !errors.Is(err, ErrMissingProvenance) { t.Fatalf("error = %v, want ErrMissingProvenance", err) } }