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/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

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

Accounts with stable ids; the roster moves into the system repository

31df8baea9c9e8acf74b71bdda26d27907b30f2a · Keenan Nemetz · 17d ago · parent 36293228

Accounts with stable ids; the roster moves into the system repository

Names were the primary key everywhere — the keys file name, the ACL entry, the
approval file — so renaming someone broke their access, orphaned their approvals,
and left a retired name able to hand a newcomer the old holder's rights. This
splits identity into an account (stable id, anchored by its SSH keys) and a name
(mutable, never reissued).

  data/system.git  refs/heads/main
    users/<ulid>/meta.json   name, status, keys, former_names

Everything that grants authority now holds the id: ACL entries, approval files,
and the identity resolved at the SSH handshake (VOLE_ACTOR_ID). A rename is a
one-file change that disturbs nothing else, and both the old and new names stay
reserved forever.

The roster moved from a trusted-keys directory back into git, which reverses an
earlier simplification — deliberately, and in a smaller shape (one file per
account, one writer, no CAS plumbing for unused fields). Git buys what a directory
could not: `git log` answers who renamed whom and when a key appeared, and the
roster is now readable by anyone who can clone, since it is served read-only at
ssh://host/system.git. `vole request new` and `approve` use exactly that to turn a
name typed on the command line into the account id they store.

Two latent bugs surfaced, both caught by tests added here:

- gitx inherited git's repository-scoped environment. pre-receive runs with
  GIT_OBJECT_DIRECTORY and GIT_QUARANTINE_PATH pointing at the pushed repository's
  quarantine, so every git call a hook made for a *different* repository silently
  read the pushed one: the hook saw an empty roster. gitx now scrubs those
  variables and the hook restores them for the pushed repository only.
- The hook built the repository name with filepath.Base on receive-pack's path,
  which carries a trailing separator, so refusals read "does not have write access
  to .". Cleaned first.

Verified end to end on a live instance: two accounts file, approve and land a
change; `vole user rename wilbur robert` keeps the same key's write access and the
approval still reads as current, now credited to robert; the roster history shows
"user: rename wilbur to robert"; retiring an account rejects its key within five
seconds and refuses to reissue the name.

internal/server/integration_test.go +154−50

@@ -51,14 +51,16 @@ func TestMain(m *testing.M) {
51 51 }
52 52
53 53 type fixture struct {
54 t *testing.T
55 cfg *config.Config
56 srv *server.Server
57 http *httptest.Server
58 sshAddr string
59 keyPath string
60 keyPub string
61 repo string
54 t *testing.T
55 cfg *config.Config
56 srv *server.Server
57 http *httptest.Server
58 sshAddr string
59 keyPath string
60 keyPub string
61 repo string
62 ownerID string
63 writerID string
62 64 }
63 65
64 66 func newFixture(t *testing.T) *fixture {
@@ -71,15 +73,16 @@ func newFixture(t *testing.T) *fixture {
71 73 if err := os.MkdirAll(cfg.ReposDir(), 0o755); err != nil {
72 74 t.Fatal(err)
73 75 }
74 if err := identity.Init(cfg.TrustedKeysDir()); err != nil {
76 if err := identity.Init(cfg.SystemRepo()); err != nil {
75 77 t.Fatal(err)
76 78 }
77 79 pub, priv := keypair(t, filepath.Join(cfg.DataDir, "id_test"))
78 ident, err := identity.Open(cfg.TrustedKeysDir())
80 ident, err := identity.Open(cfg.SystemRepo())
79 81 if err != nil {
80 82 t.Fatal(err)
81 83 }
82 if err := ident.Create("keenan"); err != nil {
84 owner, err := ident.Create("keenan", "test")
85 if err != nil {
83 86 t.Fatal(err)
84 87 }
85 88 if _, err := ident.AddKey("keenan", pub, "test"); err != nil {
@@ -100,7 +103,7 @@ func newFixture(t *testing.T) *fixture {
100 103
101 104 f := &fixture{
102 105 t: t, cfg: cfg, srv: srv, http: ts, sshAddr: ln.Addr().String(),
103 keyPath: priv, keyPub: pub, repo: "demo",
106 keyPath: priv, keyPub: pub, repo: "demo", ownerID: owner.ID,
104 107 }
105 108 t.Cleanup(func() {
106 109 ts.Close()
@@ -108,7 +111,7 @@ func newFixture(t *testing.T) *fixture {
108 111 srv.Close()
109 112 })
110 113
111 m, err := srv.Repos.Create(f.repo, "keenan", "demo repo")
114 m, err := srv.Repos.Create(f.repo, owner.ID, "demo repo")
112 115 if err != nil {
113 116 t.Fatal(err)
114 117 }
@@ -142,10 +145,11 @@ func keypair(t *testing.T, path string) (pubLine, privPath string) {
142 145 func (f *fixture) addWriter(t *testing.T) string {
143 146 t.Helper()
144 147 pub, priv := keypair(t, filepath.Join(f.cfg.DataDir, "id_writer"))
145 if err := f.srv.Identity.Create("wilbur"); err != nil {
148 writer, err := f.srv.Identity.Create("wilbur", "test")
149 if err != nil {
146 150 t.Fatal(err)
147 151 }
148 if _, err := f.srv.Identity.AddKey("wilbur", pub, "test"); err != nil {
152 if _, err := f.srv.Identity.AddKey(writer.ID, pub, "test"); err != nil {
149 153 t.Fatal(err)
150 154 }
151 155 m := mustRepo(t, f)
@@ -153,15 +157,27 @@ func (f *fixture) addWriter(t *testing.T) string {
153 157 if err != nil {
154 158 t.Fatal(err)
155 159 }
156 if err := a.SetEntry("wilbur", "write"); err != nil {
160 // ACL entries are keyed by account id, not by name.
161 if err := a.SetEntry(writer.ID, "write"); err != nil {
157 162 t.Fatal(err)
158 163 }
159 164 if err := m.SetACL(a, "test"); err != nil {
160 165 t.Fatal(err)
161 166 }
167 f.writerID = writer.ID
162 168 return priv
163 169 }
164 170
171 // accountID resolves a name to the account id that approvals and ACLs store.
172 func (f *fixture) accountID(name string) string {
173 f.t.Helper()
174 accountID, ok := f.srv.Identity.ResolveID(name)
175 if !ok {
176 f.t.Fatalf("no account %q", name)
177 }
178 return accountID
179 }
180
165 181 // gitWithKey is f.git with a specific identity.
166 182 func (f *fixture) gitWithKey(key, dir string, args ...string) (string, string, error) {
167 183 f.t.Helper()
@@ -413,21 +429,21 @@ func TestRosterDirectoryIsTheWholeIdentity(t *testing.T) {
413 429 }
414 430
415 431 copied := filepath.Join(t.TempDir(), "trusted_keys")
416 if out, err := exec.Command("cp", "-r", f.cfg.TrustedKeysDir(), copied).CombinedOutput(); err != nil {
432 if out, err := exec.Command("cp", "-r", f.cfg.SystemRepo(), copied).CombinedOutput(); err != nil {
417 433 t.Fatalf("copy roster: %v: %s", err, out)
418 434 }
419 435 ident, err := identity.Open(copied)
420 436 if err != nil {
421 437 t.Fatal(err)
422 438 }
423 if _, ok := ident.Snapshot().Users["keenan"]; !ok {
439 if _, ok := ident.Snapshot().ByName["keenan"]; !ok {
424 440 t.Fatal("copied roster has no users")
425 441 }
426 if len(ident.Snapshot().ByFingerprint) != 1 {
427 t.Fatalf("copied roster has %d keys", len(ident.Snapshot().ByFingerprint))
442 if len(ident.Snapshot().ByKey) != 1 {
443 t.Fatalf("copied roster has %d keys", len(ident.Snapshot().ByKey))
428 444 }
429 445
430 for _, absent := range []string{"auth.db", "system.git"} {
446 for _, absent := range []string{"auth.db", "trusted_keys"} {
431 447 if _, err := os.Stat(filepath.Join(f.cfg.DataDir, absent)); err == nil {
432 448 t.Fatalf("%s exists; identity is supposed to be a keys directory only", absent)
433 449 }
@@ -447,7 +463,7 @@ func TestRosterIsReloadable(t *testing.T) {
447 463 if _, err := f.srv.Identity.AddKey("keenan", pub, "test"); err != nil {
448 464 t.Fatal(err)
449 465 }
450 if got := len(f.srv.Identity.Snapshot().ByFingerprint); got != 2 {
466 if got := len(f.srv.Identity.Snapshot().ByKey); got != 2 {
451 467 t.Fatalf("keys = %d, want 2", got)
452 468 }
453 469 audit, err := os.ReadFile(f.cfg.AuditLog())
@@ -1130,9 +1146,9 @@ func (f *fixture) cloneWithChange(t *testing.T, files map[string]string, msg str
1130 1146
1131 1147 const reqID = "01M5JR2JK29KKMAA1T9MDNTR01"
1132 1148
1133 func requestMeta(base, head, author string) string {
1134 return fmt.Sprintf(`{"title":"Land the feature","author":%q,"base":%q,"head":%q,"state":"open","created":"2026-10-22T22:00:00Z"}`+"\n",
1135 author, base, head)
1149 func requestMeta(base, head, author, authorID string) string {
1150 return fmt.Sprintf(`{"title":"Land the feature","author":%q,"author_id":%q,"base":%q,"head":%q,"state":"open","created":"2026-10-22T22:00:00Z"}`+"\n",
1151 author, authorID, base, head)
1136 1152 }
1137 1153
1138 1154 func approvalFile(base, head string) string {
@@ -1179,7 +1195,7 @@ func TestProtectedBranchRequiresApprovedRequest(t *testing.T) {
1179 1195 // 2. Request open, no approvals: still refused.
1180 1196 meta := f.newMetaRepo(t)
1181 1197 if err := meta.apply(map[string]string{
1182 "requests/" + reqID + "/meta.json": requestMeta("main", head, "wilbur"),
1198 "requests/" + reqID + "/meta.json": requestMeta("main", head, "wilbur", f.accountID("wilbur")),
1183 1199 "requests/" + reqID + "/description.md": "please\n",
1184 1200 }, "request"); err != nil {
1185 1201 t.Fatalf("request push: %v", err)
@@ -1193,7 +1209,7 @@ func TestProtectedBranchRequiresApprovedRequest(t *testing.T) {
1193 1209 // 3. Approval by another principal: allowed.
1194 1210 approver := f.newMetaRepo(t)
1195 1211 if err := approver.apply(map[string]string{
1196 "requests/" + reqID + "/approvals/keenan.json": approvalFile("main", head),
1212 "requests/" + reqID + "/approvals/" + f.accountID("keenan") + ".json": approvalFile("main", head),
1197 1213 }, "approve"); err != nil {
1198 1214 t.Fatalf("approval push: %v", err)
1199 1215 }
@@ -1231,13 +1247,13 @@ func TestApprovalIsBoundToTheCommit(t *testing.T) {
1231 1247
1232 1248 meta := f.newMetaRepo(t)
1233 1249 if err := meta.apply(map[string]string{
1234 "requests/" + reqID + "/meta.json": requestMeta("main", approved, "wilbur"),
1250 "requests/" + reqID + "/meta.json": requestMeta("main", approved, "wilbur", f.accountID("wilbur")),
1235 1251 }, "request"); err != nil {
1236 1252 t.Fatalf("request: %v", err)
1237 1253 }
1238 1254 approver := f.newMetaRepo(t)
1239 1255 if err := approver.apply(map[string]string{
1240 "requests/" + reqID + "/approvals/keenan.json": approvalFile("main", approved),
1256 "requests/" + reqID + "/approvals/" + f.accountID("keenan") + ".json": approvalFile("main", approved),
1241 1257 }, "approve"); err != nil {
1242 1258 t.Fatalf("approve: %v", err)
1243 1259 }
@@ -1265,8 +1281,8 @@ func TestSelfApprovalRejected(t *testing.T) {
1265 1281
1266 1282 meta := f.newMetaRepo(t)
1267 1283 err := meta.apply(map[string]string{
1268 "requests/" + reqID + "/meta.json": requestMeta("main", head, "wilbur"),
1269 "requests/" + reqID + "/approvals/wilbur.json": approvalFile("main", head),
1284 "requests/" + reqID + "/meta.json": requestMeta("main", head, "wilbur", f.accountID("wilbur")),
1285 "requests/" + reqID + "/approvals/" + f.accountID("wilbur") + ".json": approvalFile("main", head),
1270 1286 }, "self approve")
1271 1287 if err == nil {
1272 1288 t.Fatal("a self-approval was accepted")
@@ -1301,13 +1317,13 @@ func TestMergeCommitShapeAllowed(t *testing.T) {
1301 1317 }
1302 1318 meta := f.newMetaRepo(t)
1303 1319 if err := meta.apply(map[string]string{
1304 "requests/" + reqID + "/meta.json": requestMeta("main", head, "wilbur"),
1320 "requests/" + reqID + "/meta.json": requestMeta("main", head, "wilbur", f.accountID("wilbur")),
1305 1321 }, "request"); err != nil {
1306 1322 t.Fatalf("request: %v", err)
1307 1323 }
1308 1324 approver := f.newMetaRepo(t)
1309 1325 if err := approver.apply(map[string]string{
1310 "requests/" + reqID + "/approvals/keenan.json": approvalFile("main", head),
1326 "requests/" + reqID + "/approvals/" + f.accountID("keenan") + ".json": approvalFile("main", head),
1311 1327 }, "approve"); err != nil {
1312 1328 t.Fatalf("approve: %v", err)
1313 1329 }
@@ -1345,7 +1361,7 @@ func TestImpersonatedApprovalRejected(t *testing.T) {
1345 1361
1346 1362 meta := f.newMetaRepo(t)
1347 1363 if err := meta.apply(map[string]string{
1348 "requests/" + reqID + "/meta.json": requestMeta("main", head, "wilbur"),
1364 "requests/" + reqID + "/meta.json": requestMeta("main", head, "wilbur", f.accountID("wilbur")),
1349 1365 }, "request"); err != nil {
1350 1366 t.Fatalf("request: %v", err)
1351 1367 }
@@ -1353,12 +1369,12 @@ func TestImpersonatedApprovalRejected(t *testing.T) {
1353 1369 impostor := f.newMetaRepo(t)
1354 1370 impostor.key = writerKey
1355 1371 err := impostor.apply(map[string]string{
1356 "requests/" + reqID + "/approvals/keenan.json": approvalFile("main", head),
1372 "requests/" + reqID + "/approvals/" + f.accountID("keenan") + ".json": approvalFile("main", head),
1357 1373 }, "approve as someone else")
1358 1374 if err == nil {
1359 1375 t.Fatal("a writer approved in another principal's name")
1360 1376 }
1361 if !strings.Contains(err.Error(), "keenan's approval") && !strings.Contains(err.Error(), "in keenan's name") {
1377 if !strings.Contains(err.Error(), "keenan") {
1362 1378 t.Fatalf("rejection does not name the impersonated principal: %v", err)
1363 1379 }
1364 1380 }
@@ -1387,13 +1403,13 @@ func TestApprovalIsBoundToTheBase(t *testing.T) {
1387 1403 feature, head := f.cloneWithChange(t, map[string]string{"a.md": "v2\n"}, "feature")
1388 1404 meta := f.newMetaRepo(t)
1389 1405 if err := meta.apply(map[string]string{
1390 "requests/" + reqID + "/meta.json": requestMeta("main", head, "wilbur"),
1406 "requests/" + reqID + "/meta.json": requestMeta("main", head, "wilbur", f.accountID("wilbur")),
1391 1407 }, "request"); err != nil {
1392 1408 t.Fatalf("request: %v", err)
1393 1409 }
1394 1410 approver := f.newMetaRepo(t)
1395 1411 if err := approver.apply(map[string]string{
1396 "requests/" + reqID + "/approvals/keenan.json": approvalFile("main", head),
1412 "requests/" + reqID + "/approvals/" + f.accountID("keenan") + ".json": approvalFile("main", head),
1397 1413 }, "approve"); err != nil {
1398 1414 t.Fatalf("approve: %v", err)
1399 1415 }
@@ -1401,7 +1417,7 @@ func TestApprovalIsBoundToTheBase(t *testing.T) {
1401 1417 // Repoint the request at release. The approval named main.
1402 1418 repoint := f.newMetaRepo(t)
1403 1419 if err := repoint.apply(map[string]string{
1404 "requests/" + reqID + "/meta.json": requestMeta("release", head, "wilbur"),
1420 "requests/" + reqID + "/meta.json": requestMeta("release", head, "wilbur", f.accountID("wilbur")),
1405 1421 }, "repoint"); err != nil {
1406 1422 t.Fatalf("repoint: %v", err)
1407 1423 }
@@ -1427,13 +1443,13 @@ func TestFirstPushCannotSeedForeignApprovals(t *testing.T) {
1427 1443 meta := f.newMetaRepo(t)
1428 1444 meta.key = writerKey
1429 1445 err := meta.apply(map[string]string{
1430 "requests/" + reqID + "/meta.json": requestMeta("main", head, "wilbur"),
1431 "requests/" + reqID + "/approvals/keenan.json": approvalFile("main", head),
1446 "requests/" + reqID + "/meta.json": requestMeta("main", head, "wilbur", f.accountID("wilbur")),
1447 "requests/" + reqID + "/approvals/" + f.accountID("keenan") + ".json": approvalFile("main", head),
1432 1448 }, "seed")
1433 1449 if err == nil {
1434 1450 t.Fatal("the first push seeded an approval in another principal's name")
1435 1451 }
1436 if !strings.Contains(err.Error(), "keenan's approval") && !strings.Contains(err.Error(), "in keenan's name") {
1452 if !strings.Contains(err.Error(), "keenan") {
1437 1453 t.Fatalf("rejection does not name the impersonated principal: %v", err)
1438 1454 }
1439 1455 }
@@ -1462,13 +1478,13 @@ func TestModifiedApprovalCannotBeForged(t *testing.T) {
1462 1478 }
1463 1479 meta := f.newMetaRepo(t)
1464 1480 if err := meta.apply(map[string]string{
1465 "requests/" + reqID + "/meta.json": requestMeta("main", approvedHead, "wilbur"),
1481 "requests/" + reqID + "/meta.json": requestMeta("main", approvedHead, "wilbur", f.accountID("wilbur")),
1466 1482 }, "request"); err != nil {
1467 1483 t.Fatalf("request: %v", err)
1468 1484 }
1469 1485 approver := f.newMetaRepo(t)
1470 1486 if err := approver.apply(map[string]string{
1471 "requests/" + reqID + "/approvals/keenan.json": approvalFile("main", approvedHead),
1487 "requests/" + reqID + "/approvals/" + f.accountID("keenan") + ".json": approvalFile("main", approvedHead),
1472 1488 }, "approve"); err != nil {
1473 1489 t.Fatalf("approve: %v", err)
1474 1490 }
@@ -1484,8 +1500,8 @@ func TestModifiedApprovalCannotBeForged(t *testing.T) {
1484 1500 forger := f.newMetaRepo(t)
1485 1501 forger.key = writerKey
1486 1502 err := forger.apply(map[string]string{
1487 "requests/" + reqID + "/meta.json": requestMeta("main", sneaky, "wilbur"),
1488 "requests/" + reqID + "/approvals/keenan.json": approvalFile("main", sneaky),
1503 "requests/" + reqID + "/meta.json": requestMeta("main", sneaky, "wilbur", f.accountID("wilbur")),
1504 "requests/" + reqID + "/approvals/" + f.accountID("keenan") + ".json": approvalFile("main", sneaky),
1489 1505 }, "repoint approval")
1490 1506 if err == nil {
1491 1507 t.Fatal("a writer rewrote another principal's approval and the push was accepted")
@@ -1511,13 +1527,13 @@ func TestApprovalDeletionIsOwnerOrAdmin(t *testing.T) {
1511 1527 meta := f.newMetaRepo(t)
1512 1528 meta.key = writerKey
1513 1529 if err := meta.apply(map[string]string{
1514 "requests/" + reqID + "/meta.json": requestMeta("main", head, "wilbur"),
1530 "requests/" + reqID + "/meta.json": requestMeta("main", head, "wilbur", f.accountID("wilbur")),
1515 1531 }, "request"); err != nil {
1516 1532 t.Fatalf("setup: %v", err)
1517 1533 }
1518 1534 approver := f.newMetaRepo(t)
1519 1535 if err := approver.apply(map[string]string{
1520 "requests/" + reqID + "/approvals/keenan.json": approvalFile("main", head),
1536 "requests/" + reqID + "/approvals/" + f.accountID("keenan") + ".json": approvalFile("main", head),
1521 1537 }, "approve"); err != nil {
1522 1538 t.Fatalf("approve: %v", err)
1523 1539 }
@@ -1526,7 +1542,7 @@ func TestApprovalDeletionIsOwnerOrAdmin(t *testing.T) {
1526 1542 deleter := f.newMetaRepo(t)
1527 1543 deleter.key = writerKey
1528 1544 deleter.sync()
1529 if err := os.Remove(filepath.Join(deleter.dir, "requests", reqID, "approvals", "keenan.json")); err != nil {
1545 if err := os.Remove(filepath.Join(deleter.dir, "requests", reqID, "approvals", f.accountID("keenan")+".json")); err != nil {
1530 1546 t.Fatal(err)
1531 1547 }
1532 1548 if _, errs, err := runGit(t, deleter.dir, "add", "-A"); err != nil {
@@ -1546,7 +1562,7 @@ func TestApprovalDeletionIsOwnerOrAdmin(t *testing.T) {
1546 1562 // keenan withdraws his own.
1547 1563 own := f.newMetaRepo(t)
1548 1564 own.sync()
1549 if err := os.Remove(filepath.Join(own.dir, "requests", reqID, "approvals", "keenan.json")); err != nil {
1565 if err := os.Remove(filepath.Join(own.dir, "requests", reqID, "approvals", f.accountID("keenan")+".json")); err != nil {
1550 1566 t.Fatal(err)
1551 1567 }
1552 1568 if _, errs, cerr := runGit(t, own.dir, "add", "-A"); cerr != nil {
@@ -1560,6 +1576,94 @@ func TestApprovalDeletionIsOwnerOrAdmin(t *testing.T) {
1560 1576 }
1561 1577 }
1562 1578
1579 // TestRenameKeepsAccessAndApprovalOwnership is the whole point of putting a
1580 // stable id on the account: ACL entries and approval files hold the id, so
1581 // changing a person's name must not change what they may do, nor who owns their
1582 // past approvals.
1583 func TestRenameKeepsAccessAndApprovalOwnership(t *testing.T) {
1584 f := newFixture(t)
1585 writerKey := f.addWriter(t)
1586 f.publish(f.repo)
1587 if _, errs, err := f.git(workRepo(t, map[string]string{"a.md": "a\n"}),
1588 "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
1589 t.Fatalf("initial main: %s", errs)
1590 }
1591 _, head := f.cloneWithChange(t, map[string]string{"a.md": "b\n"}, "change")
1592
1593 // keenan authors a request; wilbur (the writer) approves it.
1594 meta := f.newMetaRepo(t)
1595 if err := meta.apply(map[string]string{
1596 "requests/" + reqID + "/meta.json": requestMeta("main", head, "keenan", f.ownerID),
1597 }, "request"); err != nil {
1598 t.Fatalf("request: %v", err)
1599 }
1600 approver := f.newMetaRepo(t)
1601 approver.key = writerKey
1602 if err := approver.apply(map[string]string{
1603 "requests/" + reqID + "/approvals/" + f.writerID + ".json": approvalFile("main", head),
1604 }, "approve"); err != nil {
1605 t.Fatalf("approve: %v", err)
1606 }
1607
1608 renamed, err := f.srv.Identity.Rename("wilbur", "robert", "test")
1609 if err != nil {
1610 t.Fatal(err)
1611 }
1612 if renamed.ID != f.writerID {
1613 t.Fatalf("rename changed the account id: %s -> %s", f.writerID, renamed.ID)
1614 }
1615
1616 // Access follows the id: the same key still has write, with a new name.
1617 issue := f.newMetaRepo(t)
1618 issue.key = writerKey
1619 if err := issue.apply(map[string]string{
1620 "issues/01M5JR2JK29KKMAA1T9MDNTZ01/meta.json": `{"title":"after rename","state":"open","author":"robert","created":"2026-10-22T22:00:00Z"}` + "\n",
1621 }, "issue after rename"); err != nil {
1622 t.Fatalf("the renamed account lost its access: %v", err)
1623 }
1624
1625 // Ownership follows the id too: robert may withdraw the approval he wrote as
1626 // wilbur, because the file is named after the account, not the person.
1627 withdraw := f.newMetaRepo(t)
1628 withdraw.key = writerKey
1629 withdraw.sync()
1630 if err := os.Remove(filepath.Join(withdraw.dir, "requests", reqID, "approvals", f.writerID+".json")); err != nil {
1631 t.Fatal(err)
1632 }
1633 if _, errs, err := runGit(t, withdraw.dir, "add", "-A"); err != nil {
1634 t.Fatalf("add: %v: %s", err, errs)
1635 }
1636 if _, errs, err := runGit(t, withdraw.dir, "commit", "-qm", "withdraw after rename"); err != nil {
1637 t.Fatalf("commit: %v: %s", err, errs)
1638 }
1639 if _, errs, err := fgit(t, writerKey, withdraw.dir, "push", "origin", "HEAD:refs/heads/vole"); err != nil {
1640 t.Fatalf("the renamed account could not withdraw its own approval: %s", errs)
1641 }
1642 }
1643
1644 // TestRetiredNameCannotBeReissued: a departure must not hand someone a retired
1645 // person's access and approvals.
1646 func TestRetiredNameCannotBeReissued(t *testing.T) {
1647 f := newFixture(t)
1648 writerKey := f.addWriter(t)
1649 f.publish(f.repo)
1650 if _, errs, err := f.git(workRepo(t, map[string]string{"a.md": "a\n"}),
1651 "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
1652 t.Fatalf("initial main: %s", errs)
1653 }
1654
1655 if _, err := f.srv.Identity.Retire("wilbur", "test"); err != nil {
1656 t.Fatal(err)
1657 }
1658 if _, err := f.srv.Identity.Create("wilbur", "test"); err == nil {
1659 t.Fatal("the retired name was reissued")
1660 }
1661 // And the retired key no longer authenticates.
1662 if _, _, err := f.gitWithKey(writerKey, t.TempDir(), "ls-remote", f.sshURL(f.repo)); err == nil {
1663 t.Fatal("a retired account's key still authenticates")
1664 }
1665 }
1666
1563 1667 func mustRepo(t *testing.T, f *fixture) *repo.Meta {
1564 1668 t.Helper()
1565 1669 m, err := f.srv.Repos.Open(f.repo)