package service
import (
"context"
"errors"
"strings"
"testing"
"sourcecraft.dev/bigbes/sr-ht-spec/authn"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
)
// agentPrincipal is the resolved identity every write test proposes as: an
// agent acting for the instance owner, with the two provenance fields a write
// demands already present.
func agentPrincipal() authn.Principal {
return authn.Principal{
Kind: authn.KindAgent,
Owner: "bigbes",
Agent: "claude-code/spec-writer",
Session: "8fb9c9a4-b078-4af1-89eb-d97c522f9921",
}
}
func ownerPrincipal() authn.Principal {
return authn.Principal{Kind: authn.KindOwner, Owner: "bigbes"}
}
// TestProposeRejectsNonAgent proves proposing is agent-only, and that the
// refusal happens before any space is opened — the guard is on the principal,
// not the request, so it holds even for a database that cannot be reached.
func TestProposeRejectsNonAgent(t *testing.T) {
svc, _ := newService(t)
_, err := svc.Propose(context.Background(), ProposeRequest{
Space: fxSpace,
Principal: ownerPrincipal(),
Title: "t",
IfMatch: headRev,
Writes: []DocumentWrite{{Path: "notes/a.md", Content: mdDoc("N-1", "A", "b")}},
})
if !errors.Is(err, ErrForbidden) {
t.Fatalf("Propose as owner: err = %v, want ErrForbidden", err)
}
}
// TestProposeRejectsEmptyWrites refuses a proposal that writes nothing before it
// touches the store, for the same reason.
func TestProposeRejectsEmptyWrites(t *testing.T) {
svc, _ := newService(t)
_, err := svc.Propose(context.Background(), ProposeRequest{
Space: fxSpace,
Principal: agentPrincipal(),
Title: "t",
IfMatch: headRev,
})
if !errors.Is(err, ErrForbidden) {
t.Fatalf("Propose with no writes: err = %v, want ErrForbidden", err)
}
}
// TestProposalURL pins the one spelling of a proposal link every surface hands
// back. A second spelling in a surface would 404 where this resolves.
func TestProposalURL(t *testing.T) {
svc, _ := newService(t)
got := svc.ProposalURL(fxSpace, 42)
want := "https://spec.srht.bigb.es/~bigbes/rfcs/p/42"
if got != want {
t.Fatalf("ProposalURL = %q, want %q", got, want)
}
}
// TestValidateWrites exercises the propose-time gate that a native push gets
// from the update hook: schema-valid frontmatter, a well-formed id, and no two
// uploads claiming one id.
func TestValidateWrites(t *testing.T) {
svc, root := newService(t)
sp := newSpace(t, root, 1)
ctx := context.Background()
base, err := sp.Repo.ApprovedHead(ctx)
if err != nil {
t.Fatalf("ApprovedHead: %v", err)
}
baseRev := base.String()
tests := []struct {
name string
writes []DocumentWrite
wantErr bool
}{
{
name: "valid document",
writes: []DocumentWrite{{Path: "notes/a.md", Content: mdDoc("N-1", "A", "body")}},
},
{
name: "unparseable frontmatter",
writes: []DocumentWrite{{Path: "notes/a.md", Content: []byte("no frontmatter here")}},
wantErr: true,
},
{
name: "missing required status key",
writes: []DocumentWrite{{Path: "notes/a.md", Content: []byte("---\nid: N-1\ntitle: A\n---\n\nbody\n")}},
wantErr: true,
},
{
name: "two uploads claiming one id",
writes: []DocumentWrite{
{Path: "notes/a.md", Content: mdDoc("N-1", "A", "one")},
{Path: "notes/b.md", Content: mdDoc("N-1", "B", "two")},
},
wantErr: true,
},
{
name: "path escaping the tree",
writes: []DocumentWrite{{Path: "../secret.md", Content: mdDoc("N-1", "A", "body")}},
wantErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := svc.validateWrites(ctx, sp, tc.writes, baseRev)
if tc.wantErr && err == nil {
t.Fatalf("validateWrites(%s) = nil, want error", tc.name)
}
if !tc.wantErr && err != nil {
t.Fatalf("validateWrites(%s) = %v, want nil", tc.name, err)
}
if tc.wantErr && err != nil && !errors.Is(err, ErrForbidden) {
t.Fatalf("validateWrites(%s) err = %v, want ErrForbidden", tc.name, err)
}
})
}
}
// TestChangedPaths proves the auto-merge decision's input is the set of document
// paths whose blob moved between the base and the proposal head — an addition
// and a modification counted, an untouched document not.
func TestChangedPaths(t *testing.T) {
svc, root := newService(t)
sp := newSpace(t, root, 1)
ctx := context.Background()
// The approved head carries two documents; the proposal modifies one and
// adds a third, leaving the second untouched.
base := commitFiles(t, sp, sp.ApprovedBranch(), 1, map[string][]byte{
"notes/a.md": mdDoc("N-1", "A", "original"),
"specs/b.md": mdDoc("S-1", "B", "kept"),
})
branch := "proposals/1"
cutBranch(t, sp, branch, base.String())
commitFiles(t, sp, branch, 2, map[string][]byte{
"notes/a.md": mdDoc("N-1", "A", "revised"),
"notes/c.md": mdDoc("N-2", "C", "new"),
})
head, err := sp.Repo.BranchHead(ctx, branch)
if err != nil {
t.Fatalf("BranchHead: %v", err)
}
changed, err := svc.changedPaths(ctx, sp, base.String(), head.String())
if err != nil {
t.Fatalf("changedPaths: %v", err)
}
want := map[string]bool{"notes/a.md": true, "notes/c.md": true}
if len(changed) != len(want) {
t.Fatalf("changedPaths = %v, want %v", keysOf(changed), keysOf(want))
}
for p := range want {
if !changed[p] {
t.Errorf("changedPaths missing %q; got %v", p, keysOf(changed))
}
}
if changed["specs/b.md"] {
t.Errorf("changedPaths includes the untouched specs/b.md")
}
}
func keysOf(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
// --- Postgres-backed integration tests (skip when SPECSRHT_TEST_PG is unset) ---
// TestProposeOpensProposal walks the whole open path: an agent uploads a
// document, gets back a proposal and its URL, and the proposal is listable in
// the open state with the provenance the agent supplied.
func TestProposeOpensProposal(t *testing.T) {
svc, _ := newTestService(t)
ctx := context.Background()
sp, err := svc.CreateSpace(ctx, fxSpace)
if err != nil {
t.Fatalf("CreateSpace: %v", err)
}
base, err := sp.Repo.ApprovedHead(ctx)
if err != nil {
t.Fatalf("ApprovedHead: %v", err)
}
res, err := svc.Propose(ctx, ProposeRequest{
Space: fxSpace,
Principal: agentPrincipal(),
Title: "Add a note",
Rationale: "because",
IfMatch: base.String(),
Message: "add notes/a.md",
Writes: []DocumentWrite{{Path: "notes/a.md", Content: mdDoc("N-1", "A", "body")}},
})
if err != nil {
t.Fatalf("Propose: %v", err)
}
if res.Merged {
t.Errorf("Merged = true, want false without an auto_merge policy")
}
if res.Proposal.State != core.StateOpen {
t.Errorf("state = %s, want open", res.Proposal.State)
}
if res.Proposal.Agent != "claude-code/spec-writer" {
t.Errorf("agent = %q, want the raw agent identity", res.Proposal.Agent)
}
if !strings.HasSuffix(res.URL, "/p/1") {
t.Errorf("URL = %q, want it to end in /p/1", res.URL)
}
open, err := svc.ListProposals(ctx, fxSpace, core.StateOpen)
if err != nil {
t.Fatalf("ListProposals: %v", err)
}
if len(open) != 1 || open[0].ID != res.Proposal.ID {
t.Fatalf("ListProposals(open) = %v, want the one just opened", open)
}
}
// TestProposeAutoMerges proves a proposal whose every changed path matches the
// space's auto_merge policy lands immediately, recorded as policy-approved.
func TestProposeAutoMerges(t *testing.T) {
svc, _ := newTestService(t)
ctx := context.Background()
sp, err := svc.CreateSpace(ctx, fxSpace)
if err != nil {
t.Fatalf("CreateSpace: %v", err)
}
// Install a policy that auto-merges everything under notes/.
commitFiles(t, sp, sp.ApprovedBranch(), 1, map[string][]byte{
".spec.yml": []byte("review:\n auto_merge: [notes/**]\n"),
})
base, err := sp.Repo.ApprovedHead(ctx)
if err != nil {
t.Fatalf("ApprovedHead: %v", err)
}
res, err := svc.Propose(ctx, ProposeRequest{
Space: fxSpace,
Principal: agentPrincipal(),
Title: "firehose note",
IfMatch: base.String(),
Message: "add notes/a.md",
Writes: []DocumentWrite{{Path: "notes/a.md", Content: mdDoc("N-1", "A", "body")}},
})
if err != nil {
t.Fatalf("Propose: %v", err)
}
if !res.Merged {
t.Fatalf("Merged = false, want the policy to have landed it")
}
if res.Proposal.State != core.StateMerged {
t.Errorf("state = %s, want merged", res.Proposal.State)
}
if res.Proposal.Approval != core.ApprovalPolicy {
t.Errorf("approval = %q, want policy", res.Proposal.Approval)
}
if res.Proposal.MergedRev == "" {
t.Errorf("MergedRev is empty on a merged proposal")
}
}
// TestProposeDoesNotAutoMergeMixedPaths proves a proposal touching one reviewed
// document is reviewed as a whole, even when its other paths would auto-merge.
func TestProposeDoesNotAutoMergeMixedPaths(t *testing.T) {
svc, _ := newTestService(t)
ctx := context.Background()
sp, err := svc.CreateSpace(ctx, fxSpace)
if err != nil {
t.Fatalf("CreateSpace: %v", err)
}
commitFiles(t, sp, sp.ApprovedBranch(), 1, map[string][]byte{
".spec.yml": []byte("review:\n auto_merge: [notes/**]\n"),
})
base, err := sp.Repo.ApprovedHead(ctx)
if err != nil {
t.Fatalf("ApprovedHead: %v", err)
}
res, err := svc.Propose(ctx, ProposeRequest{
Space: fxSpace,
Principal: agentPrincipal(),
IfMatch: base.String(),
Title: "note plus spec",
Message: "two docs",
Writes: []DocumentWrite{
{Path: "notes/a.md", Content: mdDoc("N-1", "A", "auto")},
{Path: "specs/b.md", Content: mdDoc("S-1", "B", "reviewed")},
},
})
if err != nil {
t.Fatalf("Propose: %v", err)
}
if res.Merged {
t.Fatalf("Merged = true, want a proposal touching specs/ to wait for review")
}
if res.Proposal.State != core.StateOpen {
t.Errorf("state = %s, want open", res.Proposal.State)
}
}
// TestProposeRejectsStaleBase proves the open-time 409: a base the approved
// branch has moved off cannot open a proposal.
func TestProposeRejectsStaleBase(t *testing.T) {
svc, _ := newTestService(t)
ctx := context.Background()
sp, err := svc.CreateSpace(ctx, fxSpace)
if err != nil {
t.Fatalf("CreateSpace: %v", err)
}
// A revision that is a valid object name but not on the approved branch:
// commit it onto an unrelated ref so it resolves yet is not an ancestor of
// the approved head.
detached := commitFiles(t, sp, "detached/1", 1, map[string][]byte{
"notes/x.md": mdDoc("N-9", "X", "detached"),
})
_, err = svc.Propose(ctx, ProposeRequest{
Space: fxSpace,
Principal: agentPrincipal(),
Title: "stale",
IfMatch: detached.String(),
Message: "m",
Writes: []DocumentWrite{{Path: "notes/a.md", Content: mdDoc("N-1", "A", "body")}},
})
if !errors.Is(err, ErrStale) {
t.Fatalf("Propose against a detached base: err = %v, want ErrStale", err)
}
}