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