Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 242831a4a0 | |||
| f0533dc073 |
@@ -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
|
||||
|
||||
+27
-3
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "rfc-app-frontend",
|
||||
"private": true,
|
||||
"version": "0.54.0",
|
||||
"version": "0.54.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Reference in New Issue
Block a user