Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Phase 3: change requests, with approvals bound to the commit they reviewed

1eeca4e67a65d71217d51b1c41632330225b351a · Keenan Nemetz · 18d ago · parent 92b37587

Phase 3: change requests, with approvals bound to the commit they reviewed

Review is now a git artifact. A request names a base branch and one head commit;
an approval names that same commit, so a branch that moves after approval is not
approved any more. Because `git push` is the merge, the gate lives in pre-receive
and there is no authenticated write surface to build.

  requests/<ulid>/meta.json          title, author, base, head, state
  requests/<ulid>/description.md
  requests/<ulid>/approvals/<user>.json   base + head + created

Two push shapes are accepted for a protected branch: the new tip is the approved
head, or the new tip is a merge whose parents are the branch tip and the approved
head. "The approved head is an ancestor of the new tip" is refused on purpose —
accepting it would let anyone append unapproved commits and land them together
with an approved one. Admins keep their bypass; `vole acl approvals <repo> <n>`
sets the count, and 0 restores admin-only.

Three integrity holes found while building it, each now covered by a test:

- An approval file names a principal, but nothing tied that name to the pusher, so
  any writer could commit approvals/<someone-else>.json. The hook now requires a
  newly added approval to be named after the SSH identity. That check initially
  skipped the push that *creates* the branch, where there is no old commit to diff
  against — which let whoever created the branch seed any approval they liked.
  Fixed to inspect the whole tree on a first push.
- Approvals were bound to the head but not the branch, so rewriting a request's
  base repointed an approval at somewhere the approver never looked.
- Self-approval was refused at the CLI but not in validation, so a hand-written
  tree could carry one.

Also relaxes the CLI to accept options after arguments, as git does. Refusing
`vole request approve <id> --as keenan` is a local dialect, not git's behaviour,
and `git commit -m msg path` is the shape people already type. Exit 129, no
bundling and `--`/`--end-of-options` are unchanged.

Verified end to end on a real instance with two keys: a writer's direct push to a
protected main is refused with a message naming the exact commit; the same push
still fails with a request open but unapproved; self-approval fails; an admin
approval lets it through; main advances; the web shows the approval as current and
the request as already merged; the audit log records the merge with request id and
approver.

Known rough edge: a merged request stays open until someone closes it. Marking it
merged automatically needs the server to write metadata during a push, which the
design deliberately does not do. Phase 4 CI is the natural place to close it.

internal/server/integration_test.go +423−6

@@ -464,7 +464,9 @@ const testIssueID = "01M5JR2JK29KKMAA1T9MDNTPBP"
464 464 func (f *fixture) issuesRepo(t *testing.T, files map[string]string) string {
465 465 t.Helper()
466 466 dir := t.TempDir()
467 if _, errs, err := runGit(t, dir, "init", "-q", "-b", "vole"); err != nil {
467 // Work on a scratch branch, never on vole itself: git refuses to fetch into
468 // the checked-out branch, which is exactly how a real user works too.
469 if _, errs, err := runGit(t, dir, "init", "-q", "-b", "scratch"); err != nil {
468 470 t.Fatalf("init: %v: %s", err, errs)
469 471 }
470 472 for name, content := range files {
@@ -707,20 +709,25 @@ func TestIssueCLIEndToEnd(t *testing.T) {
707 709 t.Fatalf("empty title accepted by the CLI: %s", errs)
708 710 }
709 711
710 // CLI conventions: a usage mistake exits 129, and options must come first.
711 cmd := exec.Command(binPath, "issue", "comment", short, "-m", "late option")
712 // CLI conventions: git accepts options after arguments, so Vole does too.
713 if _, errs, err := run("issue", "comment", short, "-m", "late option"); err != nil {
714 t.Fatalf("option after argument was refused, but git accepts these: %v\n%s", err, errs)
715 }
716
717 // A genuine usage mistake still exits 129 with a message naming the option.
718 cmd := exec.Command(binPath, "issue", "comment", short, "--nonsense")
712 719 cmd.Dir = clone
713 720 var errb bytes.Buffer
714 721 cmd.Stderr = &errb
715 722 err = cmd.Run()
716 723 if err == nil {
717 t.Fatal("option after argument was accepted")
724 t.Fatal("unknown option was accepted")
718 725 }
719 726 var ee *exec.ExitError
720 727 if errors.As(err, &ee) && ee.ExitCode() != 129 {
721 728 t.Fatalf("usage error exit code = %d, want 129 (%s)", ee.ExitCode(), errb.String())
722 729 }
723 if !strings.Contains(errb.String(), "options must come before arguments") {
730 if !strings.Contains(errb.String(), "unknown option --nonsense") {
724 731 t.Fatalf("unhelpful usage error: %s", errb.String())
725 732 }
726 733 }
@@ -857,7 +864,7 @@ func TestMetadataRejectsMergeCommits(t *testing.T) {
857 864 writeIssueFile(t, work, "01M5JR2JK29KKMAA1T9MDNTPE1", "side")
858 865 git("add", "-A")
859 866 git("commit", "-qm", "side issue")
860 git("checkout", "-q", "vole")
867 git("checkout", "-q", "scratch")
861 868 writeIssueFile(t, work, "01M5JR2JK29KKMAA1T9MDNTPE2", "main")
862 869 git("add", "-A")
863 870 git("commit", "-qm", "main issue")
@@ -1015,6 +1022,416 @@ func writeIssueFile(t *testing.T, dir, id, title string) {
1015 1022 }
1016 1023 }
1017 1024
1025 // metaRepo is a worktree for editing the metadata branch, always committing on
1026 // top of what is on the server.
1027 type metaRepo struct {
1028 t *testing.T
1029 f *fixture
1030 dir string
1031 key string
1032 }
1033
1034 func (f *fixture) newMetaRepo(t *testing.T) *metaRepo {
1035 t.Helper()
1036 dir := filepath.Join(t.TempDir(), "meta")
1037 if err := os.MkdirAll(dir, 0o755); err != nil {
1038 t.Fatal(err)
1039 }
1040 // Work on a scratch branch, never on vole itself: git refuses to fetch into
1041 // the checked-out branch, which is exactly how a real user works too.
1042 if _, errs, err := runGit(t, dir, "init", "-q", "-b", "scratch"); err != nil {
1043 t.Fatalf("init: %v: %s", err, errs)
1044 }
1045 if _, errs, err := runGit(t, dir, "remote", "add", "origin", f.sshURL(f.repo)); err != nil {
1046 t.Fatalf("remote: %v: %s", err, errs)
1047 }
1048 return &metaRepo{t: t, f: f, dir: dir, key: f.keyPath}
1049 }
1050
1051 func (m *metaRepo) apply(files map[string]string, msg string) error {
1052 m.t.Helper()
1053 if _, _, err := fgit(m.t, m.key, m.dir, "fetch", "-q", "origin", "+refs/heads/vole:refs/heads/meta-base"); err == nil {
1054 if _, errs, rerr := runGit(m.t, m.dir, "reset", "-q", "--hard", "refs/heads/meta-base"); rerr != nil {
1055 m.t.Fatalf("reset: %v: %s", rerr, errs)
1056 }
1057 }
1058 for name, content := range files {
1059 p := filepath.Join(m.dir, name)
1060 if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
1061 m.t.Fatal(err)
1062 }
1063 if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
1064 m.t.Fatal(err)
1065 }
1066 }
1067 if _, errs, err := runGit(m.t, m.dir, "add", "-A"); err != nil {
1068 m.t.Fatalf("add: %v: %s", err, errs)
1069 }
1070 if _, errs, err := runGit(m.t, m.dir, "commit", "-qm", msg); err != nil {
1071 m.t.Fatalf("commit: %v: %s", err, errs)
1072 }
1073 _, errs, err := fgit(m.t, m.key, m.dir, "push", "origin", "HEAD:refs/heads/vole")
1074 if err != nil {
1075 return fmt.Errorf("%v: %s", err, errs)
1076 }
1077 return nil
1078 }
1079
1080 // fgit runs git as a specific key holding user, for tests that need identities.
1081 func fgit(t *testing.T, key, dir string, args ...string) (string, string, error) {
1082 t.Helper()
1083 cmd := exec.Command("git", args...)
1084 cmd.Dir = dir
1085 cmd.Env = append(os.Environ(),
1086 "GIT_TERMINAL_PROMPT=0", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
1087 "GIT_SSH_COMMAND=ssh -i "+key+" -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR",
1088 "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.com",
1089 "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.com",
1090 )
1091 var out, errb bytes.Buffer
1092 cmd.Stdout, cmd.Stderr = &out, &errb
1093 err := cmd.Run()
1094 return out.String(), errb.String(), err
1095 }
1096
1097 // cloneWithChange clones the repo and commits one change, returning the worktree
1098 // and its head. Building features as clones keeps them descendants of main, which
1099 // keeps the push a fast-forward so the hook, not the client, decides.
1100 func (f *fixture) cloneWithChange(t *testing.T, files map[string]string, msg string) (string, string) {
1101 t.Helper()
1102 dir := filepath.Join(t.TempDir(), "work")
1103 if _, errs, err := f.git("", "clone", "-q", f.sshURL(f.repo), dir); err != nil {
1104 t.Fatalf("clone: %s", errs)
1105 }
1106 for name, content := range files {
1107 p := filepath.Join(dir, name)
1108 if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
1109 t.Fatal(err)
1110 }
1111 if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
1112 t.Fatal(err)
1113 }
1114 }
1115 if _, errs, err := runGit(t, dir, "add", "-A"); err != nil {
1116 t.Fatalf("add: %v: %s", err, errs)
1117 }
1118 if _, errs, err := runGit(t, dir, "commit", "-qm", msg); err != nil {
1119 t.Fatalf("commit: %v: %s", err, errs)
1120 }
1121 out, _, _ := runGit(t, dir, "rev-parse", "HEAD")
1122 return dir, strings.TrimSpace(out)
1123 }
1124
1125 const reqID = "01M5JR2JK29KKMAA1T9MDNTR01"
1126
1127 func requestMeta(base, head, author string) string {
1128 return fmt.Sprintf(`{"title":"Land the feature","author":%q,"base":%q,"head":%q,"state":"open","created":"2026-10-22T22:00:00Z"}`+"\n",
1129 author, base, head)
1130 }
1131
1132 func approvalFile(base, head string) string {
1133 return fmt.Sprintf(`{"base":%q,"head":%q,"created":"2026-10-22T22:10:00Z"}`+"\n", base, head)
1134 }
1135
1136 // TestProtectedBranchRequiresApprovedRequest is the whole Phase 3 contract: a
1137 // writer cannot land on a protected branch until a change request for that exact
1138 // commit has the required approvals, and an admin still bypasses.
1139 func TestProtectedBranchRequiresApprovedRequest(t *testing.T) {
1140 f := newFixture(t)
1141 writerKey := f.addWriter(t)
1142 f.publish(f.repo)
1143
1144 // Initial main, pushed by the admin.
1145 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
1146 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
1147 t.Fatalf("initial main: %s", errs)
1148 }
1149
1150 // Protect main.
1151 m := mustRepo(t, f)
1152 a, _ := m.ACL()
1153 a.Protected = []string{"refs/heads/main"}
1154 if err := m.SetACL(a, "test"); err != nil {
1155 t.Fatal(err)
1156 }
1157
1158 // The writer pushes a feature branch and gets its head.
1159 feature, head := f.cloneWithChange(t, map[string]string{"readme.md": "v2\n"}, "feature")
1160 if _, errs, err := f.gitWithKey(writerKey, feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
1161 t.Fatalf("feature push: %s", errs)
1162 }
1163
1164 // 1. No request at all: refused, and the message names the commit.
1165 _, errs, err := f.gitWithKey(writerKey, feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/main")
1166 if err == nil {
1167 t.Fatal("a writer landed on a protected branch with no change request")
1168 }
1169 if !strings.Contains(errs, "needs 1 approval") || !strings.Contains(errs, head[:8]) {
1170 t.Fatalf("rejection is not actionable:\n%s", errs)
1171 }
1172
1173 // 2. Request open, no approvals: still refused.
1174 meta := f.newMetaRepo(t)
1175 if err := meta.apply(map[string]string{
1176 "requests/" + reqID + "/meta.json": requestMeta("main", head, "wilbur"),
1177 "requests/" + reqID + "/description.md": "please\n",
1178 }, "request"); err != nil {
1179 t.Fatalf("request push: %v", err)
1180 }
1181 if _, errs, err := f.gitWithKey(writerKey, feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/main"); err == nil {
1182 t.Fatal("a request with no approvals was enough to land")
1183 } else if !strings.Contains(errs, "needs 1 approval") {
1184 t.Fatalf("unexpected rejection: %s", errs)
1185 }
1186
1187 // 3. Approval by another principal: allowed.
1188 approver := f.newMetaRepo(t)
1189 if err := approver.apply(map[string]string{
1190 "requests/" + reqID + "/approvals/keenan.json": approvalFile("main", head),
1191 }, "approve"); err != nil {
1192 t.Fatalf("approval push: %v", err)
1193 }
1194 if _, errs, err := f.gitWithKey(writerKey, feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/main"); err != nil {
1195 t.Fatalf("approved push was refused: %s", errs)
1196 }
1197
1198 audit, err := os.ReadFile(f.cfg.AuditLog())
1199 if err != nil {
1200 t.Fatal(err)
1201 }
1202 if !strings.Contains(string(audit), "merge refs/heads/main via request "+reqID) {
1203 t.Fatalf("merge not audited:\n%s", audit)
1204 }
1205 }
1206
1207 // TestApprovalIsBoundToTheCommit is the property that makes approvals mean
1208 // something: approve A, amend to B, and the approval no longer applies.
1209 func TestApprovalIsBoundToTheCommit(t *testing.T) {
1210 f := newFixture(t)
1211 writerKey := f.addWriter(t)
1212 f.publish(f.repo)
1213 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
1214 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
1215 t.Fatalf("initial main: %s", errs)
1216 }
1217 m := mustRepo(t, f)
1218 a, _ := m.ACL()
1219 a.Protected = []string{"refs/heads/main"}
1220 if err := m.SetACL(a, "test"); err != nil {
1221 t.Fatal(err)
1222 }
1223
1224 feature, approved := f.cloneWithChange(t, map[string]string{"readme.md": "v2\n"}, "feature")
1225
1226 meta := f.newMetaRepo(t)
1227 if err := meta.apply(map[string]string{
1228 "requests/" + reqID + "/meta.json": requestMeta("main", approved, "wilbur"),
1229 }, "request"); err != nil {
1230 t.Fatalf("request: %v", err)
1231 }
1232 approver := f.newMetaRepo(t)
1233 if err := approver.apply(map[string]string{
1234 "requests/" + reqID + "/approvals/keenan.json": approvalFile("main", approved),
1235 }, "approve"); err != nil {
1236 t.Fatalf("approve: %v", err)
1237 }
1238
1239 // Amend the feature so the head is a different commit.
1240 if _, errs, err := runGit(t, feature, "commit", "-q", "--amend", "-m", "sneaky change"); err != nil {
1241 t.Fatalf("amend: %v: %s", err, errs)
1242 }
1243 if _, _, err := f.gitWithKey(writerKey, feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/main"); err == nil {
1244 t.Fatal("a commit that was never approved landed on main")
1245 }
1246 }
1247
1248 // TestSelfApprovalRejected: the metadata push itself fails, so the approval never
1249 // exists to be counted.
1250 func TestSelfApprovalRejected(t *testing.T) {
1251 f := newFixture(t)
1252 writerKey := f.addWriter(t)
1253 f.publish(f.repo)
1254 if _, errs, err := f.git(workRepo(t, map[string]string{"a.md": "a\n"}),
1255 "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
1256 t.Fatalf("initial main: %s", errs)
1257 }
1258 _, head := f.cloneWithChange(t, map[string]string{"a.md": "b\n"}, "change")
1259
1260 meta := f.newMetaRepo(t)
1261 err := meta.apply(map[string]string{
1262 "requests/" + reqID + "/meta.json": requestMeta("main", head, "wilbur"),
1263 "requests/" + reqID + "/approvals/wilbur.json": approvalFile("main", head),
1264 }, "self approve")
1265 if err == nil {
1266 t.Fatal("a self-approval was accepted")
1267 }
1268 if !strings.Contains(err.Error(), "cannot approve their own request") {
1269 t.Fatalf("unexpected error: %v", err)
1270 }
1271 _ = writerKey
1272 }
1273
1274 // TestMergeCommitShapeAllowed: landing through a merge commit whose parents are
1275 // the branch tip and the approved head is allowed; smuggling an extra commit into
1276 // that merge is not.
1277 func TestMergeCommitShapeAllowed(t *testing.T) {
1278 f := newFixture(t)
1279 writerKey := f.addWriter(t)
1280 f.publish(f.repo)
1281 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
1282 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
1283 t.Fatalf("initial main: %s", errs)
1284 }
1285 m := mustRepo(t, f)
1286 a, _ := m.ACL()
1287 a.Protected = []string{"refs/heads/main"}
1288 if err := m.SetACL(a, "test"); err != nil {
1289 t.Fatal(err)
1290 }
1291
1292 feature, head := f.cloneWithChange(t, map[string]string{"readme.md": "v2\n"}, "feature")
1293 if _, errs, err := f.gitWithKey(writerKey, feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
1294 t.Fatalf("feature: %s", errs)
1295 }
1296 meta := f.newMetaRepo(t)
1297 if err := meta.apply(map[string]string{
1298 "requests/" + reqID + "/meta.json": requestMeta("main", head, "wilbur"),
1299 }, "request"); err != nil {
1300 t.Fatalf("request: %v", err)
1301 }
1302 approver := f.newMetaRepo(t)
1303 if err := approver.apply(map[string]string{
1304 "requests/" + reqID + "/approvals/keenan.json": approvalFile("main", head),
1305 }, "approve"); err != nil {
1306 t.Fatalf("approve: %v", err)
1307 }
1308
1309 // A merge of main + the approved head is allowed.
1310 mergeWork := filepath.Join(t.TempDir(), "merge")
1311 if err := os.MkdirAll(mergeWork, 0o755); err != nil {
1312 t.Fatal(err)
1313 }
1314 if _, errs, err := f.git("", "clone", "-q", f.sshURL(f.repo), mergeWork); err != nil {
1315 t.Fatalf("clone: %s", errs)
1316 }
1317 if _, errs, err := f.git(mergeWork, "fetch", "-q", "origin", "feature:feature"); err != nil {
1318 t.Fatalf("fetch feature: %v: %s", err, errs)
1319 }
1320 if _, errs, err := runGit(t, mergeWork, "merge", "-q", "--no-ff", "-m", "merge feature", "feature"); err != nil {
1321 t.Fatalf("merge: %v: %s", err, errs)
1322 }
1323 if _, errs, err := f.gitWithKey(writerKey, mergeWork, "push", f.sshURL(f.repo), "HEAD:refs/heads/main"); err != nil {
1324 t.Fatalf("approved merge was refused: %s", errs)
1325 }
1326 }
1327
1328 // TestImpersonatedApprovalRejected: an approval file is only written by the person
1329 // it names, otherwise "approval" means nothing.
1330 func TestImpersonatedApprovalRejected(t *testing.T) {
1331 f := newFixture(t)
1332 writerKey := f.addWriter(t)
1333 f.publish(f.repo)
1334 if _, errs, err := f.git(workRepo(t, map[string]string{"a.md": "a\n"}),
1335 "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
1336 t.Fatalf("initial main: %s", errs)
1337 }
1338 _, head := f.cloneWithChange(t, map[string]string{"a.md": "b\n"}, "change")
1339
1340 meta := f.newMetaRepo(t)
1341 if err := meta.apply(map[string]string{
1342 "requests/" + reqID + "/meta.json": requestMeta("main", head, "wilbur"),
1343 }, "request"); err != nil {
1344 t.Fatalf("request: %v", err)
1345 }
1346
1347 impostor := f.newMetaRepo(t)
1348 impostor.key = writerKey
1349 err := impostor.apply(map[string]string{
1350 "requests/" + reqID + "/approvals/keenan.json": approvalFile("main", head),
1351 }, "approve as someone else")
1352 if err == nil {
1353 t.Fatal("a writer approved in another principal's name")
1354 }
1355 if !strings.Contains(err.Error(), "may not add an approval in keenan's name") {
1356 t.Fatalf("unexpected error: %v", err)
1357 }
1358 }
1359
1360 // TestApprovalIsBoundToTheBase: repointing a request at another branch must not
1361 // carry its approvals along.
1362 func TestApprovalIsBoundToTheBase(t *testing.T) {
1363 f := newFixture(t)
1364 writerKey := f.addWriter(t)
1365 f.publish(f.repo)
1366 if _, errs, err := f.git(workRepo(t, map[string]string{"a.md": "a\n"}),
1367 "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
1368 t.Fatalf("initial main: %s", errs)
1369 }
1370 if _, errs, err := f.git(workRepo(t, map[string]string{"b.md": "b\n"}),
1371 "push", f.sshURL(f.repo), "main:refs/heads/release"); err != nil {
1372 t.Fatalf("release: %s", errs)
1373 }
1374 m := mustRepo(t, f)
1375 a, _ := m.ACL()
1376 a.Protected = []string{"refs/heads/main", "refs/heads/release"}
1377 if err := m.SetACL(a, "test"); err != nil {
1378 t.Fatal(err)
1379 }
1380
1381 feature, head := f.cloneWithChange(t, map[string]string{"a.md": "v2\n"}, "feature")
1382 meta := f.newMetaRepo(t)
1383 if err := meta.apply(map[string]string{
1384 "requests/" + reqID + "/meta.json": requestMeta("main", head, "wilbur"),
1385 }, "request"); err != nil {
1386 t.Fatalf("request: %v", err)
1387 }
1388 approver := f.newMetaRepo(t)
1389 if err := approver.apply(map[string]string{
1390 "requests/" + reqID + "/approvals/keenan.json": approvalFile("main", head),
1391 }, "approve"); err != nil {
1392 t.Fatalf("approve: %v", err)
1393 }
1394
1395 // Repoint the request at release. The approval named main.
1396 repoint := f.newMetaRepo(t)
1397 if err := repoint.apply(map[string]string{
1398 "requests/" + reqID + "/meta.json": requestMeta("release", head, "wilbur"),
1399 }, "repoint"); err != nil {
1400 t.Fatalf("repoint: %v", err)
1401 }
1402 if _, _, err := f.gitWithKey(writerKey, feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/release"); err == nil {
1403 t.Fatal("an approval for main landed the commit on release")
1404 }
1405 }
1406
1407 // TestFirstPushCannotSeedForeignApprovals: the check that an approval is written by
1408 // its named principal must also cover the push that creates the branch, when there
1409 // is no previous commit to diff against.
1410 func TestFirstPushCannotSeedForeignApprovals(t *testing.T) {
1411 f := newFixture(t)
1412 writerKey := f.addWriter(t)
1413 f.publish(f.repo)
1414 if _, errs, err := f.git(workRepo(t, map[string]string{"a.md": "a\n"}),
1415 "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
1416 t.Fatalf("initial main: %s", errs)
1417 }
1418 _, head := f.cloneWithChange(t, map[string]string{"a.md": "b\n"}, "change")
1419
1420 // The writer creates the metadata branch, seeding an approval in keenan's name.
1421 meta := f.newMetaRepo(t)
1422 meta.key = writerKey
1423 err := meta.apply(map[string]string{
1424 "requests/" + reqID + "/meta.json": requestMeta("main", head, "wilbur"),
1425 "requests/" + reqID + "/approvals/keenan.json": approvalFile("main", head),
1426 }, "seed")
1427 if err == nil {
1428 t.Fatal("the first push seeded an approval in another principal's name")
1429 }
1430 if !strings.Contains(err.Error(), "may not add an approval in keenan's name") {
1431 t.Fatalf("unexpected error: %v", err)
1432 }
1433 }
1434
1018 1435 func mustRepo(t *testing.T, f *fixture) *repo.Meta {
1019 1436 t.Helper()
1020 1437 m, err := f.srv.Repos.Open(f.repo)