Status: Adopted
Date: 2026-05-10 UTC
Refs: #1535 (immediate fix), PR #1501 (SC-A1), PR #1500 (RV-1), PR #1465 / #1502 / #1516 (SC-A*), #1552 (Postgres CI)
A credential-boundary test exercises a hard isolation invariant: that one role, token, or identity cannot reach data or operations reserved for another.
Concrete test surfaces in this repo:
| Surface | Examples | Relevant work |
|---|---|---|
| DB role separation | raptor_app vs DATABASE_URL owner — RLS, partial-index predicates |
PR #1501 (SC-A1) |
| KMS / SSM credential boundaries | HMAC chain verification, audit event writer | PR #1465, #1502, #1516 (SC-A2..SC-A19) |
| Service-token validation | Queue API JWT offline verify, Velvet rotation tokens, Reasonator audit tokens | ADR-0067 |
| RBAC permission boundaries | Grant + check API, break-glass time-limit, ticket-scoped grants | PR #1500 (RV-1), RV-2..RV-14 |
| Anti-replay / refresh-token rotation | JWT 7-day refresh (locked 2026-05-09) | ADR-0032 |
These tests need stricter hygiene than typical unit tests because:
# conftest.py (backend_v2/tests/integration/)
import os
import pytest
from contextlib import contextmanager
@pytest.fixture
def credential_env(monkeypatch, tmp_path):
"""
Factory fixture: yields a context manager that injects test credential
env-vars and returns a scratch path, then asserts full cleanup.
Usage:
def test_role_resolution(credential_env):
with credential_env("raptor_app") as env:
assert env["db_url"].startswith("postgres://raptor_app:")
"""
@contextmanager
def _make(role_name: str, extra_env: dict | None = None):
db_url = f"postgres://{role_name}:testpass@localhost:5432/testdb"
monkeypatch.setenv("RAPTOR_APP_DATABASE_URL", db_url)
if extra_env:
for k, v in extra_env.items():
monkeypatch.setenv(k, v)
scratch = tmp_path / role_name
scratch.mkdir()
try:
yield {"db_url": db_url, "scratch": scratch}
finally:
# monkeypatch auto-restores env after the test.
# Assert scratch is clean so tests don't silently leave state.
remaining = list(scratch.iterdir())
assert remaining == [], (
f"credential_env({role_name!r}) left files in scratch: {remaining}"
)
return _make
Three rules, applied everywhere in SC-A and RV- test files:
tmp_path for all filesystem state. Never use a hardcoded /tmp/...
path. tmp_path is pytest-managed, unique per test invocation, and cleaned
up automatically. This resolves the B108 finding in #1535 directly.
monkeypatch.setenv for all env-var injection. Auto-restored after
each test regardless of pass/fail. Never os.environ[k] = v directly.
pytest-postgresql or testcontainers for Postgres-specific behavior.
Use when the test depends on RLS, partial-index predicates, Postgres-specific
DDL, or role GRANTs that SQLite cannot emulate. Connects to #1552 which adds
Postgres-aware migration CI. SQLite-only tests remain SQLite — don't
over-provision.
Every credential-boundary test class or module should include an explicit negative assertion after teardown:
def test_raptor_app_cannot_read_owner_table(credential_env):
with credential_env("raptor_app") as env:
# Exercise the boundary
with pytest.raises(PermissionError):
_query_as_role(env["db_url"], "SELECT * FROM _owner_only_table")
# Positive path still works
result = _query_as_role(env["db_url"], "SELECT 1")
assert result == 1
# No additional teardown needed: monkeypatch + tmp_path handle it.
The negative assertion (the pytest.raises) is mandatory. A test that only
checks the happy path does not prove the boundary exists.
1. Hardcoded /tmp/... paths.
The original finding in #1535. Bandit B108 flags these. The fix is
tmp_path, not # nosec B108. Never paper over this with a suppression
comment — fix the root issue.
2. Tests that mutate the dev database without explicit teardown.
Use pytest-postgresql or testcontainers for an ephemeral Postgres
instance. The dev DATABASE_URL is not a test database.
3. Implicit test-order dependencies.
tmp_path makes each test hermetic. If a test relies on state from a
previous test, it is broken by definition. Fixtures must be self-contained.
4. # nosec suppressions for B108 or B310.
These mask real violations. Bandit suppression comments are not acceptable
in credential-boundary tests. Fix the path, don't suppress the scan.
5. Logging or printing raw credential strings.
Even "postgres://raptor_app:testpass@..." is a credential. Use
pytest-redacted or mask at assertion time. Do not let credential strings
appear in captured stdout/stderr (which CI logs and artifacts retain).
| Artifact | What it is |
|---|---|
| PR #1501 (SC-A1) | First credential-boundary test — raptor_app role separation; tmp_path refactor target from #1535 |
| PR #1500 (RV-1) | RBAC V2 schema migration 0021 + audit-role seed; drives upcoming RV-2..RV-14 test slate |
| PR #1465 | Audit HMAC chain design doc v2 (operator + security feedback) |
| PR #1502 | SC-A2..SC-A19 audit/HMAC test slate baseline |
| PR #1516 | Queue API audit event writer + HMAC chain (SC-A3) |
| #1552 | Postgres-aware migration CI — ephemeral Postgres in PR CI; prerequisite for RLS-dependent boundary tests |
The tmp_path refactor in #1535 is fully consistent with this pattern —
tmp_path is the exact fix for the B108 finding at line 41 of
test_raptor_app_role_separation_1481.py. The credential_env factory fixture
above is an optional follow-on abstraction for reuse across the SC-A and RV-
test slates. Security-agent may adopt it now or file a follow-up sub-card; the
immediate #1535 fix is just the tmp_path swap.
No structural divergence from security-agent's approach — this doc endorses the same fix. The factory fixture is additive, not a prerequisite.