diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ea247d..579bab3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,24 @@ skip versions are the composition of each intervening adjacent release's steps in order — no A-to-B path is pre-computed beyond that. +## 0.54.1 — 2026-06-09 + +**Patch — the Gitea-OAuth sign-in no longer 500s when an account was first +created via OTC; no operator action required.** + +`auth.provision_user` (the Gitea-OAuth callback path) matched an existing +`users` row only by `gitea_id`. A human who signed in first via the OTC +email path owns an email-only row (`gitea_id` NULL); when they later signed +in via Gitea OAuth with the same email, the lookup missed and the code +INSERTed a new row — colliding on the `idx_users_email` case-insensitive +unique index and raising `IntegrityError`, which 500-ed the callback. Now, +when no `gitea_id` row exists, `provision_user` reconciles by email and links +the OAuth identity (`gitea_id` / `gitea_login` / profile) onto the existing +row — the mirror of the OTC linker, which already links an OAuth-era email row +on first OTC sign-in. The §6.1 owner-zero bootstrap is applied on link +(matching the fresh-insert path); the row's `permission_state` is preserved so +linking never changes a human's admission status as a side effect. + ## 0.54.0 — 2026-06-09 **Minor — the AI "Ask" affordance now works from an entry's canonical view diff --git a/VERSION b/VERSION index 524456c..1942d77 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.54.0 +0.54.1 diff --git a/backend/app/auth.py b/backend/app/auth.py index c7ab37f..dc37912 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -145,6 +145,19 @@ def provision_user(config: Config, profile: dict[str, Any]) -> SessionUser: c = db.conn() existing = c.execute("SELECT * FROM users WHERE gitea_id = ?", (gitea_id,)).fetchone() + # No row for this gitea_id yet. A prior OTC sign-in (`provision_or_link_user`) + # may have created an email-only row with this same email and `gitea_id` + # NULL; `idx_users_email` is a unique index, so a blind INSERT below would + # raise IntegrityError and 500 the OAuth callback. Reconcile by email: link + # the OAuth identity onto that existing row instead. Mirror of the OTC + # linker, which links an OAuth-era email row on first OTC sign-in. + linked = False + if existing is None and email: + existing = c.execute( + "SELECT * FROM users WHERE email = ? COLLATE NOCASE AND gitea_id IS NULL LIMIT 1", + (email,), + ).fetchone() + linked = existing is not None if existing is None: role = "owner" if config.owner_gitea_login and login == config.owner_gitea_login else "contributor" # v0.8.0: a fresh OAuth-provisioned user is also subject to @@ -166,15 +179,26 @@ def provision_user(config: Config, profile: dict[str, Any]) -> SessionUser: permission_state = "granted" else: user_id = existing["id"] - role = existing["role"] + # On a fresh link this OAuth sign-in is the row's first, so apply the + # §6.1 owner-zero bootstrap (matching the INSERT path). For a row already + # matched by gitea_id the role is settled — preserve it. `permission_state` + # is preserved either way: linking an OAuth identity onto an existing + # email row must not silently change the human's admission status. + if linked and config.owner_gitea_login and login == config.owner_gitea_login: + role = "owner" + else: + role = existing["role"] permission_state = existing["permission_state"] or "granted" + # Setting `gitea_id` matters only on the link path (it was NULL); on the + # gitea_id-matched path it re-writes the same value. `role` is likewise a + # no-op there. Always refresh the mutable profile fields + last_seen. c.execute( """ UPDATE users - SET gitea_login = ?, email = ?, display_name = ?, avatar_url = ?, last_seen_at = datetime('now') + SET gitea_id = ?, gitea_login = ?, email = ?, display_name = ?, avatar_url = ?, role = ?, last_seen_at = datetime('now') WHERE id = ? """, - (login, email, display, avatar, user_id), + (gitea_id, login, email, display, avatar, role, user_id), ) return SessionUser( diff --git a/backend/tests/test_provision_user_email_link.py b/backend/tests/test_provision_user_email_link.py new file mode 100644 index 0000000..eca0c8c --- /dev/null +++ b/backend/tests/test_provision_user_email_link.py @@ -0,0 +1,107 @@ +"""§6 hardening — the Gitea-OAuth `auth.provision_user` reconciles by email. + +Regression cover for a §9-surfaced fragility: a human who signed in first via +OTC owns an email-only `users` row (`gitea_id` NULL). When they later sign in +via Gitea OAuth with the SAME email, `provision_user` used to match only by +`gitea_id`, miss the OTC row, and INSERT a new row — colliding on the +`idx_users_email` unique index and 500-ing the callback. The fix links the OAuth +identity onto the existing email row (the mirror of the OTC linker). +""" +from __future__ import annotations + +from test_propose_vertical import ( # noqa: F401 + FakeGitea, + app_with_fake_gitea, + provision_user_row, + tmp_env, +) + + +def _cfg(): + from app.config import load_config + return load_config() + + +def test_oauth_links_onto_existing_otc_email_row(app_with_fake_gitea): + from fastapi.testclient import TestClient + from app import auth, db + + app, _fake = app_with_fake_gitea + with TestClient(app): + # An OTC-first user: email-only row, gitea_id NULL, still 'pending'. + db.conn().execute( + """ + INSERT INTO users (gitea_id, gitea_login, email, display_name, avatar_url, role, permission_state) + VALUES (NULL, NULL, ?, ?, '', 'contributor', 'pending') + """, + ("dual@example.com", "dual"), + ) + otc_id = db.conn().execute( + "SELECT id FROM users WHERE email = ? COLLATE NOCASE", ("dual@example.com",) + ).fetchone()["id"] + + # Now the same human signs in via Gitea OAuth (new gitea_id, same email). + # Case-different email proves the NOCASE match. + user = auth.provision_user( + _cfg(), + {"id": 9001, "login": "dualgitea", "email": "Dual@Example.com", + "full_name": "Dual User", "avatar_url": "http://x/a.png"}, + ) + + # Linked onto the SAME row — no second row, no 500. + assert user.user_id == otc_id + rows = db.conn().execute( + "SELECT id, gitea_id, gitea_login, permission_state, role FROM users WHERE email = ? COLLATE NOCASE", + ("dual@example.com",), + ).fetchall() + assert len(rows) == 1 + assert rows[0]["id"] == otc_id + assert rows[0]["gitea_id"] == 9001 # OAuth identity attached + assert rows[0]["gitea_login"] == "dualgitea" + assert rows[0]["permission_state"] == "pending" # admission state preserved + assert rows[0]["role"] == "contributor" + + +def test_oauth_provisions_fresh_user_when_email_matches_no_one(app_with_fake_gitea): + from fastapi.testclient import TestClient + from app import auth, db + + app, _fake = app_with_fake_gitea + with TestClient(app): + user = auth.provision_user( + _cfg(), + {"id": 9100, "login": "freshoauth", "email": "fresh@example.com", + "full_name": "Fresh", "avatar_url": ""}, + ) + row = db.conn().execute( + "SELECT gitea_id, gitea_login, role, permission_state FROM users WHERE id = ?", + (user.user_id,), + ).fetchone() + assert row["gitea_id"] == 9100 + assert row["gitea_login"] == "freshoauth" + # Reaching provision_user means admission passed → granted (unchanged). + assert row["permission_state"] == "granted" + + +def test_returning_oauth_user_matched_by_gitea_id_not_duplicated(app_with_fake_gitea): + from fastapi.testclient import TestClient + from app import auth, db + + app, _fake = app_with_fake_gitea + with TestClient(app): + provision_user_row(user_id=55, login="returning", role="contributor") + before = db.conn().execute("SELECT COUNT(*) AS n FROM users").fetchone()["n"] + + user = auth.provision_user( + _cfg(), + {"id": 55, "login": "returning-renamed", "email": "returning@test", + "full_name": "Returning", "avatar_url": ""}, + ) + after = db.conn().execute("SELECT COUNT(*) AS n FROM users").fetchone()["n"] + + assert user.user_id == 55 + assert after == before # matched by gitea_id; no new row + row = db.conn().execute( + "SELECT gitea_login FROM users WHERE id = ?", (55,) + ).fetchone() + assert row["gitea_login"] == "returning-renamed" # profile refreshed diff --git a/frontend/package.json b/frontend/package.json index 5f2cc41..4790ae4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "rfc-app-frontend", "private": true, - "version": "0.54.0", + "version": "0.54.1", "type": "module", "scripts": { "dev": "vite",