Coverage for src/lexigram/admin/auth/store/email_otp_sql.py: 87%
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 AdminEmailOtpStoreProtocol.
3Owns all DDL and DML for the ``admin_email_otps`` table. The service layer
4depends only on ``AdminEmailOtpStoreProtocol`` 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
12import uuid
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_email_otps"
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 AdminEmailOtpSqlStore:
38 """SQL-backed store for email one-time-password codes.
40 Implements ``AdminEmailOtpStoreProtocol`` via structural subtyping.
41 Manages the ``admin_email_otps`` table including DDL bootstrap and
42 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 OTP 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 id TEXT PRIMARY KEY,
67 user_id TEXT NOT NULL,
68 code_hash VARCHAR(64) NOT NULL,
69 expires_at TIMESTAMPTZ NOT NULL,
70 used_at TIMESTAMPTZ,
71 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
72 )
73 """
74 else:
75 create_sql = f"""
76 CREATE TABLE IF NOT EXISTS {_TABLE} (
77 id TEXT PRIMARY KEY,
78 user_id TEXT NOT NULL,
79 code_hash VARCHAR(64) NOT NULL,
80 expires_at TIMESTAMP NOT NULL,
81 used_at TIMESTAMP,
82 created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
83 )
84 """
85 await self._db.execute(create_sql, [])
86 self._initialized = True
88 # ------------------------------------------------------------------
89 # AdminEmailOtpStoreProtocol
90 # ------------------------------------------------------------------
92 async def save(self, user_id: str, code_hash: str, expires_at: datetime) -> None:
93 """Persist a new emailed code (see protocol docs)."""
94 await self._db.execute(
95 f"INSERT INTO {_TABLE} (id, user_id, code_hash, expires_at) " # noqa: S608 — table name is module constant "admin_email_otps", never user input
96 "VALUES (?, ?, ?, ?)",
97 [str(uuid.uuid4()), user_id, code_hash, expires_at],
98 )
100 async def consume(self, user_id: str, code_hash: str) -> bool:
101 """Atomically consume a matching unexpired code (see protocol docs)."""
102 result = await self._db.execute(
103 f"""
104 UPDATE {_TABLE} SET used_at = {now_expr(self._db)}
105 WHERE user_id = ?
106 AND code_hash = ?
107 AND used_at IS NULL
108 AND expires_at > {now_expr(self._db)}
109 """, # noqa: S608 — table name is module constant, now_expr yields fixed NOW()/CURRENT_TIMESTAMP
110 [user_id, code_hash],
111 )
112 row_count = getattr(result, "row_count", None)
113 if row_count is not None:
114 return int(row_count) > 0
115 return True
117 async def last_sent_at(self, user_id: str) -> datetime | None:
118 """Return the creation time of the most recent code (see protocol docs)."""
119 result = await self._db.execute_query(
120 f"SELECT created_at FROM {_TABLE} " # noqa: S608 — table name is module constant "admin_email_otps", never user input
121 "WHERE user_id = ? ORDER BY created_at DESC LIMIT 1",
122 [user_id],
123 )
124 row = None
125 if hasattr(result, "rows") and result.rows:
126 row = result.rows[0]
127 elif isinstance(result, list) and result:
128 row = result[0]
129 elif isinstance(result, dict):
130 row = result
131 if not row:
132 return None
133 return _parse_dt(row.get("created_at"))
136__all__ = ["AdminEmailOtpSqlStore"]