Single consolidated commit for four completed, validated phases (77, 78, 79, 80). The pipeline run left all work uncommitted because the harness commits only with PHASE_COMMIT=1 while child executors are forbidden from committing; the phases themselves all passed validation and moved to .agents/phases/complete/. Phase 77 — navbar view refresh - router.js dispatches bor:view-refresh on re-show / active re-click / popstate (gated on wasMounted; first show and boot exempt) - History / RAG / Sources / Tuning re-fetch on refresh (admin branch); Chat deliberately excluded (stream survival) - History "Refresh" button (admin-only, in-flight disable + status line) - New story suite tests/e2e/test_navbar_refresh.py (7 tests) Phase 78 — static background - Removed the animated glow layers; static 44px grid over the flat --bg canvas; default and reduced-motion renders byte-identical - Updated background/theme E2E suites; removed bg-glow test pins Phase 79 — API tokens - api_tokens model + migration 0012; hash-only token service - Admin tokens API + Tokens admin view; POST /api/token-auth; live-revoking require_user on chat / suggestions / document content - Frontend token gate with localStorage cache; anonymous E2E suites migrated to token login - New story suite tests/e2e/test_api_tokens.py (9 tests) Phase 80 — history suggestion chips - last_questions() endpoint with SEED fallback; startNewChat() refetch - Seed-semantics docs (config.py, .env.example, README) - Integration state matrix + E2E suite rewritten to the 4 chip states Also included: phase-76 report artifacts and the repo restore-test-db skill (previously untracked), scripts/* ruff fixes from phase 77. Final gate state (phase 80 final pass, covers everything above): - uv run pytest --cov=app → 1637 passed, 0 failed, app/ coverage 99% - uv run ruff check . && uv run pyright → clean, 0 errors - Per-phase story E2E suites green in isolation
314 lines
12 KiB
Python
314 lines
12 KiB
Python
"""Integration: migration 0012 (api_tokens) schema contract.
|
|
|
|
Drives the **real Alembic engine** against the live dev database
|
|
(``podman compose up -d db``), mirroring the house pattern of
|
|
``test_migration_0011.py`` (information_schema / pg_indexes assertions
|
|
on the state the migration must leave). The tests target revision
|
|
``0012`` explicitly so later migrations cannot break them:
|
|
|
|
* upgrade 0011 → 0012 → the ``api_tokens`` table exists with the full
|
|
column contract (``id`` UUID PK; ``label`` VARCHAR(120) NOT NULL;
|
|
``token_hash`` VARCHAR(64) NOT NULL + the UNIQUE index
|
|
``ix_api_tokens_token_hash`` — the stored credential;
|
|
``created_at`` TIMESTAMPTZ NOT NULL default now(); ``last_used_at`` /
|
|
``revoked_at`` TIMESTAMPTZ NULL);
|
|
* inserted rows round-trip: ``created_at`` is stamped server-side and
|
|
``last_used_at`` / ``revoked_at`` are NULL until the service
|
|
(tasks 02/03) sets them; explicit lifecycle values round-trip
|
|
verbatim;
|
|
* two identical token hashes are rejected by the unique index (the hash
|
|
is the unique lookup key), while a repeated ``label`` is fine
|
|
(display-only);
|
|
* downgrade to 0011 → the table and index are gone (A13 — reversible),
|
|
the rest of the schema (e.g. ``doc_drafts.token``) survives;
|
|
* upgrade back to 0012 → the table and the unique index are back
|
|
(round-trip).
|
|
|
|
The ``alembic`` fixture guarantees the DB ends at head even if a test
|
|
fails or the process is interrupted.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import uuid
|
|
from collections.abc import Iterator
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from alembic.config import Config
|
|
from sqlalchemy import text
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.orm import Session
|
|
|
|
from alembic import command
|
|
from app.db import db_available
|
|
|
|
|
|
def _hash(token: str = "bor_0123456789abcdef0123456789abcdef") -> str:
|
|
"""The stored credential: the sha256 hex digest of the FULL token
|
|
string (always 64 hex chars — exactly what the String(64) column
|
|
width pins). The probes use fixed tokens distinct from any real
|
|
``bor_`` + 32-hex token an operator might hold."""
|
|
return hashlib.sha256(token.encode()).hexdigest()
|
|
|
|
|
|
@pytest.fixture()
|
|
def alembic(db: Session) -> Iterator[Config]:
|
|
"""Real Alembic config bound to the dev DB (URL from app settings).
|
|
|
|
Starts at head (repairs an interrupted earlier run); teardown upgrades
|
|
to head no matter what happened, so the dev DB is never left below
|
|
head.
|
|
"""
|
|
if not db_available():
|
|
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
|
|
cfg = Config() # no alembic.ini file — env.py gets the URL from app config
|
|
cfg.set_main_option("script_location", "alembic")
|
|
command.upgrade(cfg, "head")
|
|
try:
|
|
yield cfg
|
|
finally:
|
|
command.upgrade(cfg, "head")
|
|
|
|
|
|
def _version(db: Session) -> str | None:
|
|
return db.execute(text("SELECT version_num FROM alembic_version")).scalar()
|
|
|
|
|
|
def _table_exists(db: Session, table: str) -> bool:
|
|
count: Any = db.execute(
|
|
text(
|
|
"SELECT count(*) FROM information_schema.tables"
|
|
" WHERE table_schema = 'public' AND table_name = :t"
|
|
),
|
|
{"t": table},
|
|
).scalar()
|
|
assert count is not None, "information_schema count must be an int"
|
|
return int(count) == 1
|
|
|
|
|
|
def _column(db: Session, table: str, column: str) -> tuple[Any, ...] | None:
|
|
"""(data_type, is_nullable, column_default, character_maximum_length)
|
|
for one table column."""
|
|
row = db.execute(
|
|
text(
|
|
"SELECT data_type, is_nullable, column_default, character_maximum_length"
|
|
" FROM information_schema.columns"
|
|
" WHERE table_name = :t AND column_name = :c"
|
|
),
|
|
{"t": table, "c": column},
|
|
).fetchone()
|
|
return tuple(row) if row is not None else None
|
|
|
|
|
|
def _unique_hash_index(db: Session) -> int:
|
|
"""1 iff ``ix_api_tokens_token_hash`` exists as a UNIQUE index."""
|
|
count: Any = db.execute(
|
|
text(
|
|
"SELECT count(*) FROM pg_indexes"
|
|
" WHERE tablename = 'api_tokens'"
|
|
" AND indexname = 'ix_api_tokens_token_hash'"
|
|
),
|
|
).scalar()
|
|
assert count is not None, "pg_indexes count must be an int"
|
|
is_unique: Any = db.execute(
|
|
text(
|
|
"SELECT indisunique FROM pg_index"
|
|
" WHERE indexrelid ="
|
|
" (SELECT oid FROM pg_class WHERE relname = 'ix_api_tokens_token_hash')"
|
|
),
|
|
).scalar()
|
|
return int(count) if is_unique else 0
|
|
|
|
|
|
def _insert(db: Session, *, label: str, token_hash: str | None = None) -> uuid.UUID:
|
|
"""Insert one api_tokens row. ``token_hash=None`` is not a valid
|
|
state (NOT NULL) — the migration carries no server default; the
|
|
service (task 02) always supplies the digest of the full token."""
|
|
sql = (
|
|
"INSERT INTO api_tokens (id, label, token_hash) "
|
|
"VALUES (gen_random_uuid(), :l, :h) RETURNING id"
|
|
)
|
|
token_id: uuid.UUID = db.execute(
|
|
text(sql), {"l": label, "h": token_hash or _hash()}
|
|
).scalar_one()
|
|
db.commit()
|
|
return token_id
|
|
|
|
|
|
def _delete(db: Session, token_id: uuid.UUID) -> None:
|
|
db.execute(text("DELETE FROM api_tokens WHERE id = :i"), {"i": token_id})
|
|
db.commit()
|
|
|
|
|
|
def test_upgrade_to_0012_adds_api_tokens(db: Session, alembic: Config) -> None:
|
|
"""Upgrade 0011 → 0012: the table + the unique token-hash index
|
|
exist with the full column contract; the table is absent at 0011."""
|
|
command.downgrade(alembic, "0011") # start from the pre-0012 state
|
|
assert _version(db) == "0011"
|
|
assert not _table_exists(db, "api_tokens"), "api_tokens must be absent at 0011"
|
|
assert _unique_hash_index(db) == 0, "the token-hash index must be absent at 0011"
|
|
|
|
command.upgrade(alembic, "0012")
|
|
assert _version(db) == "0012", "alembic_version must be at 0012"
|
|
assert _table_exists(db, "api_tokens"), "api_tokens must exist at 0012"
|
|
|
|
id_col = _column(db, "api_tokens", "id")
|
|
assert id_col is not None, "api_tokens.id is missing"
|
|
assert id_col[0] == "uuid", "api_tokens.id must be UUID"
|
|
assert id_col[1] == "NO", "api_tokens.id must be NOT NULL (PK)"
|
|
|
|
label = _column(db, "api_tokens", "label")
|
|
assert label is not None, "api_tokens.label is missing"
|
|
assert label[0] == "character varying", "api_tokens.label must be VARCHAR"
|
|
assert label[1] == "NO", "api_tokens.label must be NOT NULL"
|
|
assert label[3] == 120, "api_tokens.label must be String(120)"
|
|
|
|
token_hash = _column(db, "api_tokens", "token_hash")
|
|
assert token_hash is not None, "api_tokens.token_hash is missing"
|
|
assert token_hash[0] == "character varying", "api_tokens.token_hash must be VARCHAR"
|
|
assert token_hash[1] == "NO", "api_tokens.token_hash must be NOT NULL"
|
|
assert token_hash[3] == 64, (
|
|
"api_tokens.token_hash must be String(64) — the sha256 hex digest"
|
|
)
|
|
assert _unique_hash_index(db) == 1, "the unique token-hash index is missing"
|
|
|
|
created = _column(db, "api_tokens", "created_at")
|
|
assert created is not None, "api_tokens.created_at is missing"
|
|
assert created[0] == "timestamp with time zone", (
|
|
"api_tokens.created_at must be TIMESTAMPTZ"
|
|
)
|
|
assert created[1] == "NO", "api_tokens.created_at must be NOT NULL"
|
|
assert str(created[2]).startswith("now("), (
|
|
"api_tokens.created_at must have server default now()"
|
|
)
|
|
|
|
for name in ("last_used_at", "revoked_at"):
|
|
col = _column(db, "api_tokens", name)
|
|
assert col is not None, f"api_tokens.{name} is missing"
|
|
assert col[0] == "timestamp with time zone", (
|
|
f"api_tokens.{name} must be TIMESTAMPTZ"
|
|
)
|
|
assert col[1] == "YES", f"api_tokens.{name} must be NULL until set"
|
|
|
|
|
|
def test_inserted_rows_round_trip_the_lifecycle_states(
|
|
db: Session, alembic: Config
|
|
) -> None:
|
|
"""At 0012, an inserted row has a server-stamped ``created_at`` and
|
|
NULL ``last_used_at`` / ``revoked_at`` (the fresh-credential state);
|
|
explicit lifecycle values round-trip verbatim (the service's
|
|
mark_used / revoke paths, tasks 02/03)."""
|
|
command.upgrade(alembic, "head")
|
|
token_id = _insert(db, label="alice", token_hash=_hash())
|
|
try:
|
|
row = db.execute(
|
|
text(
|
|
"SELECT label, token_hash, created_at, last_used_at, revoked_at"
|
|
" FROM api_tokens WHERE id = :i"
|
|
),
|
|
{"i": token_id},
|
|
).fetchone()
|
|
assert row is not None, "the token row must exist"
|
|
assert row[0] == "alice", "the label must round-trip verbatim"
|
|
assert row[1] == _hash(), "the token hash must round-trip verbatim"
|
|
assert row[2] is not None, "created_at must be stamped server-side"
|
|
assert row[3] is None, "last_used_at must be NULL until first use"
|
|
assert row[4] is None, "revoked_at must be NULL while active"
|
|
|
|
# The service's lifecycle updates (mark_used / revoke) round-trip.
|
|
db.execute(
|
|
text(
|
|
"UPDATE api_tokens SET last_used_at = now(), revoked_at = now()"
|
|
" WHERE id = :i"
|
|
),
|
|
{"i": token_id},
|
|
)
|
|
db.commit()
|
|
used = db.execute(
|
|
text(
|
|
"SELECT last_used_at, revoked_at FROM api_tokens WHERE id = :i"
|
|
),
|
|
{"i": token_id},
|
|
).fetchone()
|
|
assert used is not None, "the updated row must exist"
|
|
assert used[0] is not None and used[1] is not None, (
|
|
"last_used_at/revoked_at must round-trip explicit values"
|
|
)
|
|
finally:
|
|
_delete(db, token_id)
|
|
|
|
|
|
def test_unique_index_rejects_duplicate_hashes_label_is_not_unique(
|
|
db: Session, alembic: Config
|
|
) -> None:
|
|
"""Two identical token hashes are rejected by the unique index —
|
|
the hash is the unique lookup key (the share-token precedent,
|
|
phase 51); a repeated ``label`` is fine (display-only)."""
|
|
command.upgrade(alembic, "head")
|
|
dup_hash = _hash("bor_11111111111111111111111111111111")
|
|
first_id = _insert(db, label="alice", token_hash=dup_hash)
|
|
other_id: uuid.UUID | None = None
|
|
twin_id: uuid.UUID | None = None
|
|
try:
|
|
try:
|
|
_insert(db, label="bob", token_hash=dup_hash)
|
|
except IntegrityError:
|
|
db.rollback() # the aborted transaction must not leak
|
|
else:
|
|
pytest.fail("a duplicate api_tokens.token_hash must be rejected")
|
|
|
|
# A different hash is fine — only the exact duplicate is unique.
|
|
other_id = _insert(db, label="bob", token_hash=_hash("bor_deadbeef" * 4))
|
|
# The same label under a different hash is fine — display-only.
|
|
twin_id = _insert(db, label="alice", token_hash=_hash("bor_cafebabe" * 4))
|
|
finally:
|
|
_delete(db, first_id)
|
|
if other_id is not None:
|
|
_delete(db, other_id)
|
|
if twin_id is not None:
|
|
_delete(db, twin_id)
|
|
|
|
|
|
def test_downgrade_to_0011_drops_the_table(db: Session, alembic: Config) -> None:
|
|
"""Downgrade to 0011: the table and the unique index are gone
|
|
(A13 — reversible) while the rest of the schema survives."""
|
|
command.downgrade(alembic, "0011")
|
|
assert _version(db) == "0011"
|
|
assert not _table_exists(db, "api_tokens"), "api_tokens must be dropped"
|
|
assert _unique_hash_index(db) == 0, "the token-hash index must be dropped"
|
|
|
|
token_col = _column(db, "doc_drafts", "token")
|
|
assert token_col is not None and token_col[0] == "uuid", (
|
|
"doc_drafts.token must survive the downgrade"
|
|
)
|
|
meta = _column(db, "saved_chats", "share_token")
|
|
assert meta is not None and meta[0] == "uuid", (
|
|
"saved_chats.share_token must survive the downgrade"
|
|
)
|
|
|
|
|
|
def test_upgrade_round_trip_restores_the_table(db: Session, alembic: Config) -> None:
|
|
"""Downgrade to 0011, then upgrade back to 0012: the table and the
|
|
unique index are back."""
|
|
command.downgrade(alembic, "0011")
|
|
command.upgrade(alembic, "0012")
|
|
assert _version(db) == "0012", "round-trip upgrade must land at 0012"
|
|
|
|
assert _table_exists(db, "api_tokens"), "api_tokens must be back"
|
|
assert _unique_hash_index(db) == 1, "the unique token-hash index must be back"
|
|
|
|
token_hash = _column(db, "api_tokens", "token_hash")
|
|
assert token_hash is not None and token_hash[1] == "NO", (
|
|
"token_hash must be VARCHAR NOT NULL after the round-trip"
|
|
)
|
|
assert token_hash[3] == 64, "token_hash must be String(64) after the round-trip"
|
|
|
|
created = _column(db, "api_tokens", "created_at")
|
|
assert created is not None and created[1] == "NO", (
|
|
"created_at must be TIMESTAMPTZ NOT NULL after the round-trip"
|
|
)
|
|
assert str(created[2]).startswith("now("), (
|
|
"created_at must default to now() after the round-trip"
|
|
)
|