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: mdDoc("SPEC-0007", "Storage", "body")}
spec8 := Document{Path: "specs/0008.md", Blob: "b", Data: mdDoc("SPEC-0008", "Other", "body")}
dupe := Document{Path: "notes/copy.md", Blob: "c", Data: mdDoc("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: mdDoc("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": mdDoc("SPEC-0007", "Storage", "one"),
})
second := commitFiles(t, sp, sp.ApprovedBranch(), 2, map[string][]byte{
"specs/0007.md": mdDoc("SPEC-0007", "Storage", "two"),
})
cutBranch(t, sp, "proposals/1", sp.ApprovedBranch())
branch := commitFiles(t, sp, "proposals/1", 3, map[string][]byte{
"specs/0008.md": mdDoc("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": mdDoc("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": mdDoc("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": mdDoc("SPEC-0007", "Storage", "one"),
"specs/0008.md": mdDoc("SPEC-0008", "Other", "one"),
})
newHead := commitFiles(t, sp, sp.ApprovedBranch(), 2, map[string][]byte{
"specs/0008.md": mdDoc("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
}