From 14ccbd59f07c55c77f570be44a1c1c7c02ba1db3 Mon Sep 17 00:00:00 2001 From: Nirav Patel Date: Wed, 19 Aug 2026 09:25:13 -0400 Subject: [PATCH] test+docs: startup-refusal exit code, real-body 413, pre-1.1.0 migration note - main() exits 2 on permissive config (not just the predicate) - 413 proven with an actual over-limit wire body, not header-only - README: machine-user accounts need a seeded state.json mapping; only granthi-sync-e2e required it in beta Co-Authored-By: Claude Fable 5 --- README.md | 10 ++++++++++ tests/test_server.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/README.md b/README.md index 96cdeed..8cba3f7 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,16 @@ A Gitea token is **never minted before the binding rule passes**, and a corrupt/unreadable `state.json` fails closed (500) instead of falling back to an empty map. +**Migration (pre-1.1.0 accounts):** identities whose userinfo carries no +verified `email` (e.g. Zitadel *machine* users) cannot self-adopt an +existing forge login under rule (c) — for them the first link after the +upgrade would 409 forever. Any forge account the v1 service created before +this change must be seeded into `state.json` once, as +`{"": {"login": "", "created_by_service": true, ...}}`, written +0600 atomically. As of the beta rollout the only such account is the E2E +machine user `granthi-sync-e2e` (seeded); the forge's human admin `nirpa` +is never provisioned through `/v1/link`, so nothing else needed seeding. + Empirically verified mechanics on Gitea **1.27.1** (beta forge): * Token minting: `POST /api/v1/users/{login}/tokens` returns **401 for diff --git a/tests/test_server.py b/tests/test_server.py index 7ce3789..2582ef7 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -364,6 +364,22 @@ class TestConfigPerms(unittest.TestCase): err = granthi_link.check_config_perms(self.tmp.name, euid=not_me) self.assertIn("owned by uid", err) + def test_main_exits_2_on_permissive_config(self): + """The refusal must actually stop startup (exit nonzero), not just + return a string -- verified through main().""" + with open(self.tmp.name, "w") as f: + json.dump({"gitea_base": "http://x", "admin_token": "t", + "admin_login": "r", "admin_password": "p"}, f) + os.chmod(self.tmp.name, 0o644) + with mock.patch.object(granthi_link.sys, "argv", + ["granthi_link.py", self.tmp.name]), \ + mock.patch.object(granthi_link, "serve") as served, \ + self.assertLogs("granthi-link", level="ERROR"): + with self.assertRaises(SystemExit) as cm: + granthi_link.main() + self.assertEqual(cm.exception.code, 2) + served.assert_not_called() # never reached serve() + class HandlerTestBase(ServiceTestBase): def setUp(self): @@ -397,6 +413,18 @@ class TestBodyLimits(HandlerTestBase): self.assertEqual(status, 413) self.assertIn("too large", resp["error"]) + def test_oversized_real_body_413(self): + """Send an actual over-limit payload on the wire (not just the + header), so the response is proven, not the predicate alone.""" + body = json.dumps( + {"pad": "x" * (granthi_link.MAX_BODY_BYTES + 2000)}).encode() + self.assertGreater(len(body), granthi_link.MAX_BODY_BYTES) + status, resp = self.raw_post( + "/v1/link", body_bytes=body, + headers={"Content-Length": str(len(body)), + "Content-Type": "application/json"}) + self.assertEqual(status, 413) + def test_missing_content_length_411(self): status, _ = self.raw_post("/v1/link") self.assertEqual(status, 411)