diff --git a/tests/test_web.py b/tests/test_web.py index c4c524a..17b8d93 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -354,6 +354,42 @@ class PortalApplicationTests(unittest.TestCase): ) self.assertEqual("403 Forbidden", replay["status"]) + def test_public_registration_duplicate_inputs_never_take_over_identity(self): + verifier = FakeRegistrationVerification() + self.app.registration_verification = verifier + self.app.provisioning = FakeProvisioning() + self.app.registration_clients = frozenset({"coulomb-social"}) + self.app.registration_tenants = frozenset({"tenant:coulomb"}) + self.app.registration_oidc_issuer = "https://kc.example" + self.app.registration_password_setup_origins = frozenset({"https://kc.example"}) + + def register(username, email): + invoke(self.app, "/api/v1/public/registrations", method="POST", body={ + "username": username, "email": email, + "client_id": "coulomb-social", "tenant": "tenant:coulomb", + }) + registration_id = verifier.requested.registration_id + verifier.registration_id = registration_id + result, _ = invoke( + self.app, "/api/v1/public/registrations/verify", method="POST", + body={"handle": "x" * 32}, + ) + return result, self.app.service.store.registration_session(registration_id) + + first, first_session = register("first.person", "shared@example.test") + second, second_session = register("second.person", "shared@example.test") + collision, collision_session = register("first.person", "other@example.test") + + self.assertEqual("303 See Other", first["status"]) + self.assertEqual("303 See Other", second["status"]) + self.assertNotEqual(first_session.user_id, second_session.user_id) + self.assertEqual("409 Conflict", collision["status"]) + linked = self.app.service.store.find_identity( + "https://kc.example", "first.person" + ) + self.assertEqual(first_session.user_id, linked.user_id) + self.assertNotEqual(collision_session.user_id, linked.user_id) + def test_public_registration_rejects_unregistered_client(self): self.app.registration_verification = FakeRegistrationVerification() self.app.registration_clients = frozenset({"coulomb-social"}) diff --git a/workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md b/workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md index 4fe0b3f..f09ea0d 100644 --- a/workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md +++ b/workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md @@ -197,3 +197,10 @@ verification expiry/replay redaction, peer rate limiting, identity-link collision, cross-tenant denial, lifecycle deletion, audit/outbox redaction, and request correlation. Deployed consumer conformance remains gated on public runtime credentials and activation. + +Duplicate-input conformance now also proves that a verified mailbox reused +with a different username creates a distinct local user instead of implicitly +linking to an existing account. Reusing an existing provider username fails at +the identity-link uniqueness boundary, leaves the original link unchanged, and +never transfers that identity to the later registration. The full suite passes +131 tests with three environment-dependent skips.