feat: phases 77–80 — navbar view refresh, static background, API tokens, history suggestion chips
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
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
"""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"
|
||||
)
|
||||
Reference in New Issue
Block a user