6a514aa56b
Groundwork for self-hosted accounts (magic link + Google later), no third parties. - db: account tables (users, identities, login_tokens, sessions, saved_articles, user_history, user_prefs); identities link multiple sign-in methods to one user by verified email. connect() now enables WAL + busy_timeout so the API can write account data alongside the host ingestion cycle. - auth.py: users/identities (find-or-create + link), single-use magic-link tokens, opaque sessions — all secrets stored only as SHA-256 hashes. - email_send.py: minimal STARTTLS SMTP sender + the magic-link email. Secrets (SMTP, Google, session) live in the API container's env_file, not git. API endpoints + sign-in UI come next. 105 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
72 lines
2.6 KiB
Python
72 lines
2.6 KiB
Python
import sqlite3
|
|
from datetime import timedelta
|
|
|
|
from goodnews import auth
|
|
from goodnews.db import connect, init_db
|
|
|
|
|
|
def _db():
|
|
c = connect(":memory:")
|
|
init_db(c)
|
|
# an article to save/history against later flows
|
|
c.execute("INSERT INTO sources (id,name,feed_url,trust_score) VALUES (1,'S','http://s/f',5)")
|
|
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash) "
|
|
"VALUES (1,1,'http://s/1','t1','h1')")
|
|
c.commit()
|
|
return c
|
|
|
|
|
|
def test_normalize_email():
|
|
assert auth.normalize_email(" Foo@Bar.COM ") == "foo@bar.com"
|
|
|
|
|
|
def test_find_or_create_links_by_email_and_dedupes_identity():
|
|
c = _db()
|
|
uid = auth.find_or_create_user(c, "a@b.com", "email", "a@b.com")
|
|
# same identity again → same user, no duplicate
|
|
assert auth.find_or_create_user(c, "a@b.com", "email", "a@b.com") == uid
|
|
# a different provider with the SAME verified email links to the same user
|
|
uid2 = auth.find_or_create_user(c, "A@B.com", "google", "google-sub-123", display_name="A")
|
|
assert uid2 == uid
|
|
assert c.execute("SELECT COUNT(*) FROM users").fetchone()[0] == 1
|
|
assert c.execute("SELECT COUNT(*) FROM identities WHERE user_id=?", (uid,)).fetchone()[0] == 2
|
|
assert auth.get_user(c, uid)["display_name"] == "A"
|
|
|
|
|
|
def test_magic_link_token_single_use():
|
|
c = _db()
|
|
raw = auth.create_login_token(c, "a@b.com")
|
|
assert auth.consume_login_token(c, raw) == "a@b.com" # first use works
|
|
assert auth.consume_login_token(c, raw) is None # reuse rejected
|
|
assert auth.consume_login_token(c, "nonsense") is None # unknown rejected
|
|
|
|
|
|
def test_magic_link_token_expiry():
|
|
c = _db()
|
|
raw = auth.create_login_token(c, "a@b.com")
|
|
# backdate expiry into the past
|
|
c.execute("UPDATE login_tokens SET expires_at = ?",
|
|
(auth._iso(auth._now() - timedelta(minutes=1)),))
|
|
assert auth.consume_login_token(c, raw) is None
|
|
|
|
|
|
def test_session_lifecycle():
|
|
c = _db()
|
|
uid = auth.find_or_create_user(c, "a@b.com", "email", "a@b.com")
|
|
tok = auth.create_session(c, uid, user_agent="pytest")
|
|
user = auth.resolve_session(c, tok)
|
|
assert user and user["id"] == uid and user["email"] == "a@b.com"
|
|
assert auth.resolve_session(c, None) is None
|
|
assert auth.resolve_session(c, "bogus") is None
|
|
auth.revoke_session(c, tok)
|
|
assert auth.resolve_session(c, tok) is None
|
|
|
|
|
|
def test_session_expiry():
|
|
c = _db()
|
|
uid = auth.find_or_create_user(c, "a@b.com", "email", "a@b.com")
|
|
tok = auth.create_session(c, uid)
|
|
c.execute("UPDATE sessions SET expires_at = ?",
|
|
(auth._iso(auth._now() - timedelta(seconds=1)),))
|
|
assert auth.resolve_session(c, tok) is None
|