package service import ( "context" "errors" "strings" "testing" "github.com/go-git/go-git/v5/plumbing" "sourcecraft.dev/bigbes/sr-ht-spec/authn" "sourcecraft.dev/bigbes/sr-ht-spec/core" "sourcecraft.dev/bigbes/sr-ht-spec/gitx" ) func owner() authn.Principal { return authn.Principal{Kind: authn.KindOwner, Owner: "bigbes"} } func agent() authn.Principal { return authn.Principal{ Kind: authn.KindAgent, Owner: "bigbes", Agent: "claude-code/spec-writer", Session: "8fb9c9a4", } } func TestValidateDocuments(t *testing.T) { schema := core.DefaultSchema() spec7 := Document{Path: "specs/0007.md", Blob: "a", Data: doc("SPEC-0007", "Storage", "body")} spec8 := Document{Path: "specs/0008.md", Blob: "b", Data: doc("SPEC-0008", "Other", "body")} dupe := Document{Path: "notes/copy.md", Blob: "c", Data: doc("SPEC-0007", "Copy", "body")} noStatus := Document{Path: "specs/0009.md", Blob: "d", Data: []byte("---\nid: SPEC-0009\ntitle: No status\n---\n\nbody\n")} noFrontmatter := Document{Path: "specs/0010.md", Blob: "e", Data: []byte("# just a heading\n")} badID := Document{Path: "specs/0011.md", Blob: "f", Data: doc("spec-11", "Lowercase id", "body")} tests := []struct { name string all []Document changed []Document wantKind []PushProblemKind wantIn []string wantRefs []string }{ { name: "clean push", all: []Document{spec7, spec8}, changed: []Document{spec8}, wantRefs: []string{"SPEC-0008"}, }, { name: "missing required key", all: []Document{noStatus}, changed: []Document{noStatus}, wantKind: []PushProblemKind{ProblemFrontmatter}, wantIn: []string{"status"}, }, { name: "no frontmatter at all", all: []Document{noFrontmatter}, changed: []Document{noFrontmatter}, wantKind: []PushProblemKind{ProblemFrontmatter}, wantIn: []string{"frontmatter"}, }, { name: "malformed id", all: []Document{badID}, changed: []Document{badID}, wantKind: []PushProblemKind{ProblemFrontmatter}, wantIn: []string{"document id"}, }, { // The failure mode the whole escape hatch exists to guard: a // duplicated id: corrupts the global registry. name: "duplicate id introduced by this push", all: []Document{dupe, spec7}, changed: []Document{dupe}, wantKind: []PushProblemKind{ProblemDuplicateID}, wantIn: []string{"SPEC-0007", "specs/0007.md"}, wantRefs: []string{"SPEC-0007"}, }, { // Both halves of the collision were already there, pushed under // skip-validation. Rejecting every later push would turn a // cosmetic error into a lockout — and the fix unpushable. name: "pre-existing duplicate untouched by this push", all: []Document{dupe, spec7, spec8}, changed: []Document{spec8}, wantRefs: []string{"SPEC-0008"}, }, { // A malformed document already on the branch is tolerated, not // fatal: it must not block every future push. name: "malformed document not part of this push", all: []Document{noFrontmatter, spec8}, changed: []Document{spec8}, wantRefs: []string{"SPEC-0008"}, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { problems, refs := validateDocuments(tc.all, tc.changed, schema) if len(problems) != len(tc.wantKind) { t.Fatalf("problems = %+v, want %d", problems, len(tc.wantKind)) } for i, kind := range tc.wantKind { if problems[i].Kind != kind { t.Errorf("problem %d kind = %q, want %q", i, problems[i].Kind, kind) } } for _, want := range tc.wantIn { found := false for _, p := range problems { if strings.Contains(p.Detail, want) { found = true } } if !found { t.Errorf("no problem mentions %q: %+v", want, problems) } } var got []string for _, r := range refs { got = append(got, r.ID.String()) } if strings.Join(got, ",") != strings.Join(tc.wantRefs, ",") { t.Errorf("refs = %v, want %v", got, tc.wantRefs) } }) } } // The message is what a human reads in their terminal after a rejected push, so // it is a product surface and is asserted as one. func TestPushRejectionMessage(t *testing.T) { rej := &PushRejection{ Space: fxSpace, Ref: "refs/heads/main", Problems: []PushProblem{ {Kind: ProblemFrontmatter, Path: "specs/0007.md", Detail: `missing required frontmatter field: "status"`}, {Kind: ProblemIDCollision, Path: "notes/x.md", Detail: "id RFC-0001 is already registered to ~bigbes/notes at rfcs/0001.md"}, }, Skippable: true, } msg := rej.Error() for _, want := range []string{ "~bigbes/rfcs", "refs/heads/main", "specs/0007.md", `missing required frontmatter field: "status"`, "notes/x.md", "RFC-0001", "~bigbes/notes", "2 problems", "--push-option=skip-validation", } { if !strings.Contains(msg, want) { t.Errorf("message does not contain %q:\n%s", want, msg) } } if !errors.Is(rej, ErrPushRejected) { t.Error("rejection does not satisfy errors.Is(err, ErrPushRejected)") } for _, line := range strings.Split(msg, "\n") { if len(line) > 72 { t.Errorf("line is %d chars, too long once git prefixes it with %q: %s", len(line), "remote: ", line) } } } // A refs-rule rejection must not suggest a flag that will not help. func TestPushRejectionNeverOffersToSkipTheRefsRule(t *testing.T) { rej := &PushRejection{ Space: fxSpace, Ref: "refs/heads/main", Problems: []PushProblem{{Kind: ProblemRefsRule, Detail: "agents may only write proposals/*"}}, } msg := rej.Error() if strings.Contains(msg, "Re-push with") { t.Errorf("refs-rule rejection offers the escape hatch:\n%s", msg) } if !strings.Contains(msg, "cannot be bypassed") { t.Errorf("refs-rule rejection does not say the rule is absolute:\n%s", msg) } if !strings.Contains(msg, "1 problem") { t.Errorf("singular problem count is wrong:\n%s", msg) } } func TestParseObjectName(t *testing.T) { tests := []struct { in string wantErr bool wantNil bool // resolves to the zero object name }{ {in: "", wantNil: true}, {in: zeroObjectName, wantNil: true}, {in: "1f0c1d1a1e2b3c4d5e6f708192a3b4c5d6e7f809"}, {in: "1f0c1d1a", wantErr: true}, {in: "1f0c1d1a1e2b3c4d5e6f708192a3b4c5d6e7f80z", wantErr: true}, {in: "refs/heads/main", wantErr: true}, } for _, tc := range tests { got, err := parseObjectName("new", tc.in) if tc.wantErr != (err != nil) { t.Errorf("parseObjectName(%q) err = %v, wantErr %v", tc.in, err, tc.wantErr) continue } if err == nil && got.IsZero() != tc.wantNil { t.Errorf("parseObjectName(%q) = %s, wantZero %v", tc.in, got, tc.wantNil) } } } // Anonymous must not default to either principal: there is no unauthenticated // write path, and mapping it to "agent" would hand it the proposal namespace. func TestPrincipalKind(t *testing.T) { if got, err := principalKind(owner()); err != nil || got != gitx.PrincipalHuman { t.Errorf("owner -> %q, %v", got, err) } if got, err := principalKind(agent()); err != nil || got != gitx.PrincipalAgent { t.Errorf("agent -> %q, %v", got, err) } if _, err := principalKind(authn.Anonymous()); err == nil { t.Error("anonymous resolved to a writing principal") } } func TestCheckRefsRule(t *testing.T) { svc, root := newService(t) sp := newSpace(t, root, 1) ctx := context.Background() first := commitFiles(t, sp, sp.ApprovedBranch(), 1, map[string][]byte{ "specs/0007.md": doc("SPEC-0007", "Storage", "one"), }) second := commitFiles(t, sp, sp.ApprovedBranch(), 2, map[string][]byte{ "specs/0007.md": doc("SPEC-0007", "Storage", "two"), }) cutBranch(t, sp, "proposals/1", sp.ApprovedBranch()) branch := commitFiles(t, sp, "proposals/1", 3, map[string][]byte{ "specs/0008.md": doc("SPEC-0008", "Other", "one"), }) tests := []struct { name string principal authn.Principal ref string old, new string wantOK bool }{ {"owner fast-forwards the approved branch", owner(), "refs/heads/main", first.String(), second.String(), true}, {"owner force-pushes the approved branch", owner(), "refs/heads/main", second.String(), first.String(), false}, {"owner deletes the approved branch", owner(), "refs/heads/main", second.String(), zeroObjectName, false}, {"agent moves the approved branch", agent(), "refs/heads/main", first.String(), second.String(), false}, {"agent writes a proposal branch", agent(), "refs/heads/proposals/1", zeroObjectName, branch.String(), true}, {"agent force-updates its own proposal branch", agent(), "refs/heads/proposals/1", branch.String(), first.String(), true}, {"owner pushes a tag", owner(), "refs/tags/v1", zeroObjectName, second.String(), false}, {"owner pushes an unrelated branch", owner(), "refs/heads/scratch", zeroObjectName, second.String(), false}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { old, err := parseObjectName("old", tc.old) if err != nil { t.Fatal(err) } newHash, err := parseObjectName("new", tc.new) if err != nil { t.Fatal(err) } problem := svc.checkRefsRule(ctx, sp, PushRequest{ Principal: tc.principal, Ref: tc.ref, }, old, newHash) if tc.wantOK != (problem == nil) { t.Fatalf("problem = %+v, wantOK %v", problem, tc.wantOK) } if problem != nil && problem.Kind != ProblemRefsRule { t.Errorf("kind = %q", problem.Kind) } }) } } // A new proposal branch is compared against the approved head, not against // nothing: comparing against nothing would revalidate the whole space and let // one skip-validation typo block every future proposal branch. func TestChangedDocumentsBaselinesANewBranchOnTheApprovedHead(t *testing.T) { svc, root := newService(t) sp := newSpace(t, root, 1) ctx := context.Background() commitFiles(t, sp, sp.ApprovedBranch(), 1, map[string][]byte{ "specs/0007.md": doc("SPEC-0007", "Storage", "one"), "specs/0009.md": []byte("not a document at all\n"), }) cutBranch(t, sp, "proposals/1", sp.ApprovedBranch()) branch := commitFiles(t, sp, "proposals/1", 2, map[string][]byte{ "specs/0008.md": doc("SPEC-0008", "Other", "one"), }) all, changed, err := svc.changedDocuments(ctx, sp, zeroHash(t), branch) if err != nil { t.Fatalf("changedDocuments: %v", err) } if len(all) != 3 { t.Errorf("all = %d documents, want 3", len(all)) } if len(changed) != 1 || changed[0].Path != "specs/0008.md" { t.Fatalf("changed = %+v, want only specs/0008.md", changed) } } func TestChangedDocumentsUsesTheOldRefValueWhenThereIsOne(t *testing.T) { svc, root := newService(t) sp := newSpace(t, root, 1) ctx := context.Background() old := commitFiles(t, sp, sp.ApprovedBranch(), 1, map[string][]byte{ "specs/0007.md": doc("SPEC-0007", "Storage", "one"), "specs/0008.md": doc("SPEC-0008", "Other", "one"), }) newHead := commitFiles(t, sp, sp.ApprovedBranch(), 2, map[string][]byte{ "specs/0008.md": doc("SPEC-0008", "Other", "two"), }) _, changed, err := svc.changedDocuments(ctx, sp, old, newHead) if err != nil { t.Fatalf("changedDocuments: %v", err) } if len(changed) != 1 || changed[0].Path != "specs/0008.md" { t.Fatalf("changed = %+v, want only specs/0008.md", changed) } } func zeroHash(t *testing.T) plumbing.Hash { t.Helper() h, err := parseObjectName("old", "") if err != nil { t.Fatal(err) } return h }