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