Coverage for src/lexigram/admin/auth/store/password_reset_token_sql.py: 89%
53 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 14:56 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 14:56 +0800
1"""SQL-backed implementation of AdminPasswordResetTokenStoreProtocol.
3Owns all DDL and DML for the ``admin_password_reset_tokens`` table. The
4service layer depends only on ``AdminPasswordResetTokenStoreProtocol`` from
5``lexigram.admin.auth.protocols`` — never on this class directly.
6"""
8from __future__ import annotations
10from datetime import UTC, datetime
11from typing import Any
13from lexigram.admin.auth.types import AdminPasswordResetToken
14from lexigram.admin.sql_dialect import is_postgres, now_expr
15from lexigram.contracts.data import DatabaseProviderProtocol
16from lexigram.di.decorators import inject
17from lexigram.logging import get_logger
19logger = get_logger(__name__)
21_TABLE = "admin_password_reset_tokens"
24def _parse_dt(value: Any) -> datetime | None:
25 """Parse a provider-returned timestamp into a UTC-aware datetime."""
26 if value is None:
27 return None
28 if isinstance(value, datetime):
29 return value
30 parsed = datetime.fromisoformat(str(value))
31 if parsed.tzinfo is None:
32 return parsed.replace(tzinfo=UTC)
33 return parsed
36@inject
37class AdminPasswordResetTokenSqlStore:
38 """SQL-backed store for password reset tokens.
40 Implements ``AdminPasswordResetTokenStoreProtocol`` via structural
41 subtyping. Manages the ``admin_password_reset_tokens`` table including
42 DDL bootstrap and single-use consumption semantics.
43 """
45 def __init__(self, db: DatabaseProviderProtocol) -> None:
46 """Initialise with a resolved database provider.
48 Args:
49 db: Framework database provider exposing ``execute`` and
50 ``execute_query``.
51 """
52 self._db = db
53 self._initialized = False
55 # ------------------------------------------------------------------
56 # Schema bootstrap (DDL)
57 # ------------------------------------------------------------------
59 async def ensure_schema(self) -> None:
60 """Create the token table if it does not exist (idempotent)."""
61 if self._initialized:
62 return
63 if is_postgres(self._db):
64 create_sql = f"""
65 CREATE TABLE IF NOT EXISTS {_TABLE} (
66 token_hash VARCHAR(64) PRIMARY KEY,
67 email VARCHAR(255) NOT NULL,
68 expires_at TIMESTAMPTZ NOT NULL,
69 consumed_at TIMESTAMPTZ,
70 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
71 )
72 """
73 else:
74 create_sql = f"""
75 CREATE TABLE IF NOT EXISTS {_TABLE} (
76 token_hash VARCHAR(64) PRIMARY KEY,
77 email VARCHAR(255) NOT NULL,
78 expires_at TIMESTAMP NOT NULL,
79 consumed_at TIMESTAMP,
80 created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
81 )
82 """
83 await self._db.execute(create_sql, [])
84 self._initialized = True
86 # ------------------------------------------------------------------
87 # AdminPasswordResetTokenStoreProtocol
88 # ------------------------------------------------------------------
90 async def create(self, email: str, token_hash: str, expires_at: datetime) -> None:
91 """Persist a new token record (see protocol docs)."""
92 await self._db.execute(
93 f"INSERT INTO {_TABLE} (token_hash, email, expires_at) VALUES (?, ?, ?)", # noqa: S608 — table name is module constant "admin_password_reset_tokens", never user input
94 [token_hash, email, expires_at],
95 )
97 async def find_by_hash(self, token_hash: str) -> AdminPasswordResetToken | None:
98 """Look up a token by sha256 hash (see protocol docs)."""
99 result = await self._db.execute_query(
100 f"SELECT token_hash, email, expires_at, consumed_at FROM {_TABLE} WHERE token_hash = ?", # noqa: S608 — table name is module constant "admin_password_reset_tokens", never user input
101 [token_hash],
102 )
103 row = None
104 if hasattr(result, "rows") and result.rows:
105 row = result.rows[0]
106 elif isinstance(result, list) and result:
107 row = result[0]
108 elif isinstance(result, dict):
109 row = result
110 if not row:
111 return None
112 return AdminPasswordResetToken(
113 email=str(row.get("email", "")),
114 token_hash=str(row.get("token_hash", token_hash)),
115 expires_at=_parse_dt(row.get("expires_at")) or datetime.now(UTC),
116 consumed_at=_parse_dt(row.get("consumed_at")),
117 )
119 async def mark_consumed(self, token_hash: str) -> bool:
120 """Atomically verify-and-consume a token in one statement.
122 Returns False when the token is missing, already consumed, or
123 expired — the caller cannot distinguish which without a separate
124 lookup.
125 """
126 result = await self._db.execute(
127 f"UPDATE {_TABLE} SET consumed_at = {now_expr(self._db)} " # noqa: S608 — table name is module constant, now_expr yields fixed NOW()/CURRENT_TIMESTAMP
128 "WHERE token_hash = ? AND consumed_at IS NULL "
129 f"AND expires_at > {now_expr(self._db)}",
130 [token_hash],
131 )
132 row_count = getattr(result, "row_count", None)
133 if row_count is not None:
134 return int(row_count) > 0
135 return True
138__all__ = ["AdminPasswordResetTokenSqlStore"]