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
175 lines
6.2 KiB
Python
175 lines
6.2 KiB
Python
"""Unit: the API-token service (phase 79, task 02).
|
|
|
|
Covers ``app.core.tokens`` — the create/lookup/revoke service behind the
|
|
admin API (task 02's endpoints) and the future token-auth login (task
|
|
03):
|
|
|
|
* ``generate_token`` — the ``bor_`` + 32-hex shape, two calls differ;
|
|
* ``hash_token`` — deterministic 64-hex digest of the FULL string (a
|
|
stripped prefix can never collide);
|
|
* ``create_token`` / ``find_active_by_token`` — the round-trip (the
|
|
plaintext resolves to its active row) and the GENERIC-MISS contract:
|
|
revoked, unknown, empty, short and wrong-prefix candidates all return
|
|
the same ``None`` (hash matches no row — there is no "almost" path,
|
|
which is what makes the task-03 one-generic-401 safe);
|
|
* ``revoke`` — stamps ``revoked_at`` once (idempotent re-call keeps the
|
|
original stamp; False only for a missing id);
|
|
* ``mark_used`` — bumps ``last_used_at``.
|
|
|
|
House DB-test pattern (the ``test_sources_meta`` precedent): the service
|
|
is a thin session wrapper whose contract (server-default ``created_at``,
|
|
the unique hash index) only holds against a real database — runs against
|
|
the local compose Postgres, skips with clear instructions when the stack
|
|
is not up. The service flushes, never commits: the tests commit, as the
|
|
endpoints do.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import uuid
|
|
from collections.abc import Iterator
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
import pytest
|
|
from sqlalchemy import text
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core import tokens as tok
|
|
from app.models import ApiToken
|
|
|
|
TOKEN_SHAPE = re.compile(r"^bor_[0-9a-f]{32}$")
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clean_tokens(db: Session) -> Iterator[None]:
|
|
"""api_tokens is global state: reset around every test."""
|
|
db.execute(text("TRUNCATE api_tokens"))
|
|
db.commit()
|
|
yield
|
|
db.execute(text("TRUNCATE api_tokens"))
|
|
db.commit()
|
|
|
|
|
|
def _create_and_commit(db: Session, label: str = "alice") -> tuple[ApiToken, str]:
|
|
"""Service create + the endpoint's commit/refresh (the house split)."""
|
|
row, plaintext = tok.create_token(db, label)
|
|
db.commit()
|
|
db.refresh(row)
|
|
return row, plaintext
|
|
|
|
|
|
def test_generate_token_shape_and_uniqueness() -> None:
|
|
"""``bor_`` + exactly 32 lowercase hex chars (128-bit), no reuse."""
|
|
a = tok.generate_token()
|
|
b = tok.generate_token()
|
|
assert TOKEN_SHAPE.fullmatch(a), a
|
|
assert TOKEN_SHAPE.fullmatch(b), b
|
|
assert a != b
|
|
|
|
|
|
def test_hash_token_deterministic_64_hex_full_string() -> None:
|
|
"""Same token → same digest; 64 hex chars; the FULL string is hashed
|
|
(hashing ``bor_X`` ≠ hashing ``X`` — a stripped prefix can never
|
|
collide with another token's hash)."""
|
|
plain = tok.generate_token()
|
|
h1 = tok.hash_token(plain)
|
|
h2 = tok.hash_token(plain)
|
|
assert h1 == h2
|
|
assert re.fullmatch(r"[0-9a-f]{64}", h1), h1
|
|
assert tok.hash_token(plain.removeprefix("bor_")) != h1
|
|
|
|
|
|
def test_create_and_find_round_trip(db: Session) -> None:
|
|
"""create → the row carries ONLY the hash; the plaintext resolves
|
|
back to the same active row (the task-03 login path)."""
|
|
row, plaintext = _create_and_commit(db, " alice ")
|
|
|
|
# The service strips the label; the row only ever carries the hash.
|
|
assert row.label == "alice"
|
|
assert row.token_hash == tok.hash_token(plaintext)
|
|
assert row.token_hash != plaintext
|
|
assert row.last_used_at is None
|
|
assert row.revoked_at is None
|
|
|
|
hit = tok.find_active_by_token(db, plaintext)
|
|
assert hit is not None
|
|
assert hit.id == row.id
|
|
assert hit.label == "alice"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("candidate", "why"),
|
|
[
|
|
("", "empty string"),
|
|
("bor_ab", "short string"),
|
|
("bor_" + "f" * 31, "31 hex chars (one short)"),
|
|
(
|
|
"zzz_" + "0" * 32,
|
|
"wrong prefix",
|
|
),
|
|
],
|
|
ids=["empty", "short", "31-hex", "wrong-prefix"],
|
|
)
|
|
def test_find_active_by_token_generic_miss(db: Session, candidate: str, why: str) -> None:
|
|
"""Every malformed/unknown candidate is the SAME miss — the hash
|
|
simply matches no row (no "almost" path, no per-shape error)."""
|
|
_, plaintext = _create_and_commit(db) # the table is NOT empty
|
|
assert tok.find_active_by_token(db, candidate) is None, why
|
|
# …while the stored token still resolves (the miss is specific).
|
|
assert tok.find_active_by_token(db, plaintext) is not None
|
|
|
|
|
|
def test_find_active_by_token_unknown_well_formed(db: Session) -> None:
|
|
"""A well-formed token that was never stored (or belongs to another
|
|
admin) misses — no enumeration surface beyond the unique-index hit."""
|
|
unknown = tok.generate_token() # never persisted
|
|
assert tok.find_active_by_token(db, unknown) is None
|
|
|
|
|
|
def test_find_active_by_token_revoked_misses(db: Session) -> None:
|
|
"""A revoked row never resolves: the hash still matches the row, but
|
|
``revoked_at IS NULL`` is part of the contract (A4 — dead is dead,
|
|
enforced immediately)."""
|
|
row, plaintext = _create_and_commit(db)
|
|
assert tok.revoke(db, row.id) is True
|
|
db.commit()
|
|
assert tok.find_active_by_token(db, plaintext) is None
|
|
|
|
|
|
def test_revoke_stamps_once_and_false_only_for_missing(db: Session) -> None:
|
|
"""First revoke stamps ``revoked_at`` (True); the second call is
|
|
idempotent (True, the ORIGINAL stamp kept — no re-stamp); False only
|
|
when the row does not exist."""
|
|
row, _ = _create_and_commit(db)
|
|
assert tok.revoke(db, row.id) is True
|
|
db.commit()
|
|
db.refresh(row)
|
|
first_stamp = row.revoked_at
|
|
assert first_stamp is not None
|
|
|
|
# Idempotent: True again, and the first stamp survives.
|
|
assert tok.revoke(db, row.id) is True
|
|
db.commit()
|
|
db.refresh(row)
|
|
assert row.revoked_at == first_stamp
|
|
|
|
# A missing id is the only False.
|
|
assert tok.revoke(db, uuid.uuid4()) is False
|
|
|
|
|
|
def test_mark_used_stamps_last_used_at(db: Session) -> None:
|
|
"""NULL until first use, then "now" (UTC, tz-aware)."""
|
|
row, _ = _create_and_commit(db)
|
|
assert row.last_used_at is None
|
|
|
|
before = datetime.now(UTC)
|
|
tok.mark_used(row)
|
|
after = datetime.now(UTC)
|
|
|
|
assert row.last_used_at is not None
|
|
assert row.last_used_at.tzinfo is not None
|
|
assert before - timedelta(seconds=1) <= row.last_used_at <= after + timedelta(
|
|
seconds=1
|
|
)
|
|
db.commit()
|