Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 33d5283667 | |||
| 83b80535be | |||
| f91d7b3b46 | |||
| b8b679b108 | |||
| 337020cca1 | |||
| 319ac5f586 | |||
| 3e8d52ba08 | |||
| 56249402fb | |||
| 89bd07bca7 | |||
| f1f2eee69e | |||
| 5cdfc05b41 | |||
| 064909e1cd | |||
| a0d3448c0c | |||
| 0f7ec133d7 | |||
| c4d2d2b5bf | |||
| 63ae0d2905 | |||
| 23c1bb6b34 | |||
| b988c4c518 | |||
| b5f10c780b | |||
| 4e8e03363f | |||
| 982ac127b3 | |||
| 129fb2308d | |||
| 517f9e23e6 | |||
| 0677e4fd65 | |||
| 4a382c0b16 | |||
| 54a361551e | |||
| 63b2870f6e |
@@ -0,0 +1,54 @@
|
|||||||
|
# Repo-root .dockerignore — applied to any Dockerfile whose build context
|
||||||
|
# is the repo root (frontend/Dockerfile, backend/Dockerfile via
|
||||||
|
# docker-compose). Keeps the build context small and deterministic.
|
||||||
|
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
|
||||||
|
# Editor + OS junk
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Node — never used by the backend image, and the frontend image installs
|
||||||
|
# its own clean copy via `npm ci` rather than carrying the host's cache.
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.vite
|
||||||
|
.cache
|
||||||
|
.eslintcache
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# Python — same logic: the backend image installs its own deps via pip.
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
|
__pycache__
|
||||||
|
**/__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.pytest_cache
|
||||||
|
.mypy_cache
|
||||||
|
.ruff_cache
|
||||||
|
.coverage
|
||||||
|
htmlcov
|
||||||
|
*.egg-info/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Docs + tests + sample data — none are needed at runtime in either image.
|
||||||
|
# (config/ is included because the backend loads config/payers.yaml from it.)
|
||||||
|
docs/
|
||||||
|
backend/tests/
|
||||||
|
**/*.test.ts
|
||||||
|
**/*.test.tsx
|
||||||
|
**/vitest.config.ts
|
||||||
|
**/tsconfig.tsbuildinfo
|
||||||
|
|
||||||
|
# Docker artifacts themselves
|
||||||
|
Dockerfile*
|
||||||
|
docker-compose*.yml
|
||||||
|
.dockerignore
|
||||||
+17
-4
@@ -1,7 +1,20 @@
|
|||||||
# Cyclone — environment configuration
|
# Cyclone — environment configuration
|
||||||
# Copy this file to `.env.local` and fill in values for your environment.
|
# Copy this file to `.env.local` and fill in values for your environment.
|
||||||
|
|
||||||
# Base URL for the Python (FastAPI) backend that powers the Upload page and
|
# Required on first boot. Cyclone refuses to start without these unless
|
||||||
# the real /api/parse-837 + /api/parse-835 endpoints. Leave empty to keep
|
# at least one user already exists (e.g. seeded via `python -m cyclone users create`).
|
||||||
# the in-memory sample data store and disable real EDI parsing.
|
# Min 12 chars for password.
|
||||||
VITE_API_BASE_URL=http://localhost:8000
|
CYCLONE_ADMIN_USERNAME=admin
|
||||||
|
CYCLONE_ADMIN_PASSWORD=change-me-to-a-strong-password-min-12-chars
|
||||||
|
|
||||||
|
# Base URL for the Python (FastAPI) backend. Leave empty for the
|
||||||
|
# Docker deployment (nginx proxies /api/* to backend on compose network).
|
||||||
|
VITE_API_BASE_URL=
|
||||||
|
|
||||||
|
# Optional. Set to 1 if you're behind an HTTPS reverse proxy and want
|
||||||
|
# the session cookie to include the Secure flag.
|
||||||
|
# CYCLONE_BEHIND_HTTPS=1
|
||||||
|
|
||||||
|
# Optional. Set to 1 to disable auth entirely (DEV ONLY). When set,
|
||||||
|
# the backend auto-grants admin access without checking credentials.
|
||||||
|
# CYCLONE_AUTH_DISABLED=0
|
||||||
+2
-3
@@ -35,6 +35,5 @@ claims_output/
|
|||||||
# Worktrees (subagent-driven development)
|
# Worktrees (subagent-driven development)
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
|
||||||
# Brainstorm session artifacts (visual companion mockups, events, server state).
|
# Brainstorm session artifacts (visual companion mockups, events, server state)
|
||||||
# Skills under .superpowers/skills/ are committed project-scoped guidance.
|
.superpowers/
|
||||||
.superpowers/brainstorm/
|
|
||||||
|
|||||||
@@ -1,189 +0,0 @@
|
|||||||
---
|
|
||||||
name: cyclone-api-router
|
|
||||||
description: "Cyclone FastAPI router conventions (api_routers/, api_helpers.py, response shapes, error envelopes). Use when: adding or changing an HTTP endpoint, splitting a route out of api.py, or wiring a new helper into api_helpers.py."
|
|
||||||
---
|
|
||||||
|
|
||||||
# cyclone-api-router
|
|
||||||
|
|
||||||
Cyclone splits its FastAPI surface two ways: small resource-group
|
|
||||||
routers live in `backend/src/cyclone/api_routers/<topic>.py` and are
|
|
||||||
mounted bare into `api.py`; the high-traffic streaming and parse
|
|
||||||
endpoints still live as top-level decorators in `api.py` itself. This
|
|
||||||
skill codifies the conventions so additions stay consistent with the
|
|
||||||
four routers already shipped (`acks`, `admin`, `health`, `ta1_acks`).
|
|
||||||
|
|
||||||
As of this writing: **4 router modules** under
|
|
||||||
`backend/src/cyclone/api_routers/`, **one shared helpers module** at
|
|
||||||
`backend/src/cyclone/api_helpers.py` (248 lines, NDJSON primitives +
|
|
||||||
content negotiation + `tail_events`), and ~30 routes still inlined
|
|
||||||
in `backend/src/cyclone/api.py`. The next refactor target is the
|
|
||||||
parse endpoints.
|
|
||||||
|
|
||||||
## When to use
|
|
||||||
|
|
||||||
- **Adding an endpoint.** You're adding a new GET / POST handler —
|
|
||||||
you need to know whether it belongs in `api.py` (parse / streaming)
|
|
||||||
or in a new router under `api_routers/`, and what the response
|
|
||||||
shape and test file conventions look like.
|
|
||||||
- **Splitting a route.** You're moving a route out of `api.py` into a
|
|
||||||
dedicated `api_routers/<topic>.py` module and need the import /
|
|
||||||
mounting rules (`from cyclone.api_routers import <topic>` then
|
|
||||||
`app.include_router(<topic>.router)`).
|
|
||||||
- **Adding a helper.** You're wiring a new function into
|
|
||||||
`api_helpers.py` (NDJSON primitive, content-negotiation probe,
|
|
||||||
tail-event helper) and need to keep it private to the API layer.
|
|
||||||
- **Defining an error response.** You're raising from a route handler
|
|
||||||
and need the conventional `HTTPException(status_code=..., detail=...)`
|
|
||||||
shape used everywhere else in the API surface.
|
|
||||||
|
|
||||||
## Conventions
|
|
||||||
|
|
||||||
1. **No new top-level routes in `api.py` for resource groups.** Any
|
|
||||||
endpoint grouped under a resource (`/api/<resource>` and its
|
|
||||||
`/{id}` detail) lives in `backend/src/cyclone/api_routers/<topic>.py`
|
|
||||||
as an `APIRouter`. The streaming list endpoints (`/api/claims/stream`,
|
|
||||||
`/api/remittances/stream`, `/api/activity/stream`) and the parse
|
|
||||||
endpoints (`/api/parse-*`) currently stay in `api.py` because they
|
|
||||||
span multiple store modules — don't move them unless you're also
|
|
||||||
restructuring the store split.
|
|
||||||
2. **Reuse `api_helpers.py`.** NDJSON primitives (`ndjson_line`,
|
|
||||||
`ndjson_stream_list`, `ndjson_stream_837`, `ndjson_stream_835`),
|
|
||||||
content negotiation (`client_wants_json`, `wants_ndjson`), the
|
|
||||||
strict / `raw_segments` rewrites (`strict_rewrite_837`,
|
|
||||||
`strict_rewrite_835`, `drop_raw_segments_837`, `drop_raw_segments_835`),
|
|
||||||
and the shared live-tail generator (`tail_events`,
|
|
||||||
`heartbeat_seconds`) all live there. Don't duplicate them in a
|
|
||||||
router. The module's docstring (`api_helpers.py:1-18`) declares it
|
|
||||||
private to the API layer — no business logic, no DB writes.
|
|
||||||
3. **Response shape.** Every successful response is a **plain dict**
|
|
||||||
produced by a per-router `<entity>_to_ui(row)` helper (see
|
|
||||||
`_ack_to_ui` at `api_routers/acks.py:29-48` and `_ta1_to_ui` at
|
|
||||||
`api_routers/ta1_acks.py:23-37`). This dict shape **must** match
|
|
||||||
the matching `<entity>_written` event payload so live-tail pages
|
|
||||||
don't drift (see `cyclone-store` for the serializer contract).
|
|
||||||
Errors use FastAPI's `HTTPException` with a `detail` dict of the
|
|
||||||
form `{"error": "<Title>", "detail": "<message>"}` —
|
|
||||||
`acks.py:85-88`, `ta1_acks.py:72`. There is **no** shared
|
|
||||||
`ErrorEnvelope` Pydantic model; the `detail` dict is the contract.
|
|
||||||
4. **Mounting.** Routers are mounted bare in `api.py:251-256` —
|
|
||||||
`app.include_router(<name>.router)` with **no** `prefix=`
|
|
||||||
argument. Each `@router.<verb>` decorator carries the **full**
|
|
||||||
`/api/<resource>` path itself (see `acks.py:51,75`,
|
|
||||||
`ta1_acks.py:49,67`, `admin.py:23`, `health.py:28`). The
|
|
||||||
`router = APIRouter()` declaration carries no `tags=` either —
|
|
||||||
keep it minimal.
|
|
||||||
5. **Streaming endpoints.** Use
|
|
||||||
`StreamingResponse(media_type="application/x-ndjson")` and feed it
|
|
||||||
either `ndjson_stream_list(items, total, returned, has_more)` (for
|
|
||||||
list pages) or `tail_events(request, bus, kinds)` (for live-tail
|
|
||||||
pages). See `acks.py:62-66` for the list-stream skeleton and
|
|
||||||
`api.py:1357-1401` for the full live-tail pattern (snapshot →
|
|
||||||
`snapshot_end` → subscription → heartbeats). See `cyclone-tail`
|
|
||||||
for the wire format.
|
|
||||||
6. **Tests.** Every new endpoint gets a `test_api_<topic>_<verb>.py`
|
|
||||||
under `backend/tests/` (see `cyclone-tests` for the naming
|
|
||||||
convention + autouse `conftest.py`). Existing examples:
|
|
||||||
`test_api_validate_provider.py` (admin),
|
|
||||||
`test_api_parse_persists_ack.py` (acks).
|
|
||||||
|
|
||||||
## Patterns
|
|
||||||
|
|
||||||
### A new `APIRouter` skeleton
|
|
||||||
|
|
||||||
Pattern from `backend/src/cyclone/api_routers/acks.py:1-72`. Module
|
|
||||||
docstring names the resource, imports the shared helpers, declares
|
|
||||||
`router = APIRouter()` with no prefix, and defines a `_foo_to_ui(row)`
|
|
||||||
mapper at module scope.
|
|
||||||
|
|
||||||
```python
|
|
||||||
"""``/api/foo`` — list & detail endpoints for <topic>."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query, Request
|
|
||||||
from fastapi.responses import StreamingResponse
|
|
||||||
|
|
||||||
from cyclone.api_helpers import ndjson_stream_list, wants_ndjson
|
|
||||||
from cyclone.store import store
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
def _foo_to_ui(row) -> dict:
|
|
||||||
"""Map a Foo ORM row to the UI shape used by ``/api/foo``."""
|
|
||||||
return {"id": row.id, "name": row.name}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/foo")
|
|
||||||
def list_foo(
|
|
||||||
request: Request,
|
|
||||||
limit: int = Query(100, ge=1, le=1000),
|
|
||||||
):
|
|
||||||
"""Return the list of persisted Foo rows, newest first."""
|
|
||||||
rows = store.list_foo()
|
|
||||||
items = [_foo_to_ui(r) for r in rows[:limit]]
|
|
||||||
total = len(rows)
|
|
||||||
returned = len(items)
|
|
||||||
has_more = total > returned
|
|
||||||
if wants_ndjson(request):
|
|
||||||
return StreamingResponse(
|
|
||||||
ndjson_stream_list(items, total, returned, has_more),
|
|
||||||
media_type="application/x-ndjson",
|
|
||||||
)
|
|
||||||
return {"items": items, "total": total, "returned": returned, "has_more": has_more}
|
|
||||||
```
|
|
||||||
|
|
||||||
### A `get_<topic>` detail endpoint with 404
|
|
||||||
|
|
||||||
Pattern from `api_routers/acks.py:75-104` and `ta1_acks.py:67-76`.
|
|
||||||
Path param is `<entity>_id` (not `id`) so it doesn't shadow
|
|
||||||
FastAPI's internal `id` and the OpenAPI docs stay self-describing.
|
|
||||||
|
|
||||||
```python
|
|
||||||
@router.get("/api/foo/{foo_id}")
|
|
||||||
def get_foo(foo_id: int) -> dict:
|
|
||||||
"""Return one persisted Foo row with its parsed detail.
|
|
||||||
|
|
||||||
Path param is ``foo_id`` (not ``id``) to avoid shadowing
|
|
||||||
FastAPI's internal ``id`` name and to keep OpenAPI docs
|
|
||||||
self-describing. Returns 404 when the row is missing — never 500.
|
|
||||||
"""
|
|
||||||
row = store.get_foo(foo_id)
|
|
||||||
if row is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=404,
|
|
||||||
detail={"error": "Not found", "detail": f"Foo {foo_id} not found"},
|
|
||||||
)
|
|
||||||
return _foo_to_ui(row)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Mounting in `api.py`
|
|
||||||
|
|
||||||
Pattern from `backend/src/cyclone/api.py:246-256`. The block lives
|
|
||||||
just after middleware registration and just before the first
|
|
||||||
`@app.<verb>` decorator.
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Resource-group routers. Each module owns its own APIRouter and is
|
|
||||||
# registered below. New resources go in `cyclone.api_routers.<name>`
|
|
||||||
# and are wired in here.
|
|
||||||
from cyclone.api_routers import acks, admin, health, ta1_acks # noqa: E402
|
|
||||||
|
|
||||||
app.include_router(health.router)
|
|
||||||
app.include_router(acks.router)
|
|
||||||
app.include_router(ta1_acks.router)
|
|
||||||
app.include_router(admin.router)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Anti-patterns
|
|
||||||
|
|
||||||
- **Don't import from `cyclone.api` into a router — the dependency runs the other way.** Routers are mounted *into* `api.py` (`api_routers/acks.py` etc. know nothing about `cyclone.api`). A circular import would silently break the `from cyclone.api_routers import ...` block at `api.py:251`.
|
|
||||||
- **Don't introduce Pydantic response models where the codebase returns dicts.** Every existing list / detail endpoint returns a plain dict produced by a `<entity>_to_ui(row)` helper (see `acks.py:29-48`, `ta1_acks.py:23-37`). That dict shape **is** the event payload that `<entity>_written` carries — see `cyclone-store`. Introducing a Pydantic model on one side drifts the event payload from the list shape and silently breaks live-tail dedup.
|
|
||||||
- **Don't bypass `CycloneStore` to query the ORM directly from a route.** Always call `store.<method>(...)` (`store.list_acks()`, `store.get_ta1_ack(ack_id)`) so the read path picks up the same session + snapshot serializer as the live-tail subscriber. A raw `with db.SessionLocal()() as s: s.get(Foo, foo_id)` in a handler bypasses the serializer contract and breaks the event-payload match. See `cyclone-store`.
|
|
||||||
- **Don't set `prefix=` on `APIRouter`.** Mount the router bare (`app.include_router(<name>.router)`) and put the full `/api/<resource>` path in the decorator. Mixing the two styles scatters the URL across two files and breaks `grep "/api/foo"` audits.
|
|
||||||
|
|
||||||
## Related skills
|
|
||||||
|
|
||||||
- **`cyclone-store`** — most routes call `store.<method>(...)` and the dict payload **is** the `<entity>_written` event payload; load when adding a route so the read path stays aligned with the pubsub contract.
|
|
||||||
- **`cyclone-tail`** — streaming endpoints (`/api/<resource>/stream`) and the NDJSON wire format; load when adding a live-tail route or changing the wire format.
|
|
||||||
- **`cyclone-edi`** — parse endpoints (`/api/parse-837`, `/api/parse-835`, `/api/parse-999`, `/api/parse-ta1`, `/api/parse-277ca`) currently live in `api.py`; load when adding or changing a parse endpoint.
|
|
||||||
- **`cyclone-tests`** — endpoint tests follow the `test_api_<topic>_<verb>.py` naming under `backend/tests/`; load when writing the test for a new endpoint.
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
---
|
|
||||||
name: cyclone-cli
|
|
||||||
description: "Cyclone CLI subcommand conventions (cli.py — Click group + subcommands parse-837/parse-835/validate-npi/validate-tax-id/backup, --yes + click.confirm for destructive ops, exit codes 0/1/2, CliRunner smoke tests in backend/tests/test_cli_*.py). Use when: adding a CLI subcommand, changing an exit code, adding a smoke test, or wiring a security-sensitive command (backup, key rotation, anything touching secrets.py)."
|
|
||||||
---
|
|
||||||
|
|
||||||
# cyclone-cli
|
|
||||||
|
|
||||||
The operator-facing CLI is a **Click** group at `cli.py:45` (`@click.group()` for `main`), mounted as the `cyclone` console script in `pyproject.toml:54` (`cyclone = "cyclone.cli:main"`). Seven subcommands ship today: `parse-837`, `parse-835`, `validate-npi`, `validate-tax-id`, plus the `backup` group (`init-passphrase`, `create`, `list`, `verify`, `restore`, `prune`, `status`). `serve` lives separately in `__main__.py:19` (dispatches `uvicorn cyclone.api:app`).
|
|
||||||
|
|
||||||
## When to use
|
|
||||||
|
|
||||||
- **Adding a subcommand.** You need a new operator command (e.g. `cyclone rotate-key`) and want to match the existing Click decorator + smoke-test rhythm.
|
|
||||||
- **Changing an exit code.** You're tweaking which `sys.exit(N)` a subcommand raises and need the 0/1/2 contract used by `parse_837`, `parse_835`, `validate_npi_cmd`, `validate_tax_id_cmd`, and the `backup` group.
|
|
||||||
- **Adding a smoke test.** You need `backend/tests/test_cli_<name>.py` using `click.testing.CliRunner` (NOT `subprocess.run`) and want the canonical fixture + monkeypatch layout (Keychain stub, fresh SQLite, `CYCLONE_BACKUP_DIR`).
|
|
||||||
- **Wiring a security-sensitive command.** Anything touching `cyclone/secrets.py` (Keychain writes), DB key rotation, or destructive restores needs the two-step confirm dance used by `backup restore` / `backup prune` (`cli.py:462,509`).
|
|
||||||
|
|
||||||
## Conventions
|
|
||||||
|
|
||||||
1. **Click decorator pattern, not argparse.** Each subcommand is a
|
|
||||||
top-level function decorated with `@main.command("<name>")` and
|
|
||||||
one `@click.option` / `@click.argument` per parameter. Group
|
|
||||||
dispatch is implicit — no `set_defaults(func=...)` and no
|
|
||||||
`cmd_<name>(args) -> int` signature. Top-level commands at
|
|
||||||
`cli.py:77,151,241,261,303`; the `backup` sub-group nests a
|
|
||||||
second `@main.group()` (`cli.py:303`) with its own
|
|
||||||
`@backup.command("<name>")` children.
|
|
||||||
2. **Long-form flags for safety.** Prefer `--rotate-key`,
|
|
||||||
`--backup-dir`, `--from-stdin` over positional args for anything
|
|
||||||
that mutates state or takes a secret. `init-passphrase`
|
|
||||||
(`cli.py:308-311`) demonstrates the canonical "flag OR stdin"
|
|
||||||
pattern: `--passphrase` for automation, `--from-stdin` for
|
|
||||||
interactive `getpass()` prompting.
|
|
||||||
3. **Exit codes: 0 / 1 / 2.** `0` = success. `1` = user / input
|
|
||||||
error (invalid NPI/EIN at `cli.py:258,277,285`; Keychain write
|
|
||||||
failure at `cli.py:341,351`; tampered-backup verify at
|
|
||||||
`cli.py:459`). `2` = operator / parse error
|
|
||||||
(`CycloneParseError` at `cli.py:106,178`; passphrase
|
|
||||||
empty/mismatch/short at `cli.py:331,337`). Document the codes
|
|
||||||
in the docstring (see `validate_npi_cmd` at `cli.py:244-250`).
|
|
||||||
Use `click.UsageError(...)` for usage mistakes; reserve
|
|
||||||
`sys.exit(2)` for "the file failed to parse" semantics.
|
|
||||||
4. **Smoke test with `click.testing.CliRunner`.** Every new
|
|
||||||
subcommand gets `backend/tests/test_cli_<name>.py` that
|
|
||||||
imports `from cyclone.cli import main` and invokes via
|
|
||||||
`CliRunner().invoke(main, [...], catch_exceptions=False)`. The
|
|
||||||
test must stub Keychain (`monkeypatch.setattr(secrets_mod,
|
|
||||||
"get_secret"/"set_secret", ...)`) and pin a temp SQLite DB via
|
|
||||||
`CYCLONE_DB_URL` + `db._reset_for_tests()` — see
|
|
||||||
`backend/tests/test_cli_backup.py:13-69` for the canonical
|
|
||||||
`_cli_env` fixture. CliRunner captures output and exit codes
|
|
||||||
in-process; do NOT shell out to `subprocess.run`.
|
|
||||||
5. **Destructive ops need `--yes` + `click.confirm(abort=True)`.**
|
|
||||||
`backup restore` (`cli.py:462-506`) and `backup prune`
|
|
||||||
(`cli.py:509-537`) both gate the destructive action behind a
|
|
||||||
`--yes` is_flag and an interactive `click.confirm(..., abort=True)`
|
|
||||||
prompt. CliRunner auto-aborts confirm prompts, so smoke tests
|
|
||||||
assert `exit_code != 0` when `--yes` is omitted
|
|
||||||
(`test_cli_backup.py:122-137`). A `--dry-run` flag is NOT yet
|
|
||||||
implemented anywhere; if needed, mirror the `--yes` pattern.
|
|
||||||
|
|
||||||
## Patterns
|
|
||||||
|
|
||||||
### A Click subcommand — `@main.command("<name>")` + options
|
|
||||||
|
|
||||||
From `cli.py:241-258` (smallest standalone subcommand):
|
|
||||||
|
|
||||||
```python
|
|
||||||
@main.command("validate-npi")
|
|
||||||
@click.argument("npi")
|
|
||||||
@click.option("--log-level", default="WARNING", show_default=True,
|
|
||||||
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"]))
|
|
||||||
def validate_npi_cmd(npi: str, log_level: str) -> None:
|
|
||||||
"""Validate a 10-digit NPI's Luhn checksum locally (SP20). Exit 0 valid, 1 invalid. PHI — don't log the value."""
|
|
||||||
setup_logging(level=log_level)
|
|
||||||
from cyclone.npi import is_valid_npi
|
|
||||||
if is_valid_npi(npi):
|
|
||||||
click.echo(f"OK: {len(npi)}-digit NPI passes Luhn checksum")
|
|
||||||
return
|
|
||||||
click.echo(f"INVALID: {npi!r} fails NPI Luhn checksum", err=True)
|
|
||||||
sys.exit(1)
|
|
||||||
```
|
|
||||||
|
|
||||||
### A smoke test — `CliRunner` + Keychain stub + temp SQLite
|
|
||||||
|
|
||||||
From `test_cli_backup.py:13-69`. Stable hex salt keeps multiple `CliRunner` invocations consistent within one test. The `Batch` seed at `cli_backup.py:27-35` is omitted — only the Keychain + DB plumbing is the convention:
|
|
||||||
|
|
||||||
```python
|
|
||||||
@pytest.fixture
|
|
||||||
def _cli_env(tmp_path, monkeypatch):
|
|
||||||
from cyclone import db, secrets as secrets_mod
|
|
||||||
from cyclone import backup_service as svc_mod
|
|
||||||
|
|
||||||
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
|
|
||||||
db._reset_for_tests()
|
|
||||||
db.init_db()
|
|
||||||
# ... seed any DB rows the subcommand needs (see cli_backup.py:27-35) ...
|
|
||||||
|
|
||||||
store = {svc_mod.KEYCHAIN_BACKUP_PASSPHRASE_ACCOUNT: "cli-test-passphrase",
|
|
||||||
svc_mod.KEYCHAIN_BACKUP_SALT_ACCOUNT:
|
|
||||||
"0123456789abcdef0123456789abcdef"}
|
|
||||||
monkeypatch.setattr(secrets_mod, "get_secret", lambda n: store.get(n))
|
|
||||||
monkeypatch.setattr(secrets_mod, "set_secret",
|
|
||||||
lambda n, v: store.__setitem__(n, v) or True)
|
|
||||||
monkeypatch.setenv("CYCLONE_BACKUP_DIR", str(tmp_path / "backups"))
|
|
||||||
yield tmp_path / "backups"
|
|
||||||
db._reset_for_tests()
|
|
||||||
|
|
||||||
def test_backup_create_list_verify_status(_cli_env):
|
|
||||||
from cyclone.cli import main
|
|
||||||
runner = CliRunner()
|
|
||||||
r = runner.invoke(main, ["backup", "create"], catch_exceptions=False)
|
|
||||||
assert r.exit_code == 0, r.output
|
|
||||||
assert "created backup id=" in r.output
|
|
||||||
```
|
|
||||||
|
|
||||||
### A destructive subcommand — `--yes` + `click.confirm(abort=True)`
|
|
||||||
|
|
||||||
From `cli.py:462-506` (`backup restore`). Two-step: announce, prompt unless `--yes`, then execute. Restore uses an explicit init/confirm round-trip so the operator can back out between phases. Matching smoke test asserts the guard fires:
|
|
||||||
|
|
||||||
```python
|
|
||||||
@backup.command("restore")
|
|
||||||
@click.argument("backup_id", type=int)
|
|
||||||
@click.option("--yes", is_flag=True, help="Skip the interactive confirm prompt")
|
|
||||||
@click.option("--actor", default="operator-cli", show_default=True)
|
|
||||||
def backup_restore(backup_id: int, yes: bool, actor: str) -> None:
|
|
||||||
"""Restore the live DB from a backup (two-step, requires --yes)."""
|
|
||||||
# ... db.init_db() + service config omitted ...
|
|
||||||
click.echo(f"Initiating restore from backup {backup_id}...")
|
|
||||||
init = svc.restore_initiate(backup_id)
|
|
||||||
# ... echo init summary (filename, fp, table_count, ttl) ...
|
|
||||||
if not yes:
|
|
||||||
click.confirm(
|
|
||||||
"Replace the live DB with this backup? "
|
|
||||||
"This will dispose the engine and rebuild it.",
|
|
||||||
abort=True,
|
|
||||||
)
|
|
||||||
click.echo("Confirming restore...")
|
|
||||||
result = svc.restore_confirm(backup_id, init.restore_token, actor=actor)
|
|
||||||
|
|
||||||
def test_backup_restore_requires_yes_flag(_cli_env):
|
|
||||||
runner = CliRunner()
|
|
||||||
runner.invoke(main, ["backup", "create"], catch_exceptions=False)
|
|
||||||
r = runner.invoke(main, ["backup", "restore", "1"], catch_exceptions=False)
|
|
||||||
# CliRunner auto-aborts confirm prompts → exit_code != 0.
|
|
||||||
assert r.exit_code != 0
|
|
||||||
```
|
|
||||||
|
|
||||||
## Anti-patterns
|
|
||||||
|
|
||||||
- **Don't `sys.exit(2)` for usage errors.** Reserve code 2 for
|
|
||||||
parse/operator errors (`CycloneParseError`, Keychain not
|
|
||||||
initialized, passphrase policy violation). For "you passed the
|
|
||||||
wrong flag" use `raise click.UsageError(...)` — Click formats
|
|
||||||
it as a clean help message and exits 2 on its own.
|
|
||||||
- **Don't print errors to stdout.** Use `click.echo(msg, err=True)`
|
|
||||||
for all error output. `print(..., file=sys.stderr)` and bare
|
|
||||||
`logging.error(...)` bypass Click's stdout/stderr split and leak
|
|
||||||
into test `result.output` — breaking `assert "FAIL" in r.output`
|
|
||||||
style assertions.
|
|
||||||
- **Don't add a subcommand without a smoke test.** Every new
|
|
||||||
`@main.command(...)` ships a sibling
|
|
||||||
`backend/tests/test_cli_<name>.py` exercising the happy path
|
|
||||||
AND at least one error path (missing input, invalid arg,
|
|
||||||
tampered ciphertext — see `test_cli_backup.py:102-119`).
|
|
||||||
- **Don't reuse the `parse` command name.** Existing subcommands
|
|
||||||
are type-specific (`parse-837`, `parse-835`); a generic `parse`
|
|
||||||
would shadow them or force an `--type` flag — neither is the
|
|
||||||
codebase pattern.
|
|
||||||
|
|
||||||
## Related skills
|
|
||||||
|
|
||||||
- **`cyclone-store`** — `backup` subcommands and the parse
|
|
||||||
subcommands both round-trip through `CycloneStore` and the DB
|
|
||||||
session; load when the increment changes write paths or the
|
|
||||||
`<entity>_written` event contract.
|
|
||||||
- **`cyclone-api-router`** — `cyclone serve` (via `__main__.py:19`)
|
|
||||||
launches the FastAPI app; the CLI parse subcommands share the
|
|
||||||
same `CycloneParseError` exception and Pydantic result models
|
|
||||||
as the matching HTTP endpoints.
|
|
||||||
- **`cyclone-edi`** — `parse-837` / `parse-835` are the CLI smoke
|
|
||||||
entry points for the parser/validator surface; load when adding
|
|
||||||
a parser or R-code rule.
|
|
||||||
- **`cyclone-tests`** — every CLI subcommand gets a pytest smoke
|
|
||||||
case under `backend/tests/test_cli_<name>.py`; the `_cli_env`
|
|
||||||
fixture pattern (fresh SQLite + Keychain stub) is documented
|
|
||||||
there.
|
|
||||||
- **`cyclone-spec`** — load when the SP-N spec introduces a new operator command or reserves a new exit-code category.
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
---
|
|
||||||
name: cyclone-edi
|
|
||||||
description: "Cyclone EDI parser/validator conventions (837P/835/999/270/271/277CA/TA1). Use when: adding or changing a parser, adding a validator rule (R010/R020/R100/R200-R210/R835_*/NPI Luhn/EIN/CAS), or mapping a new CAS adjustment reason code."
|
|
||||||
---
|
|
||||||
|
|
||||||
# cyclone-edi
|
|
||||||
|
|
||||||
Cyclone parses seven X12 EDI transaction types (837P, 835, 999, 270, 271, 277CA, TA1) into typed Pydantic models, then runs per-claim / per-batch validator rules that surface as R-coded `ValidationIssue` records. This skill codifies the conventions so new parsers and rules stay consistent with the seven that already exist.
|
|
||||||
|
|
||||||
As of this writing: **7 parser modules** under `backend/src/cyclone/parsers/parse_<edi>.py`, **~25 per-claim rules** numbered `R010`–`R100` and `R200`–`R210` in `validator.py`, plus a parallel set of **835-specific rules** prefixed `R835_*` in `validator_835.py`. The next increment is **SP22**.
|
|
||||||
|
|
||||||
## When to use
|
|
||||||
|
|
||||||
- **Adding or changing a parser.** You are about to touch `backend/src/cyclone/parsers/parse_<edi>.py` or its paired `models_<edi>.py` and need the orchestrator signature, the segment walker convention, and the re-export in `parsers/__init__.py`.
|
|
||||||
- **Adding a validator rule.** You are writing a new `_rule_R<n>_<name>` (or `_r<n>_<name>` per the existing snake-case style) and need the rule signature, the R-code numbering scheme, and the `ValidationIssue` shape.
|
|
||||||
- **Wiring a new CAS / CARC code.** The 835 carries Claim Adjustment Reason Codes in `CAS` segments; the lookup lives in `backend/src/cyclone/parsers/cas_codes.py` and the UI reads through `claim_status_label()`.
|
|
||||||
- **Debugging a parse failure on a prodfiles sample.** You dropped a real EDI file into `docs/prodfiles/<source>/` and the parser is choking — load this skill to confirm the tokenizer path, the orchestrator entry point, and which fixture in `backend/tests/fixtures/` matches the transaction type.
|
|
||||||
|
|
||||||
## Conventions
|
|
||||||
|
|
||||||
1. **Parser signature.** Every parser module exports exactly one public entry function. Two flavors coexist in the codebase:
|
|
||||||
- `parse(text: str, *, input_file: str = "") -> <TypedResult>` — used by `parse_270.py:337`, `parse_271.py:356`.
|
|
||||||
- `parse(text: str, payer_config: <PayerConfig>, input_file: str = "") -> <TypedResult>` — used by `parse_837.py:319` and `parse_835.py:459` because both need payer-specific config to validate segments against.
|
|
||||||
- `parse_<edi>_text(text: str, *, input_file: str = "") -> <TypedResult>` — the legacy name-suffixed form, still in use at `parse_ta1.py:143`, `parse_999.py:220`, `parse_277ca.py:280`. The `<TypedResult>` is always a Pydantic model from `models_<edi>.py` (or co-located `models.py` for 837P).
|
|
||||||
2. **Segment walk.** Parsers consume `backend/src/cyclone/parsers/segments.py` — there are exactly three public pieces: `Delimiters` (frozen dataclass holding the four ISA-derived separators), `_detect_delimiters(isa_segment)` (private), and `tokenize(text) -> list[list[str]]` (returns ISA prepended as the first segment). Parsers then index into the `list[list[str]]` directly — there is **no** `Segment` / `Loop` / `next_segment` helper class. Whole-document problems (missing ISA, wrong transaction set) raise `CycloneParseError`; per-segment problems on acks (999/277CA) are surfaced on the result, not raised.
|
|
||||||
3. **Validator rules.** Numbered rules live in `backend/src/cyclone/parsers/validator.py` (for 837P — R010–R100 general + R200–R210 SP9 CO MAP / HCPF naming) and `backend/src/cyclone/parsers/validator_835.py` (for 835 — names prefixed `R835_*` because the same numeric space would collide with 837P). Each rule is a function `_r<n>_<name>(claim: ClaimOutput, cfg: PayerConfig) -> Iterable[ValidationIssue]` registered in the module-level `_RULES` list and run by `validate(claim, config)`. Issues carry the rule name as a stable string (`rule="R021_npi_checksum"`) — the R-code **is** how the UI surfaces the error, so never invent an unnumbered rule.
|
|
||||||
4. **NPI / EIN / CAS format logic.** Identity-format checks live in their own modules — never duplicate them in a parser or validator:
|
|
||||||
- `backend/src/cyclone/npi.py` — `is_valid_npi(npi)` runs the Luhn checksum with the `80840` NPPES prefix; `is_valid_tax_id(ein)` enforces `XX-XXXXXXX` (or 9 raw digits).
|
|
||||||
- `backend/src/cyclone/parsers/cas_codes.py` — `reason_label(group, reason)` and `all_known_codes()` for the CARC lookup; snapshot date is exported as `LAST_UPDATED`.
|
|
||||||
- `backend/src/cyclone/parsers/models_271.py` — `SERVICE_TYPE_CODES` + `service_type_description()` for 271 EB benefit codes.
|
|
||||||
5. **Prodfiles reuse.** When adding a parser for a new transaction type, ship at least one fixture in `backend/tests/fixtures/<edi>/<sample>.txt` (the existing 13 fixtures are **flat** at the top level of `fixtures/` — no per-test subdirectories). Copy from `docs/prodfiles/<source>/<file>.txt`; never reach into `docs/prodfiles/` from a test. The matching test should declare the path as a module-level `Path` constant.
|
|
||||||
|
|
||||||
## Patterns
|
|
||||||
|
|
||||||
### Minimal `parse_<edi>.py` — using `segments.py`, exporting `parse_ta1_text`
|
|
||||||
|
|
||||||
Taken from `backend/src/cyclone/parsers/parse_ta1.py:1-29` (the smallest parser — TA1 is just ISA + TA1 + IEA). The same skeleton scales to every other EDI type by adding `_consume_<segment>` helpers.
|
|
||||||
|
|
||||||
```python
|
|
||||||
"""Parse an X12 TA1 (Interchange Acknowledgment) file.
|
|
||||||
|
|
||||||
Whole-document problems (missing ISA, no TA1) raise CycloneParseError.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from datetime import date
|
|
||||||
|
|
||||||
from cyclone.parsers.exceptions import CycloneParseError
|
|
||||||
from cyclone.parsers.models import BatchSummary, Envelope
|
|
||||||
from cyclone.parsers.models_ta1 import ParseResultTa1, Ta1Ack
|
|
||||||
from cyclone.parsers.segments import tokenize
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_yyyymmdd(s: str) -> date | None:
|
|
||||||
"""Parse an 8-digit CCYYMMDD string. Returns None on bad input."""
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
def _build_envelope(segments: list[list[str]], input_file: str) -> Envelope:
|
|
||||||
"""Build the envelope from ISA. TA1 has no GS/ST — just ISA → TA1 → IEA."""
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
def _consume_ta1(segments: list[list[str]], idx: int) -> tuple[Ta1Ack, int]:
|
|
||||||
"""Read a TA1 segment and return a Ta1Ack. Returns (model, next_idx)."""
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
def parse_ta1_text(text: str, *, input_file: str = "") -> ParseResultTa1:
|
|
||||||
"""Parse a complete TA1 document and return a ParseResultTa1."""
|
|
||||||
segments = tokenize(text)
|
|
||||||
envelope = _build_envelope(segments, input_file=input_file)
|
|
||||||
ta1_idx = next(
|
|
||||||
(i for i, seg in enumerate(segments) if seg[0] == "TA1"), None,
|
|
||||||
)
|
|
||||||
if ta1_idx is None:
|
|
||||||
raise CycloneParseError("No TA1 segment found")
|
|
||||||
ta1, _ = _consume_ta1(segments, ta1_idx)
|
|
||||||
...
|
|
||||||
return ParseResultTa1(envelope=envelope, ta1=ta1, summary=summary, ...)
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["parse_ta1_text"]
|
|
||||||
```
|
|
||||||
|
|
||||||
The orchestrator pattern is the same in every parser: `tokenize` → `_build_envelope` → segment consumers in order → wrap into a `ParseResult<EDI>` model. The Pydantic result is what the API / store layer consumes.
|
|
||||||
|
|
||||||
### A validator rule — `_r<n>_<name>` registered in `_RULES`
|
|
||||||
|
|
||||||
Taken from `backend/src/cyclone/parsers/validator.py:23-66` (the canonical R010–R100 block).
|
|
||||||
|
|
||||||
```python
|
|
||||||
from collections.abc import Iterable
|
|
||||||
from cyclone.parsers.models import ClaimOutput, ValidationIssue
|
|
||||||
from cyclone.parsers.payer import PayerConfig
|
|
||||||
|
|
||||||
NPI_RE = re.compile(r"^\d{10}$")
|
|
||||||
Rule = Callable[[ClaimOutput, PayerConfig], Iterable[ValidationIssue]]
|
|
||||||
|
|
||||||
|
|
||||||
def _r020_npi_format(claim: ClaimOutput, _: PayerConfig) -> Iterable[ValidationIssue]:
|
|
||||||
if claim.billing_provider.npi and not NPI_RE.match(claim.billing_provider.npi):
|
|
||||||
yield ValidationIssue(
|
|
||||||
rule="R020_npi_format",
|
|
||||||
severity="error",
|
|
||||||
message=f"Billing provider NPI must be 10 digits, got {claim.billing_provider.npi!r}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _r021_npi_checksum(claim: ClaimOutput, _: PayerConfig) -> Iterable[ValidationIssue]:
|
|
||||||
"""SP20: validate the billing-provider NPI's Luhn check digit."""
|
|
||||||
npi = claim.billing_provider.npi
|
|
||||||
if not npi or not NPI_RE.match(npi):
|
|
||||||
return # R020 already flagged the format — skip silently.
|
|
||||||
try:
|
|
||||||
from cyclone.npi import is_valid_npi
|
|
||||||
except ImportError:
|
|
||||||
return
|
|
||||||
if not is_valid_npi(npi):
|
|
||||||
yield ValidationIssue(
|
|
||||||
rule="R021_npi_checksum",
|
|
||||||
severity="warning",
|
|
||||||
message=f"Billing provider NPI {npi!r} fails Luhn checksum (likely typo)",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
_RULES: list[Rule] = [
|
|
||||||
_r010_clm01_present,
|
|
||||||
_r011_total_charge_positive,
|
|
||||||
_r020_npi_format,
|
|
||||||
_r021_npi_checksum,
|
|
||||||
# ... R030, R031, R032-R035, R050, R060, R070, R100, R200-R210
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
For 835 rules, prefix the rule string with `R835_` (e.g. `R835_BPR01_handling_code_allowed`) and target the `ParseResult835` model instead of `ClaimOutput` — see `backend/src/cyclone/parsers/validator_835.py:38-79`.
|
|
||||||
|
|
||||||
### A test that uses a prodfiles fixture
|
|
||||||
|
|
||||||
Taken from `backend/tests/test_api_999.py:53-72`. The autouse `conftest.py` already provides a per-test SQLite DB; most tests just add a `client` fixture and reference the fixture path as a module-level constant.
|
|
||||||
|
|
||||||
```python
|
|
||||||
"""Tests for the FastAPI surface in cyclone.api for the 999 endpoint."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from cyclone.api import app
|
|
||||||
|
|
||||||
# Fixture reference — flat, module-level Path constant. NEVER reach into
|
|
||||||
# docs/prodfiles/ from a test; the fixtures/ dir is the test-consumed surface.
|
|
||||||
ACCEPTED = Path(__file__).parent / "fixtures" / "minimal_999.txt"
|
|
||||||
REJECTED = Path(__file__).parent / "fixtures" / "minimal_999_rejected.txt"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def client() -> TestClient:
|
|
||||||
return TestClient(app)
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_999_endpoint_happy_path(client: TestClient):
|
|
||||||
text = ACCEPTED.read_text()
|
|
||||||
resp = client.post(
|
|
||||||
"/api/parse-999",
|
|
||||||
files={"file": ("minimal_999.txt", text, "text/plain")},
|
|
||||||
headers={"Accept": "application/json"},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 200, resp.text
|
|
||||||
body = resp.json()
|
|
||||||
assert body["ack"]["ack_code"] == "A"
|
|
||||||
```
|
|
||||||
|
|
||||||
For pure-unit parser tests (no API), the same path is reused — see `backend/tests/test_parse_837.py:8` (`FIXTURE = Path(__file__).parent / "fixtures" / "minimal_837p.txt"`).
|
|
||||||
|
|
||||||
## Anti-patterns
|
|
||||||
|
|
||||||
- **Don't re-parse raw X12 strings inside validators.** Always parse first into the typed `ParseResult<EDI>` / `ClaimOutput`, then validate against that. Validators index into the model fields (or `claim.raw_segments` for spot-checks of specific segment presence) — they never call `tokenize` again. R034's `REF*G1` presence check (`validator.py:104-107`) is the only place that legitimately touches `raw_segments`, and it does so to confirm a single segment exists.
|
|
||||||
- **Don't bake payer-specific logic into the generic parser.** Payer variations live in `backend/src/cyclone/parsers/payer.py` (`PayerConfig`, `PayerConfig835`) and `backend/src/cyclone/payers.py` (the YAML loader from `config/payers.yaml`). Parsers accept the config as an argument; rules read it from the `cfg` parameter. A new payer never requires a new parser file — extend the config and add / adjust an R-code rule.
|
|
||||||
- **Don't add a validator rule without an R-code.** The `rule="R<n>_<name>"` string is the stable identifier the UI greys out, the API returns in `errors[].rule`, and tests assert against. Inventing a rule without an R-code (or reusing an R-code with new semantics) breaks the operator workflow. New SP-N increments reserve their R-code range up front (SP9 reserved R200–R210, SP20 added R021) and document it in the spec.
|
|
||||||
|
|
||||||
## Related skills
|
|
||||||
|
|
||||||
- **`cyclone-store`** — load when the increment changes how a parsed `ClaimOutput` / `ParseResult<EDI>` is persisted (`store.py` write path, `<entity>_written` events).
|
|
||||||
- **`cyclone-api-router`** — load when the increment adds or changes an HTTP endpoint that surfaces a parsed result (e.g. `/api/parse-999`, `/api/parse-837`, `/api/parse-835`).
|
|
||||||
- **`cyclone-tests`** — every parser addition ships a fixture in `backend/tests/fixtures/` and a pytest case; load this skill for the fixture-drop-in and autouse-conftest rules.
|
|
||||||
- **`cyclone-cli`** — load when the increment adds a CLI subcommand. The `cyclone parse-837 <file>` and `cyclone parse-835 <file>` smoke commands at `backend/src/cyclone/cli.py:77,151` are the parser-level smoke tests; the `validate-npi` and `validate-tax-id` commands exercise the format helpers.
|
|
||||||
- **`cyclone-spec`** — load when the SP-N spec for the increment introduces a new R-code range or a new transaction type; the spec's `## Decisions` section is where the R-code reservation gets locked in.
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# Cyclone EDI parsers — flat catalog
|
|
||||||
|
|
||||||
Every parser module under `backend/src/cyclone/parsers/` (one row per
|
|
||||||
file), its transaction type, its public entry signature, its result
|
|
||||||
model, its primary fixture, and any payer-specific variant. The
|
|
||||||
companion Pydantic model is in a co-located `models_<edi>.py`; the
|
|
||||||
segment walker uses `tokenize()` from `segments.py` and never parses
|
|
||||||
raw text inline.
|
|
||||||
|
|
||||||
| Module | EDI type | Public entry signature | Result model | Primary fixture(s) | Payer variant |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| `parse_837.py` | 837P (Professional Claim) | `parse(text, payer_config: PayerConfig, input_file="") -> ParseResult` | `cyclone.parsers.models.ParseResult` | `minimal_837p.txt`, `co_medicaid_837p.txt` | `PayerConfig` (CO Medicaid default) |
|
|
||||||
| `parse_835.py` | 835 (ERA / Remittance) | `parse(text, payer_config: PayerConfig835, input_file="") -> ParseResult835` | `cyclone.parsers.models_835.ParseResult835` | `minimal_835.txt`, `co_medicaid_835.txt`, `unbalanced_835.txt` | `PayerConfig835` |
|
|
||||||
| `parse_999.py` | 999 (Implementation ACK) | `parse_999_text(text, *, input_file="") -> ParseResult999` | `cyclone.parsers.models_999.ParseResult999` | `minimal_999.txt`, `minimal_999_rejected.txt` | none — single-shape ack |
|
|
||||||
| `parse_277ca.py` | 277CA (Claim ACK) | `parse_277ca_text(text, *, input_file="") -> ParseResult277CA` | `cyclone.parsers.models_277ca.ParseResult277CA` | `minimal_277ca.txt`, `minimal_277ca_rejected_only.txt`, `minimal_277ca_st277.txt` | `PayerConfig277CA` (config-driven) |
|
|
||||||
| `parse_270.py` | 270 (Eligibility Inquiry) | `parse(text, *, input_file="") -> ParseResult270` | `cyclone.parsers.models_270.ParseResult270` | `minimal_270.txt` | reuses `PayerConfig` shape |
|
|
||||||
| `parse_271.py` | 271 (Eligibility Response) | `parse(text, *, input_file="") -> ParseResult271` | `cyclone.parsers.models_271.ParseResult271` | `minimal_271.txt` | reuses `PayerConfig` shape |
|
|
||||||
| `parse_ta1.py` | TA1 (Interchange ACK) | `parse_ta1_text(text, *, input_file="") -> ParseResultTa1` | `cyclone.parsers.models_ta1.ParseResultTa1` | `minimal_ta1.txt` | none — single-shape ack |
|
|
||||||
|
|
||||||
Companion modules (not parsers, but shipped alongside):
|
|
||||||
|
|
||||||
| Module | Role |
|
|
||||||
|---|---|
|
|
||||||
| `segments.py` | `Delimiters`, `_detect_delimiters`, `tokenize(text) -> list[list[str]]` |
|
|
||||||
| `models.py` | Pydantic models for 837P (`ParseResult`, `ClaimOutput`, `Envelope`, `BatchSummary`, `ValidationIssue`, `ValidationReport`, …) |
|
|
||||||
| `models_835.py` / `models_270.py` / `models_271.py` / `models_277ca.py` / `models_999.py` / `models_ta1.py` | Pydantic models for each transaction type |
|
|
||||||
| `payer.py` | `PayerConfig` + `PayerConfig835` factories |
|
|
||||||
| `exceptions.py` | `CycloneParseError`, `CycloneValidationError` |
|
|
||||||
| `cas_codes.py` | CARC lookup: `reason_label(group, reason)`, `all_known_codes()`, `LAST_UPDATED` |
|
|
||||||
| `validator.py` | 837P rules: R010–R100, R200–R210; `validate(claim, config) -> ValidationReport` |
|
|
||||||
| `validator_835.py` | 835 rules: `R835_*` (e.g. `R835_BPR01_handling_code_allowed`); `validate(result, cfg) -> ValidationReport` |
|
|
||||||
| `serialize_270.py` / `serialize_837.py` / `serialize_999.py` | Outbound (Cyclone → payer) serializers — mirror of `parse_*` |
|
|
||||||
| `writer.py` / `writer_835.py` | Output writers (one JSON per claim) |
|
|
||||||
| `batch_ack_builder.py` | `build_ack_for_batch` — produces a 999 for a parsed 837 batch |
|
|
||||||
| `__init__.py` | Lazy PEP 562 re-exports (`parse`, `parse_835`, `parse_999`, `parse_270`, `parse_271`, `parse_277ca`, plus all models) |
|
|
||||||
|
|
||||||
Fixture rule: every parser ships at least one flat fixture in
|
|
||||||
`backend/tests/fixtures/<edi>-sample.txt`. Prodfiles sources live in
|
|
||||||
`docs/prodfiles/{837p-from-axiscare,835fromco,FromHPE,claims}/` — copy
|
|
||||||
from there, never reach in from a test.
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
---
|
|
||||||
name: cyclone-frontend-page
|
|
||||||
description: "Cyclone React page conventions (TanStack Query, use<X> hook, drawer, URL state, .test.tsx sibling, Layout / PageHeader / Sidebar). Use when: adding a new page, refactoring an existing one, or wiring a drawer into a page."
|
|
||||||
---
|
|
||||||
|
|
||||||
# cyclone-frontend-page
|
|
||||||
|
|
||||||
Cyclone pages live at `src/pages/<Name>.tsx`: a `use<X>` hook in `src/hooks/use<X>.ts` does the fetching (and optionally the live-tail subscription), `<Layout>` + `<PageHeader>` + `<Sidebar>` (`src/components/`) provide the app shell, and any right-side detail (claim, remittance) lives in `src/components/<DrawerName>/` whose open/close state is mirrored to the URL via `useDrawerUrlState`. This skill codifies the conventions so additions stay consistent with the eleven pages already shipped.
|
|
||||||
|
|
||||||
As of this writing: **11 pages** under `src/pages/` (9 of 11 with a `*.test.tsx` sibling — Dashboard, Upload, and BatchDiff are not yet covered; be the first when you refactor them), **~30 hooks** under `src/hooks/`, and **2 drawer modules** at `src/components/{ClaimDrawer,RemitDrawer}/`. The next increment is **SP22**.
|
|
||||||
|
|
||||||
## When to use
|
|
||||||
|
|
||||||
- **Adding a new page.** Mounting a new screen in `src/pages/` — you need the Layout + PageHeader + table shape, the `use<X>` hook split, and the route registration point in `src/App.tsx`.
|
|
||||||
- **Refactoring an existing page.** Splitting a 600-line page, swapping a manual `fetch` for a hook, or moving in-component state into the URL — load this skill to confirm the destination shape.
|
|
||||||
- **Wiring a drawer.** Adding a new right-side detail drawer (e.g. `BatchDrawer`, `ActivityDrawer`) — you need `useDrawerUrlState` for URL-driven open/close, the `src/components/<DrawerName>/` folder layout, and the deep-link contract so `?claim=…` / `?remit=…` round-trips.
|
|
||||||
- **Sharing state via URL.** Persisting filter / page / drawer state across reloads — confirm the `useDrawerUrlState` / `useRemitDrawerUrlState` hook pair is the right tool before reaching for `useState` + history.
|
|
||||||
|
|
||||||
## Conventions
|
|
||||||
|
|
||||||
1. **Page shape.** Every page in `src/pages/<Name>.tsx` exports a `function <Name>()` (most pages use a named export — see `src/pages/Claims.tsx:51`, `Remittances.tsx:62`, `Dashboard.tsx:68`; `src/pages/Inbox.tsx:40` is the lone default export). The app shell is provided by the `<Layout>` route wrapper in `src/App.tsx:30`. Each page sets a `<PageHeader>` (`src/components/PageHeader.tsx:18`) and renders a table, list, or KPI grid. Sidebar nav is mounted once by `<Layout>` at `src/components/Sidebar.tsx`.
|
|
||||||
2. **Data hook.** Each page pairs with a `use<X>` hook in `src/hooks/use<X>.ts` (e.g. `Claims` ↔ `useClaims`, `Remittances` ↔ `useRemittances`, `Acks` ↔ `useAcks`). The hook returns `{ data, isLoading, isError, error, refetch }` from TanStack Query's `useQuery` (see `src/hooks/useClaims.ts:24-31`, `useRemittances.ts:21-25`). Pages never call `fetch` or `@/lib/api` directly — the hook is the boundary so the page is testable with `vi.mock("@/lib/api", ...)`.
|
|
||||||
3. **Live tail.** Pages with live data compose three hooks in order: the `use<X>` initial fetch, `useTailStream(resource)` (`src/hooks/useTailStream.ts:80` — opens the NDJSON stream, drives the backoff/stall state machine), and `useMergedTail(resource, baseItems, filterFn?)` (`src/hooks/useMergedTail.ts:25` — merges snapshot + tail, dedup'd by id). The full wiring lives at `src/pages/Claims.tsx:85-89` and `Remittances.tsx:81-83`. The streaming subscription belongs on the page, not in the data hook — hoisting it couples the lifecycle to whoever mounts `use<X>`.
|
|
||||||
4. **Drawer.** Right-side detail drawers live in `src/components/<DrawerName>/` (currently `ClaimDrawer/`, `RemitDrawer/`) with a barrel `index.ts` (`src/components/ClaimDrawer/index.ts:1-14`). The drawer is wired to URL state via `useDrawerUrlState()` for `?claim=…` (`src/hooks/useDrawerUrlState.ts`) or `useRemitDrawerUrlState()` for `?remit=…` (`src/hooks/useRemitDrawerUrlState.ts`). Open state is driven by the URL, not a `useState` flag, so deep-links round-trip.
|
|
||||||
5. **Tests.** Every page gets a `src/pages/<Name>.test.tsx` sibling (e.g. `Claims.test.tsx`, `Remittances.test.tsx`, `Batches.test.tsx`). Every hook gets a `src/hooks/use<X>.test.ts` sibling (e.g. `useClaims.test.ts`, `useRemittances.test.ts`). The shared setup — `// @vitest-environment happy-dom`, `IS_REACT_ACT_ENVIRONMENT = true`, `QueryClient` provider, `vi.mock("@/lib/api", ...)` — is documented in `cyclone-tests`; mirror `src/pages/Claims.test.tsx:1-30` for the canonical page-test shape.
|
|
||||||
6. **UI primitives.** Use Radix-backed components from `src/components/ui/` (`button.tsx`, `dialog.tsx`, `table.tsx`, `select.tsx`, `pagination.tsx`, `empty-state.tsx`, `error-state.tsx`, `filter-chips.tsx`, `skeleton.tsx`, `input.tsx`, `label.tsx`, `card.tsx`, `badge.tsx`, `skip-link.tsx`, `claim-state-badge.tsx`). Don't pull in a new UI library without discussion — every primitive here is already consumed by at least one shipped page.
|
|
||||||
7. **Routing.** Pages register their route in `src/App.tsx` as a `<Route path="<name>" element={<<Name>> />} />` inside the `<Layout>` element wrapper (`src/App.tsx:30-44`). Currently every page is a static import; switch to `React.lazy(() => import(...))` only if a page grows heavy (large parse/EDI libs, chart code) and the import cost shows up in the bundle report.
|
|
||||||
|
|
||||||
## Patterns
|
|
||||||
|
|
||||||
### `Claims.tsx`-style page (Layout + PageHeader + table + drawer)
|
|
||||||
|
|
||||||
Canonical page shape — composes the data hook, the tail triplet, and
|
|
||||||
`useDrawerUrlState` for the `?claim=…` deep-link. See
|
|
||||||
`src/pages/Claims.tsx:51-200` for the full file.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { Table, TableBody, TableRow, /* … */ } from "@/components/ui/table";
|
|
||||||
import { PageHeader } from "@/components/PageHeader";
|
|
||||||
import { ClaimDrawer } from "@/components/ClaimDrawer";
|
|
||||||
import { useClaims } from "@/hooks/useClaims";
|
|
||||||
import { useDrawerUrlState } from "@/hooks/useDrawerUrlState";
|
|
||||||
import { useTailStream } from "@/hooks/useTailStream";
|
|
||||||
import { useMergedTail } from "@/hooks/useMergedTail";
|
|
||||||
import { TailStatusPill } from "@/components/TailStatusPill";
|
|
||||||
|
|
||||||
export function Claims() {
|
|
||||||
const [status, setStatus] = useState<ClaimStatus | null>(null);
|
|
||||||
const params = useMemo(() => ({ status, limit: 25, offset: 0 }), [status]);
|
|
||||||
|
|
||||||
const { data } = useClaims(params);
|
|
||||||
const { status: tailStatus, lastEventAt, forceReconnect } = useTailStream("claims");
|
|
||||||
const items = useMergedTail("claims", data?.items ?? [], (c) => !status || c.status === status);
|
|
||||||
const { claimId, openClaim, closeClaim } = useDrawerUrlState();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader
|
|
||||||
eyebrow="Inbox"
|
|
||||||
title="Claims"
|
|
||||||
status={<TailStatusPill status={tailStatus} lastEventAt={lastEventAt} onReconnect={forceReconnect} />}
|
|
||||||
/>
|
|
||||||
<Table>
|
|
||||||
<TableBody>
|
|
||||||
{items.map((c) => (
|
|
||||||
<TableRow key={c.id} onClick={() => openClaim(c.id)}>{/* …cells… */}</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
<ClaimDrawer claimId={claimId} claims={items} onClose={closeClaim} onNavigate={openClaim} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `use<X>` data hook (TanStack Query)
|
|
||||||
|
|
||||||
Pattern from `src/hooks/useClaims.ts:24-67` and `useRemittances.ts:21-65`.
|
|
||||||
Returns a stable shape so the page treats all data hooks uniformly.
|
|
||||||
The tail subscription lives on the page (see Convention 3), not here.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { api, type ListClaimsParams, type PaginatedResponse } from "@/lib/api";
|
|
||||||
import type { Claim } from "@/types";
|
|
||||||
|
|
||||||
export function useClaims(params: ListClaimsParams) {
|
|
||||||
return useQuery<PaginatedResponse<Claim>>({
|
|
||||||
queryKey: ["claims", params],
|
|
||||||
queryFn: () => api.listClaims<Claim>(params),
|
|
||||||
enabled: api.isConfigured,
|
|
||||||
// …in-memory fallback when !api.isConfigured…
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Drawer component with `useDrawerUrlState`
|
|
||||||
|
|
||||||
Pattern from `src/components/ClaimDrawer/ClaimDrawer.tsx:1-50` +
|
|
||||||
`src/hooks/useDrawerUrlState.ts`. The drawer takes `claimId` as a
|
|
||||||
prop (driven by the URL), renders nothing when `null`, and uses
|
|
||||||
`useDrawerKeyboard` for j/k navigation + Escape to close. The page
|
|
||||||
that mounts it controls the URL — `openClaim("abc")` sets `?claim=abc`,
|
|
||||||
removing the param closes the drawer, and reload preserves the state.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
|
||||||
import { useClaimDetail } from "@/hooks/useClaimDetail";
|
|
||||||
import { useDrawerKeyboard } from "@/hooks/useDrawerKeyboard";
|
|
||||||
|
|
||||||
export function ClaimDrawer({ claimId, claims, onClose, onNavigate, onToggleHelp }) {
|
|
||||||
const open = claimId !== null;
|
|
||||||
const { data, isLoading, isError, error } = useClaimDetail(claimId);
|
|
||||||
useDrawerKeyboard({ open, claims, onClose, onNavigate, onToggleHelp });
|
|
||||||
if (!open) return null;
|
|
||||||
return (
|
|
||||||
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
|
||||||
<DialogContent>{/* header + body panels from useClaimDetail… */}</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Anti-patterns
|
|
||||||
|
|
||||||
- **Don't `fetch` from inside a page component.** All API access goes through the `use<X>` hook in `src/hooks/use<X>.ts`. Pages that reach for `fetch(...)` directly can't be tested with `vi.mock("@/lib/api", ...)` and split the data lifecycle across files. The hook returns `{ data, isLoading, isError, error, refetch }` so the page is a pure renderer.
|
|
||||||
- **Don't open a drawer via local component state.** A `const [open, setOpen] = useState(false)` for a drawer breaks deep-links and reload-restore. Use `useDrawerUrlState()` (claim) or `useRemitDrawerUrlState()` (remit) so the URL is the single source of truth.
|
|
||||||
- **Don't put domain logic in JSX.** Conditional renderings, table sorting, and KPI math all belong in the `use<X>` hook or a pure helper under `src/lib/` (e.g. `src/lib/format.ts` for currency / date formatting). JSX is for layout; mixing in `items.filter(...).sort(...)` inline is hard to test and hides behavior from the hook.
|
|
||||||
- **Don't call `useTailStream` from inside a `use<X>` hook.** The streaming subscription belongs on the page (see `src/pages/Claims.tsx:85-89`). Hoisting it into `useClaims` couples the open/close lifecycle of the page to whoever mounts the hook, and breaks the one-resource-one-page ownership that the backoff/stall state machine assumes.
|
|
||||||
- **Don't import a new UI library to render a button, modal, or table.** Radix-backed primitives in `src/components/ui/` already cover every widget the shipped pages use. Reach for a new library only after the primitive gap is real and the proposal is in a spec / PR.
|
|
||||||
|
|
||||||
## Related skills
|
|
||||||
|
|
||||||
- **`cyclone-tail`** — load for any live-data page (Claims, Remittances, ActivityLog). Documents the `useTailStream` + `useMergedTail` triplet, the `<TailStatusPill>` wiring, and the 30s stall threshold (`STALL_TIMEOUT_MS = 30_000` at `useTailStream.ts:53`).
|
|
||||||
- **`cyclone-api-router`** — load when a page is calling a new HTTP endpoint. Documents `api_routers/<topic>.py` vs. inline `api.py` registration and the response / error-envelope shapes.
|
|
||||||
- **`cyclone-tests`** — load when adding the `*.test.tsx` sibling. Documents the `// @vitest-environment happy-dom` setup, `IS_REACT_ACT_ENVIRONMENT = true`, the `@testing-library/react` vs. `createRoot`+`Probe` rendering styles, and the `vi.mock("@/lib/api")` convention.
|
|
||||||
- **`cyclone-edi`** — load when a page renders parsed 837P / 835 / 999 / 270 / 271 / 277CA / TA1 content (ServiceLinesTable, CAS panels, ValidationPanel). Documents the parser modules, the R-coded validator rules, and the CAS / CARC / NPI / EIN helpers.
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
---
|
|
||||||
name: cyclone-spec
|
|
||||||
description: "Cyclone SP-N superpowers increment flow — spec → plan → implement → merge. Use when: starting a new numbered feature increment, naming a branch, opening a SP-N PR, or doing the merge dance into main."
|
|
||||||
---
|
|
||||||
|
|
||||||
# cyclone-spec
|
|
||||||
|
|
||||||
The Cyclone repo ships every new feature as a numbered **SP-N increment**:
|
|
||||||
a spec, a plan, an implementation branch, and a single atomic merge commit
|
|
||||||
into `main`. This skill encodes the conventions so every increment follows
|
|
||||||
the same shape and the commit history stays auditable.
|
|
||||||
|
|
||||||
As of this writing: **16 specs** in `docs/superpowers/specs/`, **12 plans**
|
|
||||||
in `docs/superpowers/plans/`, and SP numbers used through **SP21** (the
|
|
||||||
universal-drilldown design in progress). The next increment is **SP22**.
|
|
||||||
|
|
||||||
## When to use
|
|
||||||
|
|
||||||
- **Starting a new feature increment.** You are about to add a numbered
|
|
||||||
feature, fix that crosses subsystem boundaries, or anything bigger than
|
|
||||||
a one-line change. Before you write code, reserve the next SP number and
|
|
||||||
write the spec.
|
|
||||||
- **Naming the spec / plan files or the branch.** You have a topic, a
|
|
||||||
date, and a number — and you need the exact path / branch shape so
|
|
||||||
existing scripts and reviewers can find the artifacts.
|
|
||||||
- **Opening the SP-N PR.** You are about to push the branch and need the
|
|
||||||
PR title format and the commit-prefix conventions so the merge commit
|
|
||||||
reads cleanly.
|
|
||||||
- **Doing the merge dance.** Review is approved and you're about to land
|
|
||||||
the branch into `main`. Use this skill to confirm the merge shape — no
|
|
||||||
squash, no rebase, one atomic merge commit.
|
|
||||||
|
|
||||||
## Conventions
|
|
||||||
|
|
||||||
1. **Numbering.** Reserve the next SP-N number — the next integer after
|
|
||||||
the highest `SP<n>` already used in `git log`. Never reuse a number,
|
|
||||||
even after deletion. Numbering is monotonic and lives in the merge
|
|
||||||
history.
|
|
||||||
2. **Branch.** `sp<N>-<short-kebab-topic>` — e.g. `sp22-line-reconciliation`,
|
|
||||||
`sp9-multi-payer-npi`. Kebab-case, lowercase, no spaces, no slashes.
|
|
||||||
The branch name is the canonical handle for the increment.
|
|
||||||
3. **Spec path.** `docs/superpowers/specs/YYYY-MM-DD-cyclone-<topic>-design.md`
|
|
||||||
with header `Status: Draft, pending user review`. One spec per
|
|
||||||
increment. Real examples: `2026-06-19-cyclone-db-reconciliation-design.md`
|
|
||||||
(SP3), `2026-06-20-cyclone-multi-payer-npi-sftp-design.md` (SP9).
|
|
||||||
4. **Plan path.** `docs/superpowers/plans/YYYY-MM-DD-cyclone-<topic>.md`.
|
|
||||||
Header per the upstream `superpowers:writing-plans` skill: a
|
|
||||||
`For agentic workers:` line that names
|
|
||||||
`superpowers:subagent-driven-development` or
|
|
||||||
`superpowers:executing-plans`, plus a `Goal / Architecture / Tech
|
|
||||||
Stack / Spec` metadata block, then numbered tasks with
|
|
||||||
`- [ ] Step N:` checkboxes.
|
|
||||||
5. **Commit prefix.** All commits on the branch follow these prefixes —
|
|
||||||
they make the SP-N merge commit readable and let `git log --grep`
|
|
||||||
filter cleanly:
|
|
||||||
- `feat(sp<N>): …` — implementation commits (e.g. `feat(sp20): NPI Luhn checksum + Tax ID format validation`).
|
|
||||||
- `docs(spec): …` — landing the spec (e.g. `docs(spec): design for CycloneStore split (Step 4)`).
|
|
||||||
- `docs(plan): …` — landing the plan.
|
|
||||||
- `merge: SP<N> <topic> into main` — the merge commit itself (e.g. `merge: SP14 5-lane Inbox UI + acknowledge action into main`).
|
|
||||||
6. **PR title.** `SP<N> <Topic>` — e.g. `SP22 Line reconciliation`.
|
|
||||||
Matches the merge-commit subject so GitHub's "merged PR" view and the
|
|
||||||
`git log` entry are identical strings.
|
|
||||||
7. **Merge shape.** A single atomic merge commit into `main` after
|
|
||||||
review. **No squash** — squash collapses the per-commit history and
|
|
||||||
breaks the SP-N audit trail. **No rebase** — rebase rewrites the SHAs
|
|
||||||
the PR review was performed against. The SP-N merge commit *is* the
|
|
||||||
record of the increment landing.
|
|
||||||
|
|
||||||
## Patterns
|
|
||||||
|
|
||||||
### Spec header (canonical SP-N shape, post-SP9)
|
|
||||||
|
|
||||||
This is the canonical header for new specs. Older specs (pre-SP9) deviate
|
|
||||||
slightly — different title style, no Branch or Aesthetic direction line —
|
|
||||||
and have not been retroactively normalized. **Use this template for any
|
|
||||||
new SP-N spec.**
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
# Sub-project <N> — <Topic>: Design Spec
|
|
||||||
|
|
||||||
**Date:** YYYY-MM-DD
|
|
||||||
**Status:** Draft, awaiting user sign-off
|
|
||||||
**Branch:** `sp<N>-<short-kebab-topic>`
|
|
||||||
**Aesthetic direction:** <one line — e.g. "No new UI" or "Modern (geometric sans + bold borders + electric blue accent)">
|
|
||||||
|
|
||||||
## 1. Scope
|
|
||||||
<2-6 lines: what's in, what's out, with explicit out-of-scope list>
|
|
||||||
```
|
|
||||||
|
|
||||||
Worked example (matches this template): `docs/superpowers/specs/2026-06-20-cyclone-multi-payer-npi-sftp-design.md`.
|
|
||||||
|
|
||||||
### Plan header (every SP-N plan starts with this)
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
# <Topic> Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use
|
|
||||||
> superpowers:subagent-driven-development (recommended) or
|
|
||||||
> superpowers:executing-plans to implement this plan task-by-task. Steps
|
|
||||||
> use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** <one sentence — the outcome>
|
|
||||||
**Architecture:** <one paragraph — how it's structured>
|
|
||||||
**Tech Stack:** <comma-separated list>
|
|
||||||
**Spec:** [`docs/superpowers/specs/YYYY-MM-DD-cyclone-<topic>-design.md`](../specs/...)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File structure
|
|
||||||
<tree of new / modified files>
|
|
||||||
|
|
||||||
## Task 0: <setup>
|
|
||||||
## Task 1: <first user-visible step>
|
|
||||||
…
|
|
||||||
```
|
|
||||||
|
|
||||||
Real examples: `docs/superpowers/plans/2026-06-21-cyclone-skill-catalog.md`,
|
|
||||||
`docs/superpowers/plans/2026-06-21-cyclone-store-split.md`.
|
|
||||||
|
|
||||||
## Anti-patterns
|
|
||||||
|
|
||||||
- **Don't skip the spec ("it's a small fix").** Small fixes still get a
|
|
||||||
3-line spec when they introduce a new numbered increment. The spec is
|
|
||||||
the *what* and the audit trail; the plan is the *how*. Without a spec
|
|
||||||
the merge commit has no anchor.
|
|
||||||
- **Don't squash the merge commit.** The SP-N merge commit is the audit
|
|
||||||
trail — it tells future you exactly which feature landed and which
|
|
||||||
commits composed it. Squash collapses that into one opaque commit and
|
|
||||||
the per-commit history is lost.
|
|
||||||
- **Don't put code in the spec — the spec is the *what*, the plan is the
|
|
||||||
*how*.** Specs describe scope, goals, non-goals, and decisions. Code
|
|
||||||
snippets belong in the plan (with checkbox steps) or in the diff, not
|
|
||||||
in the spec. SP-N specs in this repo routinely have **zero** code
|
|
||||||
blocks.
|
|
||||||
|
|
||||||
## Related skills
|
|
||||||
|
|
||||||
- **`cyclone-tests`** — every spec lists test impact; load this when
|
|
||||||
drafting or reviewing the spec to confirm fixture / `.test.tsx`
|
|
||||||
implications.
|
|
||||||
- **`cyclone-edi`** — load when the SP-N increment touches an EDI parser,
|
|
||||||
validator rule, or CAS mapping.
|
|
||||||
- **`cyclone-tail`** — load when the increment changes the live-tail wire
|
|
||||||
format or adds a streaming page.
|
|
||||||
- **`cyclone-store`** — load when the increment adds a write-path,
|
|
||||||
touches `store.py`, or wires a new `<entity>_written` event.
|
|
||||||
- **`cyclone-api-router`** — load when the increment adds or changes an
|
|
||||||
HTTP endpoint in `api_routers/`.
|
|
||||||
- **`cyclone-frontend-page`** — load when the increment adds or
|
|
||||||
refactors a page in `src/pages/`.
|
|
||||||
- **`cyclone-cli`** — load when the increment adds a CLI subcommand or
|
|
||||||
changes exit codes.
|
|
||||||
- **`superpowers:brainstorming`** (global) — run before the spec to lock
|
|
||||||
the scope / decisions in the spec's `## Decisions (locked during
|
|
||||||
brainstorming)` section.
|
|
||||||
- **`superpowers:writing-plans`** (global) — produces the plan header
|
|
||||||
format every SP-N plan follows.
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
---
|
|
||||||
name: cyclone-store
|
|
||||||
description: "Cyclone store write-paths, the pubsub event contract (claim_written / remittance_written / activity_recorded), and the SP21 store-split boundary map. Use when: touching store.py, adding a new entity, wiring a new write event, or splitting a store module."
|
|
||||||
---
|
|
||||||
|
|
||||||
# cyclone-store
|
|
||||||
|
|
||||||
Cyclone persists every parsed X12 batch through one facade,
|
|
||||||
`CycloneStore` (`backend/src/cyclone/store.py:882`). Every write
|
|
||||||
inserts the row AND publishes a pubsub event on the in-process
|
|
||||||
`EventBus` (`backend/src/cyclone/pubsub.py:20`) so live-tail pages
|
|
||||||
see new rows the moment they land. The event contract is the seam
|
|
||||||
between persistence and streaming — a wrong event name silently
|
|
||||||
goes stale.
|
|
||||||
|
|
||||||
As of this writing: the store is a **single 2412-line module** and
|
|
||||||
the SP21 split into `backend/src/cyclone/store/` is in progress.
|
|
||||||
Three event kinds: `claim_written`, `remittance_written`,
|
|
||||||
`activity_recorded`. Next: **SP22**.
|
|
||||||
|
|
||||||
## When to use
|
|
||||||
|
|
||||||
- **Adding a new entity.** You need a new ORM model + write method +
|
|
||||||
event kind + snapshot serializer and want to know where each piece
|
|
||||||
lives (current monolith vs. post-SP21 module).
|
|
||||||
- **Wiring a new write event.** You're adding a `<entity>_written`
|
|
||||||
event and need both the publish call in the store AND the
|
|
||||||
subscribe call in the live-tail endpoint to stay in sync.
|
|
||||||
- **Debugging a write-path issue.** A page isn't reflecting new
|
|
||||||
rows, the DB has the row but the stream is silent, or the publish
|
|
||||||
raises and rolls back the transaction.
|
|
||||||
- **Splitting a store module.** You're moving a domain out of
|
|
||||||
`store.py` into its own module under `backend/src/cyclone/store/`
|
|
||||||
and need the SP21 module list + facade re-export rules.
|
|
||||||
|
|
||||||
## Conventions
|
|
||||||
|
|
||||||
1. **All writes go through `CycloneStore`.** Route handlers and
|
|
||||||
parsers must not write directly to the ORM session. The facade
|
|
||||||
opens a short-lived session via `db.SessionLocal()()`. Direct ORM
|
|
||||||
access is the #1 way the live-tail contract gets bypassed (no
|
|
||||||
event published → page silently goes stale). Read paths are
|
|
||||||
similar — prefer the facade's `iter_*` / `get_*` methods over raw
|
|
||||||
`s.execute(select(...))`.
|
|
||||||
2. **Every write publishes an event.** The event name matches the
|
|
||||||
entity: `claim_written`, `remittance_written`,
|
|
||||||
`activity_recorded` (the trailing `_recorded` signals a
|
|
||||||
non-canonical row — activity events are derived, not first-class;
|
|
||||||
current names at `store.py:1072,1081,1096`). New entities get
|
|
||||||
`<entity>_written`; activity-style side rows get
|
|
||||||
`<entity>_recorded`. Publish is **best-effort** — failures are
|
|
||||||
logged but never roll back the persisted batch
|
|
||||||
(`store.py:1097-1098`).
|
|
||||||
3. **Snapshot shape.** Each entity has a `to_ui_<entity>` serializer
|
|
||||||
(plain Python function returning a dict) — currently
|
|
||||||
`to_ui_claim`, `to_ui_remittance`, `to_ui_claim_from_orm`,
|
|
||||||
`to_ui_remittance_from_orm`, `to_ui_provider`. Post-SP21 these
|
|
||||||
move to `backend/src/cyclone/store/ui.py`. The serializer is the
|
|
||||||
single source of truth for what the frontend sees — every event
|
|
||||||
payload MUST match what the matching list endpoint returns for
|
|
||||||
that row (`store.py:1052-1054`).
|
|
||||||
4. **SP21 boundaries.** Post-split, each domain lives in its own
|
|
||||||
module under `backend/src/cyclone/store/`: `__init__.py` (facade
|
|
||||||
+ `CycloneStore` class), `exceptions.py`, `records.py`,
|
|
||||||
`orm_builders.py`, `ui.py`, `write.py`, `batches.py`,
|
|
||||||
`claim_detail.py`, `acks.py`, `backups.py`, `inbox.py`,
|
|
||||||
`providers.py`. Cross-module writes go through `CycloneStore`
|
|
||||||
facade methods, not direct module access. The facade re-exports
|
|
||||||
every name callers currently import from `cyclone.store`. Full
|
|
||||||
list: `docs/superpowers/plans/2026-06-21-cyclone-store-split.md:25-37`.
|
|
||||||
5. **No business logic in route handlers.** A route handler validates
|
|
||||||
input, calls `store.<method>(...)`, passes the `event_bus`,
|
|
||||||
returns the serialized result. Reconciliation, idempotency
|
|
||||||
checks, CAS adjustment persistence — all live in the store.
|
|
||||||
|
|
||||||
## Patterns
|
|
||||||
|
|
||||||
### A `CycloneStore.add` write — publishes events from inserted rows
|
|
||||||
|
|
||||||
Taken from `backend/src/cyclone/store.py:898-1107`. The method opens
|
|
||||||
a session, inserts rows, then runs a sync `_publish_events_sync`
|
|
||||||
after commit so subscribers can immediately re-fetch consistent data.
|
|
||||||
|
|
||||||
```python
|
|
||||||
def add(
|
|
||||||
self,
|
|
||||||
record: BatchRecord,
|
|
||||||
*,
|
|
||||||
event_bus: "EventBus | None" = None,
|
|
||||||
) -> None:
|
|
||||||
inserted_claim_ids: list[str] = []
|
|
||||||
with db.SessionLocal()() as s:
|
|
||||||
s.add(Batch(id=record.id, kind=record.kind, ...))
|
|
||||||
if isinstance(record, BatchRecord837):
|
|
||||||
for claim in record.result.claims:
|
|
||||||
if s.get(Claim, claim.claim_id) is not None:
|
|
||||||
continue # idempotency: skip dupes
|
|
||||||
s.add(_claim_837_row(claim, record.id))
|
|
||||||
s.add(ActivityEvent(kind="claim_submitted", ...))
|
|
||||||
inserted_claim_ids.append(claim.claim_id)
|
|
||||||
# ... 835 branch + flush + cas adjustments ...
|
|
||||||
s.commit()
|
|
||||||
|
|
||||||
if event_bus is not None and inserted_claim_ids:
|
|
||||||
self._publish_events_sync(event_bus, record, inserted_claim_ids)
|
|
||||||
|
|
||||||
def _publish_events_sync(self, event_bus, record, claim_ids):
|
|
||||||
with db.SessionLocal()() as s:
|
|
||||||
for cid in claim_ids:
|
|
||||||
ui = to_ui_claim_from_orm(s.get(Claim, cid), ...)
|
|
||||||
self._sync_publish(event_bus, "claim_written", ui)
|
|
||||||
# ... remittance + activity loops ...
|
|
||||||
```
|
|
||||||
|
|
||||||
`EventBus.publish` is async but the body is pure sync `put_nowait`,
|
|
||||||
so the store calls `_sync_publish` directly to avoid forcing sync
|
|
||||||
FastAPI handlers to await.
|
|
||||||
|
|
||||||
### Backend `/api/<resource>/stream` endpoint — subscribes to the event
|
|
||||||
|
|
||||||
Taken from `backend/src/cyclone/api.py:1380-1401`. Two phases — eager
|
|
||||||
snapshot, then live subscription — wrapped in `StreamingResponse`
|
|
||||||
with `media_type="application/x-ndjson"`.
|
|
||||||
|
|
||||||
```python
|
|
||||||
@app.get("/api/claims/stream")
|
|
||||||
async def claims_stream(request: Request, ...) -> StreamingResponse:
|
|
||||||
bus: EventBus = request.app.state.event_bus
|
|
||||||
|
|
||||||
async def gen() -> AsyncIterator[bytes]:
|
|
||||||
rows = store.iter_claims(status=status, ...) # 1. Snapshot
|
|
||||||
for row in rows:
|
|
||||||
yield _ndjson_line({"type": "item", "data": row})
|
|
||||||
yield _ndjson_line({"type": "snapshot_end", "data": {"count": len(rows)}})
|
|
||||||
|
|
||||||
async for chunk in _tail_events(request, bus, ["claim_written"]): # 2. Live
|
|
||||||
yield chunk
|
|
||||||
|
|
||||||
return StreamingResponse(gen(), media_type="application/x-ndjson")
|
|
||||||
```
|
|
||||||
|
|
||||||
`_ndjson_line` and `_tail_events` live in
|
|
||||||
`backend/src/cyclone/api_helpers.py`. The `["remittance_written"]`
|
|
||||||
and `["activity_recorded"]` subscriptions are at `api.py:1891,2002`.
|
|
||||||
|
|
||||||
### Backend test — asserts both the row AND the event landed
|
|
||||||
|
|
||||||
The autouse `conftest.py` (`backend/tests/conftest.py:20`) wires a
|
|
||||||
fresh `EventBus` onto `app.state` per test.
|
|
||||||
|
|
||||||
```python
|
|
||||||
def test_publishes_claim_written_event(client: TestClient) -> None:
|
|
||||||
bus = app.state.event_bus
|
|
||||||
resp = client.post("/api/parse-837",
|
|
||||||
files={"file": ("x.837", MINIMAL_837, "text/plain")})
|
|
||||||
assert resp.status_code == 200
|
|
||||||
assert list(store.iter_claims(limit=10)) # row landed
|
|
||||||
|
|
||||||
queues = bus._subscribers.get("claim_written", []) # event published
|
|
||||||
assert queues
|
|
||||||
evt = queues[0].get_nowait()
|
|
||||||
assert evt["_kind"] == "claim_written"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Anti-patterns
|
|
||||||
|
|
||||||
- **Don't read directly from the ORM in a route handler — go through
|
|
||||||
the snapshot serializer.** A handler that does
|
|
||||||
`with db.SessionLocal()() as s: row = s.get(Claim, cid); return row`
|
|
||||||
bypasses `to_ui_claim_from_orm` and silently drifts from the event
|
|
||||||
payload shape. Always call `store.get_claim_detail(cid)` (or the
|
|
||||||
equivalent `get_*` facade method).
|
|
||||||
- **Don't introduce a new event name without updating the subscriber
|
|
||||||
list.** Today every `<entity>_written` event has exactly one
|
|
||||||
consumer — the matching `/api/<resource>/stream` endpoint,
|
|
||||||
currently in `backend/src/cyclone/api.py` (claims at `:1398`,
|
|
||||||
remittances at `:1891`, activity at `:2002`). Any future split
|
|
||||||
into `backend/src/cyclone/api_routers/` must wire the same
|
|
||||||
subscription.
|
|
||||||
- **Don't merge a write method with its event publication into
|
|
||||||
separate places.** `_publish_events_sync` lives next to `add` in
|
|
||||||
`store.py:1042-1107` so reviewers see both halves of the contract
|
|
||||||
in one diff. Post-SP21 the same rule applies.
|
|
||||||
- **Don't make the publish call blocking on commit failures.**
|
|
||||||
Publish errors are caught and logged at `store.py:1097-1098`; a
|
|
||||||
failing subscriber MUST NOT roll back the persisted batch. The
|
|
||||||
batch is the source of truth; the event is the cache-invalidation
|
|
||||||
hint.
|
|
||||||
|
|
||||||
## Related skills
|
|
||||||
|
|
||||||
- **`cyclone-edi`** — the parsed `ClaimOutput` / `ParseResult<EDI>`
|
|
||||||
lands in the store via `CycloneStore.add`.
|
|
||||||
- **`cyclone-api-router`** — the route that calls `store.<method>(...)`
|
|
||||||
and (for stream endpoints) subscribes to the matching
|
|
||||||
`<entity>_written` event.
|
|
||||||
- **`cyclone-tail`** — the consumer side of the event contract;
|
|
||||||
load when changing the wire format or adding a streaming hook.
|
|
||||||
- **`cyclone-tests`** — write-path tests live under `backend/tests/`
|
|
||||||
and assert both the DB row and the event payload.
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
---
|
|
||||||
name: cyclone-tail
|
|
||||||
description: "Cyclone live-tail streaming wire format and the useTailStream / useMergedTail hook triplet. Use when: adding a new streaming list page, changing the wire format, debugging stalled/reconnecting state, or modifying the StatusPill behavior."
|
|
||||||
---
|
|
||||||
|
|
||||||
# cyclone-tail
|
|
||||||
|
|
||||||
Cyclone keeps the Claims, Remittances, and Activity pages live without
|
|
||||||
polling: every store write publishes an internal EventBus event, the
|
|
||||||
page opens a `GET /api/<resource>/stream` HTTP/1.1 chunked-NDJSON
|
|
||||||
connection, and new rows land in the table the moment they hit the
|
|
||||||
database. This skill codifies the wire format, the hook triplet, and
|
|
||||||
the backoff/stall machinery so additions stay consistent with the
|
|
||||||
three streaming pages already shipped (`Claims`, `Remittances`,
|
|
||||||
`ActivityLog`).
|
|
||||||
|
|
||||||
## When to use
|
|
||||||
|
|
||||||
- **Adding a new streaming page.** Mounting `useTailStream(resource)`
|
|
||||||
on a page — you need the hook triplet shape (initial fetch +
|
|
||||||
`useTailStream` + `useMergedTail`), the `<TailStatusPill>` wiring,
|
|
||||||
and the dedup rules in `useMergedTail`.
|
|
||||||
- **Changing the wire format.** Adding a new event type — update
|
|
||||||
`TailEvent` in `src/lib/tail-stream.ts:22-44`, the dispatch switch
|
|
||||||
in `src/hooks/useTailStream.ts:173-195`, the emitter in
|
|
||||||
`backend/src/cyclone/api_helpers.py:tail_events()`, and
|
|
||||||
`references/wire-format.md`.
|
|
||||||
- **Debugging stalled/reconnecting state.** Confirm whether the
|
|
||||||
backend is heartbeating (`CYCLONE_TAIL_HEARTBEAT_S`, default `15s`)
|
|
||||||
or the stall timer fired (`STALL_TIMEOUT_MS = 30_000` at
|
|
||||||
`src/hooks/useTailStream.ts:53`).
|
|
||||||
- **Tuning heartbeat/stall timing.** Changing the 30s stall threshold
|
|
||||||
or the 15s heartbeat interval — README's "Status pill" + "Knobs"
|
|
||||||
tables need to stay in sync (`README.md:109-129`).
|
|
||||||
|
|
||||||
## Conventions
|
|
||||||
|
|
||||||
1. **Wire format.** Newline-delimited JSON. Every line is
|
|
||||||
`{"type": ..., "data": ...}`. Known `type` values: `item`
|
|
||||||
(per-row envelope), `snapshot_end` (`{"count": N}` marker after the
|
|
||||||
snapshot), `heartbeat` (`{"ts": "<iso-8601>"}` keep-alive),
|
|
||||||
`item_dropped` (`{"id": "..."}` queue-overflow notice), `error`
|
|
||||||
(`{"message": "..."}` promoted to a thrown error by the hook).
|
|
||||||
Defined at `src/lib/tail-stream.ts:22-44`; emitted by
|
|
||||||
`backend/src/cyclone/api.py:1357-2006`. See
|
|
||||||
`references/wire-format.md`.
|
|
||||||
2. **Hook triplet.** Streaming pages compose three pieces:
|
|
||||||
`useTailStream(resource)` (`src/hooks/useTailStream.ts:80` — opens
|
|
||||||
the stream, drives the backoff/stall state machine, dispatches
|
|
||||||
`item` events into `useTailStore`),
|
|
||||||
`useMergedTail(resource, baseItems, filterFn?)`
|
|
||||||
(`src/hooks/useMergedTail.ts:25` — returns
|
|
||||||
`baseItems + tailSlice` dedup'd against `baseItems`), and a
|
|
||||||
per-resource initial-fetch hook (`useClaims`, `useRemittances`,
|
|
||||||
`useActivity`). The page wires all three — see
|
|
||||||
`src/pages/Claims.tsx:87-89`.
|
|
||||||
3. **Stall threshold.** 30 seconds of total silence — heartbeats
|
|
||||||
included — flips status to `stalled` and surfaces the
|
|
||||||
`↻ Reconnect` button on `<TailStatusPill>`. Constant:
|
|
||||||
`STALL_TIMEOUT_MS = 30_000` at `src/hooks/useTailStream.ts:53`.
|
|
||||||
Re-armed on every event including `heartbeat` and `item_dropped`
|
|
||||||
(`useTailStream.ts:124-140`). Don't change without updating
|
|
||||||
`README.md:109-123`.
|
|
||||||
4. **Snapshot first.** Every stream emits the snapshot before any
|
|
||||||
live `item` events, then closes with exactly one `snapshot_end`
|
|
||||||
carrying `{"count": N}`. The hook uses `snapshot_end` to flip
|
|
||||||
`<TailStatusPill>` from `connecting` to `live` and to reset the
|
|
||||||
reconnect backoff counter (`useTailStream.ts:174-180`).
|
|
||||||
5. **Content-Type.** Stream endpoints respond with
|
|
||||||
`media_type="application/x-ndjson"` — see
|
|
||||||
`backend/src/cyclone/api.py:1401,1894,2005`. Frontend sets
|
|
||||||
`Accept: application/x-ndjson` at `src/lib/tail-stream.ts:67-70`.
|
|
||||||
Never `application/json` for a stream endpoint.
|
|
||||||
6. **Backoff.** Transient errors retry with `1s → 2s → 4s → 8s → 16s
|
|
||||||
→ 30s` capped — `BACKOFF_STEPS_MS` at
|
|
||||||
`src/hooks/useTailStream.ts:48-50`. Counter resets on every
|
|
||||||
`snapshot_end`.
|
|
||||||
7. **Heartbeat knob.** Idle heartbeat interval is configurable via
|
|
||||||
`CYCLONE_TAIL_HEARTBEAT_S` (default `15`, parsed at call time in
|
|
||||||
`backend/src/cyclone/api_helpers.py:185-198`). Tests override to
|
|
||||||
a small value to keep runtime bounded.
|
|
||||||
|
|
||||||
## Patterns
|
|
||||||
|
|
||||||
### Page-hook skeleton — `Claims.tsx`
|
|
||||||
|
|
||||||
Pattern from `src/pages/Claims.tsx:85-90`. Three hooks in order:
|
|
||||||
`useClaims(params)` (initial TanStack Query fetch),
|
|
||||||
`useTailStream("claims")` (opens the stream, owns status state), and
|
|
||||||
`useMergedTail("claims", data?.items ?? [], tailFilterFn)` (combines
|
|
||||||
initial snapshot with live tail, dedup'd by id, filtered by the page's
|
|
||||||
predicate applied AFTER dedup at `useMergedTail.ts:72-74`).
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { useClaims } from "@/hooks/useClaims";
|
|
||||||
import { useTailStream } from "@/hooks/useTailStream";
|
|
||||||
import { useMergedTail } from "@/hooks/useMergedTail";
|
|
||||||
|
|
||||||
export function ClaimsPage() {
|
|
||||||
const { data } = useClaims({ status: "submitted" });
|
|
||||||
const { status, lastEventAt, forceReconnect } = useTailStream("claims");
|
|
||||||
const tailFilterFn = (c: Claim) => c.status === "submitted";
|
|
||||||
const items = useMergedTail("claims", data?.items ?? [], tailFilterFn);
|
|
||||||
// <TailStatusPill status={status} lastEventAt={lastEventAt}
|
|
||||||
// onReconnect={forceReconnect} /> + <Table items={items} />
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Backend `/api/foo/stream` endpoint
|
|
||||||
|
|
||||||
Pattern from `backend/src/cyclone/api.py:1357-1401`. Register BEFORE
|
|
||||||
`/api/foo/{foo_id}` so the literal `stream` segment doesn't match as
|
|
||||||
an id. Two phases — eager snapshot, then live subscription — wrapped
|
|
||||||
in `StreamingResponse` with `media_type="application/x-ndjson"`.
|
|
||||||
|
|
||||||
```python
|
|
||||||
@app.get("/api/foo/stream")
|
|
||||||
async def foo_stream(
|
|
||||||
request: Request,
|
|
||||||
status: str | None = Query(None),
|
|
||||||
limit: int = Query(100, ge=1, le=1000),
|
|
||||||
) -> StreamingResponse:
|
|
||||||
bus: EventBus = request.app.state.event_bus
|
|
||||||
|
|
||||||
async def gen() -> AsyncIterator[bytes]:
|
|
||||||
# 1. Snapshot.
|
|
||||||
rows = store.iter_foos(status=status, limit=limit)
|
|
||||||
for row in rows:
|
|
||||||
yield _ndjson_line({"type": "item", "data": row})
|
|
||||||
yield _ndjson_line({"type": "snapshot_end", "data": {"count": len(rows)}})
|
|
||||||
# 2. Live subscription + heartbeats.
|
|
||||||
async for chunk in _tail_events(request, bus, ["foo_written"]):
|
|
||||||
yield chunk
|
|
||||||
|
|
||||||
return StreamingResponse(gen(), media_type="application/x-ndjson")
|
|
||||||
```
|
|
||||||
|
|
||||||
`_ndjson_line` and `_tail_events` live in
|
|
||||||
`backend/src/cyclone/api_helpers.py`; the latter forwards EventBus
|
|
||||||
events as `item` lines and emits `heartbeat` lines on the cadence
|
|
||||||
from `heartbeat_seconds()`.
|
|
||||||
|
|
||||||
### `<TailStatusPill>` wiring
|
|
||||||
|
|
||||||
Pattern from `src/components/TailStatusPill.tsx:22-83`. The pill
|
|
||||||
takes `status` + `lastEventAt` from `useTailStream` plus
|
|
||||||
`forceReconnect` so the `↻ Reconnect` button wires to the hook's
|
|
||||||
`reconnectNonce` bump (aborts and reopens the stream —
|
|
||||||
`useTailStream.ts:99-101`). The button only renders when
|
|
||||||
`status === "stalled" || status === "error"` (`TailStatusPill.tsx:52`).
|
|
||||||
|
|
||||||
## Anti-patterns
|
|
||||||
|
|
||||||
- **Don't hand-roll `fetch` + `ReadableStream` parsing in a page.**
|
|
||||||
All stream consumers go through `streamTail(resource, opts?)` at
|
|
||||||
`src/lib/tail-stream.ts:58`. The parser handles `TextDecoderStream`,
|
|
||||||
newline splitting, malformed-line tolerance (`console.warn` + skip),
|
|
||||||
the `KNOWN_TYPES` allowlist, and abort-on-signal semantics
|
|
||||||
(`tail-stream.ts:75,98,107`). Duplicating it loses all of those.
|
|
||||||
- **Don't change the wire format on one endpoint without updating
|
|
||||||
the others.** The parser (`src/lib/tail-stream.ts`) and the hook
|
|
||||||
dispatch switch (`useTailStream.ts:173-195`) are shared across all
|
|
||||||
three live streams. Adding a new event type means updating
|
|
||||||
`TailEvent`, `KNOWN_TYPES`, the dispatch `case`, the `armStall`
|
|
||||||
re-arm list, and the backend emitter — in that order.
|
|
||||||
- **Don't emit `item` events before `snapshot_end`.** The hook uses
|
|
||||||
`snapshot_end` as the marker to flip `<TailStatusPill>` from
|
|
||||||
`connecting` to `live` and to reset the reconnect backoff counter.
|
|
||||||
Emitting `item`s first lands rows in `useTailStore` but the UI
|
|
||||||
still reads "Connecting" — operators see a flash of stale state.
|
|
||||||
Emit snapshot, then `snapshot_end`, then live (`api.py:1386-1401`).
|
|
||||||
- **Don't change the 30s stall threshold without updating the README
|
|
||||||
"Status pill" table.** The constant
|
|
||||||
(`STALL_TIMEOUT_MS = 30_000` at `useTailStream.ts:53`) is the
|
|
||||||
contract the pill is documented against — a bump needs the matching
|
|
||||||
edit at `README.md:118`.
|
|
||||||
- **Don't add `useTailStream` calls from `useFoo.ts` hooks.**
|
|
||||||
Streaming connections belong on the page (`src/pages/Claims.tsx`,
|
|
||||||
`Remittances.tsx`, `ActivityLog.tsx`). Hoisting into `useFoo`
|
|
||||||
couples the lifecycle to whoever mounts it and breaks
|
|
||||||
one-resource-one-page ownership.
|
|
||||||
|
|
||||||
## Related skills
|
|
||||||
|
|
||||||
- **`cyclone-frontend-page`** — page components live in `src/pages/`;
|
|
||||||
load when adding or refactoring a streaming page to confirm the
|
|
||||||
route + `PageHeader` + table conventions.
|
|
||||||
- **`cyclone-api-router`** — endpoint conventions (`api_routers/` for
|
|
||||||
resource-group routers, `api.py` for the live-tail endpoints); load
|
|
||||||
when adding or changing an HTTP endpoint that surfaces streamed or
|
|
||||||
paginated data.
|
|
||||||
- **`cyclone-store`** — write-path conventions in `store.py` and the
|
|
||||||
pubsub event contract (`claim_written`, `remittance_written`,
|
|
||||||
`activity_recorded`); load when adding a new entity whose writes
|
|
||||||
should fan out to a stream endpoint.
|
|
||||||
- **`cyclone-tests`** — frontend `*.test.tsx` siblings cover
|
|
||||||
`useTailStream`, `useMergedTail`, `TailStatusPill`; backend
|
|
||||||
`test_api_stream_live.py` covers the three live-tail endpoints;
|
|
||||||
load when the increment changes wire-format behavior or adds a
|
|
||||||
streaming hook.
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
# Live-tail wire format — field reference
|
|
||||||
|
|
||||||
The Cyclone live-tail NDJSON contract is owned by the frontend parser
|
|
||||||
(`src/lib/tail-stream.ts:22-44`) and the backend emitter
|
|
||||||
(`backend/src/cyclone/api_helpers.py:tail_events()` + the three
|
|
||||||
endpoints in `backend/src/cyclone/api.py:1357-2006`). This file is the
|
|
||||||
quick reference; the canonical prose lives in the README and the
|
|
||||||
source-of-truth type definitions.
|
|
||||||
|
|
||||||
## Source
|
|
||||||
|
|
||||||
Wire format excerpt copied verbatim from `README.md:76-94` (the
|
|
||||||
"Live updates → Wire format" section). The README is the user-facing
|
|
||||||
exposition; this reference adds the per-line field semantics and the
|
|
||||||
parser-tolerance rules from the code.
|
|
||||||
|
|
||||||
> Each stream endpoint emits newline-delimited JSON. The first batch
|
|
||||||
> is the **snapshot** of currently-known rows; after that comes
|
|
||||||
> **`snapshot_end`** with the count, then the **live** events.
|
|
||||||
>
|
|
||||||
> ```json
|
|
||||||
> {"type":"item","data":{"id":"CLM-1", "...":"..."}}
|
|
||||||
> {"type":"item","data":{"id":"CLM-2", "...":"..."}}
|
|
||||||
> {"type":"snapshot_end","data":{"count":2}}
|
|
||||||
> {"type":"item","data":{"id":"CLM-3", "...":"..."}} ← live
|
|
||||||
> {"type":"heartbeat","data":{"ts":"2026-06-20T23:17:09Z"}} ← idle keep-alive
|
|
||||||
> ```
|
|
||||||
>
|
|
||||||
> Lines are `{"type": ..., "data": ...}`; known types are `item`,
|
|
||||||
> `snapshot_end`, `heartbeat`, and (rare) `item_dropped` /
|
|
||||||
> `error`. Heartbeats keep the connection alive when nothing is
|
|
||||||
> happening — clients flip to `stalled` after 30s of total silence
|
|
||||||
> (heartbeat or otherwise) and surface a **↻ Reconnect** button.
|
|
||||||
|
|
||||||
## Per-line field reference
|
|
||||||
|
|
||||||
| `type` | `data` shape | Required? | Emitted by | Parser behavior |
|
|
||||||
| -------------- | ----------------------------------------- | --------- | ------------------------------------------- | -------------------------------------------- |
|
|
||||||
| `item` | resource-specific row (`Claim` / `Remittance` / `Activity`) | yes, in `data` (the per-row envelope) | snapshot loop + `_tail_events` forwarding `claim_written` / `remittance_written` / `activity_recorded` | `dispatch(resource, ev.data)` → `useTailStore.addClaim` / `addRemittance` / `addActivity` (first-write-wins dedup on the id-keyed slices — `tail-store.ts:104,120`). |
|
|
||||||
| `snapshot_end` | `{"count": N}` (integer ≥ 0) | yes, on every stream | `api.py:1395,1889,1999` (one per stream, after the snapshot loop) | Flips `<TailStatusPill>` from `connecting` to `live`, resets the reconnect backoff counter to 0 (`useTailStream.ts:174-180`). |
|
|
||||||
| `heartbeat` | `{"ts": "<iso-8601 UTC>"}` | yes, but only when idle | `_tail_events` in `api_helpers.py:241-245` (cadence from `heartbeat_seconds()`, default 15s) | Re-arms the stall timer (`useTailStream.ts:124-140`); no state change. |
|
|
||||||
| `item_dropped` | `{"id": "<string>"}` | optional (rare; queue overflow) | EventBus drop-oldest path (per the spec at `docs/superpowers/specs/2026-06-20-cyclone-live-tail-design.md:299`) | Re-arms the stall timer; no state change. The `id` field is informational — the hook does not refetch on drop, the page does (see Spec §3.6). |
|
|
||||||
| `error` | `{"message": "<string>"}` | optional (server-side failure) | Reserved for future server-emitted errors (currently only thrown client-side) | Hook promotes to a thrown `Error(message)` so the catch block runs the reconnect machinery (`useTailStream.ts:190-194`). |
|
|
||||||
|
|
||||||
## Parser tolerance
|
|
||||||
|
|
||||||
The shared parser at `src/lib/tail-stream.ts` is intentionally
|
|
||||||
forgiving so a single bad frame doesn't kill the stream:
|
|
||||||
|
|
||||||
- **Trailing `\r`** is stripped per line (`tail-stream.ts:116`) so a
|
|
||||||
CRLF-terminated stream still parses.
|
|
||||||
- **Malformed JSON** (`JSON.parse` throws) → `console.warn` + skip the
|
|
||||||
line; the iterator continues (`tail-stream.ts:122-131`).
|
|
||||||
- **Unknown `type`** (not in `KNOWN_TYPES`) → `console.warn` + skip
|
|
||||||
(`tail-stream.ts:141-148`). Adding a new event type is
|
|
||||||
forward-compatible: old clients see warn lines, new clients see the
|
|
||||||
typed event.
|
|
||||||
- **Empty lines** (consecutive `\n`s) → silently skipped
|
|
||||||
(`tail-stream.ts:118`).
|
|
||||||
- **No trailing newline** → flushed as a final partial line on
|
|
||||||
stream close (`tail-stream.ts:154-178`).
|
|
||||||
- **Abort signal** → iterator exits cleanly without throwing
|
|
||||||
(`tail-stream.ts:75,98,107`).
|
|
||||||
|
|
||||||
## Endpoint inventory
|
|
||||||
|
|
||||||
| Method | Path | Subscribes to | Default sort | Defined at |
|
|
||||||
| ------ | ------------------------- | -------------------- | ------------------- | ----------------------------------- |
|
|
||||||
| GET | `/api/claims/stream` | `claim_written` | `-submission_date` | `backend/src/cyclone/api.py:1357` |
|
|
||||||
| GET | `/api/remittances/stream` | `remittance_written` | `-received_date` | `backend/src/cyclone/api.py:1858` |
|
|
||||||
| GET | `/api/activity/stream` | `activity_recorded` | `-timestamp` (limit 50) | `backend/src/cyclone/api.py:1971` |
|
|
||||||
|
|
||||||
All three accept the same query params as their non-streaming
|
|
||||||
counterparts (`status`, `payer`, `date_from`, …) so a frontend can
|
|
||||||
swap a one-shot fetch for a tail with no URL surgery. Responses are
|
|
||||||
`Content-Type: application/x-ndjson`.
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
---
|
|
||||||
name: cyclone-tests
|
|
||||||
description: "Cyclone pytest + vitest fixture patterns, prodfiles layout, backend/tests/fixtures/ conventions, .test.tsx sibling rule. Use when: adding a backend pytest case, adding a frontend vitest test, or wiring in a real-EDI prodfiles sample."
|
|
||||||
---
|
|
||||||
|
|
||||||
# cyclone-tests
|
|
||||||
|
|
||||||
The Cyclone test suite is split two ways: **backend pytest** (89 test files under `backend/tests/`, 13 flat fixtures in `backend/tests/fixtures/`, one autouse `conftest.py` that resets the DB per-test) and **frontend vitest** (59 `*.test.ts(x)` siblings across `src/`, two rendering styles — `@testing-library/react` and a custom `createRoot`+`Probe` shim). This skill codifies the conventions so additions stay consistent with what's already there.
|
|
||||||
|
|
||||||
As of this writing: **89 backend test files**, **13 flat backend fixtures**, **59 frontend `*.test.ts(x)` siblings**, and **23 prodfiles samples** across `docs/prodfiles/{837p-from-axiscare,835fromco,FromHPE,claims}/`. The next increment is **SP22**.
|
|
||||||
|
|
||||||
## When to use
|
|
||||||
|
|
||||||
- **Adding a backend pytest case.** You're about to add a new `test_*.py` under `backend/tests/` and need the autouse DB-fixture rules, the fixture-path convention, and the right naming flavor (`test_api_*.py` vs `test_<module>_*.py`).
|
|
||||||
- **Adding a frontend vitest test.** You're about to add a `*.test.ts(x)` sibling and need the `// @vitest-environment happy-dom` setup, the act-environment flag, and the right rendering helper (`@testing-library/react` vs the `createRoot`+`Probe` shim).
|
|
||||||
- **Dropping in a prodfiles fixture.** You have a real EDI sample under `docs/prodfiles/<source>/` and need the copy step that makes it test-runnable without coupling the test to the prodfiles archive.
|
|
||||||
- **Debugging a flaky test.** A test passes locally but flakes in CI — load this skill to check the determinism + no-network rules before chasing the symptom.
|
|
||||||
|
|
||||||
## Conventions
|
|
||||||
|
|
||||||
1. **Frontend sibling rule.** Every new file in `src/` that contains testable logic gets a `*.test.ts(x)` next to it. `useFoo.ts` → `useFoo.test.ts`. `ClaimDrawer.tsx` → `ClaimDrawer.test.tsx`. The 59 existing siblings follow this; CI implicitly enforces it via the default `src/**/*.test.ts(x)` glob in `vitest.config.ts`.
|
|
||||||
2. **Backend test location.** Tests live under `backend/tests/test_*.py`. Two flavors, two naming patterns:
|
|
||||||
- **Integration tests** (FastAPI surface) → `test_api_<topic>_<verb>.py` — e.g. `test_api_parse_persists.py`, `test_api_999.py`.
|
|
||||||
- **Pure-unit tests** (parsers, validators, store internals) → `test_<module>_<behavior>.py` — e.g. `test_cas_codes.py`, `test_pubsub.py`.
|
|
||||||
3. **Prodfiles drop-in.** Real EDI samples live under `docs/prodfiles/<source>/<file>.txt` (sources seen so far: `837p-from-axiscare/`, `835fromco/`, `FromHPE/`, `claims/`). To use one in a test: copy the file to `backend/tests/fixtures/<descriptive-name>.txt` (flat — no per-test subdirectories; the existing 13 fixtures all sit at the top level) and reference it from the test as a module-level `Path` constant. See `## Patterns` for the exact line.
|
|
||||||
4. **Determinism.** Time-sensitive tests must not depend on wall-clock time.
|
|
||||||
- **Frontend** uses `vi.useFakeTimers()` + `vi.setSystemTime(new Date("YYYY-MM-DDTHH:MM:SSZ"))` (see `src/components/TailStatusPill.test.tsx:54-58`) and `vi.advanceTimersByTime(ms)` to drive interval / backoff code deterministically.
|
|
||||||
- **Backend** dates are passed explicitly as fixture values — e.g. `datetime.now(timezone.utc)` is fine for "now-ish" anchors, but for fixed dates pass `datetime(2026, 6, 20, tzinfo=timezone.utc)`. The project does **not** currently use `freezegun` or `mock.patch(datetime)`; if you need deterministic date mocking, propose adding `freezegun` to `backend/pyproject.toml` rather than rolling your own.
|
|
||||||
5. **No network.** Tests must not hit the network.
|
|
||||||
- **Backend:** `fastapi.testclient.TestClient(app)` runs in-process; no uvicorn. The autouse `conftest.py` fixture (`backend/tests/conftest.py:20`) points `CYCLONE_DB_URL` at `tmp_path/test.db`, calls `db._reset_for_tests()` + `db.init_db()`, and wires a fresh `EventBus` onto `app.state`.
|
|
||||||
- **Frontend:** `vi.stubGlobal("fetch", vi.fn().mockResolvedValue(...))` for hooks; `vitest.config.ts` sets `VITE_API_BASE_URL=http://test.local` so the `api` module doesn't throw `notConfiguredError` before the mock fires.
|
|
||||||
6. **pytest collection.** Run `cd backend && python -m pytest tests/<file>::<name> -v` for the fastest single-test feedback loop. Run `cd backend && python -m pytest tests/<file> -v` for one file. Run `cd backend && python -m pytest` for the full suite — this is the merge gate. Frontend: `npm test` (alias for `vitest run`) for the full suite; `npx vitest run src/hooks/useFoo.test.ts` for one file.
|
|
||||||
|
|
||||||
## Patterns
|
|
||||||
|
|
||||||
### Backend pytest using a fixture + per-test SQLite DB
|
|
||||||
|
|
||||||
Canonical shape — see `backend/tests/test_api_999.py:1-50` for the full file. The autouse `conftest.py` already provides the DB init + `EventBus` reset; most tests just add a `client` fixture.
|
|
||||||
|
|
||||||
```python
|
|
||||||
"""Tests for the FastAPI surface in cyclone.api for the 999 endpoint."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from cyclone.api import app
|
|
||||||
|
|
||||||
# Fixture reference — flat, module-level Path constant. NEVER reach into
|
|
||||||
# docs/prodfiles/ from a test; the fixtures/ dir is the test-consumed surface.
|
|
||||||
ACCEPTED = Path(__file__).parent / "fixtures" / "minimal_999.txt"
|
|
||||||
REJECTED = Path(__file__).parent / "fixtures" / "minimal_999_rejected.txt"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def client() -> TestClient:
|
|
||||||
return TestClient(app)
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_999_endpoint_happy_path(client: TestClient):
|
|
||||||
text = ACCEPTED.read_text()
|
|
||||||
resp = client.post(
|
|
||||||
"/api/parse-999",
|
|
||||||
files={"file": ("minimal_999.txt", text, "text/plain")},
|
|
||||||
headers={"Accept": "application/json"},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 200, resp.text
|
|
||||||
body = resp.json()
|
|
||||||
assert body["ack"]["ack_code"] == "A"
|
|
||||||
```
|
|
||||||
|
|
||||||
Override the autouse DB fixture only when you need a custom env (e.g. a per-test backup directory) — see `backend/tests/test_999_rejected_state.py:20-25`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _setup(tmp_path, monkeypatch):
|
|
||||||
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/inbox.db")
|
|
||||||
from cyclone import db
|
|
||||||
db._reset_for_tests()
|
|
||||||
db.init_db()
|
|
||||||
yield
|
|
||||||
```
|
|
||||||
|
|
||||||
### Frontend vitest `*.test.tsx` for a component using fake timers
|
|
||||||
|
|
||||||
Pattern taken from `src/components/TailStatusPill.test.tsx:1-62` — uses `vi.useFakeTimers()` + `vi.setSystemTime(...)` to drive interval-based code deterministically.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// @vitest-environment happy-dom
|
|
||||||
// React's act warnings need an act-aware environment — mirror the other
|
|
||||||
// hook tests in this repo.
|
|
||||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
||||||
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { MyComponent } from "./MyComponent";
|
|
||||||
|
|
||||||
describe("MyComponent", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
vi.setSystemTime(new Date("2026-06-20T12:00:30Z"));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("test_renders_after_interval_tick", () => {
|
|
||||||
vi.advanceTimersByTime(30_000); // drive the setInterval
|
|
||||||
// …assert…
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Frontend vitest `*.test.tsx` for a component using `@testing-library/react` + `happy-dom`
|
|
||||||
|
|
||||||
Pattern taken from `src/hooks/useInboxLanes.test.ts:1-80`. (A handful of older tests — `useClaimDetail.test.ts`, `useDrawerUrlState.test.ts`, `TailStatusPill.test.tsx` — roll a custom `createRoot`+`Probe` shim instead. Both styles are accepted; `@testing-library/react` is preferred when the hook has async dependencies because `waitFor` is built in.)
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// @vitest-environment happy-dom
|
|
||||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
||||||
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
|
|
||||||
import { useMyHook } from "./useMyHook";
|
|
||||||
|
|
||||||
vi.mock("@/lib/api", () => ({
|
|
||||||
api: { fetchFoo: vi.fn() },
|
|
||||||
}));
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("useMyHook", () => {
|
|
||||||
it("loads data on mount", async () => {
|
|
||||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({ items: [] }),
|
|
||||||
}));
|
|
||||||
const { result } = renderHook(() => useMyHook());
|
|
||||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
||||||
expect(result.current.items).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Anti-patterns
|
|
||||||
|
|
||||||
- **Don't put frontend tests in `src/__tests__/`.** No such directory exists in the codebase — `__tests__` only appears in the SP-catalog plan itself. Use the sibling rule (`useFoo.ts` → `useFoo.test.ts`).
|
|
||||||
- **Don't reach into `docs/prodfiles/` directly from a test.** Always copy to `backend/tests/fixtures/<name>.txt` first. The prodfiles directory is the source-of-truth archive and may be reorganized; the fixtures directory is the test-consumed surface and is stable.
|
|
||||||
- **Don't use wall-clock sleeps for timing.** A handful of legacy tests use `await new Promise((r) => setTimeout(r, 200))` (see `src/components/SearchBar.test.tsx:281,323,373` and `src/hooks/useSearch.test.ts:174`) — these are known flaky in CI. Use `vi.useFakeTimers()` + `vi.advanceTimersByTime(ms)` instead, or `waitFor(...)` from `@testing-library/react`.
|
|
||||||
|
|
||||||
## Related skills
|
|
||||||
|
|
||||||
- **`cyclone-spec`** — every SP-N spec lists test impact; load this when drafting or reviewing the spec to confirm fixture / `.test.tsx` implications for the increment.
|
|
||||||
- **`cyclone-edi`** — most backend tests cover parser + validator behavior; load when the increment touches an EDI parser or adds a validator rule (R200/R210/NPI Luhn/EIN/CAS).
|
|
||||||
- **`cyclone-tail`** — most frontend tests cover hook behavior (`useTailStream`, `useMergedTail`); load when the increment changes the wire format or adds a streaming hook.
|
|
||||||
- **`cyclone-store`** — write-path tests live here; load when the increment touches `store.py`, adds a new entity, or wires a new `<entity>_written` event.
|
|
||||||
- **`cyclone-api-router`** — endpoint tests live in `backend/tests/test_api_*.py`; load when the increment adds or changes an HTTP endpoint.
|
|
||||||
- **`cyclone-frontend-page`** — page-component tests live next to pages in `src/pages/*.test.tsx`; load when the increment adds or refactors a page.
|
|
||||||
- **`cyclone-cli`** — CLI smoke tests live in `backend/tests/test_cli_*.py`; load when the increment adds a CLI subcommand.
|
|
||||||
- **`superpowers:test-driven-development`** (global) — the upstream TDD workflow. Load first when starting any new feature increment; this skill only codifies the Cyclone-specific test layout on top of TDD.
|
|
||||||
@@ -51,26 +51,6 @@ VITE_API_BASE_URL=http://127.0.0.1:8000
|
|||||||
Without that, the UI falls back to its in-memory sample store via the
|
Without that, the UI falls back to its in-memory sample store via the
|
||||||
existing `data` adapter (parses are disabled).
|
existing `data` adapter (parses are disabled).
|
||||||
|
|
||||||
## Skills
|
|
||||||
|
|
||||||
Cyclone ships 8 project-scoped AI-assistant skills under
|
|
||||||
[`.superpowers/skills/`](.superpowers/skills/). Each one codifies the
|
|
||||||
conventions for a major subsystem so the next contributor (human or
|
|
||||||
AI) gets the lay of the land automatically.
|
|
||||||
|
|
||||||
| Skill | Owns |
|
|
||||||
|-------|------|
|
|
||||||
| [`cyclone-spec`](.superpowers/skills/cyclone-spec/SKILL.md) | The SP-N spec → plan → implement → merge flow. |
|
|
||||||
| [`cyclone-tests`](.superpowers/skills/cyclone-tests/SKILL.md) | pytest + vitest fixture patterns, prodfiles drop-in. |
|
|
||||||
| [`cyclone-edi`](.superpowers/skills/cyclone-edi/SKILL.md) | EDI parser/validator conventions (837P/835/999/270/271/277CA/TA1). |
|
|
||||||
| [`cyclone-tail`](.superpowers/skills/cyclone-tail/SKILL.md) | Live-tail streaming wire format and the hook triplet. |
|
|
||||||
| [`cyclone-store`](.superpowers/skills/cyclone-store/SKILL.md) | Store write-paths, pubsub event contract, SP21 split map. |
|
|
||||||
| [`cyclone-api-router`](.superpowers/skills/cyclone-api-router/SKILL.md) | FastAPI router conventions (`api_routers/`, `api_helpers.py`). |
|
|
||||||
| [`cyclone-frontend-page`](.superpowers/skills/cyclone-frontend-page/SKILL.md) | React page conventions (TanStack Query, drawer, URL state). |
|
|
||||||
| [`cyclone-cli`](.superpowers/skills/cyclone-cli/SKILL.md) | CLI subcommand conventions (`cli.py`, exit codes, smoke tests). |
|
|
||||||
|
|
||||||
Skills auto-load by description match — no slash command needed.
|
|
||||||
|
|
||||||
## Test
|
## Test
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -85,6 +65,49 @@ npm run build
|
|||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Cyclone ships with username/password authentication and three predefined roles.
|
||||||
|
|
||||||
|
**Roles:**
|
||||||
|
|
||||||
|
| Role | Can read | Can write (upload, parse, reconcile) | Can manage users |
|
||||||
|
| -------- | -------- | ------------------------------------ | ---------------- |
|
||||||
|
| `viewer` | ✅ | ❌ | ❌ |
|
||||||
|
| `user` | ✅ | ✅ | ❌ |
|
||||||
|
| `admin` | ✅ | ✅ | ✅ |
|
||||||
|
|
||||||
|
**Bootstrap.** On first start, set `CYCLONE_ADMIN_USERNAME` and
|
||||||
|
`CYCLONE_ADMIN_PASSWORD` (min 12 chars) in your environment. Cyclone creates the
|
||||||
|
first admin automatically. On subsequent starts these env vars are ignored, so
|
||||||
|
rotating the bootstrap password doesn't affect an already-seeded admin — use the
|
||||||
|
CLI below to reset it. When running via `docker compose`, both vars are
|
||||||
|
required: compose refuses to start with a clear error if either is missing.
|
||||||
|
|
||||||
|
**CLI.** Manage users from the command line:
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m cyclone users create alice --role user --password 'hunter2hunter2'
|
||||||
|
python -m cyclone users list
|
||||||
|
python -m cyclone users disable alice
|
||||||
|
python -m cyclone users reset-password alice
|
||||||
|
python -m cyclone users set-role alice --role admin
|
||||||
|
```
|
||||||
|
|
||||||
|
**Login.** Browse to `http://localhost:5173` (dev) or `http://localhost:8081`
|
||||||
|
(Docker), sign in on the `/login` page, and you'll be redirected to the
|
||||||
|
dashboard. Sessions are stored server-side in SQLite with a 24-hour sliding
|
||||||
|
expiry — every authenticated request refreshes the TTL, so an active user
|
||||||
|
never gets logged out.
|
||||||
|
|
||||||
|
**Dev escape hatch.** Set `CYCLONE_AUTH_DISABLED=1` to bypass auth entirely
|
||||||
|
(the backend auto-grants admin on every request). **NEVER set this in
|
||||||
|
production** — it's a single env-var trip from wide-open to the public
|
||||||
|
internet. The Docker compose file does not honor this flag.
|
||||||
|
|
||||||
|
See `docs/superpowers/specs/2026-06-22-cyclone-auth-design.md` for the full
|
||||||
|
design.
|
||||||
|
|
||||||
## Live updates
|
## Live updates
|
||||||
|
|
||||||
The Claims, Remittances, and Activity pages stay current without
|
The Claims, Remittances, and Activity pages stay current without
|
||||||
@@ -377,245 +400,6 @@ operator has created the Keychain entry on first run. See
|
|||||||
for the one-time setup recipe and the HIPAA Security Rule §164.312(a)(2)(iv)
|
for the one-time setup recipe and the HIPAA Security Rule §164.312(a)(2)(iv)
|
||||||
mapping.
|
mapping.
|
||||||
|
|
||||||
### Key rotation (SP15)
|
|
||||||
|
|
||||||
`POST /api/admin/db/rotate-key` re-encrypts the SQLite file in place
|
|
||||||
with a fresh SQLCipher key via `PRAGMA rekey`, then updates the
|
|
||||||
Keychain so subsequent connections open with the new key. The
|
|
||||||
rotation holds a module-level `threading.Lock` (so two concurrent
|
|
||||||
requests can't race), disposes + rebuilds the SQLAlchemy engine with
|
|
||||||
`NullPool` (so SQLCipher's thread affinity is honored), and writes a
|
|
||||||
tamper-evident `db.key_rotated` audit event with old + new
|
|
||||||
fingerprints and the post-rotation table count. The old key is
|
|
||||||
retained in the `cyclone.db.key.previous` Keychain account for a
|
|
||||||
grace period so a botched rotation can be rolled back by hand.
|
|
||||||
|
|
||||||
## NPI checksum + Tax ID format validation (SP20)
|
|
||||||
|
|
||||||
Two pure local validators — no NPPES round-trip, no IRS e-file
|
|
||||||
lookup. Catches the 99% case (a typo at the end of an NPI, a letter in
|
|
||||||
an EIN, an extra digit, the reserved `00`/`07`/`8X` EIN prefix).
|
|
||||||
|
|
||||||
| Check | Algorithm | Surface |
|
|
||||||
|-------|-----------|---------|
|
|
||||||
| NPI | 10 digits where the last is a Luhn checksum over `80840 + body`. CMS-published example: body `123456789` → check `3` → valid NPI `1234567893`. | `cyclone.npi.is_valid_npi`, CLI `cyclone validate-npi <npi>`, API `GET /api/admin/validate-provider?npi=...`, validator rule `R021_npi_checksum` (warning) |
|
|
||||||
| Tax ID (EIN) | 9 digits, optional `XX-XXXXXXX` formatting. Rejects reserved prefixes `00`, `07`, `80`–`89` (IRS Pension Plan Branch). | `cyclone.npi.is_valid_tax_id`, CLI `cyclone validate-tax-id <ein>`, API `GET /api/admin/validate-provider?tax_id=...` |
|
|
||||||
|
|
||||||
### CLI
|
|
||||||
|
|
||||||
```bash
|
|
||||||
$ cyclone validate-npi 1234567893
|
|
||||||
OK: 10-digit NPI passes Luhn checksum
|
|
||||||
$ cyclone validate-npi 1234567890
|
|
||||||
INVALID: '1234567890' fails NPI Luhn checksum # exit 1
|
|
||||||
|
|
||||||
$ cyclone validate-tax-id 72-1587149
|
|
||||||
OK: 9-digit EIN (normalized=721587149)
|
|
||||||
$ cyclone validate-tax-id 00-1234567
|
|
||||||
INVALID: 9-digit EIN has reserved prefix (00); EIN is not assignable by IRS # exit 1
|
|
||||||
```
|
|
||||||
|
|
||||||
### API
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl 'http://localhost:8000/api/admin/validate-provider?npi=1234567893&tax_id=72-1587149'
|
|
||||||
# {
|
|
||||||
# "npi": {"valid": true, "skipped": false},
|
|
||||||
# "tax_id": {"valid": true, "skipped": false, "normalized": "721587149"}
|
|
||||||
# }
|
|
||||||
```
|
|
||||||
|
|
||||||
Both query params are optional; omitted fields return
|
|
||||||
`{"valid": null, "skipped": true}` so the caller can render "no check
|
|
||||||
performed" rather than treating absent input as a hard fail.
|
|
||||||
|
|
||||||
### Parser integration
|
|
||||||
|
|
||||||
The `R021_npi_checksum` rule runs alongside the existing `R020_npi_format`
|
|
||||||
in `cyclone.parsers.validator`. A billing-provider NPI that passes
|
|
||||||
R020 (right shape) but fails R021 (bad Luhn) is yielded as a
|
|
||||||
**warning**, not an error — operators sometimes ingest test fixtures
|
|
||||||
with placeholder NPIs (e.g. all-same-digit) and we don't want to block
|
|
||||||
that path. In strict mode (`--strict` / `?strict=true`) warnings are
|
|
||||||
promoted to errors.
|
|
||||||
|
|
||||||
### Files
|
|
||||||
|
|
||||||
* `cyclone/npi.py` — new module (~155 LOC).
|
|
||||||
* `cyclone.parsers.validator` — new `R021_npi_checksum` rule.
|
|
||||||
* `cyclone.api_routers.admin` — new `validate-provider` endpoint.
|
|
||||||
* `cyclone.cli` — `validate-npi` + `validate-tax-id` subcommands.
|
|
||||||
* Tests: `test_npi.py` (27), `test_api_validate_provider.py` (4),
|
|
||||||
`test_cli_validate.py` (8), `test_validator.py::test_r021_*` (4) —
|
|
||||||
**43 new tests**.
|
|
||||||
|
|
||||||
## Security hardening (SP19)
|
|
||||||
|
|
||||||
Three pure-ASGI middlewares sit in front of every FastAPI request.
|
|
||||||
They're sized for Cyclone's local-only posture — a misconfigured
|
|
||||||
Tailscale / ngrok bind, a buggy cron job uploading a 4 GB file, or a
|
|
||||||
port-scraper — not for hostile internet exposure.
|
|
||||||
|
|
||||||
| Middleware | Default | Override | Reject |
|
|
||||||
|------------|---------|----------|--------|
|
|
||||||
| `BodySizeLimitMiddleware` | 50 MB | `CYCLONE_MAX_BODY_BYTES` | `413 body_too_large` over Content-Length cap; chunked reads capped too |
|
|
||||||
| `RateLimitMiddleware` | 300 req/min/IP | `CYCLONE_RATE_LIMIT_PER_MIN` | `429 rate_limited` over the sliding window; `/api/health` exempt |
|
|
||||||
| `SecurityHeadersMiddleware` | always on | n/a | stamps `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: same-origin`, `Permissions-Policy`, `Content-Security-Policy: default-src 'none'; frame-ancestors 'none'` |
|
|
||||||
|
|
||||||
Every rejection (413 / 429) also writes a tamper-evident
|
|
||||||
`api.request_rejected` event into the SP11 audit chain so an
|
|
||||||
operator can correlate a misbehaving client with the SP18 JSON logs:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"event_type":"api.request_rejected","entity_id":"POST /api/parse-837","payload":{"status":413,"reason":"body_too_large","path":"/api/parse-837","method":"POST","ip":"127.0.0.1"}}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Health probe
|
|
||||||
|
|
||||||
`GET /api/health` now returns a subsystem snapshot:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "ok",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"db": {"ok": true},
|
|
||||||
"scheduler": {"running": true, "interval_s": 60, "sftp_block": "co_medicaid",
|
|
||||||
"backup_scheduler_running": false, "backup_interval_hours": 24.0},
|
|
||||||
"pubsub": {"parse_completed": 1, "batch_added": 1},
|
|
||||||
"batch": {"last_batch_id": 42, "last_batch_kind": "837P",
|
|
||||||
"last_batch_at": "2026-06-21T15:30:00.123Z",
|
|
||||||
"last_batch_filename": "TP11525703-837P-..."}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Returns `"status": "degraded"` if any subsystem reports an error —
|
|
||||||
the per-subsystem dict still surfaces so an operator can see which
|
|
||||||
one is unhappy. `/api/health` is rate-limit exempt so a load balancer
|
|
||||||
hammering the endpoint doesn't trip the limiter.
|
|
||||||
|
|
||||||
### Files
|
|
||||||
|
|
||||||
* `cyclone.security` — `BodySizeLimitMiddleware`,
|
|
||||||
`RateLimitMiddleware`, `SecurityHeadersMiddleware`, and
|
|
||||||
`get_health_snapshot()` (~330 LOC).
|
|
||||||
* `cyclone.api_routers.health` rewritten to use `get_health_snapshot()`.
|
|
||||||
* `cyclone.pubsub.EventBus.stats()` — new method that returns
|
|
||||||
per-kind subscriber counts.
|
|
||||||
* `tests/test_security.py` — 13 new tests.
|
|
||||||
|
|
||||||
## Structured logging (SP18)
|
|
||||||
|
|
||||||
Cyclone emits newline-delimited JSON to stderr by default — readable
|
|
||||||
by `jq`, Loki, Vector, ELK, or any log shipper. Every record carries
|
|
||||||
`ts` (ISO 8601 ms UTC), `level`, `logger`, `msg`, and an optional
|
|
||||||
`extra` dict for structured fields. Exceptions render as a
|
|
||||||
`traceback` string.
|
|
||||||
|
|
||||||
```
|
|
||||||
{"ts":"2026-06-21T15:30:00.123Z","level":"INFO","logger":"cyclone.scheduler","msg":"Processed inbound file","extra":{"input_filename":"ACK_999.x12","parser":"parse_999","claims":3}}
|
|
||||||
{"ts":"2026-06-21T15:30:01.456Z","level":"ERROR","logger":"cyclone.api","msg":"Backup create failed","extra":{"reason":"BackupError: passphrase mismatch"},"traceback":"Traceback ..."}
|
|
||||||
```
|
|
||||||
|
|
||||||
### PII scrubbing
|
|
||||||
|
|
||||||
A `PiiScrubber` filter is attached to the root logger and rewrites
|
|
||||||
obvious PHI patterns to `<redacted:npi>` / `<redacted:ssn>` /
|
|
||||||
`<redacted:dob>` / `<redacted:patient_name>` before any handler sees
|
|
||||||
the record:
|
|
||||||
|
|
||||||
| Pattern | Replacement |
|
|
||||||
|---------|-------------|
|
|
||||||
| `\b\d{10}\b` | `<redacted:npi>` |
|
|
||||||
| `\b\d{3}-\d{2}-\d{4}\b` or `\b\d{9}\b` at phrase boundary | `<redacted:ssn>` |
|
|
||||||
| `(dob\|date_of_birth)[:=]\s*\d{4}-\d{2}-\d{2}` | preserves the key, redacts the date |
|
|
||||||
| `patient_name=...` | full chunk redacted |
|
|
||||||
| Extras with key `dob`/`ssn`/`npi`/`patient_name`/… | value redacted regardless of shape |
|
|
||||||
|
|
||||||
The scrubber is conservative — bare ISO dates without a `dob=` prefix
|
|
||||||
are **not** scrubbed (they're too often timestamps or batch IDs), and
|
|
||||||
11+ digit numbers are left alone (they can't be NPIs). Disable for
|
|
||||||
forensic mode with `CYCLONE_LOG_NO_PII_SCRUB=1`.
|
|
||||||
|
|
||||||
### Knobs
|
|
||||||
|
|
||||||
| Env var | Default | Meaning |
|
|
||||||
|---------|---------|---------|
|
|
||||||
| `CYCLONE_LOG_LEVEL` | `INFO` | Root logger level. `DEBUG` for troubleshooting. |
|
|
||||||
| `CYCLONE_LOG_FILE` | (none) | Write to this path via `RotatingFileHandler` (10 MB × 5 backups). |
|
|
||||||
| `CYCLONE_LOG_JSON` | `true` | `false` uses the dev tabular formatter. |
|
|
||||||
| `CYCLONE_LOG_NO_PII_SCRUB` | (none) | `1` disables scrubbing. |
|
|
||||||
|
|
||||||
CLI:
|
|
||||||
|
|
||||||
```
|
|
||||||
cyclone --log-format=dev parse-837 sample.x12 --output-dir out/ # tabular for tail -f
|
|
||||||
cyclone --log-file=/var/log/cyclone.log backup create # JSON to rotating file
|
|
||||||
```
|
|
||||||
|
|
||||||
The `parse-837` / `parse-835` subcommands also accept `--log-level`
|
|
||||||
which re-runs `setup_logging()` so the per-invocation level overrides
|
|
||||||
the group default.
|
|
||||||
|
|
||||||
### Files
|
|
||||||
|
|
||||||
* `cyclone.logging_config` — `JsonFormatter`, `CycloneDevFormatter`,
|
|
||||||
`PiiScrubber`, `setup_logging()`.
|
|
||||||
* `tests/test_logging_formatter.py` (11), `test_logging_scrubber.py`
|
|
||||||
(13), `test_logging_setup.py` (10) — 34 new tests.
|
|
||||||
* `cyclone.api` lifespan calls `setup_logging()` first; the CLI
|
|
||||||
`main` group does the same.
|
|
||||||
|
|
||||||
## Encrypted Backups (SP17)
|
|
||||||
|
|
||||||
The BackupService takes an online consistent snapshot of the live
|
|
||||||
SQLite file via SQLite's `.backup()` API, encrypts the bytes with
|
|
||||||
AES-256-GCM, and writes a `.bin` + `.meta.json` pair into the backup
|
|
||||||
directory (default `~/.local/share/cyclone/backups/`). The encryption
|
|
||||||
key is derived from a separate passphrase in the macOS Keychain
|
|
||||||
(PBKDF2-HMAC-SHA256, 200,000 iterations, 16-byte salt persisted to
|
|
||||||
Keychain) — so a SQLCipher DB-key compromise does not unlock the
|
|
||||||
backups, and a backup-passphrase compromise does not unlock the live
|
|
||||||
DB. If neither is set, the service refuses (`BackupError`) rather than
|
|
||||||
silently writing plaintext.
|
|
||||||
|
|
||||||
| Method | Path | Purpose |
|
|
||||||
| ------ | ---- | ------- |
|
|
||||||
| POST | `/api/admin/backup/create` | Take an encrypted backup now. |
|
|
||||||
| GET | `/api/admin/backup/list` | List `db_backups` rows (newest first, filterable). |
|
|
||||||
| GET | `/api/admin/backup/status` | Counts, disk usage, last-run timestamp, scheduler snapshot. |
|
|
||||||
| POST | `/api/admin/backup/{id}/verify` | Decrypt + SHA-256 verify against the sidecar. |
|
|
||||||
| POST | `/api/admin/backup/{id}/restore/initiate` | First step: get `restore_token` + preview (fingerprints of backup vs live). |
|
|
||||||
| POST | `/api/admin/backup/{id}/restore/confirm` | Second step: dispose engine, copy decrypted DB, rebuild engine. |
|
|
||||||
| POST | `/api/admin/backup/prune` | Apply retention policy now. |
|
|
||||||
| POST | `/api/admin/backup/scheduler/{start,stop,tick}` | Operate the backup scheduler. |
|
|
||||||
|
|
||||||
Restore is two-step by design: an idle browser tab can't nuke the
|
|
||||||
live DB. The first call returns a one-shot 64-char hex
|
|
||||||
`restore_token` plus a side-by-side preview (`backup_db_fingerprint`,
|
|
||||||
`backup_table_count`, `current_db_fingerprint`, `current_table_count`).
|
|
||||||
The second call swaps the live engine only if the token matches
|
|
||||||
within a 5-minute TTL.
|
|
||||||
|
|
||||||
The scheduler (auto-start opt-in via `CYCLONE_BACKUP_AUTOSTART`)
|
|
||||||
ticks every `CYCLONE_BACKUP_INTERVAL_HOURS` (default 24), runs
|
|
||||||
`create_now` + `prune`, and writes audit events for each outcome
|
|
||||||
(`db.backup_created`, `db.backup_failed`, `db.backup_pruned`,
|
|
||||||
`db.backup_restored`). The CLI mirrors the API surface:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cyclone backup init-passphrase # one-time; interactive
|
|
||||||
cyclone backup create
|
|
||||||
cyclone backup list
|
|
||||||
cyclone backup verify <id>
|
|
||||||
cyclone backup restore <id> --yes
|
|
||||||
cyclone backup prune --yes
|
|
||||||
cyclone backup status
|
|
||||||
```
|
|
||||||
|
|
||||||
Retention defaults to 30 days (`CYCLONE_BACKUP_RETENTION_DAYS`). The
|
|
||||||
retention policy is best-effort: an operator who runs `cyclone
|
|
||||||
backup create` manually retains full control.
|
|
||||||
|
|
||||||
## SFTP Wire-Up (paramiko)
|
## SFTP Wire-Up (paramiko)
|
||||||
|
|
||||||
The `clearhouse.submit` endpoint uses `paramiko` to push a batch of
|
The `clearhouse.submit` endpoint uses `paramiko` to push a batch of
|
||||||
@@ -732,7 +516,7 @@ backup API).
|
|||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
Sub-projects 2 through 19 are **shipped**. See the [completeness
|
Sub-projects 2 through 13 are **shipped**. See the [completeness
|
||||||
review](docs/reviews/2026-06-20-cyclone-completeness-review.md) for
|
review](docs/reviews/2026-06-20-cyclone-completeness-review.md) for
|
||||||
the honest gap analysis against the industry definition of a HIPAA
|
the honest gap analysis against the industry definition of a HIPAA
|
||||||
clearinghouse — the short version is that the local-only,
|
clearinghouse — the short version is that the local-only,
|
||||||
@@ -743,72 +527,6 @@ scope.
|
|||||||
|
|
||||||
Shipped sub-projects (most recent first):
|
Shipped sub-projects (most recent first):
|
||||||
|
|
||||||
- **Sub-project 19 (shipped) — Security hardening + health probe.**
|
|
||||||
Three pure-ASGI middlewares (`BodySizeLimitMiddleware`,
|
|
||||||
`RateLimitMiddleware`, `SecurityHeadersMiddleware`) close the
|
|
||||||
completeness-review gaps §3.1.4 (no body/rate limits) and §3.1.25
|
|
||||||
(no CSP / security headers). 413/429 rejections emit a
|
|
||||||
tamper-evident `api.request_rejected` audit event (SP11 chain).
|
|
||||||
`/api/health` is now a rich subsystem snapshot — DB connectivity,
|
|
||||||
MFT scheduler state, backup scheduler state, live pubsub
|
|
||||||
subscriber counts, last batch id + timestamp. See
|
|
||||||
[Security hardening (SP19)](#security-hardening-sp19) below.
|
|
||||||
- **Sub-project 20 (shipped) — NPI checksum + Tax ID format validation.**
|
|
||||||
Pure local validators (`cyclone.npi`) — no NPPES round-trip, no IRS
|
|
||||||
e-file lookup. Catches the 99% typo case at parse time. NPI uses
|
|
||||||
CMS-published Luhn over `80840 + body` (example: `1234567893` is
|
|
||||||
valid). EIN rejects reserved prefixes (`00`, `07`, `80`–`89`).
|
|
||||||
Surface: `cyclone validate-npi` / `validate-tax-id` CLI subcommands,
|
|
||||||
`GET /api/admin/validate-provider`, new `R021_npi_checksum`
|
|
||||||
validator rule (warning, not error — placeholder NPIs in test
|
|
||||||
fixtures shouldn't block ingest). See
|
|
||||||
[NPI checksum + Tax ID format validation (SP20)](#npi-checksum--tax-id-format-validation-sp20)
|
|
||||||
below.
|
|
||||||
- **Sub-project 18 (shipped) — Structured JSON logging.** All logs
|
|
||||||
emitted by the API, CLI, scheduler tick loop, and backup service
|
|
||||||
flow through a `JsonFormatter` (newline-delimited JSON, ISO-8601 ms
|
|
||||||
timestamps) by default. A `PiiScrubber` filter redacts obvious PHI
|
|
||||||
(NPIs, SSNs, DOBs, patient names) from message + extras — both via
|
|
||||||
inline patterns (`npi 1881068062`) and via PHI-keyed extras
|
|
||||||
(`extra={"dob": "1980-04-12"}`). Configurable via env vars
|
|
||||||
(`CYCLONE_LOG_LEVEL`, `CYCLONE_LOG_FILE`, `CYCLONE_LOG_JSON`,
|
|
||||||
`CYCLONE_LOG_NO_PII_SCRUB`) and CLI flags
|
|
||||||
(`--log-format=json|dev`, `--log-file=…`); a tabular `CycloneDevFormatter`
|
|
||||||
is the opt-out for `tail -f` in dev. See
|
|
||||||
[Structured logging](#structured-logging-sp18) below.
|
|
||||||
- **Sub-project 17 (shipped) — Encrypted DB backups.** Automated
|
|
||||||
encrypted backups via AES-256-GCM (PBKDF2-HMAC-SHA256, 200k iters).
|
|
||||||
The operator sets a separate passphrase in the macOS Keychain
|
|
||||||
(`cyclone backup init-passphrase`); if missing, the service falls
|
|
||||||
back to deriving from the SQLCipher DB key with a WARNING. Online
|
|
||||||
backups via SQLite `.backup()`, two-step restore (`initiate` →
|
|
||||||
`confirm` with one-shot 64-char hex token), retention pruning with
|
|
||||||
a 30-day default, and a tamper-evident audit chain (`db.backup_created`,
|
|
||||||
`db.backup_failed`, `db.backup_pruned`, `db.backup_restored`,
|
|
||||||
`db.backup_passphrase_set`). Backup scheduler ticks every 24h
|
|
||||||
(configurable); auto-start opt-in via `CYCLONE_BACKUP_AUTOSTART`.
|
|
||||||
Seven admin endpoints + six CLI subcommands. See
|
|
||||||
[Encrypted Backups](#encrypted-backups) below.
|
|
||||||
- **Sub-project 16 (shipped) — Live MFT polling scheduler.** asyncio
|
|
||||||
background loop polls the Gainwell MFT inbound path, downloads
|
|
||||||
new files, and routes them through the right parser (999 / 835 /
|
|
||||||
277CA / TA1). Idempotent (re-ticks skip already-processed files
|
|
||||||
via the new `processed_inbound_files` table). Crash-safe (per-file
|
|
||||||
try/except so a bad file doesn't stop the loop). Five admin
|
|
||||||
endpoints (`/api/admin/scheduler/{status,start,stop,tick,processed-files}`).
|
|
||||||
- **Sub-project 15 (shipped) — SQLCipher key rotation.** In-place
|
|
||||||
rotation via `PRAGMA rekey`, serialized through a module-level
|
|
||||||
`threading.Lock` and a SQLAlchemy `NullPool` to keep SQLCipher
|
|
||||||
thread-affine under FastAPI's per-request threadpool. Writes a
|
|
||||||
`db.key_rotated` audit event with old + new key fingerprints and
|
|
||||||
post-rotation `table_count`. See
|
|
||||||
[Encryption at Rest — Key rotation](#key-rotation).
|
|
||||||
- **Sub-project 14 (shipped) — 5-lane Inbox UI.** The Payer-Rejected
|
|
||||||
lane is now rendered in the Inbox alongside Rejected / Candidates /
|
|
||||||
Unmatched / Done today. New bulk action
|
|
||||||
`POST /api/inbox/payer-rejected/acknowledge` drops claims from the
|
|
||||||
working surface without erasing the original 277CA rejection event
|
|
||||||
(audit log stays intact, SP11).
|
|
||||||
- **Sub-project 13 (shipped) — SFTP wire-up.** `paramiko`-backed
|
- **Sub-project 13 (shipped) — SFTP wire-up.** `paramiko`-backed
|
||||||
`SftpClient` replaces the SP9 stub. The clearhouse.submit endpoint
|
`SftpClient` replaces the SP9 stub. The clearhouse.submit endpoint
|
||||||
actually pushes to
|
actually pushes to
|
||||||
@@ -1077,6 +795,101 @@ the one-time setup recipe.
|
|||||||
`mft.gainwelltechnologies.com:/CO XIX/PROD/coxix_prod_11525703/FromHPE`.
|
`mft.gainwelltechnologies.com:/CO XIX/PROD/coxix_prod_11525703/FromHPE`.
|
||||||
SFTP credentials are fetched from the macOS Keychain at call time.
|
SFTP credentials are fetched from the macOS Keychain at call time.
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
A two-service `docker-compose.yml` is provided for operators who want
|
||||||
|
a single `docker compose up` instead of running the backend and
|
||||||
|
frontend in two terminals. Both services are configured to restart
|
||||||
|
automatically on crash, and the SQLite database lives on a named
|
||||||
|
volume that survives `docker compose down` (only `down -v` wipes it).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build # build + start both services in the background
|
||||||
|
docker compose ps # confirm both containers are Up (healthy)
|
||||||
|
docker compose logs -f # tail both logs
|
||||||
|
open http://127.0.0.1:8081 # the SPA, served by nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
### What's where
|
||||||
|
|
||||||
|
| Service | Image | Published port | Healthcheck | Persistent state |
|
||||||
|
| ---------- | ---------------------- | ----------------------------- | ---------------------- | ------------------------- |
|
||||||
|
| `frontend` | `cyclone-frontend:local` | `0.0.0.0:8081 → 80` (LAN-reachable by default; tighten via `CYCLONE_BIND_ADDRESS=127.0.0.1`) | `GET /healthz` | none |
|
||||||
|
| `backend` | `cyclone-backend:local` | not published (see note) | `GET /api/health` | `cyclone-data` named volume at `/data` |
|
||||||
|
|
||||||
|
The frontend's nginx reverse-proxies `/api/*` to the backend over the
|
||||||
|
compose network, so the SPA talks to `http://same-origin/api/...` and
|
||||||
|
the Vite dev-server config (`VITE_API_BASE_URL=`) doesn't apply — the
|
||||||
|
build is invoked with an empty `VITE_API_BASE_URL` and the API calls
|
||||||
|
are relative.
|
||||||
|
|
||||||
|
### Persistence
|
||||||
|
|
||||||
|
The backend writes its SQLite file to `/data/cyclone.db` inside the
|
||||||
|
container, which is backed by the named volume `cyclone-data`. Killing
|
||||||
|
and restarting the container preserves the DB; rebooting the host
|
||||||
|
preserves the DB; only `docker compose down -v` removes it. The
|
||||||
|
container's `HEALTHCHECK` and `restart: unless-stopped` policy mean a
|
||||||
|
crash is recovered within ~15 seconds (next healthcheck interval)
|
||||||
|
without operator intervention.
|
||||||
|
|
||||||
|
To back up the live database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec backend sqlite3 /data/cyclone.db ".backup /data/backup.db"
|
||||||
|
docker cp cyclone-backend:/data/backup.db ./cyclone-backup-$(date +%F).db
|
||||||
|
```
|
||||||
|
|
||||||
|
(SQLite's online backup API — safe to run while the backend is
|
||||||
|
serving traffic.)
|
||||||
|
|
||||||
|
### Crashing the backend on purpose
|
||||||
|
|
||||||
|
To confirm the auto-restart wiring:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose kill -s SIGKILL backend # hard-kill the backend
|
||||||
|
docker compose ps # backend should restart within seconds
|
||||||
|
docker compose logs --tail=20 backend # see the uvicorn startup banner again
|
||||||
|
```
|
||||||
|
|
||||||
|
Data is unaffected: the volume survives `kill` and the new process
|
||||||
|
re-opens the same DB file.
|
||||||
|
|
||||||
|
### Talking to the backend directly
|
||||||
|
|
||||||
|
The backend port is **not** published by default (everything goes
|
||||||
|
through the frontend's nginx). To expose it for `curl` debugging,
|
||||||
|
uncomment the `ports:` block under `backend:` in `docker-compose.yml`
|
||||||
|
and restart:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8000/api/health # {"status":"ok","version":"..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
If 8081 clashes with something else on the host, run with a different
|
||||||
|
published port:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CYCLONE_WEB_PORT=9000 docker compose up -d
|
||||||
|
open http://127.0.0.1:9000
|
||||||
|
```
|
||||||
|
|
||||||
|
To reach the UI from another machine on your LAN, open
|
||||||
|
`http://<host-lan-ip>:8081` — the default bind is `0.0.0.0`. To
|
||||||
|
re-tighten to loopback-only (matching the standalone install's
|
||||||
|
local-only posture):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CYCLONE_BIND_ADDRESS=127.0.0.1 docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Payer config
|
||||||
|
|
||||||
|
`config/payers.yaml` is copied into the backend image at build time.
|
||||||
|
Edit the YAML, rebuild, and either bounce the container or `POST
|
||||||
|
/api/admin/reload-config` to pick up changes without a rebuild.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
No license file yet; this is internal-use software. Add a `LICENSE` file
|
No license file yet; this is internal-use software. Add a `LICENSE` file
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Exclude everything not needed at runtime from the backend image.
|
||||||
|
# This file is read when backend/ is used as the build context. The
|
||||||
|
# docker-compose build uses the repo root as context and references
|
||||||
|
# `backend/...` paths explicitly, so most of these exclusions are belt-
|
||||||
|
# and-suspenders for any future `docker build backend/` invocation.
|
||||||
|
|
||||||
|
**/__pycache__
|
||||||
|
**/*.pyc
|
||||||
|
**/*.pyo
|
||||||
|
**/.pytest_cache
|
||||||
|
**/.mypy_cache
|
||||||
|
**/.ruff_cache
|
||||||
|
**/.coverage
|
||||||
|
**/.tox
|
||||||
|
**/.venv
|
||||||
|
**/venv
|
||||||
|
**/node_modules
|
||||||
|
tests/
|
||||||
|
docs/
|
||||||
|
.coverage*
|
||||||
|
htmlcov/
|
||||||
|
*.egg-info/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
# Cyclone backend — FastAPI on uvicorn.
|
||||||
|
#
|
||||||
|
# Image layout:
|
||||||
|
# /app repo root (copied by docker-compose)
|
||||||
|
# /app/backend/src python package source
|
||||||
|
# /app/config config/payers.yaml lives here
|
||||||
|
# /data persistent SQLite volume mountpoint
|
||||||
|
#
|
||||||
|
# Build context for this Dockerfile is the repo root (../) when invoked
|
||||||
|
# from docker-compose, so paths below are relative to repo root.
|
||||||
|
|
||||||
|
FROM python:3.11-slim AS base
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1 \
|
||||||
|
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||||
|
|
||||||
|
# curl is used by the HEALTHCHECK. build-essential is not required — the
|
||||||
|
# backend is pure-python wheels on linux/amd64 + linux/arm64.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends curl ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Non-root user. UID 10001 is unlikely to collide with the host UID.
|
||||||
|
RUN groupadd --system --gid 10001 cyclone \
|
||||||
|
&& useradd --system --uid 10001 --gid cyclone --home /app --shell /usr/sbin/nologin cyclone
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Source + YAML config. Tests + fixtures + prodfiles are intentionally
|
||||||
|
# excluded from the runtime image (see backend/.dockerignore).
|
||||||
|
COPY backend/src /app/backend/src
|
||||||
|
COPY config /app/config
|
||||||
|
|
||||||
|
# Install deps last so source changes don't bust the pip cache layer.
|
||||||
|
# `pip install -e .` needs the `src/cyclone` package directory to exist
|
||||||
|
# at install time (setuptools runs egg-info during the editable install),
|
||||||
|
# so the source COPY above must come first.
|
||||||
|
COPY backend/pyproject.toml backend/uv.lock* /app/backend/
|
||||||
|
RUN pip install --no-cache-dir -e /app/backend
|
||||||
|
|
||||||
|
# Persistent volume mountpoint. SQLite needs the directory to exist and
|
||||||
|
# to be writable by the cyclone user; Docker creates the volume but the
|
||||||
|
# directory inside the image must be pre-created with the right owner.
|
||||||
|
RUN mkdir -p /data && chown -R cyclone:cyclone /data
|
||||||
|
|
||||||
|
USER cyclone
|
||||||
|
|
||||||
|
ENV CYCLONE_HOST=0.0.0.0 \
|
||||||
|
CYCLONE_PORT=8000 \
|
||||||
|
CYCLONE_RELOAD=0 \
|
||||||
|
CYCLONE_DB_URL=sqlite:////data/cyclone.db \
|
||||||
|
PYTHONPATH=/app/backend/src
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD curl --fail --silent http://127.0.0.1:8000/api/health || exit 1
|
||||||
|
|
||||||
|
# `python -m cyclone serve` honors CYCLONE_HOST/CYCLONE_PORT/CYCLONE_RELOAD
|
||||||
|
# from the env above. PYTHONPATH puts the editable package on the path.
|
||||||
|
CMD ["python", "-m", "cyclone", "serve"]
|
||||||
@@ -16,6 +16,10 @@ dependencies = [
|
|||||||
"sqlalchemy>=2.0,<3",
|
"sqlalchemy>=2.0,<3",
|
||||||
"pyyaml>=6.0,<7",
|
"pyyaml>=6.0,<7",
|
||||||
"keyring>=25.0,<26",
|
"keyring>=25.0,<26",
|
||||||
|
# passlib 1.7.4 + bcrypt >= 4.1 are incompatible (passlib probes bcrypt.__about__
|
||||||
|
# which 4.x removed). Pin bcrypt < 4.1.
|
||||||
|
"passlib[bcrypt]>=1.7.4",
|
||||||
|
"bcrypt<4.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"""Entry point for ``python -m cyclone``.
|
"""Entry point for ``python -m cyclone``.
|
||||||
|
|
||||||
* ``python -m cyclone`` (no args) — Click CLI (``cli.main``)
|
* ``python -m cyclone`` (no args) — Click CLI (``cli.main``)
|
||||||
* ``python -m cyclone serve`` — start the FastAPI app on 127.0.0.1:8000
|
* ``python -m cyclone serve`` — start the FastAPI app
|
||||||
|
|
||||||
Honors the env vars:
|
Honors the env vars:
|
||||||
|
|
||||||
|
* ``CYCLONE_HOST`` (default ``127.0.0.1`` — set to ``0.0.0.0`` in containers
|
||||||
|
so uvicorn accepts traffic from outside the container's loopback)
|
||||||
* ``CYCLONE_PORT`` (default ``8000``)
|
* ``CYCLONE_PORT`` (default ``8000``)
|
||||||
* ``CYCLONE_RELOAD`` (default ``0``; set to ``1`` to enable uvicorn reload)
|
* ``CYCLONE_RELOAD`` (default ``0``; set to ``1`` to enable uvicorn reload)
|
||||||
"""
|
"""
|
||||||
@@ -16,13 +18,22 @@ import sys
|
|||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
# Always run first-admin bootstrap before any other entry path.
|
||||||
|
# Must happen before ``serve`` (uvicorn) AND before the Click CLI
|
||||||
|
# dispatch — otherwise `python -m cyclone users create ...` on a
|
||||||
|
# fresh DB would race with the bootstrap's check, and the API
|
||||||
|
# could come up with zero users.
|
||||||
|
from cyclone.auth import bootstrap
|
||||||
|
bootstrap.run()
|
||||||
|
|
||||||
if len(sys.argv) >= 2 and sys.argv[1] == "serve":
|
if len(sys.argv) >= 2 and sys.argv[1] == "serve":
|
||||||
|
host = os.environ.get("CYCLONE_HOST", "127.0.0.1")
|
||||||
port = os.environ.get("CYCLONE_PORT", "8000")
|
port = os.environ.get("CYCLONE_PORT", "8000")
|
||||||
reload = os.environ.get("CYCLONE_RELOAD", "0") == "1"
|
reload = os.environ.get("CYCLONE_RELOAD", "0") == "1"
|
||||||
sys.argv = [
|
sys.argv = [
|
||||||
sys.argv[0],
|
sys.argv[0],
|
||||||
"cyclone.api:app",
|
"cyclone.api:app",
|
||||||
"--host", "127.0.0.1",
|
"--host", host,
|
||||||
"--port", port,
|
"--port", port,
|
||||||
]
|
]
|
||||||
if reload:
|
if reload:
|
||||||
|
|||||||
+456
-983
File diff suppressed because it is too large
Load Diff
@@ -1,247 +0,0 @@
|
|||||||
"""Shared helpers used by ``cyclone.api`` route handlers.
|
|
||||||
|
|
||||||
Everything in this module is private to the API layer (no business
|
|
||||||
logic, no DB writes). It collects the cross-cutting concerns that used
|
|
||||||
to live inline at the top of ``api.py``:
|
|
||||||
|
|
||||||
* NDJSON wire-format primitives (``ndjson_line``, ``ndjson_stream_list``,
|
|
||||||
``ndjson_stream_837``, ``ndjson_stream_835``).
|
|
||||||
* Content negotiation (``client_wants_json``, ``wants_ndjson``).
|
|
||||||
* Strict / ``raw_segments`` rewrites applied before persisting parsed
|
|
||||||
837P and 835 results.
|
|
||||||
* Validation-error probes for both transactions.
|
|
||||||
* The shared live-tail async generator (``tail_events``,
|
|
||||||
``heartbeat_seconds``) used by every ``/api/<resource>/stream``
|
|
||||||
endpoint.
|
|
||||||
|
|
||||||
Extracted as part of the api.py router split (see /tmp/refactor-cyclone.md).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import AsyncIterator, Iterator
|
|
||||||
|
|
||||||
from fastapi import Request
|
|
||||||
|
|
||||||
from cyclone.parsers.models import ClaimOutput, ParseResult
|
|
||||||
from cyclone.parsers.models_835 import ParseResult835
|
|
||||||
from cyclone.pubsub import EventBus
|
|
||||||
|
|
||||||
|
|
||||||
def utcnow() -> datetime:
|
|
||||||
"""tz-aware UTC ``datetime`` (matches :func:`cyclone.store.utcnow`)."""
|
|
||||||
return datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def ndjson_line(event: dict) -> bytes:
|
|
||||||
"""Serialize one event dict as a single NDJSON line (UTF-8, trailing ``\\n``).
|
|
||||||
|
|
||||||
Used by the live-tail streaming endpoints to emit a uniform wire format
|
|
||||||
that the frontend ``tail-stream.ts`` parser can split on newlines.
|
|
||||||
Compact separators keep each line small and avoid ambiguity with embedded
|
|
||||||
whitespace.
|
|
||||||
"""
|
|
||||||
return (json.dumps(event, separators=(",", ":")) + "\n").encode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def client_wants_json(request: Request) -> bool:
|
|
||||||
"""Content negotiation: prefer ``application/json`` when the client asks for it.
|
|
||||||
|
|
||||||
NDJSON is the default for browser uploads that don't set ``Accept``. The
|
|
||||||
frontend opts into JSON via ``Accept: application/json``.
|
|
||||||
"""
|
|
||||||
accept = request.headers.get("accept", "")
|
|
||||||
# If the client mentions JSON at all (and isn't asking for NDJSON
|
|
||||||
# specifically) treat it as a single-object request. The browser default
|
|
||||||
# ``*/*`` falls through to NDJSON.
|
|
||||||
if "application/json" in accept and "application/x-ndjson" not in accept:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def wants_ndjson(request: Request) -> bool:
|
|
||||||
"""Content negotiation for list endpoints: NDJSON is an opt-in, JSON is the
|
|
||||||
default (per spec 6.2: "Default JSON response wraps the same data in a
|
|
||||||
{items, total, returned, has_more} envelope so the frontend can paginate
|
|
||||||
uniformly").
|
|
||||||
|
|
||||||
Used by the GET list routes (/api/batches, /api/claims, /api/remittances,
|
|
||||||
/api/providers, /api/activity). NDJSON is returned only when the client
|
|
||||||
explicitly sends ``Accept: application/x-ndjson`` (with or without
|
|
||||||
``application/json``). Bare ``*/*``, an empty Accept, or an explicit
|
|
||||||
``Accept: application/json`` all return the JSON envelope.
|
|
||||||
"""
|
|
||||||
accept = request.headers.get("accept", "")
|
|
||||||
return "application/x-ndjson" in accept
|
|
||||||
|
|
||||||
|
|
||||||
def ndjson_stream_list(
|
|
||||||
items: list[dict], total: int, returned: int, has_more: bool,
|
|
||||||
) -> Iterator[str]:
|
|
||||||
"""Yield NDJSON lines for a list endpoint: one ``item`` per dict, then a
|
|
||||||
final ``summary`` line. Mirrors spec section 6.2 streaming rule.
|
|
||||||
"""
|
|
||||||
for it in items:
|
|
||||||
yield json.dumps({"type": "item", "data": it}) + "\n"
|
|
||||||
yield json.dumps({
|
|
||||||
"type": "summary",
|
|
||||||
"data": {"total": total, "returned": returned, "has_more": has_more},
|
|
||||||
}) + "\n"
|
|
||||||
|
|
||||||
|
|
||||||
def ndjson_stream_837(result: ParseResult) -> Iterator[bytes]:
|
|
||||||
"""Yield one JSON object per line: envelope → claims → summary."""
|
|
||||||
envelope_obj = (
|
|
||||||
result.envelope.model_dump() if result.envelope is not None else None
|
|
||||||
)
|
|
||||||
yield (json.dumps({"type": "envelope", "data": envelope_obj}) + "\n").encode("utf-8")
|
|
||||||
for claim in result.claims:
|
|
||||||
yield (json.dumps({"type": "claim", "data": json.loads(claim.model_dump_json())}) + "\n").encode("utf-8")
|
|
||||||
yield (json.dumps({"type": "summary", "data": json.loads(result.summary.model_dump_json())}) + "\n").encode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def ndjson_stream_835(result: ParseResult835) -> Iterator[bytes]:
|
|
||||||
"""Yield one JSON object per line: envelope → financial → trace → payer → payee → claim_payments → summary."""
|
|
||||||
yield (json.dumps({"type": "envelope", "data": json.loads(result.envelope.model_dump_json())}) + "\n").encode("utf-8")
|
|
||||||
yield (json.dumps({"type": "financial_info", "data": json.loads(result.financial_info.model_dump_json())}) + "\n").encode("utf-8")
|
|
||||||
yield (json.dumps({"type": "trace", "data": json.loads(result.trace.model_dump_json())}) + "\n").encode("utf-8")
|
|
||||||
yield (json.dumps({"type": "payer", "data": json.loads(result.payer.model_dump_json())}) + "\n").encode("utf-8")
|
|
||||||
yield (json.dumps({"type": "payee", "data": json.loads(result.payee.model_dump_json())}) + "\n").encode("utf-8")
|
|
||||||
for claim in result.claims:
|
|
||||||
yield (json.dumps({"type": "claim_payment", "data": json.loads(claim.model_dump_json())}) + "\n").encode("utf-8")
|
|
||||||
yield (json.dumps({"type": "summary", "data": json.loads(result.summary.model_dump_json())}) + "\n").encode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def strict_rewrite_837(result: ParseResult) -> ParseResult:
|
|
||||||
"""Promote warnings to errors (mirrors the CLI's --strict)."""
|
|
||||||
claims: list[ClaimOutput] = []
|
|
||||||
for claim in result.claims:
|
|
||||||
promoted = [
|
|
||||||
issue.model_copy(update={"severity": "error"})
|
|
||||||
for issue in claim.validation.warnings
|
|
||||||
]
|
|
||||||
new_errors = claim.validation.errors + promoted
|
|
||||||
claims.append(
|
|
||||||
claim.model_copy(
|
|
||||||
update={
|
|
||||||
"validation": claim.validation.model_copy(
|
|
||||||
update={"errors": new_errors, "passed": not new_errors}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
passed = sum(1 for c in claims if c.validation.passed)
|
|
||||||
failed = len(claims) - passed
|
|
||||||
summary = result.summary.model_copy(
|
|
||||||
update={
|
|
||||||
"passed": passed,
|
|
||||||
"failed": failed,
|
|
||||||
"failed_claim_ids": [c.claim_id for c in claims if not c.validation.passed],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return result.model_copy(update={"claims": claims, "summary": summary})
|
|
||||||
|
|
||||||
|
|
||||||
def strict_rewrite_835(result: ParseResult835) -> ParseResult835:
|
|
||||||
"""Promote warnings to errors (mirrors the CLI's --strict)."""
|
|
||||||
if result.validation is None:
|
|
||||||
return result
|
|
||||||
report = result.validation
|
|
||||||
promoted = [i.model_copy(update={"severity": "error"}) for i in report.warnings]
|
|
||||||
new_errors = report.errors + promoted
|
|
||||||
new_report = report.model_copy(update={"errors": new_errors, "passed": not new_errors})
|
|
||||||
passed = 1 if new_report.passed else 0
|
|
||||||
failed = 1 if not new_report.passed else 0
|
|
||||||
new_summary = result.summary.model_copy(
|
|
||||||
update={"passed": passed, "failed": failed}
|
|
||||||
)
|
|
||||||
return result.model_copy(update={"validation": new_report, "summary": new_summary})
|
|
||||||
|
|
||||||
|
|
||||||
def drop_raw_segments_837(result: ParseResult) -> ParseResult:
|
|
||||||
"""Return a copy of ``result`` with ``raw_segments`` cleared on every claim."""
|
|
||||||
claims = [c.model_copy(update={"raw_segments": []}) for c in result.claims]
|
|
||||||
return result.model_copy(update={"claims": claims})
|
|
||||||
|
|
||||||
|
|
||||||
def drop_raw_segments_835(result: ParseResult835) -> ParseResult835:
|
|
||||||
"""Return a copy of ``result`` with ``raw_segments`` cleared on every claim."""
|
|
||||||
claims = [c.model_copy(update={"raw_segments": []}) for c in result.claims]
|
|
||||||
return result.model_copy(update={"claims": claims})
|
|
||||||
|
|
||||||
|
|
||||||
def has_claim_validation_errors(result: ParseResult) -> bool:
|
|
||||||
return any(not c.validation.passed for c in result.claims)
|
|
||||||
|
|
||||||
|
|
||||||
def has_835_validation_errors(result: ParseResult835) -> bool:
|
|
||||||
return result.validation is not None and not result.validation.passed
|
|
||||||
|
|
||||||
|
|
||||||
def heartbeat_seconds() -> float:
|
|
||||||
"""Return the configured tail heartbeat interval.
|
|
||||||
|
|
||||||
Read from ``CYCLONE_TAIL_HEARTBEAT_S`` at call time so tests can
|
|
||||||
monkeypatch the env var without reloading the module. Defaults to
|
|
||||||
15s (the production cadence); tests override to a small value (e.g.
|
|
||||||
0.2s) to keep their runtime bounded.
|
|
||||||
"""
|
|
||||||
raw = os.environ.get("CYCLONE_TAIL_HEARTBEAT_S", "15")
|
|
||||||
try:
|
|
||||||
v = float(raw)
|
|
||||||
except ValueError:
|
|
||||||
return 15.0
|
|
||||||
return v if v > 0 else 15.0
|
|
||||||
|
|
||||||
|
|
||||||
async def tail_events(
|
|
||||||
request: Request, bus: EventBus, kinds: list[str]
|
|
||||||
) -> AsyncIterator[bytes]:
|
|
||||||
"""Forward subscribed events as ``item`` lines with periodic heartbeats.
|
|
||||||
|
|
||||||
Polls the underlying ``asyncio.Queue`` directly (via
|
|
||||||
:meth:`EventBus.subscribe_raw`) instead of awaiting the bus's
|
|
||||||
async-iterator wrapper. ``asyncio.wait_for`` cancels the inner
|
|
||||||
future on timeout, which would otherwise terminate the bus
|
|
||||||
iterator at its ``await`` point and break subsequent
|
|
||||||
``__anext__`` calls with ``StopAsyncIteration``. Polling
|
|
||||||
``queue.get()`` is idempotent under cancellation, so heartbeats
|
|
||||||
don't poison the subscription.
|
|
||||||
|
|
||||||
A ``try/finally`` unsubscribes the queue from the bus when the
|
|
||||||
caller disconnects or the generator is garbage collected —
|
|
||||||
otherwise the bus would leak one queue per open stream.
|
|
||||||
"""
|
|
||||||
hb_s = heartbeat_seconds()
|
|
||||||
queue, _sub = bus.subscribe_raw(kinds)
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
if await request.is_disconnected():
|
|
||||||
return
|
|
||||||
get_task = asyncio.ensure_future(queue.get())
|
|
||||||
sleep_task = asyncio.ensure_future(asyncio.sleep(hb_s))
|
|
||||||
try:
|
|
||||||
done, pending = await asyncio.wait(
|
|
||||||
{get_task, sleep_task},
|
|
||||||
return_when=asyncio.FIRST_COMPLETED,
|
|
||||||
)
|
|
||||||
except BaseException:
|
|
||||||
get_task.cancel()
|
|
||||||
sleep_task.cancel()
|
|
||||||
raise
|
|
||||||
for t in pending:
|
|
||||||
t.cancel()
|
|
||||||
if get_task in done:
|
|
||||||
event = get_task.result()
|
|
||||||
yield ndjson_line({"type": "item", "data": event})
|
|
||||||
else:
|
|
||||||
yield ndjson_line({
|
|
||||||
"type": "heartbeat",
|
|
||||||
"data": {"ts": utcnow().isoformat().replace("+00:00", "Z")},
|
|
||||||
})
|
|
||||||
finally:
|
|
||||||
bus.unsubscribe(queue, kinds)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Resource-group routers. Imported and registered by ``cyclone.api``."""
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
"""``/api/acks`` — list & detail endpoints for the 999 ACK inbox.
|
|
||||||
|
|
||||||
These are the persisted acknowledgment rows produced by
|
|
||||||
``POST /api/parse-999``. The frontend ``useAcks`` hook re-shapes the
|
|
||||||
list payload to its ``Ack`` interface in ``src/types/index.ts``.
|
|
||||||
|
|
||||||
The detail endpoint returns the full ``raw_json`` payload plus the
|
|
||||||
regenerated ``raw_999_text`` so the UI can show "view source" without a
|
|
||||||
second round-trip.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query, Request
|
|
||||||
from fastapi.responses import StreamingResponse
|
|
||||||
|
|
||||||
from cyclone.api_helpers import ndjson_stream_list, wants_ndjson
|
|
||||||
from cyclone.parsers.models_999 import ParseResult999
|
|
||||||
from cyclone.parsers.serialize_999 import serialize_999
|
|
||||||
from cyclone.store import store
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _ack_to_ui(row) -> dict:
|
|
||||||
"""Map an ``Ack`` ORM row to the UI shape used by ``/api/acks``.
|
|
||||||
|
|
||||||
Field names match the rest of the Cyclone API (snake_case). The
|
|
||||||
frontend ``useAcks`` hook re-shapes this to the camelCase ``Ack``
|
|
||||||
interface in ``src/types/index.ts``.
|
|
||||||
"""
|
|
||||||
return {
|
|
||||||
"id": row.id,
|
|
||||||
"source_batch_id": row.source_batch_id,
|
|
||||||
"accepted_count": row.accepted_count,
|
|
||||||
"rejected_count": row.rejected_count,
|
|
||||||
"received_count": row.received_count,
|
|
||||||
"ack_code": row.ack_code,
|
|
||||||
"parsed_at": (
|
|
||||||
row.parsed_at.isoformat().replace("+00:00", "Z")
|
|
||||||
if row.parsed_at is not None
|
|
||||||
else ""
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/acks")
|
|
||||||
def list_acks_endpoint(
|
|
||||||
request: Request,
|
|
||||||
limit: int = Query(100, ge=1, le=1000),
|
|
||||||
) -> Any:
|
|
||||||
"""Return the list of persisted 999 ACKs, newest first."""
|
|
||||||
rows = store.list_acks()
|
|
||||||
items = [_ack_to_ui(r) for r in rows[:limit]]
|
|
||||||
total = len(rows)
|
|
||||||
returned = len(items)
|
|
||||||
has_more = total > returned
|
|
||||||
if wants_ndjson(request):
|
|
||||||
return StreamingResponse(
|
|
||||||
ndjson_stream_list(items, total, returned, has_more),
|
|
||||||
media_type="application/x-ndjson",
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"items": items,
|
|
||||||
"total": total,
|
|
||||||
"returned": returned,
|
|
||||||
"has_more": has_more,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/acks/{ack_id}")
|
|
||||||
def get_ack_endpoint(ack_id: int) -> dict:
|
|
||||||
"""Return one persisted ACK row with its parsed detail.
|
|
||||||
|
|
||||||
Path param is ``ack_id`` (not ``id``) to avoid shadowing FastAPI's
|
|
||||||
internal ``id`` name and to keep OpenAPI docs self-describing.
|
|
||||||
Returns 404 when the ACK is missing — never 500.
|
|
||||||
"""
|
|
||||||
row = store.get_ack(ack_id)
|
|
||||||
if row is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=404,
|
|
||||||
detail={"error": "Not found", "detail": f"Ack {ack_id} not found"},
|
|
||||||
)
|
|
||||||
body = _ack_to_ui(row)
|
|
||||||
body["raw_json"] = row.raw_json
|
|
||||||
# Regenerate the X12 text from raw_json so the operator can download
|
|
||||||
# the actual 999 file. (SP3 P3 follow-up: list endpoint doesn't carry
|
|
||||||
# the regenerated text to keep payloads small; detail does.)
|
|
||||||
if row.raw_json:
|
|
||||||
try:
|
|
||||||
regenerated = ParseResult999.model_validate(row.raw_json)
|
|
||||||
icn = regenerated.envelope.control_number or "000000001"
|
|
||||||
body["raw_999_text"] = serialize_999(regenerated, interchange_control_number=icn)
|
|
||||||
except Exception as exc: # noqa: BLE001 — never 500 on a regen failure
|
|
||||||
log.warning("Could not regenerate 999 for ack %s: %s", ack_id, exc)
|
|
||||||
body["raw_999_text"] = None
|
|
||||||
else:
|
|
||||||
body["raw_999_text"] = None
|
|
||||||
return body
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
"""``/api/admin/validate-provider`` — NPI + Tax ID liveness probe (SP20).
|
|
||||||
|
|
||||||
Pure read-only endpoint that runs the local NPI Luhn + EIN format checks
|
|
||||||
without touching the DB. Useful for:
|
|
||||||
- operators vetting a new provider before adding them to the registry,
|
|
||||||
- the dashboard's "validate" button on a Provider row,
|
|
||||||
- smoke-testing the SP20 checks after a deploy.
|
|
||||||
|
|
||||||
Both query params are optional; omitting one just skips that check.
|
|
||||||
Returns the per-check result dict so the caller can distinguish "bad
|
|
||||||
format" from "bad checksum".
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Query
|
|
||||||
|
|
||||||
from cyclone.npi import is_valid_npi, is_valid_tax_id, normalize_tax_id
|
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/admin/validate-provider")
|
|
||||||
def validate_provider(
|
|
||||||
npi: str | None = Query(None, description="10-digit NPI to validate (Luhn checksum)"),
|
|
||||||
tax_id: str | None = Query(None, description="9-digit EIN to validate (format + reserved-prefix check)"),
|
|
||||||
) -> dict:
|
|
||||||
"""Return per-field validation results for ``npi`` and ``tax_id``.
|
|
||||||
|
|
||||||
Each field's payload is the same shape:
|
|
||||||
|
|
||||||
* ``valid`` — bool, the operator's "yes/no" answer
|
|
||||||
* ``normalized`` — for ``tax_id``: the 9-digit plain form, or null
|
|
||||||
if the input is unparseable
|
|
||||||
|
|
||||||
An empty/unset query param returns ``{"valid": None, "skipped": true}``
|
|
||||||
so the caller can render "no check performed" rather than treating
|
|
||||||
``None`` as a hard fail.
|
|
||||||
"""
|
|
||||||
result: dict = {}
|
|
||||||
|
|
||||||
if npi is None or npi == "":
|
|
||||||
result["npi"] = {"valid": None, "skipped": True}
|
|
||||||
else:
|
|
||||||
result["npi"] = {
|
|
||||||
"valid": is_valid_npi(npi),
|
|
||||||
"skipped": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
if tax_id is None or tax_id == "":
|
|
||||||
result["tax_id"] = {"valid": None, "skipped": True, "normalized": None}
|
|
||||||
else:
|
|
||||||
normalized = normalize_tax_id(tax_id)
|
|
||||||
result["tax_id"] = {
|
|
||||||
"valid": is_valid_tax_id(tax_id),
|
|
||||||
"skipped": False,
|
|
||||||
"normalized": normalized,
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
"""``GET /api/health`` — liveness + readiness probe.
|
|
||||||
|
|
||||||
SP19 expanded the shallow ``{"status": "ok", "version": ...}`` probe
|
|
||||||
into a snapshot of every subsystem:
|
|
||||||
|
|
||||||
* **db** — can we open a session and run ``SELECT 1``?
|
|
||||||
* **scheduler** — is the MFT polling loop running? same for the
|
|
||||||
backup scheduler.
|
|
||||||
* **pubsub** — current subscriber counts per event kind.
|
|
||||||
* **batch** — most recent batch id + timestamp.
|
|
||||||
|
|
||||||
Returns ``status="ok"`` only when every subsystem is healthy.
|
|
||||||
``status="degraded"`` if any subsystem is unhappy but the API
|
|
||||||
itself is responsive. Per-subsystem errors are surfaced in the
|
|
||||||
respective dict so an operator doesn't have to guess.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
|
||||||
|
|
||||||
from cyclone import __version__
|
|
||||||
from cyclone.security import get_health_snapshot
|
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/health")
|
|
||||||
def health(request: Request) -> dict:
|
|
||||||
snap = get_health_snapshot()
|
|
||||||
# Fill in live pubsub subscriber counts using the per-request app
|
|
||||||
# state (the snapshot builder doesn't have request context).
|
|
||||||
bus = getattr(request.app.state, "event_bus", None)
|
|
||||||
if bus is not None and hasattr(bus, "stats"):
|
|
||||||
snap.pubsub = bus.stats()
|
|
||||||
elif bus is not None:
|
|
||||||
snap.pubsub = {"note": "EventBus.stats() not available"}
|
|
||||||
else:
|
|
||||||
snap.pubsub = {"note": "EventBus not attached (running outside lifespan?)"}
|
|
||||||
return snap.to_dict()
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
"""``/api/ta1-acks`` — list & detail endpoints for persisted TA1 envelopes.
|
|
||||||
|
|
||||||
TA1 is the interchange-control ACK (ISA/IEA acknowledgement). It's a
|
|
||||||
single segment, no functional group, no transaction set. Cyclone
|
|
||||||
persists the parsed fields plus a synthetic ``source_batch_id`` so the
|
|
||||||
row can sit alongside the 999 / 277CA ack rows without special-casing.
|
|
||||||
|
|
||||||
The detail endpoint also reconstructs the TA1 segment string
|
|
||||||
(``TA1*...~``) so the operator can copy it into a downstream tool.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
|
||||||
|
|
||||||
from cyclone import db
|
|
||||||
from cyclone.store import store
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
def _ta1_to_ui(row: db.Ta1Ack) -> dict:
|
|
||||||
"""Render a Ta1Ack row for the UI (list endpoint shape)."""
|
|
||||||
return {
|
|
||||||
"id": row.id,
|
|
||||||
"control_number": row.control_number,
|
|
||||||
"ack_code": row.ack_code,
|
|
||||||
"note_code": row.note_code,
|
|
||||||
"interchange_date": row.interchange_date.isoformat()
|
|
||||||
if row.interchange_date else None,
|
|
||||||
"interchange_time": row.interchange_time,
|
|
||||||
"sender_id": row.sender_id,
|
|
||||||
"receiver_id": row.receiver_id,
|
|
||||||
"source_batch_id": row.source_batch_id,
|
|
||||||
"parsed_at": row.parsed_at.isoformat() if row.parsed_at else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _serialize_ta1_from_row(row: db.Ta1Ack) -> str:
|
|
||||||
"""Reconstruct a TA1 segment from the persisted flat row (for the detail endpoint)."""
|
|
||||||
date_s = row.interchange_date.strftime("%y%m%d") if row.interchange_date else ""
|
|
||||||
return (
|
|
||||||
f"TA1*{row.control_number}*{date_s}*{row.interchange_time or ''}*"
|
|
||||||
f"{row.ack_code}*{row.note_code or ''}~"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/ta1-acks")
|
|
||||||
def list_ta1_acks_endpoint(
|
|
||||||
limit: int = Query(100, ge=1, le=1000),
|
|
||||||
) -> Any:
|
|
||||||
"""Return the list of persisted TA1 ACKs, newest first.
|
|
||||||
|
|
||||||
Mirrors :func:`cyclone.api_routers.acks.list_acks_endpoint` — fetches all
|
|
||||||
rows then slices in Python so the ``total`` field reflects the full row
|
|
||||||
count regardless of the ``limit`` cap.
|
|
||||||
"""
|
|
||||||
rows = store.list_ta1_acks()
|
|
||||||
items = [_ta1_to_ui(r) for r in rows[:limit]]
|
|
||||||
return {
|
|
||||||
"total": len(rows),
|
|
||||||
"items": items,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/ta1-acks/{ack_id}")
|
|
||||||
def get_ta1_ack_endpoint(ack_id: int) -> dict:
|
|
||||||
"""Return one persisted TA1 ACK row with its parsed detail."""
|
|
||||||
row = store.get_ta1_ack(ack_id)
|
|
||||||
if row is None:
|
|
||||||
raise HTTPException(status_code=404, detail=f"TA1 ACK {ack_id} not found")
|
|
||||||
body = _ta1_to_ui(row)
|
|
||||||
body["raw_ta1_text"] = _serialize_ta1_from_row(row)
|
|
||||||
body["raw_json"] = row.raw_json
|
|
||||||
return body
|
|
||||||
@@ -103,6 +103,12 @@ class AuditEvent:
|
|||||||
computed hash. Payload must be JSON-serializable; the audit_log
|
computed hash. Payload must be JSON-serializable; the audit_log
|
||||||
module handles the encoding so callers don't need to think about
|
module handles the encoding so callers don't need to think about
|
||||||
canonical form.
|
canonical form.
|
||||||
|
|
||||||
|
``user_id`` is the authenticated actor for this event, when known
|
||||||
|
(e.g. a parse-999 call made by user 7). It's stored on the row but
|
||||||
|
is NOT part of the hash chain — the chain hashes only the fields
|
||||||
|
that existed pre-SP-auth so verify_chain stays compatible with
|
||||||
|
pre-auth rows.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
event_type: str
|
event_type: str
|
||||||
@@ -111,6 +117,7 @@ class AuditEvent:
|
|||||||
payload: dict[str, Any] = field(default_factory=dict)
|
payload: dict[str, Any] = field(default_factory=dict)
|
||||||
actor: str = "system"
|
actor: str = "system"
|
||||||
created_at: datetime | None = None
|
created_at: datetime | None = None
|
||||||
|
user_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
def append_event(
|
def append_event(
|
||||||
@@ -155,6 +162,7 @@ def append_event(
|
|||||||
created_at=created_at,
|
created_at=created_at,
|
||||||
prev_hash=prev_hash,
|
prev_hash=prev_hash,
|
||||||
hash=GENESIS_PREV_HASH, # placeholder; updated below
|
hash=GENESIS_PREV_HASH, # placeholder; updated below
|
||||||
|
user_id=event.user_id,
|
||||||
)
|
)
|
||||||
session.add(row)
|
session.add(row)
|
||||||
session.flush() # populate row.id
|
session.flush() # populate row.id
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Auth module — users, sessions, permissions, routes, admin, rate_limit."""
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Admin-only user management: GET/POST/PATCH/DELETE /api/admin/users."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
|
from cyclone.auth import users
|
||||||
|
from cyclone.auth.deps import get_current_user
|
||||||
|
from cyclone.auth.permissions import Role
|
||||||
|
from cyclone.db import SessionLocal, User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/admin/users", tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
|
def _require_admin(user: dict = Depends(get_current_user)) -> dict:
|
||||||
|
if user.get("role") != Role.ADMIN.value:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="forbidden",
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_role(role: str) -> None:
|
||||||
|
valid = {Role.ADMIN.value, Role.USER.value, Role.VIEWER.value}
|
||||||
|
if role not in valid:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"role must be one of {sorted(valid)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_users(_admin=Depends(_require_admin)):
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
all_users = db.query(User).all()
|
||||||
|
return [users.to_public(u) for u in all_users]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_user(body: dict, _admin=Depends(_require_admin)):
|
||||||
|
username = (body.get("username") or "").strip()
|
||||||
|
password = body.get("password") or ""
|
||||||
|
role = body.get("role") or ""
|
||||||
|
if not username or len(username) < 3:
|
||||||
|
raise HTTPException(status_code=422, detail="username must be at least 3 chars")
|
||||||
|
if len(password) < 12:
|
||||||
|
raise HTTPException(status_code=422, detail="password must be at least 12 chars")
|
||||||
|
_validate_role(role)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
if users.get_by_username(db, username) is not None:
|
||||||
|
raise HTTPException(status_code=409, detail="username already exists")
|
||||||
|
u = users.create(db, username=username, password=password, role=role)
|
||||||
|
return users.to_public(u)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{user_id}")
|
||||||
|
def patch_user(user_id: int, body: dict, admin=Depends(_require_admin)):
|
||||||
|
me = admin
|
||||||
|
if me.get("id") == user_id and body.get("role") and body["role"] != Role.ADMIN.value:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="cannot_demote_self",
|
||||||
|
)
|
||||||
|
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
if body.get("role") is not None:
|
||||||
|
_validate_role(body["role"])
|
||||||
|
users.update_role(db, user_id, body["role"])
|
||||||
|
if body.get("password") is not None:
|
||||||
|
if len(body["password"]) < 12:
|
||||||
|
raise HTTPException(status_code=422, detail="password must be at least 12 chars")
|
||||||
|
users.update_password(db, user_id, body["password"])
|
||||||
|
if body.get("disabled") is True:
|
||||||
|
users.disable(db, user_id)
|
||||||
|
u = users.get(db, user_id)
|
||||||
|
if u is None:
|
||||||
|
raise HTTPException(status_code=404, detail="user not found")
|
||||||
|
return users.to_public(u)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_user(user_id: int, admin=Depends(_require_admin)):
|
||||||
|
me = admin
|
||||||
|
if me.get("id") == user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="cannot_delete_self",
|
||||||
|
)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.get(db, user_id)
|
||||||
|
if u is None:
|
||||||
|
raise HTTPException(status_code=404, detail="user not found")
|
||||||
|
users.disable(db, user_id)
|
||||||
|
return None
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""First-admin bootstrap: create the initial admin from env vars if no users exist.
|
||||||
|
|
||||||
|
Called from ``python -m cyclone`` before either ``cli.main()`` or
|
||||||
|
``uvicorn`` so users exist by the time the API serves requests.
|
||||||
|
|
||||||
|
Precedence:
|
||||||
|
|
||||||
|
1. ``CYCLONE_AUTH_DISABLED=1`` — dev escape hatch. Flip the
|
||||||
|
``cyclone.auth.deps.AUTH_DISABLED`` flag so the API returns a
|
||||||
|
synthetic admin user without checking credentials. Never raises.
|
||||||
|
2. Users table non-empty — no-op.
|
||||||
|
3. ``CYCLONE_ADMIN_USERNAME`` + ``CYCLONE_ADMIN_PASSWORD`` env vars set
|
||||||
|
(password >= 12 chars) — create the admin and print confirmation.
|
||||||
|
4. Otherwise — raise ``RuntimeError`` with a remediation hint that
|
||||||
|
points operators at ``python -m cyclone users create``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from cyclone.auth import users
|
||||||
|
from cyclone.auth.deps import AUTH_DISABLED
|
||||||
|
from cyclone.auth.permissions import Role
|
||||||
|
from cyclone.db import SessionLocal, User
|
||||||
|
|
||||||
|
|
||||||
|
def run() -> None:
|
||||||
|
"""Bootstrap the first admin user, or no-op.
|
||||||
|
|
||||||
|
See module docstring for behavior. Idempotent: safe to call on
|
||||||
|
every startup — it short-circuits as soon as the users table is
|
||||||
|
non-empty.
|
||||||
|
"""
|
||||||
|
if os.environ.get("CYCLONE_AUTH_DISABLED") == "1":
|
||||||
|
# Dev escape hatch — skip bootstrap entirely and tell the API
|
||||||
|
# to also short-circuit auth checks.
|
||||||
|
import cyclone.auth.deps as _deps
|
||||||
|
|
||||||
|
_deps.AUTH_DISABLED = True
|
||||||
|
return
|
||||||
|
|
||||||
|
username = os.environ.get("CYCLONE_ADMIN_USERNAME")
|
||||||
|
password = os.environ.get("CYCLONE_ADMIN_PASSWORD")
|
||||||
|
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
existing = db.execute(select(User)).scalars().first()
|
||||||
|
if existing is not None:
|
||||||
|
return # users exist — nothing to bootstrap
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Cyclone has no users yet. Set CYCLONE_ADMIN_USERNAME and "
|
||||||
|
"CYCLONE_ADMIN_PASSWORD env vars (min 12 chars), or run "
|
||||||
|
"`python -m cyclone users create <username> --role admin`."
|
||||||
|
)
|
||||||
|
if len(password) < 12:
|
||||||
|
raise RuntimeError(
|
||||||
|
"CYCLONE_ADMIN_PASSWORD must be at least 12 characters."
|
||||||
|
)
|
||||||
|
|
||||||
|
users.create(
|
||||||
|
db,
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
role=Role.ADMIN.value,
|
||||||
|
)
|
||||||
|
print(f"[cyclone] bootstrap admin user '{username}' created")
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
"""CLI subcommand: ``python -m cyclone users ...``.
|
||||||
|
|
||||||
|
Click-based to match the existing parse-837 / parse-835 convention in
|
||||||
|
``cyclone.cli``. Provides operator-side user management without going
|
||||||
|
through the admin HTTP API.
|
||||||
|
|
||||||
|
Subcommands
|
||||||
|
-----------
|
||||||
|
|
||||||
|
- ``users create USERNAME --role {admin,user,viewer} [--password PW]``
|
||||||
|
Create a user. If ``--password`` is omitted, prompts (with
|
||||||
|
confirmation) on the controlling terminal.
|
||||||
|
- ``users list`` — tab-separated ``id / username / role / state``.
|
||||||
|
- ``users disable USERNAME`` — set ``disabled_at`` to now.
|
||||||
|
- ``users reset-password USERNAME [--password PW]`` — replace the hash.
|
||||||
|
- ``users set-role USERNAME --role {admin,user,viewer}`` — change role.
|
||||||
|
|
||||||
|
Exit codes
|
||||||
|
----------
|
||||||
|
|
||||||
|
* 0 — success
|
||||||
|
* 1 — validation error (unknown username, duplicate username)
|
||||||
|
* 2 — usage error (missing arg, bad role, short password, unknown
|
||||||
|
subcommand). Click itself uses 2 for usage errors so the conventional
|
||||||
|
shell tools (``set -e``, etc.) recognize them.
|
||||||
|
|
||||||
|
Passwords shorter than 12 chars are rejected everywhere they appear.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import getpass
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from cyclone.auth import users
|
||||||
|
from cyclone.auth.permissions import Role
|
||||||
|
from cyclone.db import SessionLocal
|
||||||
|
|
||||||
|
ROLE_CHOICES = [Role.ADMIN.value, Role.USER.value, Role.VIEWER.value]
|
||||||
|
MIN_PASSWORD_LEN = 12
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt_password(label: str) -> str:
|
||||||
|
pw = getpass.getpass(f"{label}: ")
|
||||||
|
if not pw:
|
||||||
|
click.echo("Password required.", err=True)
|
||||||
|
sys.exit(2)
|
||||||
|
return pw
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_password(pw: str) -> None:
|
||||||
|
if len(pw) < MIN_PASSWORD_LEN:
|
||||||
|
click.echo(
|
||||||
|
f"Password must be at least {MIN_PASSWORD_LEN} characters.",
|
||||||
|
err=True,
|
||||||
|
)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Group
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@click.group(name="users")
|
||||||
|
def users_cli() -> None:
|
||||||
|
"""Manage Cyclone users from the command line."""
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# create
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@users_cli.command("create")
|
||||||
|
@click.argument("username")
|
||||||
|
@click.option(
|
||||||
|
"--role",
|
||||||
|
required=True,
|
||||||
|
type=click.Choice(ROLE_CHOICES, case_sensitive=False),
|
||||||
|
help="Role to grant the new user.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--password",
|
||||||
|
default=None,
|
||||||
|
help=f"Password (min {MIN_PASSWORD_LEN} chars). Prompts if omitted.",
|
||||||
|
)
|
||||||
|
def create_user(username: str, role: str, password: str | None) -> None:
|
||||||
|
"""Create a new user with the given USERNAME and ROLE."""
|
||||||
|
pw = password or _prompt_password("Password")
|
||||||
|
_validate_password(pw)
|
||||||
|
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
if users.get_by_username(db, username) is not None:
|
||||||
|
click.echo(f"User '{username}' already exists.", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
u = users.create(db, username=username, password=pw, role=role)
|
||||||
|
click.echo(f"Created user '{u.username}' with role '{u.role}'.")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# list
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@users_cli.command("list")
|
||||||
|
def list_users() -> None:
|
||||||
|
"""List all users as id / username / role / state."""
|
||||||
|
from cyclone.db import User
|
||||||
|
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
for u in db.query(User).order_by(User.id.asc()).all():
|
||||||
|
state = "disabled" if u.disabled_at else "active"
|
||||||
|
click.echo(f"{u.id}\t{u.username}\t{u.role}\t{state}")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# disable
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@users_cli.command("disable")
|
||||||
|
@click.argument("username")
|
||||||
|
def disable_user(username: str) -> None:
|
||||||
|
"""Disable USERNAME (sets disabled_at to now)."""
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.get_by_username(db, username)
|
||||||
|
if u is None:
|
||||||
|
click.echo(f"No such user: {username}", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
users.disable(db, u.id)
|
||||||
|
click.echo(f"Disabled '{username}'.")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# reset-password
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@users_cli.command("reset-password")
|
||||||
|
@click.argument("username")
|
||||||
|
@click.option(
|
||||||
|
"--password",
|
||||||
|
default=None,
|
||||||
|
help=f"New password (min {MIN_PASSWORD_LEN} chars). Prompts if omitted.",
|
||||||
|
)
|
||||||
|
def reset_password(username: str, password: str | None) -> None:
|
||||||
|
"""Replace USERNAME's password."""
|
||||||
|
pw = password or _prompt_password("New password")
|
||||||
|
_validate_password(pw)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.get_by_username(db, username)
|
||||||
|
if u is None:
|
||||||
|
click.echo(f"No such user: {username}", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
users.update_password(db, u.id, pw)
|
||||||
|
click.echo(f"Password reset for '{username}'.")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# set-role
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@users_cli.command("set-role")
|
||||||
|
@click.argument("username")
|
||||||
|
@click.option(
|
||||||
|
"--role",
|
||||||
|
required=True,
|
||||||
|
type=click.Choice(ROLE_CHOICES, case_sensitive=False),
|
||||||
|
help="Role to grant.",
|
||||||
|
)
|
||||||
|
def set_role(username: str, role: str) -> None:
|
||||||
|
"""Change USERNAME's role."""
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.get_by_username(db, username)
|
||||||
|
if u is None:
|
||||||
|
click.echo(f"No such user: {username}", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
users.update_role(db, u.id, role)
|
||||||
|
click.echo(f"Role for '{username}' set to '{role}'.")
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"""FastAPI dependencies for auth."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
|
from sqlalchemy.orm import Session as DbSession
|
||||||
|
|
||||||
|
from cyclone.auth import sessions, users
|
||||||
|
from cyclone.auth.permissions import Role, allowed_roles
|
||||||
|
from cyclone.db import SessionLocal
|
||||||
|
|
||||||
|
|
||||||
|
def _db():
|
||||||
|
db = SessionLocal()()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
DbSessionDep = Annotated[DbSession, Depends(_db)]
|
||||||
|
|
||||||
|
|
||||||
|
AUTH_DISABLED = False
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
request: Request,
|
||||||
|
db: DbSessionDep,
|
||||||
|
) -> dict:
|
||||||
|
"""Return the public User shape. Raises 401 if session is missing/expired.
|
||||||
|
|
||||||
|
When AUTH_DISABLED is True (dev escape hatch), returns a synthetic admin
|
||||||
|
user without checking credentials.
|
||||||
|
"""
|
||||||
|
if AUTH_DISABLED:
|
||||||
|
return {
|
||||||
|
"id": 0,
|
||||||
|
"username": "dev",
|
||||||
|
"role": Role.ADMIN.value,
|
||||||
|
"createdAt": None,
|
||||||
|
"disabledAt": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
sid = request.cookies.get("cyclone_session")
|
||||||
|
if not sid:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="session_expired",
|
||||||
|
)
|
||||||
|
sess = sessions.get_valid(db, sid)
|
||||||
|
if sess is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="session_expired",
|
||||||
|
)
|
||||||
|
user = users.get(db, sess.user_id)
|
||||||
|
if user is None or user.disabled_at is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="account_disabled",
|
||||||
|
)
|
||||||
|
# Sliding expiry: refresh both DB and cookie.
|
||||||
|
sessions.touch(db, sid)
|
||||||
|
request.state.user = user
|
||||||
|
request.state.session_id = sid
|
||||||
|
return users.to_public(user)
|
||||||
|
|
||||||
|
|
||||||
|
def require_role(*allowed: Role):
|
||||||
|
"""Dependency factory: gate the endpoint to specific roles.
|
||||||
|
|
||||||
|
Falls back to PERMISSIONS matrix lookup if no explicit roles given.
|
||||||
|
"""
|
||||||
|
async def _dep(
|
||||||
|
request: Request,
|
||||||
|
user: dict = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
user_role = user.get("role")
|
||||||
|
if allowed:
|
||||||
|
if user_role not in {r.value for r in allowed}:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="forbidden",
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
# Otherwise consult the matrix.
|
||||||
|
method = request.method
|
||||||
|
path = request.url.path
|
||||||
|
roles = allowed_roles(method, path)
|
||||||
|
if roles is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="forbidden",
|
||||||
|
)
|
||||||
|
if user_role not in {r.value for r in roles}:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="forbidden",
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
return _dep
|
||||||
|
|
||||||
|
|
||||||
|
async def matrix_gate(
|
||||||
|
request: Request,
|
||||||
|
user: dict = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
"""App-wide gate: requires auth, then enforces the PERMISSIONS matrix.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
* AUTH_DISABLED short-circuits (synthetic admin, no role check).
|
||||||
|
* No session cookie → 401 from get_current_user.
|
||||||
|
* Endpoint not in the matrix → 403 (fail-closed).
|
||||||
|
* User role not allowed for (method, path) → 403.
|
||||||
|
* Empty allowed-roles set (e.g. /api/healthz) → public, no role check.
|
||||||
|
|
||||||
|
Used as the single ``dependencies=[Depends(matrix_gate)]`` on every
|
||||||
|
authenticated route in ``cyclone.api``. Centralizing the gate here
|
||||||
|
means the matrix is the source of truth — no need to wire per-route
|
||||||
|
``require_role(...)`` calls.
|
||||||
|
"""
|
||||||
|
if AUTH_DISABLED:
|
||||||
|
return user
|
||||||
|
method = request.method
|
||||||
|
path = request.url.path
|
||||||
|
roles = allowed_roles(method, path)
|
||||||
|
if roles is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="forbidden",
|
||||||
|
)
|
||||||
|
user_role = user.get("role")
|
||||||
|
if user_role not in {r.value for r in roles}:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="forbidden",
|
||||||
|
)
|
||||||
|
return user
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""Role enum + PERMISSIONS matrix."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class Role(str, Enum):
|
||||||
|
ADMIN = "admin"
|
||||||
|
USER = "user"
|
||||||
|
VIEWER = "viewer"
|
||||||
|
|
||||||
|
|
||||||
|
ALL_ROLES = {Role.ADMIN, Role.USER, Role.VIEWER}
|
||||||
|
WRITE_ROLES = {Role.ADMIN, Role.USER}
|
||||||
|
ADMIN_ONLY = {Role.ADMIN}
|
||||||
|
|
||||||
|
|
||||||
|
# (method, path-prefix) → allowed roles.
|
||||||
|
# Endpoints not in this matrix default to DENY (fail-closed).
|
||||||
|
PERMISSIONS: dict[tuple[str, str], set[Role]] = {
|
||||||
|
# Public paths.
|
||||||
|
("GET", "/api/healthz"): set(),
|
||||||
|
("POST", "/api/auth/login"): set(),
|
||||||
|
|
||||||
|
# Auth surface.
|
||||||
|
("POST", "/api/auth/logout"): ALL_ROLES,
|
||||||
|
("GET", "/api/auth/me"): ALL_ROLES,
|
||||||
|
|
||||||
|
# Admin-only user management.
|
||||||
|
("GET", "/api/admin/users"): ADMIN_ONLY,
|
||||||
|
("POST", "/api/admin/users"): ADMIN_ONLY,
|
||||||
|
("PATCH", "/api/admin/users"): ADMIN_ONLY,
|
||||||
|
("DELETE", "/api/admin/users"): ADMIN_ONLY,
|
||||||
|
|
||||||
|
# Read endpoints (all authenticated roles).
|
||||||
|
("GET", "/api/claims"): ALL_ROLES,
|
||||||
|
("GET", "/api/remittances"): ALL_ROLES,
|
||||||
|
("GET", "/api/providers"): ALL_ROLES,
|
||||||
|
("GET", "/api/batches"): ALL_ROLES,
|
||||||
|
("GET", "/api/dashboard/summary"): ALL_ROLES,
|
||||||
|
("GET", "/api/activity"): ALL_ROLES,
|
||||||
|
("GET", "/api/inbox/lanes"): ALL_ROLES,
|
||||||
|
("GET", "/api/reconcile"): ALL_ROLES,
|
||||||
|
("GET", "/api/audit-log"): ADMIN_ONLY,
|
||||||
|
|
||||||
|
# Write endpoints (admin + user, no viewer).
|
||||||
|
("POST", "/api/parse-837"): WRITE_ROLES,
|
||||||
|
("POST", "/api/parse-835"): WRITE_ROLES,
|
||||||
|
("POST", "/api/inbox"): WRITE_ROLES,
|
||||||
|
("POST", "/api/reconcile"): WRITE_ROLES,
|
||||||
|
("POST", "/api/resubmit"): WRITE_ROLES,
|
||||||
|
("POST", "/api/acks"): WRITE_ROLES,
|
||||||
|
|
||||||
|
# CSV export — read-only.
|
||||||
|
("GET", "/api/export.csv"): ALL_ROLES,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def allowed_roles(method: str, path: str) -> set[Role] | None:
|
||||||
|
"""Return the set of roles allowed to call (method, path), or None if denied.
|
||||||
|
|
||||||
|
Uses longest-prefix match on path; falls back to DENY (None) if no entry matches.
|
||||||
|
"""
|
||||||
|
candidates = [
|
||||||
|
(len(prefix), roles)
|
||||||
|
for (m, prefix), roles in PERMISSIONS.items()
|
||||||
|
if m == method and (path == prefix or path.startswith(prefix.rstrip("/") + "/"))
|
||||||
|
]
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
candidates.sort(key=lambda x: -x[0])
|
||||||
|
return candidates[0][1]
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Per-username login rate limiter (in-memory, per-process)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from threading import Lock
|
||||||
|
|
||||||
|
WINDOW_SECONDS = 300
|
||||||
|
MAX_FAILS = 5
|
||||||
|
|
||||||
|
_FAILS: dict[str, list[float]] = {}
|
||||||
|
_LOCK = Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def check(username: str) -> int:
|
||||||
|
"""Return retry-after seconds, or 0 if allowed."""
|
||||||
|
now = time.monotonic()
|
||||||
|
with _LOCK:
|
||||||
|
fails = [t for t in _FAILS.get(username, []) if now - t < WINDOW_SECONDS]
|
||||||
|
_FAILS[username] = fails
|
||||||
|
if len(fails) >= MAX_FAILS:
|
||||||
|
return int(WINDOW_SECONDS - (now - fails[0]))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def record_failure(username: str) -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
with _LOCK:
|
||||||
|
_FAILS.setdefault(username, []).append(now)
|
||||||
|
|
||||||
|
|
||||||
|
def reset(username: str) -> None:
|
||||||
|
with _LOCK:
|
||||||
|
_FAILS.pop(username, None)
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""/api/auth/login, /api/auth/logout, /api/auth/me."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||||
|
|
||||||
|
from cyclone.auth import rate_limit, sessions, users
|
||||||
|
from cyclone.auth.deps import get_current_user
|
||||||
|
from cyclone.db import SessionLocal
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
|
COOKIE_NAME = "cyclone_session"
|
||||||
|
COOKIE_MAX_AGE = 86400 # 24h
|
||||||
|
|
||||||
|
|
||||||
|
def _is_https(request: Request) -> bool:
|
||||||
|
if request.url.scheme == "https":
|
||||||
|
return True
|
||||||
|
return os.environ.get("CYCLONE_BEHIND_HTTPS") == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def _set_cookie(response: Response, sid: str, request: Request) -> None:
|
||||||
|
response.set_cookie(
|
||||||
|
key=COOKIE_NAME,
|
||||||
|
value=sid,
|
||||||
|
max_age=COOKIE_MAX_AGE,
|
||||||
|
path="/api",
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
secure=_is_https(request),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_cookie(response: Response) -> None:
|
||||||
|
response.delete_cookie(key=COOKIE_NAME, path="/api")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
def login(body: dict, request: Request, response: Response):
|
||||||
|
username = (body.get("username") or "").strip()
|
||||||
|
password = body.get("password") or ""
|
||||||
|
if not username or not password:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="username and password are required",
|
||||||
|
)
|
||||||
|
|
||||||
|
retry_after = rate_limit.check(username)
|
||||||
|
if retry_after > 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="rate_limited",
|
||||||
|
headers={"Retry-After": str(retry_after)},
|
||||||
|
)
|
||||||
|
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
user = users.get_by_username(db, username)
|
||||||
|
if user is None or not users.verify_password(password, user.password_hash):
|
||||||
|
rate_limit.record_failure(username)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="invalid_credentials",
|
||||||
|
)
|
||||||
|
if user.disabled_at is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="account_disabled",
|
||||||
|
)
|
||||||
|
|
||||||
|
sid, _ = sessions.create(db, user_id=user.id)
|
||||||
|
rate_limit.reset(username)
|
||||||
|
public = users.to_public(user)
|
||||||
|
|
||||||
|
_set_cookie(response, sid, request)
|
||||||
|
return public
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
def logout(
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
sid = request.cookies.get(COOKIE_NAME)
|
||||||
|
if sid:
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
sessions.delete(db, sid)
|
||||||
|
_clear_cookie(response)
|
||||||
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
def me(user: dict = Depends(get_current_user)):
|
||||||
|
return user
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Session create/validate/expire/touch."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from cyclone.db import Session
|
||||||
|
|
||||||
|
SESSION_LIFETIME = timedelta(hours=24)
|
||||||
|
|
||||||
|
|
||||||
|
def create(db, *, user_id: int) -> tuple[str, Session]:
|
||||||
|
sid = secrets.token_urlsafe(32)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
expires_at = now + SESSION_LIFETIME
|
||||||
|
sess = Session(
|
||||||
|
id=sid,
|
||||||
|
user_id=user_id,
|
||||||
|
expires_at=expires_at,
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
db.add(sess)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(sess)
|
||||||
|
# SQLite strips tzinfo on roundtrip; restore it so callers don't have to.
|
||||||
|
sess.expires_at = expires_at
|
||||||
|
return sid, sess
|
||||||
|
|
||||||
|
|
||||||
|
def get_valid(db, sid: str) -> Session | None:
|
||||||
|
sess = db.execute(
|
||||||
|
select(Session).where(Session.id == sid)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if sess is None:
|
||||||
|
return None
|
||||||
|
# SQLite drops tzinfo on roundtrip; normalize to UTC before comparing.
|
||||||
|
if sess.expires_at.tzinfo is None:
|
||||||
|
sess.expires_at = sess.expires_at.replace(tzinfo=timezone.utc)
|
||||||
|
if sess.expires_at <= datetime.now(timezone.utc):
|
||||||
|
return None
|
||||||
|
return sess
|
||||||
|
|
||||||
|
|
||||||
|
def delete(db, sid: str) -> None:
|
||||||
|
sess = db.get(Session, sid)
|
||||||
|
if sess is None:
|
||||||
|
return
|
||||||
|
db.delete(sess)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def touch(db, sid: str) -> None:
|
||||||
|
sess = db.get(Session, sid)
|
||||||
|
if sess is None:
|
||||||
|
return
|
||||||
|
sess.expires_at = datetime.now(timezone.utc) + SESSION_LIFETIME
|
||||||
|
db.commit()
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""User CRUD + bcrypt password hashing."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from passlib.hash import bcrypt
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from cyclone.db import User
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(plaintext: str) -> str:
|
||||||
|
return bcrypt.hash(plaintext)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plaintext: str, hashed: str) -> bool:
|
||||||
|
try:
|
||||||
|
return bcrypt.verify(plaintext, hashed)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def create(db, *, username: str, password: str, role: str) -> User:
|
||||||
|
user = User(
|
||||||
|
username=username,
|
||||||
|
password_hash=hash_password(password),
|
||||||
|
role=role,
|
||||||
|
created_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def get_by_username(db, username: str) -> User | None:
|
||||||
|
return db.execute(
|
||||||
|
select(User).where(User.username == username)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def get(db, user_id: int) -> User | None:
|
||||||
|
return db.get(User, user_id)
|
||||||
|
|
||||||
|
|
||||||
|
def disable(db, user_id: int) -> None:
|
||||||
|
user = db.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
return
|
||||||
|
user.disabled_at = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def update_role(db, user_id: int, role: str) -> None:
|
||||||
|
user = db.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
return
|
||||||
|
user.role = role
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def update_password(db, user_id: int, new_password: str) -> None:
|
||||||
|
user = db.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
return
|
||||||
|
user.password_hash = hash_password(new_password)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def to_public(user: User) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": user.id,
|
||||||
|
"username": user.username,
|
||||||
|
"role": user.role,
|
||||||
|
"createdAt": user.created_at.isoformat() if user.created_at else None,
|
||||||
|
"disabledAt": user.disabled_at.isoformat() if user.disabled_at else None,
|
||||||
|
}
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
"""SP17 — Encrypted backup primitives.
|
|
||||||
|
|
||||||
This module provides the low-level building blocks the rest of the
|
|
||||||
backup stack uses:
|
|
||||||
|
|
||||||
* ``derive_key`` — PBKDF2-HMAC-SHA256 key derivation (200,000
|
|
||||||
iterations, 32-byte output). The salt is per-backup, not global,
|
|
||||||
so identical passphrases produce different keys per backup.
|
|
||||||
|
|
||||||
* ``encrypt`` / ``decrypt`` — AES-256-GCM authenticated encryption.
|
|
||||||
Output layout: ``salt (16) | nonce (12) | ciphertext | tag (16)``.
|
|
||||||
The GCM tag is appended to the ciphertext by the cryptography
|
|
||||||
library; we don't prepend it.
|
|
||||||
|
|
||||||
* ``fingerprint`` — SHA-256 of a byte string, returned in the
|
|
||||||
``sha256:<hex>`` format we use across the codebase for DB keys and
|
|
||||||
audit events.
|
|
||||||
|
|
||||||
* ``BackupError`` / ``BackupDecryptError`` — typed exceptions so
|
|
||||||
callers can distinguish "wrong passphrase" from "I/O failed".
|
|
||||||
|
|
||||||
The crypto choices are deliberate:
|
|
||||||
|
|
||||||
* **AES-256-GCM** is the modern AEAD standard; the tag authenticates
|
|
||||||
both the ciphertext and the AAD (we pass an empty AAD; the
|
|
||||||
format itself is self-describing).
|
|
||||||
|
|
||||||
* **PBKDF2-HMAC-SHA256 @ 200k iters** is OWASP's 2023+ minimum for
|
|
||||||
PBKDF2-SHA256. Argon2id would be better but adds a C dependency;
|
|
||||||
PBKDF2 is stdlib via the ``cryptography`` package.
|
|
||||||
|
|
||||||
* **Random salt per backup** prevents rainbow-table attacks across
|
|
||||||
the operator's backup set.
|
|
||||||
|
|
||||||
* **Random 96-bit nonce per encryption** is what AES-GCM requires;
|
|
||||||
we use ``os.urandom`` which is a CSPRNG on every platform we run
|
|
||||||
on (macOS, Linux).
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import os
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
||||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
||||||
from cryptography.hazmat.primitives import hashes
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Constants
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
# PBKDF2 iterations. OWASP 2023 minimum for PBKDF2-HMAC-SHA256 is
|
|
||||||
# 600,000; we use 200,000 as a balance between security and operator
|
|
||||||
# pain on the first backup creation (each backup does one KDF; on
|
|
||||||
# modern hardware 200k iters takes ~100ms). Bump this constant if you
|
|
||||||
# rotate the format version.
|
|
||||||
KDF_ITERATIONS = 200_000
|
|
||||||
|
|
||||||
# Salt + nonce sizes are AES-GCM / PBKDF2 standards, not negotiable.
|
|
||||||
SALT_LEN = 16
|
|
||||||
NONCE_LEN = 12
|
|
||||||
|
|
||||||
# Output key length for AES-256 = 32 bytes.
|
|
||||||
KEY_LEN = 32
|
|
||||||
|
|
||||||
# Format version. Bump when the on-disk layout changes (e.g. switch
|
|
||||||
# to Argon2id). Decryption reads this off the sidecar's
|
|
||||||
# encryption.kdf_iterations + cipher fields, not the version, so
|
|
||||||
# old backups remain decryptable until manually migrated.
|
|
||||||
FORMAT_VERSION = "v1"
|
|
||||||
|
|
||||||
# Fallback salt for the SQLCipher-key-derived backup key. Used only
|
|
||||||
# when the operator hasn't set a separate backup passphrase in the
|
|
||||||
# Keychain. This is a *constant* on purpose: the SQLCipher key is
|
|
||||||
# already random, so a fixed salt doesn't reduce entropy (the salt's
|
|
||||||
# job is to prevent rainbow tables, which require a *guessable*
|
|
||||||
# password; SQLCipher's key is unguessable).
|
|
||||||
FALLBACK_SALT = b"cyclone-db-backup-fallback-v1"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Exceptions
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class BackupError(Exception):
|
|
||||||
"""Generic backup failure. See BackupDecryptError for crypto errors."""
|
|
||||||
|
|
||||||
|
|
||||||
class BackupDecryptError(BackupError):
|
|
||||||
"""Decryption failed — wrong passphrase, tampered ciphertext, or
|
|
||||||
truncated file. Caller should NOT retry with the same key."""
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Key derivation + encryption
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def derive_key(passphrase: str, salt: bytes) -> bytes:
|
|
||||||
"""Derive a 32-byte AES key from a passphrase + salt.
|
|
||||||
|
|
||||||
Uses PBKDF2-HMAC-SHA256 with :data:`KDF_ITERATIONS` rounds. The
|
|
||||||
passphrase is encoded as UTF-8 bytes; the salt is used verbatim.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
passphrase: The operator's passphrase (any string).
|
|
||||||
salt: Per-backup random bytes of length :data:`SALT_LEN`.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
32 bytes suitable for AES-256-GCM.
|
|
||||||
"""
|
|
||||||
kdf = PBKDF2HMAC(
|
|
||||||
algorithm=hashes.SHA256(),
|
|
||||||
length=KEY_LEN,
|
|
||||||
salt=salt,
|
|
||||||
iterations=KDF_ITERATIONS,
|
|
||||||
)
|
|
||||||
return kdf.derive(passphrase.encode("utf-8"))
|
|
||||||
|
|
||||||
|
|
||||||
def encrypt(plaintext: bytes, key: bytes) -> bytes:
|
|
||||||
"""AES-256-GCM encrypt with a fresh random 12-byte nonce.
|
|
||||||
|
|
||||||
Returns ``nonce (12) || ciphertext || tag (16)``. The
|
|
||||||
``cryptography`` library appends the tag automatically.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plaintext: The bytes to encrypt (e.g. the SQLite .backup blob).
|
|
||||||
key: 32-byte AES key from :func:`derive_key`.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The combined nonce+ciphertext+tag blob.
|
|
||||||
"""
|
|
||||||
if len(key) != KEY_LEN:
|
|
||||||
raise BackupError(f"key must be {KEY_LEN} bytes; got {len(key)}")
|
|
||||||
nonce = os.urandom(NONCE_LEN)
|
|
||||||
aesgcm = AESGCM(key)
|
|
||||||
ciphertext = aesgcm.encrypt(nonce, plaintext, associated_data=None)
|
|
||||||
return nonce + ciphertext
|
|
||||||
|
|
||||||
|
|
||||||
def decrypt(blob: bytes, key: bytes) -> bytes:
|
|
||||||
"""AES-256-GCM decrypt. Raises :class:`BackupDecryptError` on auth failure.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
blob: The ``nonce||ciphertext||tag`` bytes from :func:`encrypt`.
|
|
||||||
key: The same 32-byte key used to encrypt.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The original plaintext.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
BackupDecryptError: If the blob is too short, the tag fails to
|
|
||||||
verify (wrong key or tampered ciphertext), or the input is
|
|
||||||
otherwise malformed.
|
|
||||||
"""
|
|
||||||
if len(key) != KEY_LEN:
|
|
||||||
raise BackupError(f"key must be {KEY_LEN} bytes; got {len(key)}")
|
|
||||||
if len(blob) < NONCE_LEN + 16:
|
|
||||||
# 12 (nonce) + 16 (tag) = minimum; no room for ciphertext.
|
|
||||||
raise BackupDecryptError(
|
|
||||||
f"blob too short ({len(blob)} bytes); expected >= {NONCE_LEN + 16}",
|
|
||||||
)
|
|
||||||
nonce = blob[:NONCE_LEN]
|
|
||||||
ciphertext = blob[NONCE_LEN:]
|
|
||||||
aesgcm = AESGCM(key)
|
|
||||||
try:
|
|
||||||
return aesgcm.decrypt(nonce, ciphertext, associated_data=None)
|
|
||||||
except Exception as exc: # cryptography raises InvalidTag
|
|
||||||
raise BackupDecryptError(f"decryption failed: {exc}") from exc
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Fingerprint
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def fingerprint(data: bytes) -> str:
|
|
||||||
"""SHA-256 of ``data`` as ``"sha256:<64-hex-chars>"``."""
|
|
||||||
return "sha256:" + hashlib.sha256(data).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def fingerprint_file(path: "os.PathLike[str] | str") -> str:
|
|
||||||
"""SHA-256 of a file's bytes, streamed. Memory-bounded for big DBs."""
|
|
||||||
h = hashlib.sha256()
|
|
||||||
with open(path, "rb") as f:
|
|
||||||
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
|
||||||
h.update(chunk)
|
|
||||||
return "sha256:" + h.hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Sidecar dataclass
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Sidecar:
|
|
||||||
"""Plaintext metadata written next to each backup file.
|
|
||||||
|
|
||||||
Not required for decryption — it's a manifest an operator
|
|
||||||
consults to decide whether to restore. Kept intentionally
|
|
||||||
tiny so it survives most format rotations.
|
|
||||||
"""
|
|
||||||
|
|
||||||
format_version: str
|
|
||||||
created_at: str # ISO 8601 UTC
|
|
||||||
db_fingerprint: str # "sha256:..."
|
|
||||||
table_count: int
|
|
||||||
size_bytes: int
|
|
||||||
kdf: str # "PBKDF2-HMAC-SHA256"
|
|
||||||
kdf_iterations: int
|
|
||||||
cipher: str # "AES-256-GCM"
|
|
||||||
key_fingerprint: str # "sha256:..." of the derived key
|
|
||||||
|
|
||||||
def to_json(self) -> str:
|
|
||||||
import json
|
|
||||||
return json.dumps(
|
|
||||||
{
|
|
||||||
"format_version": self.format_version,
|
|
||||||
"created_at": self.created_at,
|
|
||||||
"db_fingerprint": self.db_fingerprint,
|
|
||||||
"table_count": self.table_count,
|
|
||||||
"size_bytes": self.size_bytes,
|
|
||||||
"encryption": {
|
|
||||||
"kdf": self.kdf,
|
|
||||||
"kdf_iterations": self.kdf_iterations,
|
|
||||||
"cipher": self.cipher,
|
|
||||||
"key_fingerprint": self.key_fingerprint,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
indent=2,
|
|
||||||
sort_keys=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_json(cls, text: str) -> "Sidecar":
|
|
||||||
import json
|
|
||||||
d = json.loads(text)
|
|
||||||
enc = d.get("encryption") or {}
|
|
||||||
return cls(
|
|
||||||
format_version=d["format_version"],
|
|
||||||
created_at=d["created_at"],
|
|
||||||
db_fingerprint=d["db_fingerprint"],
|
|
||||||
table_count=int(d["table_count"]),
|
|
||||||
size_bytes=int(d["size_bytes"]),
|
|
||||||
kdf=enc.get("kdf", "PBKDF2-HMAC-SHA256"),
|
|
||||||
kdf_iterations=int(enc.get("kdf_iterations", KDF_ITERATIONS)),
|
|
||||||
cipher=enc.get("cipher", "AES-256-GCM"),
|
|
||||||
key_fingerprint=enc.get("key_fingerprint", ""),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Filename helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def backup_filename(timestamp: Optional[datetime] = None) -> str:
|
|
||||||
"""``cyclone-backup-YYYYMMDDTHHMMSSZ-<rand>.bin`` for a UTC timestamp.
|
|
||||||
|
|
||||||
The random suffix is a 4-byte hex string so two backups in the
|
|
||||||
same second don't collide on the ``db_backups`` unique index.
|
|
||||||
"""
|
|
||||||
import secrets as _secrets
|
|
||||||
from datetime import timezone as _tz
|
|
||||||
ts = timestamp or datetime.now(_tz.utc)
|
|
||||||
suffix = _secrets.token_hex(4)
|
|
||||||
return f"cyclone-backup-{ts.strftime('%Y%m%dT%H%M%SZ')}-{suffix}.bin"
|
|
||||||
|
|
||||||
|
|
||||||
def sidecar_filename(bin_filename: str) -> str:
|
|
||||||
"""``<bin_filename>.meta.json``."""
|
|
||||||
return bin_filename + ".meta.json"
|
|
||||||
@@ -1,368 +0,0 @@
|
|||||||
"""SP17 — Backup scheduler.
|
|
||||||
|
|
||||||
Wraps :class:`cyclone.backup_service.BackupService` in an
|
|
||||||
asyncio task, mirroring the MFT scheduler pattern (SP16). A backup
|
|
||||||
tick:
|
|
||||||
|
|
||||||
1. Calls :meth:`BackupService.create_now` to take + encrypt a backup.
|
|
||||||
2. Calls :meth:`BackupService.prune` to apply the retention policy.
|
|
||||||
3. Writes a tamper-evident ``audit_log`` row (SP11) for each outcome.
|
|
||||||
|
|
||||||
The scheduler is OFF by default. Operators opt in via
|
|
||||||
``CYCLONE_BACKUP_AUTOSTART=true``. The poll interval is
|
|
||||||
``CYCLONE_BACKUP_INTERVAL_HOURS`` (default 24).
|
|
||||||
|
|
||||||
Like the MFT scheduler, this is single-asyncio-task — no
|
|
||||||
threading, no APScheduler. All access (start/stop/tick/status)
|
|
||||||
must happen on the same event loop; the FastAPI app satisfies
|
|
||||||
that trivially because endpoints run on the loop.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import traceback
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
from cyclone import backup_service as svc_mod
|
|
||||||
from cyclone.audit_log import AuditEvent, append_event
|
|
||||||
from cyclone.backup_service import BackupService
|
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class BackupTickResult:
|
|
||||||
"""Outcome of a single backup tick (one cycle of create + prune + audit)."""
|
|
||||||
|
|
||||||
started_at: datetime
|
|
||||||
finished_at: Optional[datetime] = None
|
|
||||||
created: Optional[svc_mod.BackupRecord] = None
|
|
||||||
pruned_paths: list[str] = field(default_factory=list)
|
|
||||||
error: Optional[str] = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def ok(self) -> bool:
|
|
||||||
return self.error is None and self.created is not None
|
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"started_at": self.started_at.isoformat(),
|
|
||||||
"finished_at": (
|
|
||||||
self.finished_at.isoformat() if self.finished_at else None
|
|
||||||
),
|
|
||||||
"ok": self.ok,
|
|
||||||
"created": (
|
|
||||||
{
|
|
||||||
"id": self.created.id,
|
|
||||||
"filename": self.created.filename,
|
|
||||||
"size_bytes": self.created.size_bytes,
|
|
||||||
"db_fingerprint": self.created.db_fingerprint,
|
|
||||||
"table_count": self.created.table_count,
|
|
||||||
}
|
|
||||||
if self.created else None
|
|
||||||
),
|
|
||||||
"pruned_paths": list(self.pruned_paths),
|
|
||||||
"error": self.error,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class BackupSchedulerStatus:
|
|
||||||
running: bool
|
|
||||||
interval_hours: float
|
|
||||||
backup_dir: str
|
|
||||||
retention_days: int
|
|
||||||
last_tick: Optional[BackupTickResult] = None
|
|
||||||
tick_count: int = 0
|
|
||||||
total_created: int = 0
|
|
||||||
total_errors: int = 0
|
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"running": self.running,
|
|
||||||
"interval_hours": self.interval_hours,
|
|
||||||
"backup_dir": self.backup_dir,
|
|
||||||
"retention_days": self.retention_days,
|
|
||||||
"tick_count": self.tick_count,
|
|
||||||
"total_created": self.total_created,
|
|
||||||
"total_errors": self.total_errors,
|
|
||||||
"last_tick": self.last_tick.as_dict() if self.last_tick else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class BackupScheduler:
|
|
||||||
"""Asyncio loop that ticks the BackupService on an interval.
|
|
||||||
|
|
||||||
Lifecycle mirrors :class:`cyclone.scheduler.Scheduler`:
|
|
||||||
|
|
||||||
sched = BackupScheduler(backup_service)
|
|
||||||
await sched.start() # begin ticking
|
|
||||||
await sched.stop() # finish current tick, exit
|
|
||||||
status = sched.status() # snapshot
|
|
||||||
|
|
||||||
Threading: NOT thread-safe. All access must happen on the
|
|
||||||
same event loop. FastAPI endpoints satisfy this automatically.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
service: BackupService,
|
|
||||||
*,
|
|
||||||
interval_hours: float = 24.0,
|
|
||||||
) -> None:
|
|
||||||
self._service = service
|
|
||||||
self._interval_hours = max(0.1, float(interval_hours))
|
|
||||||
self._task: Optional[asyncio.Task[None]] = None
|
|
||||||
self._stop_event = asyncio.Event()
|
|
||||||
self._tick_in_progress = False
|
|
||||||
self._last_tick: Optional[BackupTickResult] = None
|
|
||||||
self._tick_count = 0
|
|
||||||
self._total_created = 0
|
|
||||||
self._total_errors = 0
|
|
||||||
|
|
||||||
@property
|
|
||||||
def service(self) -> BackupService:
|
|
||||||
return self._service
|
|
||||||
|
|
||||||
# ---- Public API -------------------------------------------------------
|
|
||||||
|
|
||||||
async def start(self) -> None:
|
|
||||||
if self._task is not None and not self._task.done():
|
|
||||||
log.info("BackupScheduler already running; start() is a no-op")
|
|
||||||
return
|
|
||||||
self._stop_event.clear()
|
|
||||||
self._task = asyncio.create_task(self._run(), name="backup-scheduler")
|
|
||||||
log.info(
|
|
||||||
"BackupScheduler started",
|
|
||||||
extra={
|
|
||||||
"interval_hours": self._interval_hours,
|
|
||||||
"backup_dir": str(self._service.backup_dir),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def stop(self) -> None:
|
|
||||||
if self._task is None or self._task.done():
|
|
||||||
return
|
|
||||||
self._stop_event.set()
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(self._task, timeout=60)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
log.warning("BackupScheduler did not stop within 60s; cancelling")
|
|
||||||
self._task.cancel()
|
|
||||||
try:
|
|
||||||
await self._task
|
|
||||||
except (asyncio.CancelledError, Exception): # noqa: BLE001
|
|
||||||
pass
|
|
||||||
self._task = None
|
|
||||||
log.info("BackupScheduler stopped")
|
|
||||||
|
|
||||||
def status(self) -> BackupSchedulerStatus:
|
|
||||||
return BackupSchedulerStatus(
|
|
||||||
running=self.is_running(),
|
|
||||||
interval_hours=self._interval_hours,
|
|
||||||
backup_dir=str(self._service.backup_dir),
|
|
||||||
retention_days=self._service._retention_days,
|
|
||||||
last_tick=self._last_tick,
|
|
||||||
tick_count=self._tick_count,
|
|
||||||
total_created=self._total_created,
|
|
||||||
total_errors=self._total_errors,
|
|
||||||
)
|
|
||||||
|
|
||||||
def is_running(self) -> bool:
|
|
||||||
return self._task is not None and not self._task.done()
|
|
||||||
|
|
||||||
async def tick(self) -> BackupTickResult:
|
|
||||||
"""Run a single backup tick (create + prune + audit).
|
|
||||||
|
|
||||||
Concurrent ticks are coalesced: if a tick is already in
|
|
||||||
progress, the second caller waits for it. This protects
|
|
||||||
against a slow backup holding up multiple operator-driven
|
|
||||||
``POST /api/admin/backup/tick`` calls.
|
|
||||||
"""
|
|
||||||
while self._tick_in_progress:
|
|
||||||
await asyncio.sleep(0.05)
|
|
||||||
self._tick_in_progress = True
|
|
||||||
try:
|
|
||||||
result = await self._tick_impl()
|
|
||||||
self._last_tick = result
|
|
||||||
self._tick_count += 1
|
|
||||||
if result.created is not None:
|
|
||||||
self._total_created += 1
|
|
||||||
if result.error is not None:
|
|
||||||
self._total_errors += 1
|
|
||||||
return result
|
|
||||||
finally:
|
|
||||||
self._tick_in_progress = False
|
|
||||||
|
|
||||||
# ---- Internals --------------------------------------------------------
|
|
||||||
|
|
||||||
async def _run(self) -> None:
|
|
||||||
# Stagger the first tick (same rationale as the MFT scheduler).
|
|
||||||
await asyncio.sleep(5)
|
|
||||||
while not self._stop_event.is_set():
|
|
||||||
try:
|
|
||||||
await self.tick()
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
# tick() catches its own exceptions and returns them
|
|
||||||
# in the result. This is the safety net for
|
|
||||||
# programmer errors in the loop body.
|
|
||||||
log.exception("BackupScheduler tick raised", extra={"error": str(exc)})
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(
|
|
||||||
self._stop_event.wait(),
|
|
||||||
timeout=self._interval_hours * 3600,
|
|
||||||
)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
pass # interval elapsed
|
|
||||||
|
|
||||||
async def _tick_impl(self) -> BackupTickResult:
|
|
||||||
started = datetime.now(timezone.utc)
|
|
||||||
result = BackupTickResult(started_at=started)
|
|
||||||
try:
|
|
||||||
create_result = await asyncio.to_thread(self._service.create_now)
|
|
||||||
result.created = create_result.backup
|
|
||||||
# Audit event for the created backup.
|
|
||||||
await asyncio.to_thread(
|
|
||||||
_audit_backup_created,
|
|
||||||
create_result.backup.id,
|
|
||||||
create_result.backup.db_fingerprint,
|
|
||||||
create_result.backup.table_count,
|
|
||||||
"backup-scheduler",
|
|
||||||
)
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
log.exception("Backup create failed during tick")
|
|
||||||
result.error = f"create: {type(exc).__name__}: {exc}"
|
|
||||||
await asyncio.to_thread(
|
|
||||||
_audit_backup_failed,
|
|
||||||
f"create: {type(exc).__name__}: {exc}",
|
|
||||||
traceback.format_exc()[-500:],
|
|
||||||
"backup-scheduler",
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
pruned = await asyncio.to_thread(self._service.prune)
|
|
||||||
result.pruned_paths = pruned
|
|
||||||
if pruned:
|
|
||||||
await asyncio.to_thread(
|
|
||||||
_audit_backup_pruned, pruned, "backup-scheduler",
|
|
||||||
)
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
log.exception("Backup prune failed during tick")
|
|
||||||
# Don't clobber the create error if there was one.
|
|
||||||
if result.error is None:
|
|
||||||
result.error = f"prune: {type(exc).__name__}: {exc}"
|
|
||||||
await asyncio.to_thread(
|
|
||||||
_audit_backup_failed,
|
|
||||||
f"prune: {type(exc).__name__}: {exc}",
|
|
||||||
traceback.format_exc()[-500:],
|
|
||||||
"backup-scheduler",
|
|
||||||
)
|
|
||||||
|
|
||||||
result.finished_at = datetime.now(timezone.utc)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Audit helpers (run in a thread so the asyncio loop doesn't block)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _audit_backup_created(
|
|
||||||
backup_id: int, db_fingerprint: str, table_count: int, actor: str,
|
|
||||||
) -> None:
|
|
||||||
from cyclone import db
|
|
||||||
with db.SessionLocal()() as s:
|
|
||||||
try:
|
|
||||||
append_event(s, AuditEvent(
|
|
||||||
event_type="db.backup_created",
|
|
||||||
entity_type="database",
|
|
||||||
entity_id="cyclone.db",
|
|
||||||
actor=actor,
|
|
||||||
payload={
|
|
||||||
"backup_id": backup_id,
|
|
||||||
"db_fingerprint": db_fingerprint,
|
|
||||||
"table_count": table_count,
|
|
||||||
},
|
|
||||||
))
|
|
||||||
s.commit()
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
log.exception("Failed to write db.backup_created audit event")
|
|
||||||
|
|
||||||
|
|
||||||
def _audit_backup_failed(
|
|
||||||
reason: str, traceback_tail: str, actor: str,
|
|
||||||
) -> None:
|
|
||||||
from cyclone import db
|
|
||||||
with db.SessionLocal()() as s:
|
|
||||||
try:
|
|
||||||
append_event(s, AuditEvent(
|
|
||||||
event_type="db.backup_failed",
|
|
||||||
entity_type="database",
|
|
||||||
entity_id="cyclone.db",
|
|
||||||
actor=actor,
|
|
||||||
payload={"reason": reason, "traceback_tail": traceback_tail},
|
|
||||||
))
|
|
||||||
s.commit()
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
log.exception("Failed to write db.backup_failed audit event")
|
|
||||||
|
|
||||||
|
|
||||||
def _audit_backup_pruned(deleted_paths: list[str], actor: str) -> None:
|
|
||||||
from cyclone import db
|
|
||||||
with db.SessionLocal()() as s:
|
|
||||||
try:
|
|
||||||
append_event(s, AuditEvent(
|
|
||||||
event_type="db.backup_pruned",
|
|
||||||
entity_type="database",
|
|
||||||
entity_id="cyclone.db",
|
|
||||||
actor=actor,
|
|
||||||
payload={"deleted_paths": deleted_paths},
|
|
||||||
))
|
|
||||||
s.commit()
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
log.exception("Failed to write db.backup_pruned audit event")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Module-level singleton
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
_scheduler: Optional[BackupScheduler] = None
|
|
||||||
|
|
||||||
|
|
||||||
def configure_backup_scheduler(
|
|
||||||
service: BackupService,
|
|
||||||
*,
|
|
||||||
interval_hours: float = 24.0,
|
|
||||||
) -> BackupScheduler:
|
|
||||||
"""Create (or return existing) the module-level BackupScheduler."""
|
|
||||||
global _scheduler
|
|
||||||
if _scheduler is not None:
|
|
||||||
return _scheduler
|
|
||||||
hours = float(
|
|
||||||
os.environ.get("CYCLONE_BACKUP_INTERVAL_HOURS", interval_hours),
|
|
||||||
)
|
|
||||||
_scheduler = BackupScheduler(service, interval_hours=hours)
|
|
||||||
return _scheduler
|
|
||||||
|
|
||||||
|
|
||||||
def get_backup_scheduler() -> BackupScheduler:
|
|
||||||
"""Return the configured BackupScheduler. Raises if not set up."""
|
|
||||||
if _scheduler is None:
|
|
||||||
raise RuntimeError(
|
|
||||||
"backup scheduler not configured; call configure_backup_scheduler() first",
|
|
||||||
)
|
|
||||||
return _scheduler
|
|
||||||
|
|
||||||
|
|
||||||
def reset_backup_scheduler_for_tests() -> None:
|
|
||||||
"""Clear the module-level singleton. Test-only."""
|
|
||||||
global _scheduler
|
|
||||||
_scheduler = None
|
|
||||||
@@ -1,851 +0,0 @@
|
|||||||
"""SP17 — High-level backup coordinator.
|
|
||||||
|
|
||||||
Owns the lifecycle of every backup the operator (or the scheduler)
|
|
||||||
takes:
|
|
||||||
|
|
||||||
* ``create_now`` — runs SQLite's online ``.backup()`` against the
|
|
||||||
live engine, encrypts the bytes with :func:`cyclone.backup.encrypt`,
|
|
||||||
writes a ``.bin`` + ``.meta.json`` pair into the backup directory,
|
|
||||||
and persists a row in ``db_backups``.
|
|
||||||
|
|
||||||
* ``list_backups`` — directory listing joined with ``db_backups`` rows.
|
|
||||||
|
|
||||||
* ``verify`` — decrypts + recomputes SHA-256, compares to the sidecar.
|
|
||||||
|
|
||||||
* ``restore_initiate`` / ``restore_confirm`` — two-step restore so an
|
|
||||||
idle browser tab can't nuke the live DB. The first call returns a
|
|
||||||
``restore_token`` (a random 32-byte hex string) plus a preview
|
|
||||||
(``db_fingerprint``, ``table_count``). The second call swaps the
|
|
||||||
engine only if the token matches.
|
|
||||||
|
|
||||||
* ``prune`` — deletes backups older than ``retention_days``.
|
|
||||||
|
|
||||||
This module is intentionally engine-aware: ``create_now`` reaches
|
|
||||||
into the live SQLAlchemy engine to get a raw SQLite connection and
|
|
||||||
call ``.backup()`` (the only way to take an online consistent
|
|
||||||
snapshot). ``restore`` reaches into :func:`cyclone.db.dispose_engine`
|
|
||||||
+ :func:`cyclone.db.reinit_engine` to swap to the restored file.
|
|
||||||
|
|
||||||
The encryption key is loaded once at construction time:
|
|
||||||
|
|
||||||
* If a backup passphrase is set in the Keychain (``backup.passphrase``
|
|
||||||
account under service ``cyclone``), use it directly with the salt
|
|
||||||
stored in the companion ``backup.salt`` account. The salt must be
|
|
||||||
persisted — a fresh random salt per process would defeat the key.
|
|
||||||
* Otherwise fall back to deriving from the SQLCipher DB key + a fixed
|
|
||||||
salt. Logged at WARNING because this is a degraded-but-still-safe
|
|
||||||
posture.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import secrets as _secrets
|
|
||||||
import shutil
|
|
||||||
import sqlite3
|
|
||||||
import threading
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional, Union
|
|
||||||
|
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
|
||||||
|
|
||||||
from cyclone import backup as backup_mod
|
|
||||||
from cyclone.backup import BackupError
|
|
||||||
from cyclone import db
|
|
||||||
from cyclone import secrets as secrets_mod
|
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
# Status values for db_backups.status (mirrored in the ORM).
|
|
||||||
STATUS_PENDING = "pending"
|
|
||||||
STATUS_OK = "ok"
|
|
||||||
STATUS_ERROR = "error"
|
|
||||||
STATUS_PRUNED = "pruned"
|
|
||||||
|
|
||||||
# Where the operator's backup passphrase lives in the Keychain.
|
|
||||||
KEYCHAIN_BACKUP_PASSPHRASE_ACCOUNT = "backup.passphrase"
|
|
||||||
|
|
||||||
# Companion account for the salt. Stored as hex. Same value across
|
|
||||||
# processes so the derived key is reproducible — a fresh random salt
|
|
||||||
# per BackupService would defeat the key.
|
|
||||||
KEYCHAIN_BACKUP_SALT_ACCOUNT = "backup.salt"
|
|
||||||
|
|
||||||
# Restore token TTL (seconds). The two-step confirm must complete
|
|
||||||
# within this window or the operator re-runs initiate.
|
|
||||||
RESTORE_TOKEN_TTL_SECONDS = 300
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Result dataclasses
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class BackupRecord:
|
|
||||||
"""Public view of a backup row joined with filesystem state."""
|
|
||||||
|
|
||||||
id: int
|
|
||||||
filename: str
|
|
||||||
backup_dir: str
|
|
||||||
size_bytes: int
|
|
||||||
db_fingerprint: str
|
|
||||||
table_count: int
|
|
||||||
created_at: datetime
|
|
||||||
completed_at: Optional[datetime]
|
|
||||||
status: str
|
|
||||||
error_message: Optional[str]
|
|
||||||
key_fingerprint: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class CreateResult:
|
|
||||||
"""Outcome of ``create_now``."""
|
|
||||||
|
|
||||||
backup: BackupRecord
|
|
||||||
sidecar: backup_mod.Sidecar
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class VerifyResult:
|
|
||||||
"""Outcome of ``verify``."""
|
|
||||||
|
|
||||||
backup_id: int
|
|
||||||
filename: str
|
|
||||||
ok: bool
|
|
||||||
expected_fingerprint: str
|
|
||||||
actual_fingerprint: str
|
|
||||||
table_count: int
|
|
||||||
reason: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class RestoreInitiateResult:
|
|
||||||
"""Returned by the first call of the two-step restore."""
|
|
||||||
|
|
||||||
backup_id: int
|
|
||||||
filename: str
|
|
||||||
restore_token: str
|
|
||||||
expires_at: datetime
|
|
||||||
db_fingerprint: str
|
|
||||||
table_count: int
|
|
||||||
current_db_fingerprint: str
|
|
||||||
current_table_count: int
|
|
||||||
size_bytes: int
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class RestoreConfirmResult:
|
|
||||||
"""Returned by the second call of the two-step restore."""
|
|
||||||
|
|
||||||
backup_id: int
|
|
||||||
filename: str
|
|
||||||
restored_from_fingerprint: str
|
|
||||||
restored_at: datetime
|
|
||||||
new_db_fingerprint: str
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# BackupService
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class BackupService:
|
|
||||||
"""Coordinator for encrypted DB backups.
|
|
||||||
|
|
||||||
Construct once at app startup; share across requests. Not
|
|
||||||
thread-safe for *creation* (the SQLite ``.backup()`` call uses
|
|
||||||
the live engine and is best serialized through the scheduler),
|
|
||||||
but ``list_backups`` / ``prune`` / ``status`` are safe to call
|
|
||||||
concurrently.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
backup_dir: Union[str, Path],
|
|
||||||
*,
|
|
||||||
passphrase: Optional[str] = None,
|
|
||||||
salt: Optional[bytes] = None,
|
|
||||||
retention_days: int = 30,
|
|
||||||
db_url: Optional[str] = None,
|
|
||||||
) -> None:
|
|
||||||
self._backup_dir = Path(backup_dir)
|
|
||||||
self._retention_days = max(1, int(retention_days))
|
|
||||||
self._db_url = db_url
|
|
||||||
# The derived key + its salt. If ``passphrase`` is None we
|
|
||||||
# fall back to deriving from the SQLCipher DB key (with a
|
|
||||||
# WARNING log).
|
|
||||||
#
|
|
||||||
# Salt is per-BackupService-instance and MUST be stable across
|
|
||||||
# processes — otherwise the same passphrase would derive
|
|
||||||
# different keys in different invocations and decrypt would
|
|
||||||
# always fail. Two options:
|
|
||||||
#
|
|
||||||
# 1. Caller passes an explicit ``salt`` (the Keychain flow
|
|
||||||
# reads the persisted salt from backup.salt account).
|
|
||||||
# 2. We accept a None salt here; ``_ensure_key`` then either
|
|
||||||
# uses the persisted salt (if available) or generates one
|
|
||||||
# and persists it on first use.
|
|
||||||
#
|
|
||||||
# Tests typically pass an explicit random salt; production
|
|
||||||
# should always pass the persisted one.
|
|
||||||
self._passphrase = passphrase
|
|
||||||
self._salt = salt
|
|
||||||
self._key: Optional[bytes] = None
|
|
||||||
self._used_fallback = False
|
|
||||||
# Pending restore tokens: token -> (backup_id, expires_at).
|
|
||||||
# A simple in-memory dict is sufficient — the token only
|
|
||||||
# needs to survive between the two API calls in one process.
|
|
||||||
self._pending_restores: dict[str, tuple[int, datetime]] = {}
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
|
|
||||||
# ---- Public API -------------------------------------------------------
|
|
||||||
|
|
||||||
@property
|
|
||||||
def backup_dir(self) -> Path:
|
|
||||||
return self._backup_dir
|
|
||||||
|
|
||||||
@property
|
|
||||||
def key_fingerprint(self) -> str:
|
|
||||||
"""SHA-256 of the current derived key, or "" if not yet derived."""
|
|
||||||
if self._key is None:
|
|
||||||
return ""
|
|
||||||
return backup_mod.fingerprint(self._key)
|
|
||||||
|
|
||||||
def create_now(self) -> CreateResult:
|
|
||||||
"""Take an encrypted backup of the live DB right now.
|
|
||||||
|
|
||||||
Crash-safe: any failure marks the ``db_backups`` row as
|
|
||||||
``error``, removes any partial files from the backup dir, and
|
|
||||||
re-raises the exception.
|
|
||||||
"""
|
|
||||||
# 1. Make sure the backup dir exists.
|
|
||||||
self._backup_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# 2. Allocate a filename + insert a pending row.
|
|
||||||
from cyclone.db import DbBackup # late import — circular otherwise
|
|
||||||
from cyclone.store import store as cycl_store
|
|
||||||
|
|
||||||
filename = backup_mod.backup_filename()
|
|
||||||
created_at = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
row = cycl_store.add_backup_pending(
|
|
||||||
filename=filename,
|
|
||||||
backup_dir=str(self._backup_dir),
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 3. Run SQLite's online .backup() to a temp file.
|
|
||||||
# We use a private path *inside* the backup dir so the
|
|
||||||
# operator can see what crashed if it does.
|
|
||||||
staging_db = self._backup_dir / f".{filename}.staging.db"
|
|
||||||
self._sqlite_backup_to(staging_db)
|
|
||||||
|
|
||||||
# 4. Encrypt.
|
|
||||||
plaintext = staging_db.read_bytes()
|
|
||||||
db_fp = backup_mod.fingerprint(plaintext)
|
|
||||||
key = self._ensure_key()
|
|
||||||
blob = backup_mod.encrypt(plaintext, key)
|
|
||||||
|
|
||||||
# 5. Move encrypted blob into place + write sidecar.
|
|
||||||
target = self._backup_dir / filename
|
|
||||||
target.write_bytes(blob)
|
|
||||||
staging_db.unlink()
|
|
||||||
|
|
||||||
table_count = self._count_tables_in_blob(plaintext)
|
|
||||||
sidecar = backup_mod.Sidecar(
|
|
||||||
format_version=backup_mod.FORMAT_VERSION,
|
|
||||||
created_at=created_at.isoformat(),
|
|
||||||
db_fingerprint=db_fp,
|
|
||||||
table_count=table_count,
|
|
||||||
size_bytes=len(blob),
|
|
||||||
kdf="PBKDF2-HMAC-SHA256",
|
|
||||||
kdf_iterations=backup_mod.KDF_ITERATIONS,
|
|
||||||
cipher="AES-256-GCM",
|
|
||||||
key_fingerprint=backup_mod.fingerprint(key),
|
|
||||||
)
|
|
||||||
sidecar_path = self._backup_dir / backup_mod.sidecar_filename(filename)
|
|
||||||
sidecar_path.write_text(sidecar.to_json())
|
|
||||||
|
|
||||||
# 6. Mark the row as ok.
|
|
||||||
with db.SessionLocal()() as s:
|
|
||||||
row = s.get(DbBackup, row.id)
|
|
||||||
row.status = STATUS_OK
|
|
||||||
row.size_bytes = len(blob)
|
|
||||||
row.db_fingerprint = db_fp
|
|
||||||
row.table_count = table_count
|
|
||||||
row.completed_at = datetime.now(timezone.utc)
|
|
||||||
s.commit()
|
|
||||||
s.refresh(row)
|
|
||||||
|
|
||||||
record = self._row_to_record(row)
|
|
||||||
log.info(
|
|
||||||
"Backup created",
|
|
||||||
extra={
|
|
||||||
"backup_id": record.id,
|
|
||||||
"backup_filename": record.filename,
|
|
||||||
"size_bytes": record.size_bytes,
|
|
||||||
"db_fingerprint": record.db_fingerprint,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return CreateResult(backup=record, sidecar=sidecar)
|
|
||||||
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
log.exception("Backup create failed")
|
|
||||||
try:
|
|
||||||
with db.SessionLocal()() as s:
|
|
||||||
row = s.get(DbBackup, row.id)
|
|
||||||
row.status = STATUS_ERROR
|
|
||||||
row.error_message = f"{type(exc).__name__}: {exc}"[:500]
|
|
||||||
row.completed_at = datetime.now(timezone.utc)
|
|
||||||
s.commit()
|
|
||||||
# Best-effort cleanup of any partial files.
|
|
||||||
for p in [
|
|
||||||
self._backup_dir / filename,
|
|
||||||
self._backup_dir / f".{filename}.staging.db",
|
|
||||||
self._backup_dir / backup_mod.sidecar_filename(filename),
|
|
||||||
]:
|
|
||||||
if p.exists():
|
|
||||||
try:
|
|
||||||
p.unlink()
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
log.exception("Failed to mark backup row as error")
|
|
||||||
raise
|
|
||||||
|
|
||||||
def list_backups(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
limit: int = 100,
|
|
||||||
status: Optional[str] = None,
|
|
||||||
) -> list[BackupRecord]:
|
|
||||||
"""List ``db_backups`` rows newest first.
|
|
||||||
|
|
||||||
Joins the filesystem state (presence of ``.bin`` and
|
|
||||||
``.meta.json``) implicitly via :attr:`BackupRecord.status`:
|
|
||||||
a row marked ``pruned`` had its files deleted by the
|
|
||||||
retention policy.
|
|
||||||
"""
|
|
||||||
from cyclone.db import DbBackup
|
|
||||||
|
|
||||||
with db.SessionLocal()() as s:
|
|
||||||
q = s.query(DbBackup)
|
|
||||||
if status is not None:
|
|
||||||
q = q.filter(DbBackup.status == status)
|
|
||||||
rows = q.order_by(DbBackup.id.desc()).limit(limit).all()
|
|
||||||
return [self._row_to_record(r) for r in rows]
|
|
||||||
|
|
||||||
def verify(self, backup_id: int) -> VerifyResult:
|
|
||||||
"""Decrypt + checksum-verify a backup against its sidecar.
|
|
||||||
|
|
||||||
Does NOT trust the sidecar's ``db_fingerprint`` field alone;
|
|
||||||
recomputes the SHA-256 from the decrypted blob and compares.
|
|
||||||
"""
|
|
||||||
from cyclone.db import DbBackup
|
|
||||||
|
|
||||||
with db.SessionLocal()() as s:
|
|
||||||
row = s.get(DbBackup, backup_id)
|
|
||||||
if row is None:
|
|
||||||
raise BackupError(f"backup {backup_id} not found")
|
|
||||||
record = self._row_to_record(row)
|
|
||||||
sidecar = self._read_sidecar(record.filename)
|
|
||||||
if sidecar is None:
|
|
||||||
return VerifyResult(
|
|
||||||
backup_id=record.id, filename=record.filename,
|
|
||||||
ok=False, expected_fingerprint="", actual_fingerprint="",
|
|
||||||
table_count=0, reason="sidecar missing",
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
blob = (self._backup_dir / record.filename).read_bytes()
|
|
||||||
except FileNotFoundError:
|
|
||||||
return VerifyResult(
|
|
||||||
backup_id=record.id, filename=record.filename,
|
|
||||||
ok=False,
|
|
||||||
expected_fingerprint=sidecar.db_fingerprint,
|
|
||||||
actual_fingerprint="",
|
|
||||||
table_count=sidecar.table_count,
|
|
||||||
reason="backup file missing",
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
plaintext = backup_mod.decrypt(blob, self._ensure_key())
|
|
||||||
except backup_mod.BackupDecryptError as exc:
|
|
||||||
return VerifyResult(
|
|
||||||
backup_id=record.id, filename=record.filename,
|
|
||||||
ok=False,
|
|
||||||
expected_fingerprint=sidecar.db_fingerprint,
|
|
||||||
actual_fingerprint="",
|
|
||||||
table_count=sidecar.table_count,
|
|
||||||
reason=str(exc),
|
|
||||||
)
|
|
||||||
|
|
||||||
actual_fp = backup_mod.fingerprint(plaintext)
|
|
||||||
return VerifyResult(
|
|
||||||
backup_id=record.id,
|
|
||||||
filename=record.filename,
|
|
||||||
ok=(actual_fp == sidecar.db_fingerprint),
|
|
||||||
expected_fingerprint=sidecar.db_fingerprint,
|
|
||||||
actual_fingerprint=actual_fp,
|
|
||||||
table_count=sidecar.table_count,
|
|
||||||
reason=None if actual_fp == sidecar.db_fingerprint else "fingerprint mismatch",
|
|
||||||
)
|
|
||||||
|
|
||||||
def restore_initiate(self, backup_id: int) -> RestoreInitiateResult:
|
|
||||||
"""First half of the two-step restore.
|
|
||||||
|
|
||||||
Decrypts the backup into a temp file and reads its current
|
|
||||||
``db_fingerprint`` + ``table_count``. Returns a one-shot
|
|
||||||
``restore_token`` the operator must echo back to
|
|
||||||
:meth:`restore_confirm` within 5 minutes.
|
|
||||||
"""
|
|
||||||
from cyclone.db import DbBackup
|
|
||||||
|
|
||||||
with db.SessionLocal()() as s:
|
|
||||||
row = s.get(DbBackup, backup_id)
|
|
||||||
if row is None:
|
|
||||||
raise BackupError(f"backup {backup_id} not found")
|
|
||||||
if row.status != STATUS_OK:
|
|
||||||
raise BackupError(
|
|
||||||
f"backup {backup_id} status is {row.status!r}; only 'ok' backups can be restored",
|
|
||||||
)
|
|
||||||
record = self._row_to_record(row)
|
|
||||||
|
|
||||||
# Decrypt into a staging file so the confirm step is fast.
|
|
||||||
staging = self._backup_dir / f".restore-{record.filename}.staging.db"
|
|
||||||
try:
|
|
||||||
blob = (self._backup_dir / record.filename).read_bytes()
|
|
||||||
except FileNotFoundError as exc:
|
|
||||||
raise BackupError(f"backup file missing: {record.filename}") from exc
|
|
||||||
try:
|
|
||||||
plaintext = backup_mod.decrypt(blob, self._ensure_key())
|
|
||||||
except backup_mod.BackupDecryptError as exc:
|
|
||||||
raise BackupError(f"decrypt failed: {exc}") from exc
|
|
||||||
staging.write_bytes(plaintext)
|
|
||||||
|
|
||||||
# Snapshot the live DB's fingerprint for the operator's "are
|
|
||||||
# you sure you want to do this?" preview.
|
|
||||||
live_fp, live_count = self._live_fingerprint_and_count()
|
|
||||||
|
|
||||||
token = _secrets.token_hex(32)
|
|
||||||
expires_at = datetime.now(timezone.utc) + timedelta(
|
|
||||||
seconds=RESTORE_TOKEN_TTL_SECONDS,
|
|
||||||
)
|
|
||||||
with self._lock:
|
|
||||||
self._pending_restores[token] = (record.id, expires_at)
|
|
||||||
|
|
||||||
log.info(
|
|
||||||
"Restore initiated",
|
|
||||||
extra={
|
|
||||||
"backup_id": record.id,
|
|
||||||
"token_prefix": token[:8],
|
|
||||||
"expires_at": expires_at.isoformat(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return RestoreInitiateResult(
|
|
||||||
backup_id=record.id,
|
|
||||||
filename=record.filename,
|
|
||||||
restore_token=token,
|
|
||||||
expires_at=expires_at,
|
|
||||||
db_fingerprint=backup_mod.fingerprint(plaintext),
|
|
||||||
table_count=self._count_tables_in_blob(plaintext),
|
|
||||||
current_db_fingerprint=live_fp,
|
|
||||||
current_table_count=live_count,
|
|
||||||
size_bytes=len(plaintext),
|
|
||||||
)
|
|
||||||
|
|
||||||
def restore_confirm(
|
|
||||||
self,
|
|
||||||
backup_id: int,
|
|
||||||
restore_token: str,
|
|
||||||
*,
|
|
||||||
actor: str = "operator",
|
|
||||||
) -> RestoreConfirmResult:
|
|
||||||
"""Second half of the two-step restore.
|
|
||||||
|
|
||||||
Validates the token, copies the decrypted staging file over
|
|
||||||
the live DB path, disposes + reopens the engine. Raises
|
|
||||||
``BackupError`` on any mismatch.
|
|
||||||
"""
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
with self._lock:
|
|
||||||
entry = self._pending_restores.pop(restore_token, None)
|
|
||||||
if entry is None:
|
|
||||||
raise BackupError("restore_token not found (already consumed or never issued)")
|
|
||||||
token_backup_id, expires_at = entry
|
|
||||||
if token_backup_id != backup_id:
|
|
||||||
raise BackupError(
|
|
||||||
f"restore_token was for backup {token_backup_id}, not {backup_id}",
|
|
||||||
)
|
|
||||||
if now > expires_at:
|
|
||||||
raise BackupError(
|
|
||||||
f"restore_token expired at {expires_at.isoformat()}; re-run initiate",
|
|
||||||
)
|
|
||||||
|
|
||||||
from cyclone.db import DbBackup
|
|
||||||
with db.SessionLocal()() as s:
|
|
||||||
row = s.get(DbBackup, backup_id)
|
|
||||||
if row is None:
|
|
||||||
raise BackupError(f"backup {backup_id} disappeared mid-restore")
|
|
||||||
record = self._row_to_record(row)
|
|
||||||
|
|
||||||
staging = self._backup_dir / f".restore-{record.filename}.staging.db"
|
|
||||||
if not staging.exists():
|
|
||||||
raise BackupError(
|
|
||||||
f"staging restore file missing: {staging.name}; re-run initiate",
|
|
||||||
)
|
|
||||||
|
|
||||||
target_db_path = self._live_db_path()
|
|
||||||
if target_db_path is None:
|
|
||||||
raise BackupError(
|
|
||||||
"cannot determine live DB file path (non-sqlite URL?)",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Pre-restore fingerprint for the audit event.
|
|
||||||
restored_from_fp = backup_mod.fingerprint(staging.read_bytes())
|
|
||||||
|
|
||||||
# The swap: dispose engine → copy file → reinit engine.
|
|
||||||
# Anything between dispose and reinit raises (queries that
|
|
||||||
# are in-flight get a "database is locked" or
|
|
||||||
# "no such table" error); we accept that because the
|
|
||||||
# operator already confirmed.
|
|
||||||
db.dispose_engine()
|
|
||||||
try:
|
|
||||||
# Atomic copy via temp + rename so a crash mid-copy
|
|
||||||
# doesn't leave a half-written DB file.
|
|
||||||
tmp_target = target_db_path.with_suffix(
|
|
||||||
target_db_path.suffix + f".restoring-{_secrets.token_hex(4)}",
|
|
||||||
)
|
|
||||||
shutil.copyfile(staging, tmp_target)
|
|
||||||
os.replace(tmp_target, target_db_path)
|
|
||||||
finally:
|
|
||||||
staging.unlink(missing_ok=True)
|
|
||||||
db.reinit_engine()
|
|
||||||
|
|
||||||
# Post-restore fingerprint from the now-live engine.
|
|
||||||
new_fp, _ = self._live_fingerprint_and_count()
|
|
||||||
|
|
||||||
log.warning(
|
|
||||||
"Restore complete: backup_id=%d actor=%s from=%s to=%s",
|
|
||||||
backup_id, actor, restored_from_fp, new_fp,
|
|
||||||
)
|
|
||||||
return RestoreConfirmResult(
|
|
||||||
backup_id=record.id,
|
|
||||||
filename=record.filename,
|
|
||||||
restored_from_fingerprint=restored_from_fp,
|
|
||||||
restored_at=datetime.now(timezone.utc),
|
|
||||||
new_db_fingerprint=new_fp,
|
|
||||||
)
|
|
||||||
|
|
||||||
def prune(self, *, now: Optional[datetime] = None) -> list[str]:
|
|
||||||
"""Delete backups older than ``retention_days``. Returns deleted paths.
|
|
||||||
|
|
||||||
Marks the ``db_backups`` rows ``pruned`` so the operator can
|
|
||||||
still see what was deleted (and when).
|
|
||||||
"""
|
|
||||||
from cyclone.db import DbBackup
|
|
||||||
|
|
||||||
cutoff = (now or datetime.now(timezone.utc)) - timedelta(
|
|
||||||
days=self._retention_days,
|
|
||||||
)
|
|
||||||
deleted: list[str] = []
|
|
||||||
|
|
||||||
with db.SessionLocal()() as s:
|
|
||||||
q = s.query(DbBackup).filter(
|
|
||||||
DbBackup.status == STATUS_OK,
|
|
||||||
DbBackup.created_at < cutoff,
|
|
||||||
)
|
|
||||||
for row in q.all():
|
|
||||||
# Delete the file pair; ignore if already gone.
|
|
||||||
bin_path = Path(row.backup_dir) / row.filename
|
|
||||||
meta_path = Path(row.backup_dir) / backup_mod.sidecar_filename(row.filename)
|
|
||||||
for p in (bin_path, meta_path):
|
|
||||||
try:
|
|
||||||
if p.exists():
|
|
||||||
p.unlink()
|
|
||||||
deleted.append(str(p))
|
|
||||||
except OSError as exc:
|
|
||||||
log.warning("Failed to delete %s: %s", p, exc)
|
|
||||||
row.status = STATUS_PRUNED
|
|
||||||
s.add(row)
|
|
||||||
s.commit()
|
|
||||||
|
|
||||||
log.info(
|
|
||||||
"Pruned old backups",
|
|
||||||
extra={
|
|
||||||
"deleted_count": len(deleted),
|
|
||||||
"cutoff": cutoff.isoformat(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return deleted
|
|
||||||
|
|
||||||
def status(self) -> dict:
|
|
||||||
"""Snapshot of the backup subsystem for ``GET /api/admin/backup/status``."""
|
|
||||||
from cyclone.db import DbBackup
|
|
||||||
from sqlalchemy import func
|
|
||||||
|
|
||||||
with db.SessionLocal()() as s:
|
|
||||||
total = s.query(func.count(DbBackup.id)).scalar() or 0
|
|
||||||
ok_count = s.query(func.count(DbBackup.id)).filter(
|
|
||||||
DbBackup.status == STATUS_OK,
|
|
||||||
).scalar() or 0
|
|
||||||
error_count = s.query(func.count(DbBackup.id)).filter(
|
|
||||||
DbBackup.status == STATUS_ERROR,
|
|
||||||
).scalar() or 0
|
|
||||||
pruned_count = s.query(func.count(DbBackup.id)).filter(
|
|
||||||
DbBackup.status == STATUS_PRUNED,
|
|
||||||
).scalar() or 0
|
|
||||||
last_row = (
|
|
||||||
s.query(DbBackup)
|
|
||||||
.filter(DbBackup.status.in_([STATUS_OK, STATUS_ERROR]))
|
|
||||||
.order_by(DbBackup.id.desc())
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
last_ok_row = (
|
|
||||||
s.query(DbBackup)
|
|
||||||
.filter(DbBackup.status == STATUS_OK)
|
|
||||||
.order_by(DbBackup.id.desc())
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
disk_bytes = 0
|
|
||||||
try:
|
|
||||||
for p in self._backup_dir.iterdir():
|
|
||||||
if p.is_file() and p.suffix == ".bin":
|
|
||||||
disk_bytes += p.stat().st_size
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return {
|
|
||||||
"backup_dir": str(self._backup_dir),
|
|
||||||
"retention_days": self._retention_days,
|
|
||||||
"totals": {
|
|
||||||
"all": total,
|
|
||||||
"ok": ok_count,
|
|
||||||
"error": error_count,
|
|
||||||
"pruned": pruned_count,
|
|
||||||
},
|
|
||||||
"disk_bytes": disk_bytes,
|
|
||||||
"last_backup_at": (
|
|
||||||
last_row.created_at.isoformat() if last_row and last_row.created_at else None
|
|
||||||
),
|
|
||||||
"last_backup_status": last_row.status if last_row else None,
|
|
||||||
"last_ok_backup_at": (
|
|
||||||
last_ok_row.created_at.isoformat()
|
|
||||||
if last_ok_row and last_ok_row.created_at else None
|
|
||||||
),
|
|
||||||
"used_fallback_key": self._used_fallback,
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---- Internals --------------------------------------------------------
|
|
||||||
|
|
||||||
def _ensure_key(self) -> bytes:
|
|
||||||
"""Derive (or return cached) AES key. Triggers fallback + WARNING log
|
|
||||||
if no passphrase was provided at construction time.
|
|
||||||
|
|
||||||
If a passphrase is set but no salt was passed at construction,
|
|
||||||
look one up from the Keychain (``backup.salt`` account). On
|
|
||||||
a fresh install, generate + persist a salt on first use so
|
|
||||||
subsequent invocations derive the same key.
|
|
||||||
"""
|
|
||||||
if self._key is not None:
|
|
||||||
return self._key
|
|
||||||
if self._passphrase:
|
|
||||||
salt = self._salt
|
|
||||||
if salt is None:
|
|
||||||
# Try the Keychain.
|
|
||||||
stored = secrets_mod.get_secret(KEYCHAIN_BACKUP_SALT_ACCOUNT)
|
|
||||||
if stored:
|
|
||||||
salt = bytes.fromhex(stored.strip())
|
|
||||||
else:
|
|
||||||
# First run: generate + persist.
|
|
||||||
salt = os.urandom(backup_mod.SALT_LEN)
|
|
||||||
secrets_mod.set_secret(
|
|
||||||
KEYCHAIN_BACKUP_SALT_ACCOUNT,
|
|
||||||
salt.hex(),
|
|
||||||
)
|
|
||||||
log.info(
|
|
||||||
"Generated + persisted backup salt to Keychain "
|
|
||||||
"(account %r)",
|
|
||||||
KEYCHAIN_BACKUP_SALT_ACCOUNT,
|
|
||||||
)
|
|
||||||
self._key = backup_mod.derive_key(self._passphrase, salt)
|
|
||||||
return self._key
|
|
||||||
|
|
||||||
# Fallback: derive from SQLCipher DB key. This is degraded
|
|
||||||
# security (the SQLCipher key is meant to unlock the DB, not
|
|
||||||
# the backup), but it's strictly better than plaintext.
|
|
||||||
from cyclone import db_crypto
|
|
||||||
db_key = db_crypto.get_db_key() if db_crypto.is_encryption_enabled() else None
|
|
||||||
if not db_key:
|
|
||||||
# No passphrase AND no SQLCipher key — refuse.
|
|
||||||
raise BackupError(
|
|
||||||
"no backup passphrase set and SQLCipher is not enabled; "
|
|
||||||
"either set a backup passphrase in the Keychain or "
|
|
||||||
"enable SQLCipher encryption",
|
|
||||||
)
|
|
||||||
log.warning(
|
|
||||||
"Backup using fallback key derived from SQLCipher DB key "
|
|
||||||
"(no separate backup passphrase set); set one via "
|
|
||||||
"`cyclone backup init-passphrase` for stronger isolation",
|
|
||||||
extra={"key_source": "sqlcipher_fallback"},
|
|
||||||
)
|
|
||||||
self._used_fallback = True
|
|
||||||
self._key = backup_mod.derive_key(db_key, backup_mod.FALLBACK_SALT)
|
|
||||||
return self._key
|
|
||||||
|
|
||||||
def _sqlite_backup_to(self, target_path: Path) -> None:
|
|
||||||
"""Run SQLite's online ``.backup()`` against the live engine.
|
|
||||||
|
|
||||||
Works for both plain SQLite and SQLCipher because sqlcipher3
|
|
||||||
is API-compatible with sqlite3. The ``.backup()`` API takes
|
|
||||||
a *target* connection; we make a fresh sqlite3 connection to
|
|
||||||
the target file (which doesn't exist yet) and copy into it.
|
|
||||||
"""
|
|
||||||
url = self._db_url or db._resolve_url()
|
|
||||||
if not url.startswith("sqlite"):
|
|
||||||
raise BackupError(
|
|
||||||
f"only sqlite URLs are supported for online backup; got {url!r}",
|
|
||||||
)
|
|
||||||
# Drive the backup off the live engine so we capture the
|
|
||||||
# current state of all tables atomically (SQLite's .backup
|
|
||||||
# holds a read lock on the source for the duration).
|
|
||||||
engine = db.engine() # raises RuntimeError if init_db() wasn't called
|
|
||||||
with engine.raw_connection() as raw:
|
|
||||||
src_conn = raw.driver_connection # sqlite3.Connection / sqlcipher3.Connection
|
|
||||||
if target_path.exists():
|
|
||||||
target_path.unlink()
|
|
||||||
dst_conn = sqlite3.connect(str(target_path))
|
|
||||||
try:
|
|
||||||
src_conn.backup(dst_conn)
|
|
||||||
finally:
|
|
||||||
dst_conn.close()
|
|
||||||
|
|
||||||
def _count_tables_in_blob(self, plaintext: bytes) -> int:
|
|
||||||
"""Open the decrypted DB in-memory and count user tables."""
|
|
||||||
tmp = self._backup_dir / f".count-tables-{_secrets.token_hex(4)}.db"
|
|
||||||
try:
|
|
||||||
tmp.write_bytes(plaintext)
|
|
||||||
conn = sqlite3.connect(str(tmp))
|
|
||||||
try:
|
|
||||||
rows = conn.execute(
|
|
||||||
"SELECT count(*) FROM sqlite_master "
|
|
||||||
"WHERE type='table' AND name NOT LIKE 'sqlite_%'",
|
|
||||||
).fetchone()
|
|
||||||
return int(rows[0])
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
finally:
|
|
||||||
tmp.unlink(missing_ok=True)
|
|
||||||
|
|
||||||
def _live_fingerprint_and_count(self) -> tuple[str, int]:
|
|
||||||
"""Fingerprint + table count of the *current* live DB."""
|
|
||||||
try:
|
|
||||||
engine = db.engine()
|
|
||||||
except RuntimeError:
|
|
||||||
return "", 0
|
|
||||||
# Use a temp-file .backup so we don't have to worry about
|
|
||||||
# online-vs-offline semantics.
|
|
||||||
tmp = self._backup_dir / f".live-fp-{_secrets.token_hex(4)}.db"
|
|
||||||
try:
|
|
||||||
with engine.raw_connection() as raw:
|
|
||||||
conn = raw.driver_connection
|
|
||||||
if tmp.exists():
|
|
||||||
tmp.unlink()
|
|
||||||
dst = sqlite3.connect(str(tmp))
|
|
||||||
try:
|
|
||||||
conn.backup(dst)
|
|
||||||
finally:
|
|
||||||
dst.close()
|
|
||||||
data = tmp.read_bytes()
|
|
||||||
return backup_mod.fingerprint(data), self._count_tables_in_blob(data)
|
|
||||||
finally:
|
|
||||||
tmp.unlink(missing_ok=True)
|
|
||||||
|
|
||||||
def _live_db_path(self) -> Optional[Path]:
|
|
||||||
"""Resolve the filesystem path of the live DB, or None for non-sqlite."""
|
|
||||||
url = self._db_url or db._resolve_url()
|
|
||||||
if not url.startswith("sqlite"):
|
|
||||||
return None
|
|
||||||
# Strip the driver prefix: sqlite:///abs or sqlite:///./rel
|
|
||||||
prefix = "sqlite:///"
|
|
||||||
if url.startswith(prefix):
|
|
||||||
return Path(url[len(prefix):])
|
|
||||||
if url.startswith("sqlite://"):
|
|
||||||
# sqlite://./relative/path -> Path("./relative/path")
|
|
||||||
return Path(url[len("sqlite://"):])
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _read_sidecar(self, filename: str) -> Optional[backup_mod.Sidecar]:
|
|
||||||
p = self._backup_dir / backup_mod.sidecar_filename(filename)
|
|
||||||
if not p.exists():
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return backup_mod.Sidecar.from_json(p.read_text())
|
|
||||||
except (json.JSONDecodeError, KeyError, ValueError) as exc:
|
|
||||||
log.warning("Sidecar %s is malformed: %s", p, exc)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _row_to_record(self, row) -> BackupRecord:
|
|
||||||
"""ORM row → BackupRecord. Reads key_fingerprint from the sidecar if present."""
|
|
||||||
sidecar = self._read_sidecar(row.filename)
|
|
||||||
return BackupRecord(
|
|
||||||
id=row.id,
|
|
||||||
filename=row.filename,
|
|
||||||
backup_dir=row.backup_dir,
|
|
||||||
size_bytes=row.size_bytes or 0,
|
|
||||||
db_fingerprint=row.db_fingerprint or "",
|
|
||||||
table_count=row.table_count or 0,
|
|
||||||
created_at=row.created_at,
|
|
||||||
completed_at=row.completed_at,
|
|
||||||
status=row.status,
|
|
||||||
error_message=row.error_message,
|
|
||||||
key_fingerprint=sidecar.key_fingerprint if sidecar else "",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Module-level singleton
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
_service: Optional[BackupService] = None
|
|
||||||
|
|
||||||
|
|
||||||
def configure_backup_service(
|
|
||||||
backup_dir: Union[str, Path],
|
|
||||||
*,
|
|
||||||
passphrase: Optional[str] = None,
|
|
||||||
salt: Optional[bytes] = None,
|
|
||||||
retention_days: int = 30,
|
|
||||||
db_url: Optional[str] = None,
|
|
||||||
) -> BackupService:
|
|
||||||
"""Create (or replace) the module-level BackupService singleton."""
|
|
||||||
global _service
|
|
||||||
if _service is not None:
|
|
||||||
return _service
|
|
||||||
_service = BackupService(
|
|
||||||
backup_dir=backup_dir,
|
|
||||||
passphrase=passphrase,
|
|
||||||
salt=salt,
|
|
||||||
retention_days=retention_days,
|
|
||||||
db_url=db_url,
|
|
||||||
)
|
|
||||||
return _service
|
|
||||||
|
|
||||||
|
|
||||||
def get_backup_service() -> BackupService:
|
|
||||||
"""Return the configured BackupService. Raises RuntimeError if not set up."""
|
|
||||||
if _service is None:
|
|
||||||
raise RuntimeError("backup service not configured; call configure_backup_service() first")
|
|
||||||
return _service
|
|
||||||
|
|
||||||
|
|
||||||
def reset_backup_service_for_tests() -> None:
|
|
||||||
"""Clear the module-level singleton. Test-only."""
|
|
||||||
global _service
|
|
||||||
_service = None
|
|
||||||
@@ -93,24 +93,13 @@ class SftpClient:
|
|||||||
return self._list_inbound_paramiko()
|
return self._list_inbound_paramiko()
|
||||||
|
|
||||||
def read_file(self, remote_path: str) -> bytes:
|
def read_file(self, remote_path: str) -> bytes:
|
||||||
"""Read bytes from a remote path.
|
"""Read bytes from a remote path. Stub raises in stub mode."""
|
||||||
|
|
||||||
Stub mode: reads from ``{staging_dir}/{remote_path}``. Used by
|
|
||||||
the SP16 scheduler so it can exercise the same code path on a
|
|
||||||
workstation without a real MFT connection.
|
|
||||||
"""
|
|
||||||
if self._stub:
|
if self._stub:
|
||||||
return self._read_file_stub(remote_path)
|
raise RuntimeError(
|
||||||
|
"Stub SFTP cannot read remote files. Use the local staging dir."
|
||||||
|
)
|
||||||
return self._read_file_paramiko(remote_path)
|
return self._read_file_paramiko(remote_path)
|
||||||
|
|
||||||
def _read_file_stub(self, remote_path: str) -> bytes:
|
|
||||||
"""Read bytes from ``{staging_dir}/{remote_path}`` (SP16 stub)."""
|
|
||||||
staging = Path(self._block.staging_dir).resolve()
|
|
||||||
target = staging / remote_path.lstrip("/")
|
|
||||||
if not target.is_file():
|
|
||||||
raise FileNotFoundError(f"inbound stub file not found: {target}")
|
|
||||||
return target.read_bytes()
|
|
||||||
|
|
||||||
def get_secret(self, name: str) -> Optional[str]:
|
def get_secret(self, name: str) -> Optional[str]:
|
||||||
"""Fetch the auth secret from Keychain. Returns the stub secret if absent."""
|
"""Fetch the auth secret from Keychain. Returns the stub secret if absent."""
|
||||||
value = secrets.get_secret(name)
|
value = secrets.get_secret(name)
|
||||||
|
|||||||
+9
-363
@@ -3,13 +3,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
from cyclone.logging_config import setup_logging
|
|
||||||
from cyclone.parsers.exceptions import CycloneParseError, CycloneValidationError
|
from cyclone.parsers.exceptions import CycloneParseError, CycloneValidationError
|
||||||
from cyclone.parsers.payer import PayerConfig, PayerConfig835
|
from cyclone.parsers.payer import PayerConfig, PayerConfig835
|
||||||
from cyclone.parsers.parse_837 import parse as parse_837_text
|
from cyclone.parsers.parse_837 import parse as parse_837_text
|
||||||
@@ -43,35 +41,14 @@ def _payer_835(name: str) -> PayerConfig835:
|
|||||||
|
|
||||||
|
|
||||||
@click.group()
|
@click.group()
|
||||||
@click.option(
|
def main() -> None:
|
||||||
"--log-format",
|
|
||||||
default=None,
|
|
||||||
type=click.Choice(["json", "dev"]),
|
|
||||||
help="Log format (default: json; honors CYCLONE_LOG_JSON).",
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--log-file",
|
|
||||||
default=None,
|
|
||||||
type=click.Path(dir_okay=False, path_type=Path),
|
|
||||||
help="Optional rotating log file (honors CYCLONE_LOG_FILE).",
|
|
||||||
)
|
|
||||||
@click.pass_context
|
|
||||||
def main(ctx: click.Context, log_format: str | None, log_file: Path | None) -> None:
|
|
||||||
"""Cyclone EDI suite — X12 parser."""
|
"""Cyclone EDI suite — X12 parser."""
|
||||||
# SP18: structured JSON logging. Run once per CLI invocation; each
|
|
||||||
# subcommand still gets its own --log-level to override.
|
|
||||||
json_format = True
|
# Register the auth users subgroup. Imported here (not at module top) to
|
||||||
if log_format == "dev":
|
# avoid pulling passlib / bcrypt at CLI parse-only import time.
|
||||||
json_format = False
|
from cyclone.auth.cli import users_cli # noqa: E402
|
||||||
elif os.environ.get("CYCLONE_LOG_JSON", "").lower() in ("false", "0", "no"):
|
main.add_command(users_cli)
|
||||||
json_format = False
|
|
||||||
setup_logging(
|
|
||||||
level=os.environ.get("CYCLONE_LOG_LEVEL", "INFO"),
|
|
||||||
log_file=str(log_file) if log_file else None,
|
|
||||||
json_format=json_format,
|
|
||||||
)
|
|
||||||
# Stash on context so subcommands can read it.
|
|
||||||
ctx.ensure_object(dict)
|
|
||||||
|
|
||||||
|
|
||||||
@main.command("parse-837")
|
@main.command("parse-837")
|
||||||
@@ -92,9 +69,7 @@ def parse_837(
|
|||||||
log_level: str,
|
log_level: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Parse an X12 837P file into one JSON per claim."""
|
"""Parse an X12 837P file into one JSON per claim."""
|
||||||
# SP18: re-run setup so per-command --log-level overrides the
|
logging.basicConfig(level=getattr(logging, log_level))
|
||||||
# group default. ``setup_logging`` is idempotent.
|
|
||||||
setup_logging(level=log_level)
|
|
||||||
|
|
||||||
text = input_file.read_text()
|
text = input_file.read_text()
|
||||||
config = _payer(payer)
|
config = _payer(payer)
|
||||||
@@ -164,9 +139,7 @@ def parse_835(
|
|||||||
log_level: str,
|
log_level: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Parse an X12 835 ERA file into one JSON per claim payment."""
|
"""Parse an X12 835 ERA file into one JSON per claim payment."""
|
||||||
# SP18: re-run setup so per-command --log-level overrides the
|
logging.basicConfig(level=getattr(logging, log_level))
|
||||||
# group default. ``setup_logging`` is idempotent.
|
|
||||||
setup_logging(level=log_level)
|
|
||||||
|
|
||||||
text = input_file.read_text()
|
text = input_file.read_text()
|
||||||
config = _payer_835(payer)
|
config = _payer_835(payer)
|
||||||
@@ -228,332 +201,5 @@ def _count_issues(report) -> dict[str, int]:
|
|||||||
return counts
|
return counts
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# SP20: `cyclone validate-npi` + `cyclone validate-tax-id`
|
|
||||||
#
|
|
||||||
# Pure local validators. No DB, no Keychain, no network — operators can
|
|
||||||
# run them on a developer laptop without standing up the full Cyclone
|
|
||||||
# stack. Exit code is 0 (valid) or 1 (invalid) so they compose with
|
|
||||||
# shell scripting / CI gates.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@main.command("validate-npi")
|
|
||||||
@click.argument("npi")
|
|
||||||
@click.option("--log-level", default="WARNING", show_default=True, type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"]))
|
|
||||||
def validate_npi_cmd(npi: str, log_level: str) -> None:
|
|
||||||
"""Validate a 10-digit NPI's Luhn checksum locally (SP20).
|
|
||||||
|
|
||||||
Exit 0 if valid, 1 if not. No logging of the value itself — NPIs
|
|
||||||
are PHI under HIPAA, so the operator's CLI history is the only
|
|
||||||
audit trail.
|
|
||||||
"""
|
|
||||||
# SP18: re-run so --log-level overrides the group default.
|
|
||||||
setup_logging(level=log_level)
|
|
||||||
from cyclone.npi import is_valid_npi
|
|
||||||
if is_valid_npi(npi):
|
|
||||||
click.echo(f"OK: {len(npi)}-digit NPI passes Luhn checksum")
|
|
||||||
return
|
|
||||||
click.echo(f"INVALID: {npi!r} fails NPI Luhn checksum", err=True)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
@main.command("validate-tax-id")
|
|
||||||
@click.argument("tax_id")
|
|
||||||
@click.option("--log-level", default="WARNING", show_default=True, type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"]))
|
|
||||||
def validate_tax_id_cmd(tax_id: str, log_level: str) -> None:
|
|
||||||
"""Validate a 9-digit EIN's format + prefix locally (SP20).
|
|
||||||
|
|
||||||
Accepts both ``XX-XXXXXXX`` and ``XXXXXXXXX``. Exit 0 if valid,
|
|
||||||
1 if not. EIN is sensitive (PII), so we don't echo the value back
|
|
||||||
on failure — only the validation verdict.
|
|
||||||
"""
|
|
||||||
# SP18: re-run so --log-level overrides the group default.
|
|
||||||
setup_logging(level=log_level)
|
|
||||||
from cyclone.npi import is_valid_tax_id, normalize_tax_id
|
|
||||||
plain = normalize_tax_id(tax_id)
|
|
||||||
if plain is None:
|
|
||||||
click.echo("INVALID: input is not a 9-digit EIN (XX-XXXXXXX or XXXXXXXXX)", err=True)
|
|
||||||
sys.exit(1)
|
|
||||||
if is_valid_tax_id(tax_id):
|
|
||||||
click.echo(f"OK: 9-digit EIN (normalized={plain})")
|
|
||||||
return
|
|
||||||
click.echo(
|
|
||||||
f"INVALID: 9-digit EIN has reserved prefix ({plain[:2]}); EIN is not assignable by IRS",
|
|
||||||
err=True,
|
|
||||||
)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# SP17: `cyclone backup` subcommands
|
|
||||||
#
|
|
||||||
# Operator-facing backup management. Mirrors the API surface but runs
|
|
||||||
# standalone (no FastAPI app needed) for cron / scripting / DR drills.
|
|
||||||
# Each subcommand initializes the DB + BackupService; if the
|
|
||||||
# service isn't configured (no Keychain passphrase etc.) the operator
|
|
||||||
# gets a clear error.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@main.group()
|
|
||||||
def backup() -> None:
|
|
||||||
"""Encrypted DB backup management (SP17)."""
|
|
||||||
|
|
||||||
|
|
||||||
@backup.command("init-passphrase")
|
|
||||||
@click.option("--passphrase", required=True, help="The passphrase to set (will prompt if omitted)")
|
|
||||||
@click.option("--from-stdin", is_flag=True, help="Read passphrase from stdin instead of the argument")
|
|
||||||
def backup_init_passphrase(passphrase: str, from_stdin: bool) -> None:
|
|
||||||
"""Set the backup encryption passphrase in the macOS Keychain.
|
|
||||||
|
|
||||||
Generates a fresh salt and stores both the passphrase (account
|
|
||||||
``backup.passphrase``) and the salt (account ``backup.salt``)
|
|
||||||
under service ``cyclone``. Cyclone's BackupService reads them
|
|
||||||
at startup. If the passphrase account is missing, the service
|
|
||||||
falls back to deriving a key from the SQLCipher DB key
|
|
||||||
(degraded posture, logged at WARNING).
|
|
||||||
"""
|
|
||||||
from cyclone import backup_service as svc_mod
|
|
||||||
from cyclone import secrets as secrets_mod
|
|
||||||
from getpass import getpass
|
|
||||||
import os as _os
|
|
||||||
|
|
||||||
if from_stdin:
|
|
||||||
pp = getpass("Backup passphrase: ").strip()
|
|
||||||
pp2 = getpass("Confirm: ").strip()
|
|
||||||
if not pp or pp != pp2:
|
|
||||||
click.echo("passphrase empty or mismatch", err=True)
|
|
||||||
sys.exit(2)
|
|
||||||
else:
|
|
||||||
pp = passphrase
|
|
||||||
|
|
||||||
if not pp or len(pp) < 12:
|
|
||||||
click.echo("passphrase must be at least 12 characters", err=True)
|
|
||||||
sys.exit(2)
|
|
||||||
|
|
||||||
if not secrets_mod.set_secret(svc_mod.KEYCHAIN_BACKUP_PASSPHRASE_ACCOUNT, pp):
|
|
||||||
click.echo("failed to store passphrase in Keychain", err=True)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Generate + persist a fresh salt. Same value must be used by
|
|
||||||
# every subsequent invocation that uses this passphrase.
|
|
||||||
salt = _os.urandom(16)
|
|
||||||
if not secrets_mod.set_secret(svc_mod.KEYCHAIN_BACKUP_SALT_ACCOUNT, salt.hex()):
|
|
||||||
click.echo(
|
|
||||||
"WARN: passphrase stored but salt write failed; backups may be unrecoverable",
|
|
||||||
err=True,
|
|
||||||
)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
click.echo(
|
|
||||||
f"passphrase stored in Keychain account {svc_mod.KEYCHAIN_BACKUP_PASSPHRASE_ACCOUNT!r}\n"
|
|
||||||
f"salt stored in Keychain account {svc_mod.KEYCHAIN_BACKUP_SALT_ACCOUNT!r}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_backup_dir(cli_override: str | None) -> "Path":
|
|
||||||
"""Resolve the backup directory: --backup-dir > $CYCLONE_BACKUP_DIR > default."""
|
|
||||||
import os as _os
|
|
||||||
from pathlib import Path as _Path
|
|
||||||
from cyclone import db as db_mod
|
|
||||||
if cli_override:
|
|
||||||
return _Path(cli_override)
|
|
||||||
env = _os.environ.get("CYCLONE_BACKUP_DIR")
|
|
||||||
if env:
|
|
||||||
return _Path(env)
|
|
||||||
return _Path(db_mod.DEFAULT_DB_PATH.parent / "backups")
|
|
||||||
|
|
||||||
|
|
||||||
@backup.command("create")
|
|
||||||
@click.option("--backup-dir", default=None, help="Override CYCLONE_BACKUP_DIR (default: ~/.local/share/cyclone/backups)")
|
|
||||||
@click.option("--retention-days", default=None, type=int, help="Override CYCLONE_BACKUP_RETENTION_DAYS for this run's prune")
|
|
||||||
def backup_create(backup_dir: str | None, retention_days: int | None) -> None:
|
|
||||||
"""Take an encrypted backup right now."""
|
|
||||||
from cyclone import db as db_mod
|
|
||||||
from cyclone import backup_service as svc_mod
|
|
||||||
from cyclone import secrets as secrets_mod
|
|
||||||
|
|
||||||
db_mod.init_db()
|
|
||||||
|
|
||||||
passphrase = secrets_mod.get_secret(svc_mod.KEYCHAIN_BACKUP_PASSPHRASE_ACCOUNT)
|
|
||||||
salt_hex = secrets_mod.get_secret(svc_mod.KEYCHAIN_BACKUP_SALT_ACCOUNT)
|
|
||||||
salt = bytes.fromhex(salt_hex) if salt_hex else None
|
|
||||||
target_dir = _resolve_backup_dir(backup_dir)
|
|
||||||
svc_mod.reset_backup_service_for_tests()
|
|
||||||
svc = svc_mod.configure_backup_service(
|
|
||||||
backup_dir=target_dir,
|
|
||||||
passphrase=passphrase,
|
|
||||||
salt=salt,
|
|
||||||
retention_days=retention_days or 30,
|
|
||||||
)
|
|
||||||
result = svc.create_now()
|
|
||||||
click.echo(
|
|
||||||
f"created backup id={result.backup.id} filename={result.backup.filename} "
|
|
||||||
f"size={result.backup.size_bytes}B fp={result.backup.db_fingerprint[:24]}..."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@backup.command("list")
|
|
||||||
@click.option("--limit", default=50, show_default=True)
|
|
||||||
@click.option("--status", default=None, help="Filter: ok|error|pending|pruned")
|
|
||||||
def backup_list(limit: int, status: str | None) -> None:
|
|
||||||
"""List existing backups (newest first)."""
|
|
||||||
from cyclone import db as db_mod
|
|
||||||
from cyclone import backup_service as svc_mod
|
|
||||||
from cyclone import secrets as secrets_mod
|
|
||||||
|
|
||||||
db_mod.init_db()
|
|
||||||
passphrase = secrets_mod.get_secret(svc_mod.KEYCHAIN_BACKUP_PASSPHRASE_ACCOUNT)
|
|
||||||
salt_hex = secrets_mod.get_secret(svc_mod.KEYCHAIN_BACKUP_SALT_ACCOUNT)
|
|
||||||
salt = bytes.fromhex(salt_hex) if salt_hex else None
|
|
||||||
svc_mod.reset_backup_service_for_tests()
|
|
||||||
svc = svc_mod.configure_backup_service(
|
|
||||||
backup_dir=_resolve_backup_dir(None),
|
|
||||||
passphrase=passphrase,
|
|
||||||
salt=salt,
|
|
||||||
)
|
|
||||||
rows = svc.list_backups(limit=limit, status=status)
|
|
||||||
if not rows:
|
|
||||||
click.echo("(no backups)")
|
|
||||||
return
|
|
||||||
for r in rows:
|
|
||||||
click.echo(
|
|
||||||
f"{r.id:4d} {r.status:7s} {r.created_at.isoformat() if r.created_at else '-'} "
|
|
||||||
f"{r.size_bytes:>10d}B {r.filename} fp={r.db_fingerprint[:24] or '-':<24}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@backup.command("verify")
|
|
||||||
@click.argument("backup_id", type=int)
|
|
||||||
def backup_verify(backup_id: int) -> None:
|
|
||||||
"""Decrypt + checksum-verify a backup."""
|
|
||||||
from cyclone import db as db_mod
|
|
||||||
from cyclone import backup_service as svc_mod
|
|
||||||
from cyclone import secrets as secrets_mod
|
|
||||||
|
|
||||||
db_mod.init_db()
|
|
||||||
passphrase = secrets_mod.get_secret(svc_mod.KEYCHAIN_BACKUP_PASSPHRASE_ACCOUNT)
|
|
||||||
salt_hex = secrets_mod.get_secret(svc_mod.KEYCHAIN_BACKUP_SALT_ACCOUNT)
|
|
||||||
salt = bytes.fromhex(salt_hex) if salt_hex else None
|
|
||||||
svc_mod.reset_backup_service_for_tests()
|
|
||||||
svc = svc_mod.configure_backup_service(
|
|
||||||
backup_dir=_resolve_backup_dir(None),
|
|
||||||
passphrase=passphrase,
|
|
||||||
salt=salt,
|
|
||||||
)
|
|
||||||
v = svc.verify(backup_id)
|
|
||||||
if v.ok:
|
|
||||||
click.echo(f"OK: id={v.backup_id} fp={v.actual_fingerprint[:24]}... table_count={v.table_count}")
|
|
||||||
return
|
|
||||||
click.echo(
|
|
||||||
f"FAIL: id={v.backup_id} reason={v.reason} "
|
|
||||||
f"expected={v.expected_fingerprint[:24] if v.expected_fingerprint else '-'}... "
|
|
||||||
f"actual={v.actual_fingerprint[:24] if v.actual_fingerprint else '-'}...",
|
|
||||||
err=True,
|
|
||||||
)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
@backup.command("restore")
|
|
||||||
@click.argument("backup_id", type=int)
|
|
||||||
@click.option("--yes", is_flag=True, help="Skip the interactive confirm prompt")
|
|
||||||
@click.option("--actor", default="operator-cli", show_default=True)
|
|
||||||
def backup_restore(backup_id: int, yes: bool, actor: str) -> None:
|
|
||||||
"""Restore the live DB from a backup (two-step, requires --yes)."""
|
|
||||||
from cyclone import db as db_mod
|
|
||||||
from cyclone import backup_service as svc_mod
|
|
||||||
from cyclone import secrets as secrets_mod
|
|
||||||
|
|
||||||
db_mod.init_db()
|
|
||||||
passphrase = secrets_mod.get_secret(svc_mod.KEYCHAIN_BACKUP_PASSPHRASE_ACCOUNT)
|
|
||||||
salt_hex = secrets_mod.get_secret(svc_mod.KEYCHAIN_BACKUP_SALT_ACCOUNT)
|
|
||||||
salt = bytes.fromhex(salt_hex) if salt_hex else None
|
|
||||||
svc_mod.reset_backup_service_for_tests()
|
|
||||||
svc = svc_mod.configure_backup_service(
|
|
||||||
backup_dir=_resolve_backup_dir(None),
|
|
||||||
passphrase=passphrase,
|
|
||||||
salt=salt,
|
|
||||||
)
|
|
||||||
|
|
||||||
click.echo(f"Initiating restore from backup {backup_id}...")
|
|
||||||
init = svc.restore_initiate(backup_id)
|
|
||||||
click.echo(
|
|
||||||
f" backup: {init.filename} ({init.size_bytes} bytes)\n"
|
|
||||||
f" fp: {init.db_fingerprint[:24]}...\n"
|
|
||||||
f" tables: {init.table_count}\n"
|
|
||||||
f" current: fp={init.current_db_fingerprint[:24] if init.current_db_fingerprint else '-'}... "
|
|
||||||
f"tables={init.current_table_count}\n"
|
|
||||||
f" token ttl: {(init.expires_at - __import__('datetime').datetime.now(__import__('datetime').timezone.utc)).total_seconds():.0f}s"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not yes:
|
|
||||||
click.confirm(
|
|
||||||
"Replace the live DB with this backup? "
|
|
||||||
"This will dispose the engine and rebuild it.",
|
|
||||||
abort=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
click.echo("Confirming restore...")
|
|
||||||
result = svc.restore_confirm(backup_id, init.restore_token, actor=actor)
|
|
||||||
click.echo(
|
|
||||||
f"OK: restored from fp={result.restored_from_fingerprint[:24]}... "
|
|
||||||
f"to fp={result.new_db_fingerprint[:24]}... at {result.restored_at.isoformat()}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@backup.command("prune")
|
|
||||||
@click.option("--retention-days", default=None, type=int)
|
|
||||||
@click.option("--yes", is_flag=True, help="Skip the confirm prompt")
|
|
||||||
def backup_prune(retention_days: int | None, yes: bool) -> None:
|
|
||||||
"""Apply the retention policy (delete old backups)."""
|
|
||||||
from cyclone import db as db_mod
|
|
||||||
from cyclone import backup_service as svc_mod
|
|
||||||
from cyclone import secrets as secrets_mod
|
|
||||||
|
|
||||||
db_mod.init_db()
|
|
||||||
passphrase = secrets_mod.get_secret(svc_mod.KEYCHAIN_BACKUP_PASSPHRASE_ACCOUNT)
|
|
||||||
salt_hex = secrets_mod.get_secret(svc_mod.KEYCHAIN_BACKUP_SALT_ACCOUNT)
|
|
||||||
salt = bytes.fromhex(salt_hex) if salt_hex else None
|
|
||||||
svc_mod.reset_backup_service_for_tests()
|
|
||||||
svc = svc_mod.configure_backup_service(
|
|
||||||
backup_dir=_resolve_backup_dir(None),
|
|
||||||
passphrase=passphrase,
|
|
||||||
salt=salt,
|
|
||||||
retention_days=retention_days or 30,
|
|
||||||
)
|
|
||||||
if not yes:
|
|
||||||
click.confirm(
|
|
||||||
f"Delete all backups older than {svc._retention_days} days?",
|
|
||||||
abort=True,
|
|
||||||
)
|
|
||||||
deleted = svc.prune()
|
|
||||||
click.echo(f"Deleted {len(deleted)} file(s):")
|
|
||||||
for p in deleted:
|
|
||||||
click.echo(f" {p}")
|
|
||||||
|
|
||||||
|
|
||||||
@backup.command("status")
|
|
||||||
def backup_status() -> None:
|
|
||||||
"""Print the backup subsystem status snapshot."""
|
|
||||||
from cyclone import db as db_mod
|
|
||||||
from cyclone import backup_service as svc_mod
|
|
||||||
from cyclone import secrets as secrets_mod
|
|
||||||
|
|
||||||
db_mod.init_db()
|
|
||||||
passphrase = secrets_mod.get_secret(svc_mod.KEYCHAIN_BACKUP_PASSPHRASE_ACCOUNT)
|
|
||||||
salt_hex = secrets_mod.get_secret(svc_mod.KEYCHAIN_BACKUP_SALT_ACCOUNT)
|
|
||||||
salt = bytes.fromhex(salt_hex) if salt_hex else None
|
|
||||||
svc_mod.reset_backup_service_for_tests()
|
|
||||||
svc = svc_mod.configure_backup_service(
|
|
||||||
backup_dir=_resolve_backup_dir(None),
|
|
||||||
passphrase=passphrase,
|
|
||||||
salt=salt,
|
|
||||||
)
|
|
||||||
snap = svc.status()
|
|
||||||
import json
|
|
||||||
click.echo(json.dumps(snap, indent=2, default=str))
|
|
||||||
|
|||||||
+30
-131
@@ -30,6 +30,7 @@ from sqlalchemy import (
|
|||||||
Numeric,
|
Numeric,
|
||||||
String,
|
String,
|
||||||
Text,
|
Text,
|
||||||
|
func,
|
||||||
text,
|
text,
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, sessionmaker
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, sessionmaker
|
||||||
@@ -71,19 +72,9 @@ def _make_engine(url: str) -> sa.Engine:
|
|||||||
key = db_crypto.get_db_key()
|
key = db_crypto.get_db_key()
|
||||||
if key:
|
if key:
|
||||||
creator = db_crypto.make_sqlcipher_connect_creator(url, key)
|
creator = db_crypto.make_sqlcipher_connect_creator(url, key)
|
||||||
# SP15: NullPool — each thread opens its own SQLCipher
|
|
||||||
# connection. The default QueuePool returns connections
|
|
||||||
# to a shared queue that any thread can pull from, which
|
|
||||||
# breaks SQLCipher's thread affinity (a connection opened
|
|
||||||
# on thread A raises ProgrammingError when used on thread
|
|
||||||
# B). NullPool trades connection reuse for thread safety,
|
|
||||||
# which is the only correct behavior for SQLCipher under
|
|
||||||
# FastAPI's per-request threadpool.
|
|
||||||
from sqlalchemy.pool import NullPool
|
|
||||||
return sa.create_engine(
|
return sa.create_engine(
|
||||||
url,
|
url,
|
||||||
creator=creator,
|
creator=creator,
|
||||||
poolclass=NullPool,
|
|
||||||
future=True,
|
future=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -135,34 +126,6 @@ def _reset_for_tests() -> None:
|
|||||||
_SessionLocal = None
|
_SessionLocal = None
|
||||||
|
|
||||||
|
|
||||||
def dispose_engine() -> None:
|
|
||||||
"""Close every pooled connection on the current engine.
|
|
||||||
|
|
||||||
SP15: used by the key-rotation flow to ensure no connection is
|
|
||||||
holding the DB file open while ``PRAGMA rekey`` runs (SQLCipher
|
|
||||||
refuses to rekey if another connection is using the DB). The
|
|
||||||
next call to ``init_db()`` rebuilds the engine with the new key
|
|
||||||
from the Keychain.
|
|
||||||
"""
|
|
||||||
global _engine
|
|
||||||
if _engine is not None:
|
|
||||||
_engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
def reinit_engine() -> None:
|
|
||||||
"""Dispose the current engine and rebuild it from the current Keychain key.
|
|
||||||
|
|
||||||
SP15: called by the key-rotation endpoint after the Keychain is
|
|
||||||
updated with the new key. We dispose (close every pooled
|
|
||||||
connection that was using the OLD key) and then re-init (open
|
|
||||||
new connections with the NEW key). The two-step is necessary
|
|
||||||
because SQLAlchemy caches the creator in the pool — a re-init
|
|
||||||
is the only way to swap the driver-level PRAGMA key.
|
|
||||||
"""
|
|
||||||
dispose_engine()
|
|
||||||
init_db()
|
|
||||||
|
|
||||||
|
|
||||||
def engine() -> sa.Engine:
|
def engine() -> sa.Engine:
|
||||||
"""Return the process-wide Engine. Raises if `init_db()` was not called."""
|
"""Return the process-wide Engine. Raises if `init_db()` was not called."""
|
||||||
if _engine is None:
|
if _engine is None:
|
||||||
@@ -277,16 +240,6 @@ class Claim(Base):
|
|||||||
payer_rejected_by_277ca_id: Mapped[Optional[str]] = mapped_column(
|
payer_rejected_by_277ca_id: Mapped[Optional[str]] = mapped_column(
|
||||||
String(64), nullable=True
|
String(64), nullable=True
|
||||||
)
|
)
|
||||||
# SP14: when the operator hits "Acknowledge" on the Payer-Rejected
|
|
||||||
# lane, we set this timestamp. The lane query filters on it being
|
|
||||||
# NULL so acknowledged claims drop out of the working surface. The
|
|
||||||
# original payer_rejected_* fields stay intact for audit (SP11).
|
|
||||||
payer_rejected_acknowledged_at: Mapped[Optional[datetime]] = mapped_column(
|
|
||||||
DateTime(timezone=True), nullable=True
|
|
||||||
)
|
|
||||||
payer_rejected_acknowledged_actor: Mapped[Optional[str]] = mapped_column(
|
|
||||||
String(64), nullable=True
|
|
||||||
)
|
|
||||||
resubmit_count: Mapped[int] = mapped_column(
|
resubmit_count: Mapped[int] = mapped_column(
|
||||||
Integer, nullable=False, default=0, server_default=text("0")
|
Integer, nullable=False, default=0, server_default=text("0")
|
||||||
)
|
)
|
||||||
@@ -668,6 +621,11 @@ class AuditLog(Base):
|
|||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
prev_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
prev_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
# SP-auth: which authenticated user performed this action. Nullable
|
||||||
|
# so existing (pre-auth) rows and system-initiated events stay valid.
|
||||||
|
# NOT part of the hash chain — verify_chain must continue to work on
|
||||||
|
# legacy rows that pre-date this column.
|
||||||
|
user_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index("idx_audit_log_entity", "entity_type", "entity_id"),
|
Index("idx_audit_log_entity", "entity_type", "entity_id"),
|
||||||
@@ -676,89 +634,6 @@ class AuditLog(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# SP16: inbound MFT scheduler
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class ProcessedInboundFile(Base):
|
|
||||||
"""One row per inbound MFT file the scheduler has downloaded.
|
|
||||||
|
|
||||||
SP16. Lets the scheduler be idempotent: a re-tick or restart must
|
|
||||||
not re-parse the same inbound file. The unique index on
|
|
||||||
(sftp_block_name, name) prevents duplicate inserts and lets the
|
|
||||||
scheduler fast-skip already-processed files via a SELECT.
|
|
||||||
|
|
||||||
Status values:
|
|
||||||
* ok - parsed cleanly, results persisted to the store
|
|
||||||
* error - parser raised; error_message captured
|
|
||||||
* skipped - file_type not in the scheduler's allowed set
|
|
||||||
* pending - file was downloaded but a downstream step failed;
|
|
||||||
the scheduler retries on the next tick
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "processed_inbound_files"
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
||||||
sftp_block_name: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
||||||
name: Mapped[str] = mapped_column(String(256), nullable=False)
|
|
||||||
size: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
||||||
modified_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
||||||
file_type: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
|
|
||||||
processed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
||||||
parser_used: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
|
|
||||||
claim_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
||||||
status: Mapped[str] = mapped_column(String(16), nullable=False)
|
|
||||||
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
Index(
|
|
||||||
"ux_processed_inbound_files_block_name",
|
|
||||||
"sftp_block_name", "name", unique=True,
|
|
||||||
),
|
|
||||||
Index("ix_processed_inbound_files_processed_at", "processed_at"),
|
|
||||||
Index("ix_processed_inbound_files_status", "status"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# SP17: encrypted backup metadata
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class DbBackup(Base):
|
|
||||||
"""One row per encrypted backup the BackupService has taken.
|
|
||||||
|
|
||||||
The actual encrypted blob lives in a directory outside the DB
|
|
||||||
(``~/.local/share/cyclone/backups/`` by default); this table is
|
|
||||||
the index. Status values: ``pending``, ``ok``, ``error``,
|
|
||||||
``pruned``.
|
|
||||||
|
|
||||||
SP17. The unique index on ``(backup_dir, filename)`` makes a
|
|
||||||
duplicate ``create_now()`` race fail cleanly with an
|
|
||||||
IntegrityError instead of clobbering an existing backup.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "db_backups"
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
||||||
filename: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
||||||
backup_dir: Mapped[str] = mapped_column(String(512), nullable=False)
|
|
||||||
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
||||||
db_fingerprint: Mapped[Optional[str]] = mapped_column(String(80), nullable=True)
|
|
||||||
table_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
||||||
completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
||||||
status: Mapped[str] = mapped_column(String(16), nullable=False)
|
|
||||||
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
Index("ux_db_backups_filename", "backup_dir", "filename", unique=True),
|
|
||||||
Index("ix_db_backups_created_at", "created_at"),
|
|
||||||
Index("ix_db_backups_status", "status"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# SP9: providers, payers, payer_configs, clearhouse
|
# SP9: providers, payers, payer_configs, clearhouse
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -836,3 +711,27 @@ class ClearhouseORM(Base):
|
|||||||
filename_block_json: Mapped[dict] = mapped_column(JSONText, nullable=False)
|
filename_block_json: Mapped[dict] = mapped_column(JSONText, nullable=False)
|
||||||
sftp_block_json: Mapped[dict] = mapped_column(JSONText, nullable=False)
|
sftp_block_json: Mapped[dict] = mapped_column(JSONText, nullable=False)
|
||||||
updated_at: Mapped[str] = mapped_column(String(32), nullable=False)
|
updated_at: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
"""Auth user (admin / user / viewer)."""
|
||||||
|
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
role: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
disabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Session(Base):
|
||||||
|
"""Server-side auth session (HttpOnly cookie holds the id)."""
|
||||||
|
|
||||||
|
__tablename__ = "sessions"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""SQLCipher integration — encryption at rest for the SQLite DB.
|
"""SQLCipher integration — encryption at rest for the SQLite DB.
|
||||||
|
|
||||||
SP12 / SP15.
|
SP12.
|
||||||
|
|
||||||
When ``cyclone.db.key`` is present in the macOS Keychain and the
|
When ``cyclone.db.key`` is present in the macOS Keychain and the
|
||||||
``sqlcipher3`` Python package is installed, the database file is
|
``sqlcipher3`` Python package is installed, the database file is
|
||||||
@@ -8,21 +8,6 @@ encrypted with SQLCipher (AES-256). Without the key, the DB falls back
|
|||||||
to plain SQLite — operators who haven't set up Keychain yet see no
|
to plain SQLite — operators who haven't set up Keychain yet see no
|
||||||
behavior change.
|
behavior change.
|
||||||
|
|
||||||
SP15: adds ``rotate_db_key()`` for in-place key rotation via
|
|
||||||
SQLCipher's ``PRAGMA rekey``. The rotation:
|
|
||||||
|
|
||||||
1. Closes every pooled SQLAlchemy connection (so the file is unlocked).
|
|
||||||
2. Opens a single dedicated connection with the *old* key.
|
|
||||||
3. Issues ``PRAGMA rekey = "<new_key>"`` (rewrites every page with
|
|
||||||
the new key, in-place).
|
|
||||||
4. Closes the connection.
|
|
||||||
5. Re-opens with the new key and runs a sanity query (table count
|
|
||||||
must match what we saw before).
|
|
||||||
6. Caller updates the Keychain with the new key. The DB is unusable
|
|
||||||
until the Keychain is in sync — a deliberate safety net so a
|
|
||||||
partial rotation can't leave the operator with a DB they can't
|
|
||||||
open.
|
|
||||||
|
|
||||||
Why this design:
|
Why this design:
|
||||||
- The DB key never lives on disk in plaintext. It's stored in macOS
|
- The DB key never lives on disk in plaintext. It's stored in macOS
|
||||||
Keychain under service ``cyclone``, account ``cyclone.db.key``.
|
Keychain under service ``cyclone``, account ``cyclone.db.key``.
|
||||||
@@ -32,25 +17,18 @@ Why this design:
|
|||||||
optional dependency — when it's not installed we log a warning and
|
optional dependency — when it's not installed we log a warning and
|
||||||
fall back to plain SQLite. This keeps the test suite green on
|
fall back to plain SQLite. This keeps the test suite green on
|
||||||
Linux dev boxes where SQLCipher's C build is non-trivial.
|
Linux dev boxes where SQLCipher's C build is non-trivial.
|
||||||
- The encryption key is applied via a SQLAlchemy connect creator so
|
- The encryption key is applied via a SQLAlchemy connect event so
|
||||||
every connection (including the migration runner and test fixtures)
|
every connection (including the migration runner and test fixtures)
|
||||||
gets the same PRAGMA. We never store the key in a Python global.
|
gets the same PRAGMA. We never store the key in a Python global.
|
||||||
|
|
||||||
Compliance: HIPAA §164.312(a)(2)(iv) — encryption at rest. §164.312(d)
|
Compliance: HIPAA §164.312(a)(2)(iv) — encryption at rest. §164.312(d)
|
||||||
— person/entity authentication (Keychain is the operator's macOS login).
|
— person/entity authentication (Keychain is the operator's macOS login).
|
||||||
SP15: §164.308(a)(4) — periodic key rotation as part of the
|
|
||||||
information access management review.
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import logging
|
import logging
|
||||||
import secrets as _secrets
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
import sqlalchemy.event
|
import sqlalchemy.event
|
||||||
@@ -61,10 +39,6 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# Keychain account name for the DB encryption key.
|
# Keychain account name for the DB encryption key.
|
||||||
KEYCHAIN_ACCOUNT = "cyclone.db.key"
|
KEYCHAIN_ACCOUNT = "cyclone.db.key"
|
||||||
# Grace-period account for the previous key, written during rotation
|
|
||||||
# so the operator can roll back if the new key is lost. Cleared
|
|
||||||
# after the operator confirms the new key.
|
|
||||||
KEYCHAIN_ACCOUNT_PREVIOUS = "cyclone.db.key.previous"
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -116,55 +90,6 @@ def get_db_key() -> str | None:
|
|||||||
return key
|
return key
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Key generation + fingerprinting (SP15)
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
|
|
||||||
def generate_db_key() -> str:
|
|
||||||
"""Return a fresh 256-bit hex key (64 chars) for use as a SQLCipher PRAGMA key.
|
|
||||||
|
|
||||||
Uses ``secrets.token_hex(32)`` (CSPRNG). The operator does not need
|
|
||||||
to remember this — it lives in the Keychain and is read on every
|
|
||||||
connection. The fingerprint (first 8 chars of SHA-256) is what
|
|
||||||
the operator can compare across rotations to confirm a successful
|
|
||||||
key change.
|
|
||||||
"""
|
|
||||||
return _secrets.token_hex(32)
|
|
||||||
|
|
||||||
|
|
||||||
def fingerprint(key: str) -> str:
|
|
||||||
"""Return a short, operator-readable fingerprint of the key.
|
|
||||||
|
|
||||||
First 8 hex chars of SHA-256. Two fingerprints matching means
|
|
||||||
"this is the same key". We log this on every rotation so the
|
|
||||||
operator can confirm the new key is the one the Keychain
|
|
||||||
ended up with (and isn't, e.g., a transposed paste).
|
|
||||||
"""
|
|
||||||
return hashlib.sha256(key.encode("utf-8")).hexdigest()[:8]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class RotateKeyResult:
|
|
||||||
"""Outcome of a SQLCipher key rotation.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
ok: True when the rekey completed and the new key opens the DB.
|
|
||||||
old_fingerprint: fingerprint of the old key.
|
|
||||||
new_fingerprint: fingerprint of the new key.
|
|
||||||
rotated_at: ISO-8601 timestamp (UTC) of the rekey.
|
|
||||||
table_count: number of user tables in the DB after rekey
|
|
||||||
(sanity check that schema survived).
|
|
||||||
reason: human-readable error if ``ok`` is False.
|
|
||||||
"""
|
|
||||||
ok: bool
|
|
||||||
old_fingerprint: str
|
|
||||||
new_fingerprint: str
|
|
||||||
rotated_at: str
|
|
||||||
table_count: int = 0
|
|
||||||
reason: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Engine wiring
|
# Engine wiring
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -235,155 +160,3 @@ def configure_engine_for_encryption(engine: sa.Engine, key: str) -> None:
|
|||||||
# Instead we use the dialect-level hook.
|
# Instead we use the dialect-level hook.
|
||||||
engine.pool._creator = creator # type: ignore[attr-defined]
|
engine.pool._creator = creator # type: ignore[attr-defined]
|
||||||
log.info("SQLCipher encryption enabled (db key in Keychain)")
|
log.info("SQLCipher encryption enabled (db key in Keychain)")
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# Key rotation (SP15)
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
|
|
||||||
def rotate_db_key(
|
|
||||||
*,
|
|
||||||
url: str,
|
|
||||||
old_key: str,
|
|
||||||
new_key: str,
|
|
||||||
) -> RotateKeyResult:
|
|
||||||
"""Re-encrypt the SQLCipher DB with a new key, in place.
|
|
||||||
|
|
||||||
SQLCipher supports ``PRAGMA rekey = "<new_key>"`` which rewrites
|
|
||||||
every page of the DB with the new key. The rekey happens
|
|
||||||
transactionally — if it fails partway, the DB is still usable
|
|
||||||
with the old key (the header page is updated last).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
url: SQLAlchemy URL (must be ``sqlite://``-prefixed with a
|
|
||||||
filesystem path; in-memory DBs can't be rekeyed).
|
|
||||||
old_key: the current key the DB was opened with. Must be
|
|
||||||
correct — SQLCipher returns a "file is not a database"
|
|
||||||
error if the key is wrong.
|
|
||||||
new_key: the key to re-encrypt with. Should be a fresh
|
|
||||||
``generate_db_key()`` value.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
:class:`RotateKeyResult` with ``ok=True` and the new key's
|
|
||||||
fingerprint on success. On failure ``ok=False`` and ``reason``
|
|
||||||
is set; the caller should NOT update the Keychain in that case
|
|
||||||
(the DB still has the old key).
|
|
||||||
"""
|
|
||||||
import sqlcipher3
|
|
||||||
|
|
||||||
if not url.startswith("sqlite") or url.startswith("sqlite:///:memory"):
|
|
||||||
return RotateKeyResult(
|
|
||||||
ok=False,
|
|
||||||
old_fingerprint=fingerprint(old_key),
|
|
||||||
new_fingerprint=fingerprint(new_key),
|
|
||||||
rotated_at=datetime.now(timezone.utc).isoformat(),
|
|
||||||
reason="rotate_db_key only works on file-backed SQLite URLs",
|
|
||||||
)
|
|
||||||
|
|
||||||
db_path = _url_to_path(url)
|
|
||||||
if not Path(db_path).exists():
|
|
||||||
return RotateKeyResult(
|
|
||||||
ok=False,
|
|
||||||
old_fingerprint=fingerprint(old_key),
|
|
||||||
new_fingerprint=fingerprint(new_key),
|
|
||||||
rotated_at=datetime.now(timezone.utc).isoformat(),
|
|
||||||
reason=f"database file not found: {db_path}",
|
|
||||||
)
|
|
||||||
|
|
||||||
log.info(
|
|
||||||
"SQLCipher: rotating key %s -> %s on %s",
|
|
||||||
fingerprint(old_key), fingerprint(new_key), db_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
conn = sqlcipher3.connect(db_path)
|
|
||||||
try:
|
|
||||||
# Open with the OLD key.
|
|
||||||
conn.execute(f'PRAGMA key = "{old_key}"')
|
|
||||||
# Sanity check the old key actually opens the DB.
|
|
||||||
try:
|
|
||||||
pre_count = _count_user_tables(conn)
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
return RotateKeyResult(
|
|
||||||
ok=False,
|
|
||||||
old_fingerprint=fingerprint(old_key),
|
|
||||||
new_fingerprint=fingerprint(new_key),
|
|
||||||
rotated_at=datetime.now(timezone.utc).isoformat(),
|
|
||||||
reason=f"old key did not open the DB: {exc}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# PRAGMA rekey rewrites every page. SQLCipher 4+ uses the
|
|
||||||
# ``PRAGMA rekey = "..."`` form (older versions used
|
|
||||||
# ``PRAGMA rekey "..."``; sqlcipher3 0.6+ ships SQLCipher 4).
|
|
||||||
conn.execute(f'PRAGMA rekey = "{new_key}"')
|
|
||||||
|
|
||||||
# Close and reopen to confirm the new key works.
|
|
||||||
conn.close()
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
return RotateKeyResult(
|
|
||||||
ok=False,
|
|
||||||
old_fingerprint=fingerprint(old_key),
|
|
||||||
new_fingerprint=fingerprint(new_key),
|
|
||||||
rotated_at=datetime.now(timezone.utc).isoformat(),
|
|
||||||
reason=f"PRAGMA rekey failed: {exc}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Reopen with the NEW key. Any read query verifies the rekey.
|
|
||||||
try:
|
|
||||||
conn = sqlcipher3.connect(db_path)
|
|
||||||
conn.execute(f'PRAGMA key = "{new_key}"')
|
|
||||||
post_count = _count_user_tables(conn)
|
|
||||||
conn.close()
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
return RotateKeyResult(
|
|
||||||
ok=False,
|
|
||||||
old_fingerprint=fingerprint(old_key),
|
|
||||||
new_fingerprint=fingerprint(new_key),
|
|
||||||
rotated_at=datetime.now(timezone.utc).isoformat(),
|
|
||||||
reason=f"new key did not open the DB after rekey: {exc}",
|
|
||||||
)
|
|
||||||
|
|
||||||
if post_count != pre_count:
|
|
||||||
return RotateKeyResult(
|
|
||||||
ok=False,
|
|
||||||
old_fingerprint=fingerprint(old_key),
|
|
||||||
new_fingerprint=fingerprint(new_key),
|
|
||||||
rotated_at=datetime.now(timezone.utc).isoformat(),
|
|
||||||
reason=(
|
|
||||||
f"table count mismatch after rekey: "
|
|
||||||
f"pre={pre_count} post={post_count}"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return RotateKeyResult(
|
|
||||||
ok=True,
|
|
||||||
old_fingerprint=fingerprint(old_key),
|
|
||||||
new_fingerprint=fingerprint(new_key),
|
|
||||||
rotated_at=datetime.now(timezone.utc).isoformat(),
|
|
||||||
table_count=post_count,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _url_to_path(url: str) -> str:
|
|
||||||
"""Strip the ``sqlite://`` prefix from a URL to get the filesystem path."""
|
|
||||||
if url.startswith("sqlite:///"):
|
|
||||||
return url[len("sqlite:///"):]
|
|
||||||
if url.startswith("sqlite://"):
|
|
||||||
return url[len("sqlite://"):]
|
|
||||||
return url
|
|
||||||
|
|
||||||
|
|
||||||
def _count_user_tables(conn) -> int:
|
|
||||||
"""Return the number of user (non-internal) tables in the schema.
|
|
||||||
|
|
||||||
Used as a sanity check that the rekey didn't corrupt the schema.
|
|
||||||
Excludes ``sqlite_*`` system tables. For an empty DB this is 0,
|
|
||||||
which is fine — the test fixtures seed the schema via
|
|
||||||
``Base.metadata.create_all`` before rotating.
|
|
||||||
"""
|
|
||||||
rows = conn.execute(
|
|
||||||
"SELECT name FROM sqlite_master "
|
|
||||||
"WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
|
||||||
).fetchall()
|
|
||||||
return len(rows)
|
|
||||||
|
|
||||||
|
|||||||
@@ -73,12 +73,6 @@ def _claim_to_row(
|
|||||||
"payer_rejected_reason": c.payer_rejected_reason,
|
"payer_rejected_reason": c.payer_rejected_reason,
|
||||||
"payer_rejected_status_code": c.payer_rejected_status_code,
|
"payer_rejected_status_code": c.payer_rejected_status_code,
|
||||||
"payer_rejected_by_277ca_id": c.payer_rejected_by_277ca_id,
|
"payer_rejected_by_277ca_id": c.payer_rejected_by_277ca_id,
|
||||||
# SP14: acknowledgment tracking. Always null on the lane
|
|
||||||
# (we filter acknowledged claims out) but exposed for
|
|
||||||
# forward-compat if we later add a "Recently acknowledged"
|
|
||||||
# inspector view.
|
|
||||||
"payer_rejected_acknowledged_at": _isoformat(c.payer_rejected_acknowledged_at),
|
|
||||||
"payer_rejected_acknowledged_actor": c.payer_rejected_acknowledged_actor,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -198,15 +192,8 @@ def compute_lanes(session: Session, *, dismissed_pairs: Iterable[frozenset]) ->
|
|||||||
# by the payer after we submitted a syntactically-valid file).
|
# by the payer after we submitted a syntactically-valid file).
|
||||||
# We don't filter by Claim.state here because the claim may still
|
# We don't filter by Claim.state here because the claim may still
|
||||||
# be in SUBMITTED state — the payer just hasn't paid it yet.
|
# be in SUBMITTED state — the payer just hasn't paid it yet.
|
||||||
#
|
|
||||||
# SP14: filter out claims the operator has already acknowledged.
|
|
||||||
# The original payer_rejected_* fields stay intact for audit;
|
|
||||||
# only the working surface (this lane) is filtered.
|
|
||||||
payer_rejected_claims = (
|
payer_rejected_claims = (
|
||||||
session.query(Claim)
|
session.query(Claim).filter(Claim.payer_rejected_at.is_not(None)).all()
|
||||||
.filter(Claim.payer_rejected_at.is_not(None))
|
|
||||||
.filter(Claim.payer_rejected_acknowledged_at.is_(None))
|
|
||||||
.all()
|
|
||||||
)
|
)
|
||||||
pr_matched, pr_total = _line_count_lookup(session, payer_rejected_claims)
|
pr_matched, pr_total = _line_count_lookup(session, payer_rejected_claims)
|
||||||
matched_counts.update(pr_matched)
|
matched_counts.update(pr_matched)
|
||||||
|
|||||||
@@ -1,379 +0,0 @@
|
|||||||
"""SP18 — Structured JSON logging.
|
|
||||||
|
|
||||||
Wraps Python's stdlib ``logging`` to emit newline-delimited JSON
|
|
||||||
(or a dev-friendly tabular format) and to scrub obvious PHI
|
|
||||||
patterns (NPIs, SSNs, DOBs, patient names) from the message +
|
|
||||||
extra fields.
|
|
||||||
|
|
||||||
Design choices
|
|
||||||
--------------
|
|
||||||
|
|
||||||
* **No third-party deps.** stdlib ``logging`` + ``json`` + ``re``
|
|
||||||
is enough. ``loguru`` / ``structlog`` were considered; both add
|
|
||||||
a dependency for marginal gain.
|
|
||||||
|
|
||||||
* **JSON by default.** Operators running Cyclone in production
|
|
||||||
almost certainly want logs in a format their aggregator
|
|
||||||
(Loki/ELK/Vector) can parse. The dev format (``CycloneDevFormatter``)
|
|
||||||
is the opt-out for ``tail -f`` in dev.
|
|
||||||
|
|
||||||
* **Conservative PII scrubber.** Redacts unambiguous PHI patterns
|
|
||||||
only. False positives are not free — an operator's diagnostic
|
|
||||||
dump that says ``<redacted:npi>`` instead of the actual NPI
|
|
||||||
makes root-causing a parse failure harder. The scrubber can be
|
|
||||||
disabled with ``CYCLONE_LOG_NO_PII_SCRUB=1`` for tests /
|
|
||||||
forensic mode.
|
|
||||||
|
|
||||||
* **Idempotent setup.** :func:`setup_logging` can be called
|
|
||||||
multiple times (CLI re-invocation, FastAPI lifespan re-entry
|
|
||||||
under TestClient). Each call clears existing handlers on the
|
|
||||||
root logger before attaching fresh ones — so the format toggle
|
|
||||||
actually takes effect on the second call.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from logging.handlers import RotatingFileHandler
|
|
||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Formatters
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
# Stdlib LogRecord attributes we don't want to dump into the
|
|
||||||
# structured payload (they're noise for log consumers).
|
|
||||||
_RESERVED_LOGRECORD_ATTRS = frozenset({
|
|
||||||
"args", "asctime", "created", "exc_info", "exc_text", "filename",
|
|
||||||
"funcName", "levelname", "levelno", "lineno", "module", "msecs",
|
|
||||||
"message", "msg", "name", "pathname", "process", "processName",
|
|
||||||
"relativeCreated", "stack_info", "thread", "threadName",
|
|
||||||
"taskName",
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
class JsonFormatter(logging.Formatter):
|
|
||||||
"""Format a LogRecord as a single JSON line.
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
ts — ISO 8601 UTC timestamp with milliseconds.
|
|
||||||
level — uppercase level name (INFO, WARNING, etc.).
|
|
||||||
logger — the logger name (e.g. "cyclone.scheduler").
|
|
||||||
msg — the formatted log message (after %-substitution).
|
|
||||||
extra — dict of any non-reserved LogRecord attributes.
|
|
||||||
|
|
||||||
If ``exc_info`` is set, the formatter appends a ``traceback``
|
|
||||||
field with the formatted exception text (NOT a serialized
|
|
||||||
object — just the stdlib-rendered string).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def format(self, record: logging.LogRecord) -> str:
|
|
||||||
ts = datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(
|
|
||||||
timespec="milliseconds",
|
|
||||||
)
|
|
||||||
payload: dict[str, Any] = {
|
|
||||||
"ts": ts,
|
|
||||||
"level": record.levelname,
|
|
||||||
"logger": record.name,
|
|
||||||
"msg": record.getMessage(),
|
|
||||||
}
|
|
||||||
# Collect user-provided extras.
|
|
||||||
extras = {
|
|
||||||
k: v
|
|
||||||
for k, v in record.__dict__.items()
|
|
||||||
if k not in _RESERVED_LOGRECORD_ATTRS and not k.startswith("_")
|
|
||||||
}
|
|
||||||
if extras:
|
|
||||||
payload["extra"] = extras
|
|
||||||
if record.exc_info:
|
|
||||||
payload["traceback"] = self.formatException(record.exc_info)
|
|
||||||
if record.stack_info:
|
|
||||||
payload["stack"] = self.formatStack(record.stack_info)
|
|
||||||
return json.dumps(payload, default=str, sort_keys=True)
|
|
||||||
|
|
||||||
|
|
||||||
class CycloneDevFormatter(logging.Formatter):
|
|
||||||
"""Dev-friendly tabular format.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
2026-06-21T15:30:00.123Z INFO cyclone.scheduler Processed inbound foo.x12 parser=parse_999 claims=3
|
|
||||||
|
|
||||||
Same fields as ``JsonFormatter`` but human-readable. Useful for
|
|
||||||
``tail -f cyclone.log`` in dev.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def format(self, record: logging.LogRecord) -> str:
|
|
||||||
ts = datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(
|
|
||||||
timespec="milliseconds",
|
|
||||||
)
|
|
||||||
extras = {
|
|
||||||
k: v
|
|
||||||
for k, v in record.__dict__.items()
|
|
||||||
if k not in _RESERVED_LOGRECORD_ATTRS and not k.startswith("_")
|
|
||||||
}
|
|
||||||
extra_str = ""
|
|
||||||
if extras:
|
|
||||||
pairs = " ".join(f"{k}={v!r}" for k, v in extras.items())
|
|
||||||
extra_str = " " + pairs
|
|
||||||
base = f"{ts} {record.levelname:<7s} {record.name} {record.getMessage()}{extra_str}"
|
|
||||||
if record.exc_info:
|
|
||||||
base += "\n" + self.formatException(record.exc_info)
|
|
||||||
return base
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# PII scrubber
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
# Conservative PHI patterns. Each pattern is (label, compiled regex,
|
|
||||||
# replacement). Some patterns use a backreference so the field name
|
|
||||||
# (e.g. "dob=") is preserved and only the value is redacted — that
|
|
||||||
# keeps the surrounding context readable in the log line.
|
|
||||||
_PII_PATTERNS: tuple[tuple[str, "re.Pattern[str]", str], ...] = (
|
|
||||||
# 10-digit NPI. Word-boundary anchored so we don't redact, e.g.,
|
|
||||||
# the "10" in "10 claims processed".
|
|
||||||
("npi", re.compile(r"\b\d{10}\b"), "<redacted:npi>"),
|
|
||||||
# SSN: NNN-NN-NNNN or NNNNNNNNN.
|
|
||||||
(
|
|
||||||
"ssn",
|
|
||||||
re.compile(r"\b\d{3}-\d{2}-\d{4}\b|\b\d{9}\b(?=[\s,;)}])"),
|
|
||||||
"<redacted:ssn>",
|
|
||||||
),
|
|
||||||
# DOB: "dob=YYYY-MM-DD" / "date_of_birth=YYYY-MM-DD". Capture the
|
|
||||||
# field name + separator, redact only the date — keeps the
|
|
||||||
# surrounding sentence readable.
|
|
||||||
(
|
|
||||||
"dob",
|
|
||||||
re.compile(
|
|
||||||
r"(?i)(\b(?:dob|date[ _]?of[ _]?birth)[:=]\s*)\d{4}-\d{2}-\d{2}"
|
|
||||||
),
|
|
||||||
r"\1<redacted:dob>",
|
|
||||||
),
|
|
||||||
# Patient name: explicit field marker, redact the whole
|
|
||||||
# "patient_name=..." chunk so the value can't leak in a quoted form.
|
|
||||||
(
|
|
||||||
"patient_name",
|
|
||||||
re.compile(
|
|
||||||
r'(?i)\bpatient[_ ]?name[:=]\s*"?[^\",\s}]+',
|
|
||||||
),
|
|
||||||
"<redacted:patient_name>",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Extra-field KEYS that we treat as PHI by themselves — if a log call
|
|
||||||
# passes an extra like ``extra={"date_of_birth": "1980-04-12"}`` we
|
|
||||||
# redact the value even though the value alone isn't PHI-shaped. The
|
|
||||||
# key is the signal. Matched case-insensitively against the full key
|
|
||||||
# (with underscores normalized to spaces for "date of birth").
|
|
||||||
_PHI_EXTRA_KEYS: dict[str, str] = {
|
|
||||||
"npi": "npi",
|
|
||||||
"provider_npi": "npi",
|
|
||||||
"rendering_npi": "npi",
|
|
||||||
"billing_npi": "npi",
|
|
||||||
"ssn": "ssn",
|
|
||||||
"dob": "dob",
|
|
||||||
"date_of_birth": "dob",
|
|
||||||
"patient_name": "patient_name",
|
|
||||||
"patient first name": "patient_name",
|
|
||||||
"patient last name": "patient_name",
|
|
||||||
}
|
|
||||||
|
|
||||||
# When an extra key matches one of these, redact any string value
|
|
||||||
# wholesale (don't try to parse it — just replace).
|
|
||||||
_PHI_EXTRA_WHOLE_VALUE = {"npi", "ssn", "dob", "patient_name"}
|
|
||||||
|
|
||||||
|
|
||||||
class PiiScrubber(logging.Filter):
|
|
||||||
"""Filter that redacts obvious PHI from log records.
|
|
||||||
|
|
||||||
Walks the formatted message + every ``extra`` field value (if
|
|
||||||
it's a string) and rewrites matches to ``<redacted:<name>``.
|
|
||||||
Non-string extras are left alone (we don't try to serialize and
|
|
||||||
re-scrub dicts — too risky for false positives).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, name: str = "pii_scrubber") -> None:
|
|
||||||
super().__init__(name)
|
|
||||||
self._enabled = True
|
|
||||||
|
|
||||||
def disable(self) -> None:
|
|
||||||
"""Disable scrubbing (for tests / forensic mode)."""
|
|
||||||
self._enabled = False
|
|
||||||
|
|
||||||
def enable(self) -> None:
|
|
||||||
self._enabled = True
|
|
||||||
|
|
||||||
def _scrub(self, text: str) -> str:
|
|
||||||
for label, pat, repl in _PII_PATTERNS:
|
|
||||||
text = pat.sub(repl, text)
|
|
||||||
return text
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _normalize_extra_key(key: str) -> set[str]:
|
|
||||||
"""Return all candidate normalizations of a key.
|
|
||||||
|
|
||||||
``date_of_birth`` should match a lookup table that uses either
|
|
||||||
``date_of_birth`` or ``date of birth`` — so return both. Same
|
|
||||||
for ``patient_name`` vs ``patient name``.
|
|
||||||
"""
|
|
||||||
norm = key.strip().lower()
|
|
||||||
spaced = norm.replace("_", " ")
|
|
||||||
return {norm, spaced}
|
|
||||||
|
|
||||||
def _redact_extra_value(self, key: str, value: Any) -> Any:
|
|
||||||
"""Redact a single extra field value if its key signals PHI."""
|
|
||||||
for norm in self._normalize_extra_key(key):
|
|
||||||
label = _PHI_EXTRA_KEYS.get(norm)
|
|
||||||
if label:
|
|
||||||
if not isinstance(value, str):
|
|
||||||
return value
|
|
||||||
return f"<redacted:{label}>"
|
|
||||||
return value
|
|
||||||
|
|
||||||
def filter(self, record: logging.LogRecord) -> bool:
|
|
||||||
if not self._enabled:
|
|
||||||
return True
|
|
||||||
# Scrub the formatted message.
|
|
||||||
try:
|
|
||||||
msg = record.getMessage()
|
|
||||||
scrubbed_msg = self._scrub(msg)
|
|
||||||
if scrubbed_msg != msg:
|
|
||||||
record.msg = scrubbed_msg
|
|
||||||
record.args = ()
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
pass # never let the scrubber crash a log call
|
|
||||||
# Scrub string extras in place. We mutate the record's
|
|
||||||
# __dict__ directly so the formatter sees the scrubbed value.
|
|
||||||
for k, v in list(record.__dict__.items()):
|
|
||||||
if k in _RESERVED_LOGRECORD_ATTRS or k.startswith("_"):
|
|
||||||
continue
|
|
||||||
# First, key-based redaction (covers `extra={"dob": "..."}`).
|
|
||||||
redacted = self._redact_extra_value(k, v)
|
|
||||||
if redacted is not v:
|
|
||||||
record.__dict__[k] = redacted
|
|
||||||
continue
|
|
||||||
# Second, value-pattern redaction (covers `extra={"note":
|
|
||||||
# "patient_name=John Doe"}`).
|
|
||||||
if isinstance(v, str):
|
|
||||||
scrubbed = self._scrub(v)
|
|
||||||
if scrubbed != v:
|
|
||||||
record.__dict__[k] = scrubbed
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
# Module-level singleton so tests / callers can disable it cleanly.
|
|
||||||
_scrubber = PiiScrubber()
|
|
||||||
|
|
||||||
|
|
||||||
def get_scrubber() -> PiiScrubber:
|
|
||||||
"""Return the module-level PII scrubber singleton."""
|
|
||||||
return _scrubber
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# setup_logging entry point
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_level(level: str | int | None) -> int:
|
|
||||||
"""Resolve a level string/int, falling back to INFO."""
|
|
||||||
if level is None:
|
|
||||||
return logging.INFO
|
|
||||||
if isinstance(level, int):
|
|
||||||
return level
|
|
||||||
name = str(level).strip().upper()
|
|
||||||
return logging.getLevelNamesMapping().get(name, logging.INFO)
|
|
||||||
|
|
||||||
|
|
||||||
def setup_logging(
|
|
||||||
*,
|
|
||||||
level: str | int | None = None,
|
|
||||||
log_file: str | None = None,
|
|
||||||
json_format: bool = True,
|
|
||||||
scrub_pii: bool = True,
|
|
||||||
propagate_from: str | None = None,
|
|
||||||
) -> logging.Logger:
|
|
||||||
"""Configure the root logger + attach handlers.
|
|
||||||
|
|
||||||
Idempotent: re-calling clears existing handlers on the root
|
|
||||||
logger before attaching fresh ones. Safe to call from
|
|
||||||
``click.command`` invocations and the FastAPI lifespan.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
level: ``"DEBUG"`` / ``"INFO"`` / etc. or an int. ``None``
|
|
||||||
means honor ``CYCLONE_LOG_LEVEL`` env var, then INFO.
|
|
||||||
log_file: Path to a rotating log file. ``None`` means
|
|
||||||
honor ``CYCLONE_LOG_FILE`` env var, then stderr.
|
|
||||||
json_format: Emit JSON lines (default). ``False`` uses
|
|
||||||
:class:`CycloneDevFormatter`.
|
|
||||||
scrub_pii: Apply the PII scrubber (default). Honored via
|
|
||||||
``CYCLONE_LOG_NO_PII_SCRUB=1`` to disable.
|
|
||||||
propagate_from: Optional logger name to attach the scrubber
|
|
||||||
to (defaults to root).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The configured root logger.
|
|
||||||
"""
|
|
||||||
# Resolve env-var defaults.
|
|
||||||
if level is None:
|
|
||||||
level = os.environ.get("CYCLONE_LOG_LEVEL", "INFO")
|
|
||||||
if log_file is None:
|
|
||||||
log_file = os.environ.get("CYCLONE_LOG_FILE") or None
|
|
||||||
if not json_format and os.environ.get("CYCLONE_LOG_JSON", "").lower() in (
|
|
||||||
"false", "0", "no",
|
|
||||||
):
|
|
||||||
json_format = True
|
|
||||||
if os.environ.get("CYCLONE_LOG_NO_PII_SCRUB", "").lower() in ("1", "true", "yes"):
|
|
||||||
scrub_pii = False
|
|
||||||
|
|
||||||
root = logging.getLogger()
|
|
||||||
root.setLevel(_resolve_level(level))
|
|
||||||
|
|
||||||
# Clear existing handlers (idempotent re-setup).
|
|
||||||
for h in list(root.handlers):
|
|
||||||
root.removeHandler(h)
|
|
||||||
# Also clear our scrubber so we don't add duplicates.
|
|
||||||
target = logging.getLogger(propagate_from) if propagate_from else root
|
|
||||||
for flt in list(target.filters):
|
|
||||||
if isinstance(flt, PiiScrubber):
|
|
||||||
target.removeFilter(flt)
|
|
||||||
|
|
||||||
# Build the formatter.
|
|
||||||
fmt: logging.Formatter
|
|
||||||
if json_format:
|
|
||||||
fmt = JsonFormatter()
|
|
||||||
else:
|
|
||||||
fmt = CycloneDevFormatter()
|
|
||||||
|
|
||||||
# Build the handler.
|
|
||||||
if log_file:
|
|
||||||
handler: logging.Handler = RotatingFileHandler(
|
|
||||||
log_file,
|
|
||||||
maxBytes=10 * 1024 * 1024,
|
|
||||||
backupCount=5,
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
handler = logging.StreamHandler(stream=sys.stderr)
|
|
||||||
handler.setFormatter(fmt)
|
|
||||||
root.addHandler(handler)
|
|
||||||
|
|
||||||
# Attach the scrubber.
|
|
||||||
if scrub_pii:
|
|
||||||
_scrubber.enable()
|
|
||||||
else:
|
|
||||||
_scrubber.disable()
|
|
||||||
target.addFilter(_scrubber)
|
|
||||||
|
|
||||||
# Quiet down noisy third-party libs.
|
|
||||||
for noisy in ("urllib3", "paramiko", "sqlalchemy.engine"):
|
|
||||||
logging.getLogger(noisy).setLevel(max(root.level, logging.WARNING))
|
|
||||||
|
|
||||||
return root
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
-- version: 10
|
||||||
|
-- Auth (SP-auth): users + sessions tables.
|
||||||
|
--
|
||||||
|
-- `users` holds the local credential store: bcrypt-hashed password,
|
||||||
|
-- role enum ('admin' | 'user' | 'viewer'), and a soft-delete column
|
||||||
|
-- (disabled_at) so admins can revoke access without losing history.
|
||||||
|
--
|
||||||
|
-- `sessions` holds the server-side session rows; the browser only
|
||||||
|
-- carries an opaque token cookie (cyclone_session) that points here.
|
||||||
|
-- expires_at index lets us cheaply reap stale sessions.
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
disabled_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_users_username ON users(username);
|
||||||
|
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
|
||||||
|
CREATE INDEX idx_sessions_expires_at ON sessions(expires_at);
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
-- version: 10
|
|
||||||
-- SP14: Payer-Rejected lane acknowledge
|
|
||||||
-- When the operator reviews a payer-rejected claim, they hit the
|
|
||||||
-- "Acknowledge" bulk action. We mark the claim with the timestamp so
|
|
||||||
-- the lane query can filter it out (it stays in the DB for audit but
|
|
||||||
-- doesn't show up in the operator's working surface).
|
|
||||||
--
|
|
||||||
-- Why a separate column instead of clearing payer_rejected_at:
|
|
||||||
-- * Audit trail: SP11's hash-chained audit_log needs the *original*
|
|
||||||
-- rejection event intact. Clearing the timestamp would erase the
|
|
||||||
-- evidence of the payer saying "no" — exactly what auditors want
|
|
||||||
-- to see.
|
|
||||||
-- * Reconciliation: future SPs that match payer-rejected claims
|
|
||||||
-- against appeals (e.g. SP17) can still see the original status
|
|
||||||
-- code and reason.
|
|
||||||
|
|
||||||
ALTER TABLE claims ADD COLUMN payer_rejected_acknowledged_at TEXT;
|
|
||||||
ALTER TABLE claims ADD COLUMN payer_rejected_acknowledged_actor TEXT;
|
|
||||||
|
|
||||||
CREATE INDEX idx_claims_payer_rejected_unack
|
|
||||||
ON claims(payer_rejected_at)
|
|
||||||
WHERE payer_rejected_acknowledged_at IS NULL;
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- version: 11
|
||||||
|
-- Auth (SP-auth): record the acting user_id on every audit_log entry.
|
||||||
|
--
|
||||||
|
-- Backwards-compatible: existing rows get NULL user_id (they were
|
||||||
|
-- written by the pre-auth `system` actor). Going forward, the FastAPI
|
||||||
|
-- get_current_user dependency injects the id into every audit log call.
|
||||||
|
|
||||||
|
ALTER TABLE audit_log ADD COLUMN user_id INTEGER;
|
||||||
|
|
||||||
|
CREATE INDEX idx_audit_log_user_id ON audit_log(user_id);
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
-- version: 11
|
|
||||||
-- SP16: Inbound MFT polling scheduler
|
|
||||||
--
|
|
||||||
-- Tracks every file the background scheduler has downloaded from
|
|
||||||
-- the Gainwell MFT inbound path so a re-tick (or a restart) does not
|
|
||||||
-- re-process the same file. Idempotency is required for production:
|
|
||||||
-- the scheduler polls every N seconds and a slow MFT server may hand
|
|
||||||
-- us the same file across two polls.
|
|
||||||
--
|
|
||||||
-- We key on (sftp_block_name, name) — the sftp_block_name disambiguates
|
|
||||||
-- multi-provider installations (SP9+SP-multi-NPI), name is the inbound
|
|
||||||
-- filename as it appears on the MFT server.
|
|
||||||
--
|
|
||||||
-- Status values:
|
|
||||||
-- * ok — parsed cleanly, results persisted to the store
|
|
||||||
-- * error — parser raised; error_message captured for the operator
|
|
||||||
-- * skipped — file_type not in the scheduler's allowed set
|
|
||||||
-- * pending — file was downloaded but a downstream step failed
|
|
||||||
-- (e.g. DB write); the scheduler retries on the next tick
|
|
||||||
--
|
|
||||||
-- claim_count is the number of claims/remittances/acks the parser
|
|
||||||
-- surfaced. Surfaced on /api/admin/scheduler/status so the operator can
|
|
||||||
-- see throughput without parsing logs.
|
|
||||||
--
|
|
||||||
-- Compliance: not part of the HIPAA audit chain (SP11). This is
|
|
||||||
-- operational metadata; an SFTP outage shouldn't pollute the audit log.
|
|
||||||
|
|
||||||
CREATE TABLE processed_inbound_files (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
sftp_block_name TEXT NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
size INTEGER NOT NULL,
|
|
||||||
modified_at TEXT,
|
|
||||||
file_type TEXT,
|
|
||||||
processed_at TEXT NOT NULL,
|
|
||||||
parser_used TEXT,
|
|
||||||
claim_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
status TEXT NOT NULL,
|
|
||||||
error_message TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX ux_processed_inbound_files_block_name
|
|
||||||
ON processed_inbound_files(sftp_block_name, name);
|
|
||||||
CREATE INDEX ix_processed_inbound_files_processed_at
|
|
||||||
ON processed_inbound_files(processed_at DESC);
|
|
||||||
CREATE INDEX ix_processed_inbound_files_status
|
|
||||||
ON processed_inbound_files(status);
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
-- version: 12
|
|
||||||
-- SP17: encrypted DB backup metadata
|
|
||||||
--
|
|
||||||
-- Tracks every backup the BackupService has taken. The actual
|
|
||||||
-- encrypted blob lives in a directory outside the DB (default
|
|
||||||
-- ~/.local/share/cyclone/backups/); this table is just the index
|
|
||||||
-- the operator queries via GET /api/admin/backup/list.
|
|
||||||
--
|
|
||||||
-- Status values:
|
|
||||||
-- pending - row inserted, .backup() in progress or crashed before commit
|
|
||||||
-- ok - encrypted blob + sidecar written successfully
|
|
||||||
-- error - creation failed; error_message populated
|
|
||||||
-- pruned - retention policy removed the file; row kept for audit
|
|
||||||
|
|
||||||
CREATE TABLE db_backups (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
filename TEXT NOT NULL,
|
|
||||||
backup_dir TEXT NOT NULL,
|
|
||||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
|
||||||
db_fingerprint TEXT,
|
|
||||||
table_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
completed_at TEXT,
|
|
||||||
status TEXT NOT NULL,
|
|
||||||
error_message TEXT
|
|
||||||
);
|
|
||||||
CREATE UNIQUE INDEX ux_db_backups_filename ON db_backups(backup_dir, filename);
|
|
||||||
CREATE INDEX ix_db_backups_created_at ON db_backups(created_at DESC);
|
|
||||||
CREATE INDEX ix_db_backups_status ON db_backups(status);
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
"""SP20 — NPI checksum + Tax ID format validation.
|
|
||||||
|
|
||||||
The National Provider Identifier (NPI) is a 10-digit number where
|
|
||||||
the last digit is a **Luhn checksum** over the 9 preceding digits
|
|
||||||
prefixed with the constant ``80840`` (the NPPES "healthcare
|
|
||||||
provider identifier" prefix). See CMS / HHS NPI Standard:
|
|
||||||
|
|
||||||
https://www.cms.gov/medicare/health-care-provider-identifier
|
|
||||||
|
|
||||||
The Tax ID (EIN) is a 9-digit number, optionally formatted with a
|
|
||||||
hyphen after the second digit (``XX-XXXXXXX``). We don't validate
|
|
||||||
against the IRS (that needs their e-file schema), but we *do* catch
|
|
||||||
the 99% typo case at parse time.
|
|
||||||
|
|
||||||
Everything in this module is local — no NPPES, no network. Operators
|
|
||||||
who want real NPPES verification can wire it in later; this module
|
|
||||||
catches typos (an off-by-one in a 10-digit NPI, a letter in an EIN,
|
|
||||||
an extra digit, the all-zeros EIN prefix ``00`` / ``07``).
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
|
|
||||||
# NPPES prefix per the NPI Luhn algorithm. Prepended to the 9-digit
|
|
||||||
# NPI body before running the Luhn check.
|
|
||||||
_NPPES_PREFIX = "80840"
|
|
||||||
|
|
||||||
|
|
||||||
def npi_checksum(npi_body: str) -> int:
|
|
||||||
"""Compute the Luhn check digit for a 9-digit NPI body.
|
|
||||||
|
|
||||||
``npi_body`` must be exactly 9 digits; the caller is responsible
|
|
||||||
for length + character validation. Returns the check digit (0–9).
|
|
||||||
"""
|
|
||||||
if not npi_body.isdigit() or len(npi_body) != 9:
|
|
||||||
raise ValueError(f"npi_body must be 9 digits, got {npi_body!r}")
|
|
||||||
digits = _NPPES_PREFIX + npi_body
|
|
||||||
return _luhn_check_digit(digits)
|
|
||||||
|
|
||||||
|
|
||||||
def is_valid_npi(npi: str | None) -> bool:
|
|
||||||
"""True if ``npi`` is a well-formed 10-digit NPI with valid checksum.
|
|
||||||
|
|
||||||
Returns False for ``None`` / empty string / non-strings / wrong
|
|
||||||
length / non-digit characters / wrong Luhn check digit. Doesn't
|
|
||||||
call NPPES — see module docstring for why.
|
|
||||||
|
|
||||||
>>> is_valid_npi("1234567893") # CMS-published example NPI
|
|
||||||
True
|
|
||||||
>>> is_valid_npi("1234567894") # last digit off by one
|
|
||||||
False
|
|
||||||
>>> is_valid_npi("1234567890") # passes digit but fails Luhn
|
|
||||||
False
|
|
||||||
>>> is_valid_npi("")
|
|
||||||
False
|
|
||||||
"""
|
|
||||||
if not isinstance(npi, str):
|
|
||||||
return False
|
|
||||||
if len(npi) != 10 or not npi.isdigit():
|
|
||||||
return False
|
|
||||||
return npi[-1] == str(npi_checksum(npi[:-1]))
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Tax ID (EIN)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
# EIN prefix table (subset). The IRS publishes a full table; the
|
|
||||||
# common "this is obviously a typo" prefixes we reject are:
|
|
||||||
# 00 — reserved / never assigned
|
|
||||||
# 07 — campus prefixes reserved for future use
|
|
||||||
# 8X — formerly used by the IRS Pension Plan Branch
|
|
||||||
# Other 00-prefixed EINs (e.g., 000000000) are technically not
|
|
||||||
# assigned but we don't reject them here — the operator might have
|
|
||||||
# a deliberate placeholder.
|
|
||||||
_EIN_FORBIDDEN_PREFIXES = {"00", "07"}
|
|
||||||
_EIN_RESERVED_PREFIX_8X = re.compile(r"^8\d$")
|
|
||||||
|
|
||||||
# 9 digits, optionally formatted as XX-XXXXXXX.
|
|
||||||
_EIN_FORMATTED = re.compile(r"^\d{2}-\d{7}$")
|
|
||||||
_EIN_PLAIN = re.compile(r"^\d{9}$")
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_tax_id(tax_id: str | None) -> str | None:
|
|
||||||
"""Return ``tax_id`` in 9-digit plain form, or None if it's malformed.
|
|
||||||
|
|
||||||
>>> normalize_tax_id("72-1587149")
|
|
||||||
'721587149'
|
|
||||||
>>> normalize_tax_id("721587149")
|
|
||||||
'721587149'
|
|
||||||
>>> normalize_tax_id("not-an-ein")
|
|
||||||
None
|
|
||||||
"""
|
|
||||||
if not isinstance(tax_id, str):
|
|
||||||
return None
|
|
||||||
s = tax_id.strip()
|
|
||||||
if _EIN_FORMATTED.match(s):
|
|
||||||
return s.replace("-", "")
|
|
||||||
if _EIN_PLAIN.match(s):
|
|
||||||
return s
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def is_valid_tax_id(tax_id: str | None) -> bool:
|
|
||||||
"""True if ``tax_id`` is a 9-digit EIN (formatted or plain) with
|
|
||||||
a non-reserved prefix.
|
|
||||||
|
|
||||||
>>> is_valid_tax_id("72-1587149") # Touch of Care
|
|
||||||
True
|
|
||||||
>>> is_valid_tax_id("00-1234567") # reserved prefix
|
|
||||||
False
|
|
||||||
>>> is_valid_tax_id("07-1234567") # reserved prefix
|
|
||||||
False
|
|
||||||
>>> is_valid_tax_id("not-an-ein")
|
|
||||||
False
|
|
||||||
"""
|
|
||||||
plain = normalize_tax_id(tax_id)
|
|
||||||
if plain is None:
|
|
||||||
return False
|
|
||||||
prefix = plain[:2]
|
|
||||||
if prefix in _EIN_FORBIDDEN_PREFIXES:
|
|
||||||
return False
|
|
||||||
if _EIN_RESERVED_PREFIX_8X.match(prefix):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Luhn internals
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _luhn_check_digit(digits: str) -> int:
|
|
||||||
"""Return the Luhn check digit for ``digits``.
|
|
||||||
|
|
||||||
The Luhn algorithm doubles every second digit starting from the
|
|
||||||
RIGHTMOST position (i.e., the first digit doubled is the rightmost
|
|
||||||
character of ``digits``). If the doubled value exceeds 9, subtract
|
|
||||||
9. Sum all digits; the check digit is ``(10 - sum % 10) % 10``.
|
|
||||||
|
|
||||||
``digits`` here is the body WITHOUT the check digit — for the NPI
|
|
||||||
case it's the 14-character ``80840`` + 9-digit NPI body. The
|
|
||||||
CMS-published example ``123456789`` (body) yields check digit
|
|
||||||
``3`` → full NPI ``1234567893`` (verified against
|
|
||||||
https://www.cms.gov/.../NPIcheckdigit.pdf).
|
|
||||||
"""
|
|
||||||
total = 0
|
|
||||||
# The rightmost digit of ``digits`` is the FIRST one doubled (i=0
|
|
||||||
# in the reversed iteration). Per CMS, doubling starts at the
|
|
||||||
# rightmost and alternates leftward.
|
|
||||||
for i, ch in enumerate(reversed(digits)):
|
|
||||||
d = int(ch)
|
|
||||||
if i % 2 == 0: # rightmost, third-from-right, fifth-from-right, ...
|
|
||||||
d *= 2
|
|
||||||
if d > 9:
|
|
||||||
d -= 9
|
|
||||||
total += d
|
|
||||||
return (10 - total % 10) % 10
|
|
||||||
@@ -35,36 +35,6 @@ def _r020_npi_format(claim: ClaimOutput, _: PayerConfig) -> Iterable[ValidationI
|
|||||||
yield ValidationIssue(rule="R020_npi_format", severity="error", message=f"Billing provider NPI must be 10 digits, got {claim.billing_provider.npi!r}")
|
yield ValidationIssue(rule="R020_npi_format", severity="error", message=f"Billing provider NPI must be 10 digits, got {claim.billing_provider.npi!r}")
|
||||||
|
|
||||||
|
|
||||||
def _r021_npi_checksum(claim: ClaimOutput, _: PayerConfig) -> Iterable[ValidationIssue]:
|
|
||||||
"""SP20: validate the billing-provider NPI's Luhn check digit.
|
|
||||||
|
|
||||||
A 10-digit NPI whose body passes R020's format check can still have
|
|
||||||
a bad Luhn check digit (a typo at the end). Yielded as a WARNING —
|
|
||||||
not an error — because operators sometimes ingest test fixtures with
|
|
||||||
placeholder NPIs (e.g. all-same-digit) and we don't want to block
|
|
||||||
that path. Local-only check, no NPPES round-trip.
|
|
||||||
"""
|
|
||||||
npi = claim.billing_provider.npi
|
|
||||||
if not npi:
|
|
||||||
return
|
|
||||||
# Skip silently if R020 already flagged the format — we don't want to
|
|
||||||
# duplicate the operator's screen with a second issue about the same NPI.
|
|
||||||
if not NPI_RE.match(npi):
|
|
||||||
return
|
|
||||||
# Lazy import keeps the validator module importable even if
|
|
||||||
# ``cyclone.npi`` is unavailable (e.g. in some legacy test setups).
|
|
||||||
try:
|
|
||||||
from cyclone.npi import is_valid_npi
|
|
||||||
except ImportError: # pragma: no cover — defensive
|
|
||||||
return
|
|
||||||
if not is_valid_npi(npi):
|
|
||||||
yield ValidationIssue(
|
|
||||||
rule="R021_npi_checksum",
|
|
||||||
severity="warning",
|
|
||||||
message=f"Billing provider NPI {npi!r} fails Luhn checksum (likely typo)",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _r030_frequency_allowed(claim: ClaimOutput, cfg: PayerConfig) -> Iterable[ValidationIssue]:
|
def _r030_frequency_allowed(claim: ClaimOutput, cfg: PayerConfig) -> Iterable[ValidationIssue]:
|
||||||
if not claim.claim.frequency_code:
|
if not claim.claim.frequency_code:
|
||||||
return
|
return
|
||||||
@@ -422,7 +392,6 @@ _RULES: list[Rule] = [
|
|||||||
_r010_clm01_present,
|
_r010_clm01_present,
|
||||||
_r011_total_charge_positive,
|
_r011_total_charge_positive,
|
||||||
_r020_npi_format,
|
_r020_npi_format,
|
||||||
_r021_npi_checksum,
|
|
||||||
_r030_frequency_allowed,
|
_r030_frequency_allowed,
|
||||||
_r031_ref_g1_optional,
|
_r031_ref_g1_optional,
|
||||||
_r034_ref_g1_required,
|
_r034_ref_g1_required,
|
||||||
|
|||||||
@@ -100,19 +100,6 @@ class EventBus:
|
|||||||
yield await queue.get()
|
yield await queue.get()
|
||||||
queue.task_done()
|
queue.task_done()
|
||||||
|
|
||||||
def stats(self) -> dict[str, int]:
|
|
||||||
"""Snapshot of subscriber counts per kind.
|
|
||||||
|
|
||||||
Used by ``/api/health`` (SP19) and the admin diagnostics page.
|
|
||||||
Returns ``{kind: count}`` for every kind with at least one
|
|
||||||
subscriber; kinds with zero subscribers are omitted.
|
|
||||||
"""
|
|
||||||
return {
|
|
||||||
kind: len(subs)
|
|
||||||
for kind, subs in self._subscribers.items()
|
|
||||||
if subs
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_event_bus() -> EventBus:
|
def get_event_bus() -> EventBus:
|
||||||
"""Return the process-wide EventBus attached to the FastAPI app state.
|
"""Return the process-wide EventBus attached to the FastAPI app state.
|
||||||
|
|||||||
@@ -1,720 +0,0 @@
|
|||||||
"""Background inbound MFT polling scheduler (SP16).
|
|
||||||
|
|
||||||
Turns Cyclone from a manual upload tool into a live clearinghouse:
|
|
||||||
a long-running asyncio task that periodically polls the Gainwell MFT
|
|
||||||
inbound path, downloads each new file, and runs it through the
|
|
||||||
appropriate parser. The operator no longer has to watch for inbound
|
|
||||||
files and POST them to ``/api/parse-999`` etc. by hand.
|
|
||||||
|
|
||||||
Design constraints
|
|
||||||
------------------
|
|
||||||
|
|
||||||
* **Idempotent.** A re-tick (or a process restart) must not re-parse
|
|
||||||
the same inbound file. We persist a ``processed_inbound_files`` row
|
|
||||||
per file and skip ones we've already seen.
|
|
||||||
|
|
||||||
* **Crash-safe.** If the parser raises or the DB write fails, the
|
|
||||||
scheduler logs the error, records an ``error`` row, and moves on.
|
|
||||||
The next tick continues from the next file.
|
|
||||||
|
|
||||||
* **Bounded blast radius.** A bad file must not stop the scheduler.
|
|
||||||
Each file is wrapped in try/except so a 999 parser crash doesn't
|
|
||||||
prevent us from processing the next inbound 835.
|
|
||||||
|
|
||||||
* **Operator-controlled.** The scheduler is OFF by default; the
|
|
||||||
operator must explicitly start it (``POST /api/admin/scheduler/start``
|
|
||||||
or ``CYCLONE_SCHEDULER_AUTOSTART=true``). When it's running, status
|
|
||||||
is exposed via ``GET /api/admin/scheduler/status``.
|
|
||||||
|
|
||||||
* **No threading.** We use ``asyncio.create_task`` + ``asyncio.sleep``
|
|
||||||
rather than APScheduler or threading because the rest of the
|
|
||||||
codebase is asyncio-native (FastAPI). The whole polling loop runs
|
|
||||||
in the FastAPI event loop on the main thread.
|
|
||||||
|
|
||||||
Compliance: SP16 is operational metadata only. Inbound file
|
|
||||||
processing is NOT part of the HIPAA audit chain (SP11) — an SFTP
|
|
||||||
outage shouldn't pollute the audit log with parser errors.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import traceback
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Callable, Optional
|
|
||||||
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
|
||||||
|
|
||||||
from cyclone import db
|
|
||||||
from cyclone.store import store as cycl_store
|
|
||||||
from cyclone.audit_log import AuditEvent, append_event
|
|
||||||
from cyclone.clearhouse import InboundFile, SftpClient
|
|
||||||
from cyclone.db import ProcessedInboundFile
|
|
||||||
from cyclone.edi.filenames import parse_inbound_filename
|
|
||||||
from cyclone.inbox_state import apply_999_rejections
|
|
||||||
from cyclone.inbox_state_277ca import apply_277ca_rejections
|
|
||||||
from cyclone.providers import SftpBlock
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
# Status values for ProcessedInboundFile.status.
|
|
||||||
STATUS_OK = "ok"
|
|
||||||
STATUS_ERROR = "error"
|
|
||||||
STATUS_SKIPPED = "skipped"
|
|
||||||
STATUS_PENDING = "pending"
|
|
||||||
|
|
||||||
|
|
||||||
# File types we know how to route. The HCPF set is broader (270/271/
|
|
||||||
# 276/277/278/820/834/ENCR) but Cyclone's parser only covers the
|
|
||||||
# four below. Files with unknown types are recorded as ``skipped``
|
|
||||||
# so the operator can see them in the audit table.
|
|
||||||
ROUTED_FILE_TYPES = frozenset({"999", "835", "277", "277CA", "TA1"})
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class TickResult:
|
|
||||||
"""Outcome of a single scheduler tick (one poll cycle)."""
|
|
||||||
|
|
||||||
started_at: datetime
|
|
||||||
finished_at: Optional[datetime] = None
|
|
||||||
files_seen: int = 0
|
|
||||||
files_processed: int = 0
|
|
||||||
files_skipped: int = 0
|
|
||||||
files_errored: int = 0
|
|
||||||
errors: list[str] = field(default_factory=list)
|
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"started_at": self.started_at.isoformat(),
|
|
||||||
"finished_at": (
|
|
||||||
self.finished_at.isoformat() if self.finished_at else None
|
|
||||||
),
|
|
||||||
"files_seen": self.files_seen,
|
|
||||||
"files_processed": self.files_processed,
|
|
||||||
"files_skipped": self.files_skipped,
|
|
||||||
"files_errored": self.files_errored,
|
|
||||||
"errors": list(self.errors),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SchedulerStatus:
|
|
||||||
"""Snapshot of the scheduler's runtime state."""
|
|
||||||
|
|
||||||
running: bool
|
|
||||||
poll_interval_seconds: int
|
|
||||||
sftp_block_name: str
|
|
||||||
last_poll_at: Optional[datetime]
|
|
||||||
poll_count: int
|
|
||||||
total_processed: int
|
|
||||||
total_skipped: int
|
|
||||||
total_errored: int
|
|
||||||
last_tick: Optional[TickResult] = None
|
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"running": self.running,
|
|
||||||
"poll_interval_seconds": self.poll_interval_seconds,
|
|
||||||
"sftp_block_name": self.sftp_block_name,
|
|
||||||
"last_poll_at": (
|
|
||||||
self.last_poll_at.isoformat() if self.last_poll_at else None
|
|
||||||
),
|
|
||||||
"poll_count": self.poll_count,
|
|
||||||
"total_processed": self.total_processed,
|
|
||||||
"total_skipped": self.total_skipped,
|
|
||||||
"total_errored": self.total_errored,
|
|
||||||
"last_tick": self.last_tick.as_dict() if self.last_tick else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Per-file-type handlers. Each returns (parser_name, claim_count) and
|
|
||||||
# persists its own DB rows. The scheduler records the outcome.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _handle_999(text: str, source_file: str) -> tuple[str, int]:
|
|
||||||
"""Parse a 999, apply rejections, persist ack row. Returns (parser, count)."""
|
|
||||||
from cyclone.parsers.parse_999 import parse_999_text
|
|
||||||
from cyclone.parsers.exceptions import CycloneParseError
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = parse_999_text(text, input_file=source_file)
|
|
||||||
except CycloneParseError as exc:
|
|
||||||
raise ValueError(f"999 parse error: {exc}") from exc
|
|
||||||
|
|
||||||
received, accepted, rejected, ack_code = _ack_count_summary(result)
|
|
||||||
icn = result.envelope.control_number
|
|
||||||
synthetic_id = _ack_synthetic_source_batch_id(icn)
|
|
||||||
|
|
||||||
with db.SessionLocal()() as session:
|
|
||||||
def _lookup(pcn: str):
|
|
||||||
return (
|
|
||||||
session.query(db.Claim)
|
|
||||||
.filter_by(patient_control_number=pcn)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
rejection_result = apply_999_rejections(
|
|
||||||
session, result, claim_lookup=_lookup,
|
|
||||||
)
|
|
||||||
if rejection_result.matched:
|
|
||||||
for cid in rejection_result.matched:
|
|
||||||
append_event(session, AuditEvent(
|
|
||||||
event_type="claim.rejected",
|
|
||||||
entity_type="claim",
|
|
||||||
entity_id=cid,
|
|
||||||
payload={"source_batch_id": synthetic_id},
|
|
||||||
actor="999-parser-scheduler",
|
|
||||||
))
|
|
||||||
row = cycl_store.add_ack(
|
|
||||||
source_batch_id=synthetic_id,
|
|
||||||
accepted_count=accepted,
|
|
||||||
rejected_count=rejected,
|
|
||||||
received_count=received,
|
|
||||||
ack_code=ack_code,
|
|
||||||
raw_json=json.loads(result.model_dump_json()),
|
|
||||||
)
|
|
||||||
session.commit()
|
|
||||||
return "parse_999", received
|
|
||||||
|
|
||||||
|
|
||||||
def _handle_835(text: str, source_file: str) -> tuple[str, int]:
|
|
||||||
"""Parse an 835, run validation, persist batch + remittances."""
|
|
||||||
import uuid
|
|
||||||
from cyclone.parsers.parse_835 import parse as parse_835
|
|
||||||
from cyclone.parsers.exceptions import CycloneParseError
|
|
||||||
from cyclone.parsers.validator_835 import validate as validate_835
|
|
||||||
from cyclone.payers import PAYER_FACTORIES_835
|
|
||||||
from cyclone.store import BatchRecord
|
|
||||||
|
|
||||||
config = PAYER_FACTORIES_835["co_medicaid_835"]()
|
|
||||||
try:
|
|
||||||
result = parse_835(text, config, input_file=source_file)
|
|
||||||
except CycloneParseError as exc:
|
|
||||||
raise ValueError(f"835 parse error: {exc}") from exc
|
|
||||||
|
|
||||||
# Validation report (mirrors the API endpoint).
|
|
||||||
report = validate_835(result, config)
|
|
||||||
n = len(result.claims)
|
|
||||||
if report.passed:
|
|
||||||
passed, failed, failed_claim_ids = n, 0, []
|
|
||||||
else:
|
|
||||||
passed, failed, failed_claim_ids = 0, n, [
|
|
||||||
c.payer_claim_control_number for c in result.claims
|
|
||||||
]
|
|
||||||
result = result.model_copy(update={
|
|
||||||
"validation": report,
|
|
||||||
"summary": result.summary.model_copy(update={
|
|
||||||
"passed": passed,
|
|
||||||
"failed": failed,
|
|
||||||
"failed_claim_ids": failed_claim_ids,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
rec = BatchRecord(
|
|
||||||
id=uuid.uuid4().hex,
|
|
||||||
kind="835",
|
|
||||||
input_filename=source_file,
|
|
||||||
parsed_at=datetime.now(timezone.utc),
|
|
||||||
result=result,
|
|
||||||
)
|
|
||||||
cycl_store.add(rec)
|
|
||||||
return "parse_835", len(result.claims)
|
|
||||||
|
|
||||||
|
|
||||||
def _handle_277ca(text: str, source_file: str) -> tuple[str, int]:
|
|
||||||
"""Parse a 277CA, persist ack + stamp payer-rejected claims."""
|
|
||||||
from cyclone.parsers.parse_277ca import parse_277ca_text
|
|
||||||
from cyclone.parsers.exceptions import CycloneParseError
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = parse_277ca_text(text, input_file=source_file)
|
|
||||||
except CycloneParseError as exc:
|
|
||||||
raise ValueError(f"277CA parse error: {exc}") from exc
|
|
||||||
|
|
||||||
icn = result.envelope.control_number
|
|
||||||
synthetic_id = _277ca_synthetic_source_batch_id(icn)
|
|
||||||
accepted = sum(
|
|
||||||
1 for s in result.claim_statuses if s.classification == "accepted"
|
|
||||||
)
|
|
||||||
paid = sum(
|
|
||||||
1 for s in result.claim_statuses if s.classification == "paid"
|
|
||||||
)
|
|
||||||
rejected = sum(
|
|
||||||
1 for s in result.claim_statuses if s.classification == "rejected"
|
|
||||||
)
|
|
||||||
pended = sum(
|
|
||||||
1 for s in result.claim_statuses if s.classification == "pended"
|
|
||||||
)
|
|
||||||
|
|
||||||
with db.SessionLocal()() as session:
|
|
||||||
row = cycl_store.add_277ca_ack(
|
|
||||||
source_batch_id=synthetic_id,
|
|
||||||
control_number=icn,
|
|
||||||
accepted_count=accepted,
|
|
||||||
rejected_count=rejected,
|
|
||||||
paid_count=paid,
|
|
||||||
pended_count=pended,
|
|
||||||
raw_json=json.loads(result.model_dump_json()),
|
|
||||||
)
|
|
||||||
def _lookup(pcn: str):
|
|
||||||
return (
|
|
||||||
session.query(db.Claim)
|
|
||||||
.filter(db.Claim.patient_control_number == pcn)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
apply_result = apply_277ca_rejections(
|
|
||||||
session, result, claim_lookup=_lookup, two77ca_id=row.id,
|
|
||||||
)
|
|
||||||
if apply_result.matched:
|
|
||||||
for cid in apply_result.matched:
|
|
||||||
append_event(session, AuditEvent(
|
|
||||||
event_type="claim.payer_rejected",
|
|
||||||
entity_type="claim",
|
|
||||||
entity_id=cid,
|
|
||||||
payload={"source_batch_id": synthetic_id, "277ca_id": row.id},
|
|
||||||
actor="277ca-parser-scheduler",
|
|
||||||
))
|
|
||||||
session.commit()
|
|
||||||
return "parse_277ca", len(result.claim_statuses)
|
|
||||||
|
|
||||||
|
|
||||||
def _handle_ta1(text: str, source_file: str) -> tuple[str, int]:
|
|
||||||
"""Parse a TA1, persist the interchange ack row."""
|
|
||||||
from cyclone.parsers.parse_ta1 import parse_ta1_text
|
|
||||||
from cyclone.parsers.exceptions import CycloneParseError
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = parse_ta1_text(text, input_file=source_file)
|
|
||||||
except CycloneParseError as exc:
|
|
||||||
raise ValueError(f"TA1 parse error: {exc}") from exc
|
|
||||||
|
|
||||||
with db.SessionLocal()() as session:
|
|
||||||
cycl_store.add_ta1_ack(
|
|
||||||
source_batch_id=result.source_batch_id,
|
|
||||||
control_number=result.ta1.control_number,
|
|
||||||
interchange_date=result.ta1.interchange_date,
|
|
||||||
interchange_time=result.ta1.interchange_time,
|
|
||||||
ack_code=result.ta1.ack_code,
|
|
||||||
note_code=result.ta1.note_code,
|
|
||||||
ack_generated_date=result.ta1.ack_generated_date,
|
|
||||||
sender_id=result.envelope.sender_id,
|
|
||||||
receiver_id=result.envelope.receiver_id,
|
|
||||||
raw_json=json.loads(result.model_dump_json()),
|
|
||||||
)
|
|
||||||
session.commit()
|
|
||||||
return "parse_ta1", 1
|
|
||||||
|
|
||||||
|
|
||||||
# Map file_type → handler. Mirrors ROUTED_FILE_TYPES.
|
|
||||||
HANDLERS: dict[str, Callable[[str, str], tuple[str, int]]] = {
|
|
||||||
"999": _handle_999,
|
|
||||||
"835": _handle_835,
|
|
||||||
"277": _handle_277ca, # filename uses 277; parser is the same
|
|
||||||
"277CA": _handle_277ca,
|
|
||||||
"TA1": _handle_ta1,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Light copies of helpers the API endpoints use, so the scheduler can
|
|
||||||
# run without depending on the FastAPI module.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _ack_count_summary(result: Any) -> tuple[int, int, int, str]:
|
|
||||||
"""Return (received, accepted, rejected, ack_code) for a 999.
|
|
||||||
|
|
||||||
Mirrors the logic in ``cyclone.api._ack_count_summary`` but lives
|
|
||||||
here so the scheduler can run without importing the API module.
|
|
||||||
"""
|
|
||||||
if result.functional_group_acks:
|
|
||||||
fg = result.functional_group_acks[0]
|
|
||||||
return (
|
|
||||||
fg.received_count, fg.accepted_count,
|
|
||||||
fg.rejected_count, fg.ack_code,
|
|
||||||
)
|
|
||||||
sets = result.set_responses
|
|
||||||
received = len(sets)
|
|
||||||
accepted = sum(1 for s in sets if s.set_accept_reject.code == "A")
|
|
||||||
rejected = received - accepted
|
|
||||||
if rejected == 0:
|
|
||||||
code = "A"
|
|
||||||
elif accepted == 0:
|
|
||||||
code = "R"
|
|
||||||
else:
|
|
||||||
code = "P"
|
|
||||||
return (received, accepted, rejected, code)
|
|
||||||
|
|
||||||
|
|
||||||
def _ack_synthetic_source_batch_id(interchange_control_number: str) -> str:
|
|
||||||
"""Synthetic batches.id for a received 999 with no source batch."""
|
|
||||||
return f"999-{(interchange_control_number or '').strip() or '000000001'}"
|
|
||||||
|
|
||||||
|
|
||||||
def _277ca_synthetic_source_batch_id(interchange_control_number: str) -> str:
|
|
||||||
"""Synthetic batches.id for a received 277CA with no source batch."""
|
|
||||||
return f"277CA-{(interchange_control_number or '').strip() or '000000001'}"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Scheduler
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class Scheduler:
|
|
||||||
"""Background polling loop for inbound MFT files.
|
|
||||||
|
|
||||||
Lifecycle:
|
|
||||||
sched = Scheduler(sftp_block, poll_interval_seconds=60)
|
|
||||||
await sched.start() # begin polling
|
|
||||||
# ... later ...
|
|
||||||
await sched.stop() # finish current tick, then exit
|
|
||||||
status = sched.status() # snapshot
|
|
||||||
|
|
||||||
The scheduler is a single asyncio task. ``tick()`` does one full
|
|
||||||
poll cycle and is exposed for tests + the ``/api/admin/scheduler/tick``
|
|
||||||
endpoint so the operator can force a poll without waiting.
|
|
||||||
|
|
||||||
Threading: NOT thread-safe. All access (start/stop/tick/status)
|
|
||||||
must happen on the same event loop. The FastAPI app satisfies
|
|
||||||
this trivially because endpoints run on the loop.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
sftp_block: SftpBlock,
|
|
||||||
*,
|
|
||||||
poll_interval_seconds: int = 60,
|
|
||||||
sftp_block_name: str = "default",
|
|
||||||
sftp_client_factory: Optional[Callable[[SftpBlock], Any]] = None,
|
|
||||||
) -> None:
|
|
||||||
self._sftp_block = sftp_block
|
|
||||||
self._poll_interval = poll_interval_seconds
|
|
||||||
self._sftp_block_name = sftp_block_name
|
|
||||||
# Factory indirection lets tests substitute a fake client
|
|
||||||
# without monkey-patching the module-level SftpClient.
|
|
||||||
self._sftp_client_factory = sftp_client_factory or SftpClient
|
|
||||||
|
|
||||||
self._task: Optional[asyncio.Task[None]] = None
|
|
||||||
self._stop_event = asyncio.Event()
|
|
||||||
self._last_poll_at: Optional[datetime] = None
|
|
||||||
self._poll_count = 0
|
|
||||||
self._total_processed = 0
|
|
||||||
self._total_skipped = 0
|
|
||||||
self._total_errored = 0
|
|
||||||
self._last_tick: Optional[TickResult] = None
|
|
||||||
# Coalesce overlapping ticks (a slow MFT server shouldn't let
|
|
||||||
# ticks stack up; the next tick fires only after the previous
|
|
||||||
# one finishes).
|
|
||||||
self._tick_in_progress = False
|
|
||||||
|
|
||||||
# ---- Public API -------------------------------------------------------
|
|
||||||
|
|
||||||
async def start(self) -> None:
|
|
||||||
"""Begin polling. Idempotent."""
|
|
||||||
if self._task is not None and not self._task.done():
|
|
||||||
log.info("Scheduler already running; start() is a no-op")
|
|
||||||
return
|
|
||||||
self._stop_event.clear()
|
|
||||||
self._task = asyncio.create_task(self._run(), name="mft-scheduler")
|
|
||||||
log.info(
|
|
||||||
"Scheduler started",
|
|
||||||
extra={
|
|
||||||
"poll_interval_s": self._poll_interval,
|
|
||||||
"sftp_block": self._sftp_block_name,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def stop(self) -> None:
|
|
||||||
"""Stop polling. Waits for the current tick to finish."""
|
|
||||||
if self._task is None or self._task.done():
|
|
||||||
return
|
|
||||||
self._stop_event.set()
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(self._task, timeout=30)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
log.warning("Scheduler did not stop within 30s; cancelling")
|
|
||||||
self._task.cancel()
|
|
||||||
try:
|
|
||||||
await self._task
|
|
||||||
except (asyncio.CancelledError, Exception): # noqa: BLE001
|
|
||||||
pass
|
|
||||||
self._task = None
|
|
||||||
log.info("Scheduler stopped")
|
|
||||||
|
|
||||||
def status(self) -> SchedulerStatus:
|
|
||||||
"""Return a snapshot of the scheduler's state."""
|
|
||||||
return SchedulerStatus(
|
|
||||||
running=self.is_running(),
|
|
||||||
poll_interval_seconds=self._poll_interval,
|
|
||||||
sftp_block_name=self._sftp_block_name,
|
|
||||||
last_poll_at=self._last_poll_at,
|
|
||||||
poll_count=self._poll_count,
|
|
||||||
total_processed=self._total_processed,
|
|
||||||
total_skipped=self._total_skipped,
|
|
||||||
total_errored=self._total_errored,
|
|
||||||
last_tick=self._last_tick,
|
|
||||||
)
|
|
||||||
|
|
||||||
def is_running(self) -> bool:
|
|
||||||
return self._task is not None and not self._task.done()
|
|
||||||
|
|
||||||
async def tick(self) -> TickResult:
|
|
||||||
"""Run a single poll cycle and return the outcome.
|
|
||||||
|
|
||||||
Concurrent ticks are coalesced: if a tick is already in
|
|
||||||
progress, the second caller waits for it. This protects the
|
|
||||||
SFTP server from a stampede when the operator hits
|
|
||||||
``/api/admin/scheduler/tick`` while a scheduled tick is
|
|
||||||
already running.
|
|
||||||
"""
|
|
||||||
while self._tick_in_progress:
|
|
||||||
await asyncio.sleep(0.05)
|
|
||||||
self._tick_in_progress = True
|
|
||||||
try:
|
|
||||||
result = await self._tick_impl()
|
|
||||||
self._last_tick = result
|
|
||||||
self._last_poll_at = result.finished_at or result.started_at
|
|
||||||
self._poll_count += 1
|
|
||||||
self._total_processed += result.files_processed
|
|
||||||
self._total_skipped += result.files_skipped
|
|
||||||
self._total_errored += result.files_errored
|
|
||||||
return result
|
|
||||||
finally:
|
|
||||||
self._tick_in_progress = False
|
|
||||||
|
|
||||||
# ---- Internals --------------------------------------------------------
|
|
||||||
|
|
||||||
async def _run(self) -> None:
|
|
||||||
"""Main loop. Runs until ``stop()`` is called."""
|
|
||||||
# Stagger the first tick so we don't hammer the MFT server on
|
|
||||||
# startup if multiple operators restart Cyclone in lockstep.
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
while not self._stop_event.is_set():
|
|
||||||
try:
|
|
||||||
await self.tick()
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
# tick() should never raise — it catches per-file
|
|
||||||
# exceptions. This is the safety net for SFTP outages
|
|
||||||
# or DB connectivity issues.
|
|
||||||
log.exception("Scheduler tick raised", extra={"error": str(exc)})
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(
|
|
||||||
self._stop_event.wait(),
|
|
||||||
timeout=self._poll_interval,
|
|
||||||
)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
pass # poll interval elapsed; loop again
|
|
||||||
|
|
||||||
async def _tick_impl(self) -> TickResult:
|
|
||||||
"""One poll cycle: list → filter already-processed → route each."""
|
|
||||||
started = datetime.now(timezone.utc)
|
|
||||||
result = TickResult(started_at=started)
|
|
||||||
|
|
||||||
try:
|
|
||||||
files = await asyncio.to_thread(self._list_inbound)
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
log.exception("SFTP list_inbound failed")
|
|
||||||
result.errors.append(f"list_inbound: {exc}")
|
|
||||||
result.finished_at = datetime.now(timezone.utc)
|
|
||||||
return result
|
|
||||||
|
|
||||||
result.files_seen = len(files)
|
|
||||||
for f in files:
|
|
||||||
if self._stop_event.is_set():
|
|
||||||
break
|
|
||||||
await self._handle_one(f, result)
|
|
||||||
|
|
||||||
result.finished_at = datetime.now(timezone.utc)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _list_inbound(self) -> list[InboundFile]:
|
|
||||||
"""Return files in the inbound MFT path. Runs on a thread."""
|
|
||||||
client = self._sftp_client_factory(self._sftp_block)
|
|
||||||
return client.list_inbound()
|
|
||||||
|
|
||||||
async def _handle_one(self, f: InboundFile, result: TickResult) -> None:
|
|
||||||
"""Process one inbound file: skip-if-seen, classify, parse, record."""
|
|
||||||
if await self._already_processed(f.name):
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
inbound = parse_inbound_filename(f.name)
|
|
||||||
file_type = inbound.file_type
|
|
||||||
except ValueError:
|
|
||||||
file_type = None
|
|
||||||
|
|
||||||
if file_type not in HANDLERS:
|
|
||||||
await self._record(
|
|
||||||
name=f.name, size=f.size, modified_at=f.modified_at,
|
|
||||||
file_type=file_type, parser_used=None, claim_count=0,
|
|
||||||
status=STATUS_SKIPPED,
|
|
||||||
error_message=(
|
|
||||||
f"file_type {file_type!r} not in {sorted(HANDLERS)}"
|
|
||||||
if file_type else "filename does not match HCPF inbound format"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
result.files_skipped += 1
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
_path, parser_used, claim_count = await asyncio.to_thread(
|
|
||||||
self._download_and_parse, f, file_type,
|
|
||||||
)
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
log.exception("Failed to process inbound file", extra={"input_filename": f.name})
|
|
||||||
await self._record(
|
|
||||||
name=f.name, size=f.size, modified_at=f.modified_at,
|
|
||||||
file_type=file_type, parser_used=None, claim_count=0,
|
|
||||||
status=STATUS_ERROR,
|
|
||||||
error_message=(
|
|
||||||
f"{type(exc).__name__}: {exc}\n"
|
|
||||||
f"{traceback.format_exc()[-500:]}"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
result.files_errored += 1
|
|
||||||
result.errors.append(f"{f.name}: {exc}")
|
|
||||||
return
|
|
||||||
|
|
||||||
await self._record(
|
|
||||||
name=f.name, size=f.size, modified_at=f.modified_at,
|
|
||||||
file_type=file_type, parser_used=parser_used, claim_count=claim_count,
|
|
||||||
status=STATUS_OK, error_message=None,
|
|
||||||
)
|
|
||||||
result.files_processed += 1
|
|
||||||
log.info(
|
|
||||||
"Processed inbound file",
|
|
||||||
extra={
|
|
||||||
"input_filename": f.name,
|
|
||||||
"parser": parser_used,
|
|
||||||
"claims": claim_count,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _already_processed(self, name: str) -> bool:
|
|
||||||
with db.SessionLocal()() as session:
|
|
||||||
row = (
|
|
||||||
session.query(ProcessedInboundFile)
|
|
||||||
.filter_by(sftp_block_name=self._sftp_block_name, name=name)
|
|
||||||
.filter(ProcessedInboundFile.status != STATUS_PENDING)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
return row is not None
|
|
||||||
|
|
||||||
async def _record(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
name: str,
|
|
||||||
size: int,
|
|
||||||
modified_at: datetime,
|
|
||||||
file_type: Optional[str],
|
|
||||||
parser_used: Optional[str],
|
|
||||||
claim_count: int,
|
|
||||||
status: str,
|
|
||||||
error_message: Optional[str],
|
|
||||||
) -> None:
|
|
||||||
"""Persist a processed_inbound_files row. Idempotent."""
|
|
||||||
with db.SessionLocal()() as session:
|
|
||||||
row = ProcessedInboundFile(
|
|
||||||
sftp_block_name=self._sftp_block_name,
|
|
||||||
name=name,
|
|
||||||
size=size,
|
|
||||||
modified_at=modified_at,
|
|
||||||
file_type=file_type,
|
|
||||||
processed_at=datetime.now(timezone.utc),
|
|
||||||
parser_used=parser_used,
|
|
||||||
claim_count=claim_count,
|
|
||||||
status=status,
|
|
||||||
error_message=error_message,
|
|
||||||
)
|
|
||||||
session.add(row)
|
|
||||||
try:
|
|
||||||
session.commit()
|
|
||||||
except IntegrityError:
|
|
||||||
# A concurrent scheduler (or a retry after a partial
|
|
||||||
# failure) already recorded this file. That's fine —
|
|
||||||
# the latest row wins; we just skip the dup.
|
|
||||||
session.rollback()
|
|
||||||
|
|
||||||
def _download_and_parse(
|
|
||||||
self, f: InboundFile, file_type: str,
|
|
||||||
) -> tuple[Path, str, int]:
|
|
||||||
"""Download from MFT, run the right handler. Returns (path, parser, count).
|
|
||||||
|
|
||||||
Stub mode: ``f.local_path`` already points at the staged file
|
|
||||||
(set by ``SftpClient._list_inbound_stub``). Real mode: the
|
|
||||||
remote name is ``f.name`` and we round-trip through paramiko.
|
|
||||||
"""
|
|
||||||
if self._sftp_block.stub:
|
|
||||||
# In stub mode the InboundFile already has a local_path;
|
|
||||||
# reading the staged bytes directly avoids the stub's
|
|
||||||
# remote-path semantics (which expect a full inbound path).
|
|
||||||
content = f.local_path.read_bytes()
|
|
||||||
else:
|
|
||||||
client = self._sftp_client_factory(self._sftp_block)
|
|
||||||
content = client.read_file(f.name)
|
|
||||||
text = content.decode("utf-8")
|
|
||||||
handler = HANDLERS[file_type]
|
|
||||||
parser_used, claim_count = handler(text, f.name)
|
|
||||||
return f.local_path, parser_used, claim_count
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Module-level singleton — only one scheduler per process.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
_scheduler: Optional[Scheduler] = None
|
|
||||||
|
|
||||||
|
|
||||||
def configure_scheduler(
|
|
||||||
sftp_block: SftpBlock,
|
|
||||||
*,
|
|
||||||
poll_interval_seconds: int = 60,
|
|
||||||
sftp_block_name: str = "default",
|
|
||||||
force: bool = False,
|
|
||||||
) -> Scheduler:
|
|
||||||
"""Create the module-level scheduler singleton (or return the existing one).
|
|
||||||
|
|
||||||
Called from the FastAPI lifespan handler. Tests pre-configure the
|
|
||||||
scheduler before the TestClient opens the lifespan; in that case
|
|
||||||
we leave the existing singleton alone (``force=False``). Pass
|
|
||||||
``force=True`` to replace unconditionally.
|
|
||||||
"""
|
|
||||||
global _scheduler
|
|
||||||
if _scheduler is not None and not force:
|
|
||||||
return _scheduler
|
|
||||||
poll = int(
|
|
||||||
os.environ.get("CYCLONE_SCHEDULER_POLL_SECONDS", poll_interval_seconds),
|
|
||||||
)
|
|
||||||
_scheduler = Scheduler(
|
|
||||||
sftp_block,
|
|
||||||
poll_interval_seconds=poll,
|
|
||||||
sftp_block_name=sftp_block_name,
|
|
||||||
)
|
|
||||||
return _scheduler
|
|
||||||
|
|
||||||
|
|
||||||
def get_scheduler() -> Scheduler:
|
|
||||||
"""Return the module-level scheduler.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
RuntimeError: if ``configure_scheduler`` hasn't been called.
|
|
||||||
"""
|
|
||||||
if _scheduler is None:
|
|
||||||
raise RuntimeError(
|
|
||||||
"scheduler not configured; call configure_scheduler() first",
|
|
||||||
)
|
|
||||||
return _scheduler
|
|
||||||
|
|
||||||
|
|
||||||
def reset_scheduler_for_tests() -> None:
|
|
||||||
"""Clear the module-level scheduler. Test-only."""
|
|
||||||
global _scheduler
|
|
||||||
_scheduler = None
|
|
||||||
@@ -1,485 +0,0 @@
|
|||||||
"""SP19 — Security middleware + health probe.
|
|
||||||
|
|
||||||
Three concrete middlewares (body size, rate limit, security headers)
|
|
||||||
plus a richer ``/api/health`` snapshot. Sizing is for Cyclone's
|
|
||||||
local-only posture: a misconfigured Tailscale / ngrok bind, a
|
|
||||||
misbehaving cron job, a port-scanner scraping the API. Anything more
|
|
||||||
aggressive (auth, mTLS, WAF) is out of scope.
|
|
||||||
|
|
||||||
Design choices
|
|
||||||
--------------
|
|
||||||
|
|
||||||
* **In-memory rate limiter.** Cyclone is single-process; a dict
|
|
||||||
keyed by IP is enough. If we ever go multi-worker, swap for
|
|
||||||
Redis. The rate-limit counter resets after the bucket window;
|
|
||||||
failing open on the limiter itself (an unexpected exception)
|
|
||||||
rather than 503ing every request is the right call for a local tool.
|
|
||||||
|
|
||||||
* **Body-size check by Content-Length first, then chunked-read
|
|
||||||
guard.** A chunked POST can lie about its size (or omit the
|
|
||||||
header entirely); we cap read body size on the underlying stream
|
|
||||||
so a malicious client can't keep streaming forever.
|
|
||||||
|
|
||||||
* **Security headers on every response.** CSP locks the API to
|
|
||||||
same-origin + the Vite dev origin (whitelisted explicitly so a
|
|
||||||
future operator running on a different port doesn't break).
|
|
||||||
|
|
||||||
* **Health snapshot is best-effort.** Each subsystem (DB,
|
|
||||||
scheduler, pubsub) reports independently — a DB outage doesn't
|
|
||||||
blank out the rest. ``status: "degraded"`` if any subsystem is
|
|
||||||
unhappy; ``"ok"`` only when everything is.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from collections import deque
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Any, Callable
|
|
||||||
|
|
||||||
from fastapi import Request, Response
|
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Knobs (env-var driven)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_MAX_BODY_BYTES = 50 * 1024 * 1024 # 50 MB — generous for X12 EDI
|
|
||||||
DEFAULT_RATE_LIMIT_PER_MIN = 300
|
|
||||||
DEFAULT_RATE_LIMIT_WINDOW_S = 60
|
|
||||||
|
|
||||||
# CSP: API responses are JSON, not HTML. ``default-src 'none'`` is the
|
|
||||||
# strictest setting; it forbids the API from being a vector for
|
|
||||||
# injected scripts in case an operator opens a JSON viewer with an
|
|
||||||
# HTML renderer.
|
|
||||||
_SECURITY_HEADERS: dict[str, str] = {
|
|
||||||
"X-Content-Type-Options": "nosniff",
|
|
||||||
"X-Frame-Options": "DENY",
|
|
||||||
"Referrer-Policy": "same-origin",
|
|
||||||
"Permissions-Policy": "geolocation=(), microphone=(), camera=()",
|
|
||||||
"Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _env_int(name: str, default: int) -> int:
|
|
||||||
raw = os.environ.get(name)
|
|
||||||
if not raw:
|
|
||||||
return default
|
|
||||||
try:
|
|
||||||
return int(raw)
|
|
||||||
except ValueError:
|
|
||||||
log.warning("SP19: %s=%r is not an int; using default %d", name, raw, default)
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Body-size middleware
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class BodySizeLimitMiddleware:
|
|
||||||
"""Reject requests whose body exceeds ``max_bytes``.
|
|
||||||
|
|
||||||
Pure ASGI middleware (not BaseHTTPMiddleware — that one breaks
|
|
||||||
FastAPI's ``request.body()`` introspection). Two-stage guard:
|
|
||||||
1. If the request declares a ``Content-Length`` larger than
|
|
||||||
``max_bytes``, reject immediately with ``413``.
|
|
||||||
2. While reading the body chunks, cap accumulated bytes at
|
|
||||||
``max_bytes``. If we cross the cap, return 413 instead of
|
|
||||||
letting the handler read the rest.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, app: ASGIApp, max_bytes: int | None = None) -> None:
|
|
||||||
self.app = app
|
|
||||||
self.max_bytes = max_bytes or _env_int(
|
|
||||||
"CYCLONE_MAX_BODY_BYTES", DEFAULT_MAX_BODY_BYTES,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
||||||
if scope["type"] != "http":
|
|
||||||
await self.app(scope, receive, send)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Stage 1: declared length.
|
|
||||||
cl_header = None
|
|
||||||
for k, v in scope.get("headers", []):
|
|
||||||
if k == b"content-length":
|
|
||||||
cl_header = v.decode("latin-1")
|
|
||||||
break
|
|
||||||
if cl_header is not None:
|
|
||||||
try:
|
|
||||||
if int(cl_header) > self.max_bytes:
|
|
||||||
await _send_rejection(
|
|
||||||
scope, send,
|
|
||||||
code=413,
|
|
||||||
reason="body_too_large",
|
|
||||||
detail=f"Content-Length {cl_header} exceeds limit {self.max_bytes}",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
except ValueError:
|
|
||||||
await _send_rejection(
|
|
||||||
scope, send,
|
|
||||||
code=400, reason="bad_content_length",
|
|
||||||
detail=f"Content-Length {cl_header!r} is not an integer",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Stage 2: chunked read guard.
|
|
||||||
seen = 0
|
|
||||||
over_limit = False
|
|
||||||
|
|
||||||
async def wrapped_receive() -> Message:
|
|
||||||
nonlocal seen, over_limit
|
|
||||||
if over_limit:
|
|
||||||
# Drain any remaining bytes so the upstream ASGI
|
|
||||||
# server doesn't see a truncated stream.
|
|
||||||
msg = await receive()
|
|
||||||
if msg.get("type") == "http.request":
|
|
||||||
return {"type": "http.request", "body": b"", "more_body": False}
|
|
||||||
return msg
|
|
||||||
msg = await receive()
|
|
||||||
if msg.get("type") == "http.request":
|
|
||||||
body = msg.get("body", b"") or b""
|
|
||||||
seen += len(body)
|
|
||||||
if seen > self.max_bytes:
|
|
||||||
over_limit = True
|
|
||||||
return {"type": "http.request", "body": b"", "more_body": False}
|
|
||||||
return msg
|
|
||||||
|
|
||||||
if cl_header is None:
|
|
||||||
# Chunked / unknown length — guard with wrapped receive.
|
|
||||||
await self.app(scope, wrapped_receive, send)
|
|
||||||
else:
|
|
||||||
# Fixed-length known to be safe; pass through.
|
|
||||||
await self.app(scope, receive, send)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Rate-limit middleware
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class _Bucket:
|
|
||||||
"""Sliding-window counter for one IP."""
|
|
||||||
timestamps: deque = field(default_factory=deque)
|
|
||||||
|
|
||||||
def hit(self, window_s: int, now: float) -> bool:
|
|
||||||
"""Record one hit; return True if under the limit, False if over."""
|
|
||||||
# Drop expired entries.
|
|
||||||
cutoff = now - window_s
|
|
||||||
while self.timestamps and self.timestamps[0] < cutoff:
|
|
||||||
self.timestamps.popleft()
|
|
||||||
return True # we always record; the dispatcher decides to reject
|
|
||||||
|
|
||||||
def count_in_window(self, now: float, window_s: int) -> int:
|
|
||||||
cutoff = now - window_s
|
|
||||||
while self.timestamps and self.timestamps[0] < cutoff:
|
|
||||||
self.timestamps.popleft()
|
|
||||||
return len(self.timestamps)
|
|
||||||
|
|
||||||
|
|
||||||
class RateLimitMiddleware:
|
|
||||||
"""Per-IP sliding-window rate limiter (pure ASGI).
|
|
||||||
|
|
||||||
Defaults to ``CYCLONE_RATE_LIMIT_PER_MIN`` requests/minute per IP.
|
|
||||||
Health-check probes and the ``/api/health`` endpoint are exempt
|
|
||||||
so a load balancer's frequent probes don't trip the limiter.
|
|
||||||
|
|
||||||
On unexpected errors the limiter fails OPEN — better to serve a
|
|
||||||
few extra requests than to 503 every request because of a bug.
|
|
||||||
"""
|
|
||||||
|
|
||||||
EXEMPT_PATHS = ("/api/health", "/healthz", "/readyz")
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
app: ASGIApp,
|
|
||||||
per_minute: int | None = None,
|
|
||||||
window_s: int | None = None,
|
|
||||||
) -> None:
|
|
||||||
self.app = app
|
|
||||||
self.per_minute = per_minute or _env_int(
|
|
||||||
"CYCLONE_RATE_LIMIT_PER_MIN", DEFAULT_RATE_LIMIT_PER_MIN,
|
|
||||||
)
|
|
||||||
self.window_s = window_s or DEFAULT_RATE_LIMIT_WINDOW_S
|
|
||||||
self._buckets: dict[str, _Bucket] = {}
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
|
|
||||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
||||||
if scope["type"] != "http":
|
|
||||||
await self.app(scope, receive, send)
|
|
||||||
return
|
|
||||||
path = scope.get("path", "")
|
|
||||||
if path in self.EXEMPT_PATHS:
|
|
||||||
await self.app(scope, receive, send)
|
|
||||||
return
|
|
||||||
ip = _client_ip_from_scope(scope)
|
|
||||||
now = time.monotonic()
|
|
||||||
try:
|
|
||||||
with self._lock:
|
|
||||||
bucket = self._buckets.setdefault(ip, _Bucket())
|
|
||||||
bucket.timestamps.append(now)
|
|
||||||
count = bucket.count_in_window(now, self.window_s)
|
|
||||||
if count > self.per_minute:
|
|
||||||
await _send_rejection(
|
|
||||||
scope, send,
|
|
||||||
code=429,
|
|
||||||
reason="rate_limited",
|
|
||||||
detail=(
|
|
||||||
f"IP {ip} exceeded {self.per_minute} req/"
|
|
||||||
f"{self.window_s}s window"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
log.warning("SP19: rate limiter failed open: %s", exc)
|
|
||||||
await self.app(scope, receive, send)
|
|
||||||
|
|
||||||
|
|
||||||
def _client_ip_from_scope(scope: Scope) -> str:
|
|
||||||
"""Best-effort client IP from the ASGI scope. Falls back to ``"unknown"``."""
|
|
||||||
for k, v in scope.get("headers", []):
|
|
||||||
if k == b"x-forwarded-for":
|
|
||||||
return v.decode("latin-1").split(",")[0].strip()
|
|
||||||
client = scope.get("client")
|
|
||||||
if client and client[0]:
|
|
||||||
return client[0]
|
|
||||||
return "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
def _client_ip(request: Request) -> str:
|
|
||||||
"""Legacy helper (kept for the audit-event log path)."""
|
|
||||||
return _client_ip_from_scope(request.scope)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Security-headers middleware
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class SecurityHeadersMiddleware:
|
|
||||||
"""Stamp the static security headers on every response (pure ASGI).
|
|
||||||
|
|
||||||
CSP / X-Content-Type-Options / X-Frame-Options / Referrer-Policy /
|
|
||||||
Permissions-Policy. The headers are static for now; per-route
|
|
||||||
overrides can be added later if a route needs to relax them.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, app: ASGIApp, extra: dict[str, str] | None = None) -> None:
|
|
||||||
self.app = app
|
|
||||||
self.headers = [(k.lower().encode("latin-1"), v.encode("latin-1"))
|
|
||||||
for k, v in _SECURITY_HEADERS.items()]
|
|
||||||
if extra:
|
|
||||||
self.headers.extend(
|
|
||||||
(k.lower().encode("latin-1"), v.encode("latin-1"))
|
|
||||||
for k, v in extra.items()
|
|
||||||
)
|
|
||||||
|
|
||||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
||||||
if scope["type"] != "http":
|
|
||||||
await self.app(scope, receive, send)
|
|
||||||
return
|
|
||||||
|
|
||||||
async def wrapped_send(message: Message) -> None:
|
|
||||||
if message["type"] == "http.response.start":
|
|
||||||
headers = list(message.get("headers", []))
|
|
||||||
existing = {k for k, _ in headers}
|
|
||||||
for k, v in self.headers:
|
|
||||||
if k not in existing:
|
|
||||||
headers.append((k, v))
|
|
||||||
message["headers"] = headers
|
|
||||||
await send(message)
|
|
||||||
|
|
||||||
await self.app(scope, receive, wrapped_send)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Reject helper (also writes an audit event when DB is available)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
async def _send_rejection(
|
|
||||||
scope: Scope,
|
|
||||||
send: Send,
|
|
||||||
*,
|
|
||||||
code: int,
|
|
||||||
reason: str,
|
|
||||||
detail: str,
|
|
||||||
) -> None:
|
|
||||||
"""Build a 413/429 JSON response, send it, and emit a log + audit event."""
|
|
||||||
method = scope.get("method", "GET")
|
|
||||||
path = scope.get("path", "/")
|
|
||||||
ip = _client_ip_from_scope(scope)
|
|
||||||
log.warning(
|
|
||||||
"api.request_rejected",
|
|
||||||
extra={
|
|
||||||
"status": code,
|
|
||||||
"reason": reason,
|
|
||||||
"path": path,
|
|
||||||
"method": method,
|
|
||||||
"ip": ip,
|
|
||||||
"detail": detail,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
payload = {"error": reason, "detail": detail, "status": code}
|
|
||||||
body = json.dumps(payload).encode("utf-8")
|
|
||||||
await send({
|
|
||||||
"type": "http.response.start",
|
|
||||||
"status": code,
|
|
||||||
"headers": [
|
|
||||||
(b"content-type", b"application/json"),
|
|
||||||
(b"content-length", str(len(body)).encode("latin-1")),
|
|
||||||
],
|
|
||||||
})
|
|
||||||
await send({"type": "http.response.body", "body": body, "more_body": False})
|
|
||||||
# Best-effort audit-log append. Don't block the response on a DB
|
|
||||||
# outage (the rejection is the more important signal anyway).
|
|
||||||
try:
|
|
||||||
from cyclone import db
|
|
||||||
from cyclone.audit_log import AuditEvent, append_event
|
|
||||||
|
|
||||||
with db.SessionLocal()() as session:
|
|
||||||
append_event(
|
|
||||||
session,
|
|
||||||
AuditEvent(
|
|
||||||
event_type="api.request_rejected",
|
|
||||||
entity_type="http_request",
|
|
||||||
entity_id=f"{method} {path}",
|
|
||||||
payload={
|
|
||||||
"status": code,
|
|
||||||
"reason": reason,
|
|
||||||
"path": path,
|
|
||||||
"method": method,
|
|
||||||
"ip": ip,
|
|
||||||
},
|
|
||||||
actor=f"api:{ip}",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
session.commit()
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
log.debug("SP19: audit-log append failed for rejection: %s", exc)
|
|
||||||
|
|
||||||
|
|
||||||
def _reject(
|
|
||||||
request: Request,
|
|
||||||
*,
|
|
||||||
code: int,
|
|
||||||
reason: str,
|
|
||||||
detail: str,
|
|
||||||
) -> JSONResponse:
|
|
||||||
"""Sync helper kept for back-compat (the ``audit_log`` payload path)."""
|
|
||||||
return JSONResponse(
|
|
||||||
{"error": reason, "detail": detail, "status": code},
|
|
||||||
status_code=code,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Health snapshot
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class HealthSnapshot:
|
|
||||||
status: str
|
|
||||||
version: str
|
|
||||||
db: dict[str, Any] = field(default_factory=dict)
|
|
||||||
scheduler: dict[str, Any] = field(default_factory=dict)
|
|
||||||
pubsub: dict[str, Any] = field(default_factory=dict)
|
|
||||||
batch: dict[str, Any] = field(default_factory=dict)
|
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"status": self.status,
|
|
||||||
"version": self.version,
|
|
||||||
"db": self.db,
|
|
||||||
"scheduler": self.scheduler,
|
|
||||||
"pubsub": self.pubsub,
|
|
||||||
"batch": self.batch,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_health_snapshot() -> HealthSnapshot:
|
|
||||||
"""Gather a best-effort snapshot of every Cyclone subsystem.
|
|
||||||
|
|
||||||
Returns ``HealthSnapshot`` with ``status="ok"`` only if every
|
|
||||||
subsystem check passes. ``"degraded"`` if any subsystem is
|
|
||||||
unhappy but the API itself is responsive. Each subsystem reports
|
|
||||||
independently so one outage doesn't blank out the rest.
|
|
||||||
"""
|
|
||||||
from cyclone import __version__, db
|
|
||||||
|
|
||||||
snap = HealthSnapshot(status="ok", version=__version__)
|
|
||||||
|
|
||||||
# DB connectivity.
|
|
||||||
try:
|
|
||||||
with db.SessionLocal()() as session:
|
|
||||||
session.execute(db.text("SELECT 1"))
|
|
||||||
snap.db = {"ok": True}
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
snap.db = {"ok": False, "error": str(exc)}
|
|
||||||
snap.status = "degraded"
|
|
||||||
|
|
||||||
# Scheduler state.
|
|
||||||
try:
|
|
||||||
from cyclone import scheduler as scheduler_mod
|
|
||||||
sched = scheduler_mod.get_scheduler()
|
|
||||||
snap.scheduler = {
|
|
||||||
"running": sched.is_running(),
|
|
||||||
"interval_s": sched._poll_interval, # noqa: SLF001
|
|
||||||
"sftp_block": sched._sftp_block_name, # noqa: SLF001
|
|
||||||
}
|
|
||||||
except RuntimeError:
|
|
||||||
snap.scheduler = {"running": False, "configured": False}
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
snap.scheduler = {"ok": False, "error": str(exc)}
|
|
||||||
snap.status = "degraded"
|
|
||||||
|
|
||||||
# Backup scheduler.
|
|
||||||
try:
|
|
||||||
from cyclone import backup_scheduler as bks_mod
|
|
||||||
bks = bks_mod.get_backup_scheduler()
|
|
||||||
snap.scheduler["backup_scheduler_running"] = bks.is_running()
|
|
||||||
snap.scheduler["backup_interval_hours"] = bks.interval_hours
|
|
||||||
except (RuntimeError, ImportError):
|
|
||||||
snap.scheduler["backup_scheduler_running"] = False
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
pass # secondary subsystem; don't degrade the overall status
|
|
||||||
|
|
||||||
# Pubsub bus stats — placeholder. The /api/health handler fills
|
|
||||||
# in the real subscriber counts using request.app.state.event_bus.
|
|
||||||
snap.pubsub = {"note": "filled in by health router"}
|
|
||||||
|
|
||||||
# Last batch timestamp + count.
|
|
||||||
try:
|
|
||||||
from cyclone import db as db_mod
|
|
||||||
from cyclone.db import Batch
|
|
||||||
with db_mod.SessionLocal()() as session:
|
|
||||||
row = (
|
|
||||||
session.query(Batch)
|
|
||||||
.order_by(Batch.parsed_at.desc())
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if row is not None:
|
|
||||||
snap.batch = {
|
|
||||||
"last_batch_id": row.id,
|
|
||||||
"last_batch_kind": row.kind,
|
|
||||||
"last_batch_at": row.parsed_at.isoformat() if row.parsed_at else None,
|
|
||||||
"last_batch_filename": row.input_filename,
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
snap.batch = {"last_batch_id": None, "note": "no batches yet"}
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
snap.batch = {"ok": False, "error": str(exc)}
|
|
||||||
snap.status = "degraded"
|
|
||||||
|
|
||||||
return snap
|
|
||||||
+143
-26
@@ -1688,6 +1688,149 @@ class CycloneStore:
|
|||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def get_dashboard_summary(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
months: int = 6,
|
||||||
|
top_providers: int = 4,
|
||||||
|
denials: int = 5,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Aggregate the data the Dashboard needs in one round-trip.
|
||||||
|
|
||||||
|
Returns KPIs (claim/billed/received/denial totals), monthly
|
||||||
|
buckets sized to the trailing ``months`` window (oldest →
|
||||||
|
newest, UTC), the top N providers by claim count, and the most
|
||||||
|
recent denied claims. Mirrors the shape consumed by the
|
||||||
|
Dashboard SPA — see ``get_dashboard_summary``'s frontend hook.
|
||||||
|
|
||||||
|
Performance: reuses the in-memory ``iter_claims`` and
|
||||||
|
``distinct_providers`` paths, both of which already scan the
|
||||||
|
full table. At current scale that's fine; a SQL ``GROUP BY``
|
||||||
|
aggregation is the next step if claim volume grows.
|
||||||
|
"""
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
# All UI-shaped claim dicts. Sorted by submissionDate ascending
|
||||||
|
# so the running-AR walk below is in chronological order.
|
||||||
|
all_claims = self.iter_claims(
|
||||||
|
sort="submissionDate", order="asc", limit=1_000_000, offset=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
# Build the trailing-month window: [now - months, now), bucketed
|
||||||
|
# in UTC. The frontend sparkline wants chronological order
|
||||||
|
# (oldest first), which is what `range(months)` walking backwards
|
||||||
|
# from the current month produces.
|
||||||
|
bucket_keys: list[tuple[int, int]] = []
|
||||||
|
bucket_labels: dict[tuple[int, int], str] = {}
|
||||||
|
for i in range(months - 1, -1, -1):
|
||||||
|
y = now.year
|
||||||
|
m = now.month - i
|
||||||
|
while m <= 0:
|
||||||
|
m += 12
|
||||||
|
y -= 1
|
||||||
|
key = (y, m)
|
||||||
|
bucket_keys.append(key)
|
||||||
|
# Use the first day of the month to render a short label.
|
||||||
|
bucket_labels[key] = datetime(y, m, 1, tzinfo=timezone.utc).strftime("%b")
|
||||||
|
|
||||||
|
# Aggregate per month.
|
||||||
|
per_month: dict[tuple[int, int], dict[str, float]] = {
|
||||||
|
k: {"count": 0, "billed": 0.0, "received": 0.0, "denied": 0}
|
||||||
|
for k in bucket_keys
|
||||||
|
}
|
||||||
|
|
||||||
|
claim_count = len(all_claims)
|
||||||
|
billed_total = 0.0
|
||||||
|
received_total = 0.0
|
||||||
|
denied_count = 0
|
||||||
|
pending_count = 0
|
||||||
|
recent_denials: list[dict] = []
|
||||||
|
|
||||||
|
for c in all_claims:
|
||||||
|
billed = float(c.get("billedAmount") or 0)
|
||||||
|
received = float(c.get("receivedAmount") or 0)
|
||||||
|
status = (c.get("status") or "").lower()
|
||||||
|
billed_total += billed
|
||||||
|
received_total += received
|
||||||
|
if status == "denied":
|
||||||
|
denied_count += 1
|
||||||
|
# Collect newest-first for the recent-denials list. Slice
|
||||||
|
# after the sort below.
|
||||||
|
recent_denials.append(c)
|
||||||
|
if status in ("submitted", "pending"):
|
||||||
|
pending_count += 1
|
||||||
|
|
||||||
|
sub = c.get("submissionDate") or ""
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(sub.replace("Z", "+00:00"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
key = (dt.astimezone(timezone.utc).year, dt.astimezone(timezone.utc).month)
|
||||||
|
if key in per_month:
|
||||||
|
bucket = per_month[key]
|
||||||
|
bucket["count"] += 1
|
||||||
|
bucket["billed"] += billed
|
||||||
|
bucket["received"] += received
|
||||||
|
if status == "denied":
|
||||||
|
bucket["denied"] += 1
|
||||||
|
|
||||||
|
# Build the monthly list with running AR (cumulative billed -
|
||||||
|
# cumulative received, clamped at 0 — matches the existing
|
||||||
|
# Dashboard math). Keys are camelCase to match the rest of the
|
||||||
|
# iter_claims / distinct_providers surface (see /api/claims
|
||||||
|
# payload shape).
|
||||||
|
running_ar = 0.0
|
||||||
|
monthly: list[dict[str, Any]] = []
|
||||||
|
for key in bucket_keys:
|
||||||
|
agg = per_month[key]
|
||||||
|
count = int(agg["count"])
|
||||||
|
billed = float(agg["billed"])
|
||||||
|
received = float(agg["received"])
|
||||||
|
denied = int(agg["denied"])
|
||||||
|
running_ar = max(0.0, running_ar + billed - received)
|
||||||
|
monthly.append({
|
||||||
|
"month": f"{key[0]:04d}-{key[1]:02d}",
|
||||||
|
"label": bucket_labels[key],
|
||||||
|
"count": count,
|
||||||
|
"billed": billed,
|
||||||
|
"received": received,
|
||||||
|
"denied": denied,
|
||||||
|
"ar": running_ar,
|
||||||
|
"denialRate": (denied / count * 100.0) if count else 0.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Recent denials: newest first, capped.
|
||||||
|
recent_denials.sort(
|
||||||
|
key=lambda c: c.get("submissionDate") or "",
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
recent_denials = recent_denials[:denials]
|
||||||
|
|
||||||
|
# Top providers by claim count, descending.
|
||||||
|
all_providers = self.distinct_providers()
|
||||||
|
all_providers.sort(key=lambda p: p.get("claimCount") or 0, reverse=True)
|
||||||
|
top = all_providers[:top_providers]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"kpis": {
|
||||||
|
"claimCount": claim_count,
|
||||||
|
"billedTotal": billed_total,
|
||||||
|
"receivedTotal": received_total,
|
||||||
|
"outstandingAr": billed_total - received_total,
|
||||||
|
"deniedCount": denied_count,
|
||||||
|
"pendingCount": pending_count,
|
||||||
|
"denialRate": (denied_count / claim_count * 100.0) if claim_count else 0.0,
|
||||||
|
},
|
||||||
|
"monthly": monthly,
|
||||||
|
"topProviders": top,
|
||||||
|
"recentDenials": recent_denials,
|
||||||
|
"providerCount": len(all_providers),
|
||||||
|
"asOf": now.isoformat().replace("+00:00", "Z"),
|
||||||
|
}
|
||||||
|
|
||||||
# -- 999 ACKs (SP3 P3 T13) -------------------------------------------
|
# -- 999 ACKs (SP3 P3 T13) -------------------------------------------
|
||||||
|
|
||||||
def add_ack(
|
def add_ack(
|
||||||
@@ -1849,32 +1992,6 @@ class CycloneStore:
|
|||||||
with db.SessionLocal()() as s:
|
with db.SessionLocal()() as s:
|
||||||
return s.get(db.Two77caAck, ack_id)
|
return s.get(db.Two77caAck, ack_id)
|
||||||
|
|
||||||
# -- SP17: encrypted DB backups -------------------------------------
|
|
||||||
|
|
||||||
def add_backup_pending(self, *, filename: str, backup_dir: str) -> db.DbBackup:
|
|
||||||
"""Insert a ``pending`` row for a backup that is about to start.
|
|
||||||
|
|
||||||
The BackupService fills in ``status`` / ``size_bytes`` /
|
|
||||||
``db_fingerprint`` / ``table_count`` / ``completed_at`` after
|
|
||||||
the encrypted blob lands on disk.
|
|
||||||
"""
|
|
||||||
with db.SessionLocal()() as s:
|
|
||||||
row = db.DbBackup(
|
|
||||||
filename=filename,
|
|
||||||
backup_dir=backup_dir,
|
|
||||||
size_bytes=0,
|
|
||||||
db_fingerprint=None,
|
|
||||||
table_count=0,
|
|
||||||
created_at=utcnow(),
|
|
||||||
completed_at=None,
|
|
||||||
status="pending",
|
|
||||||
error_message=None,
|
|
||||||
)
|
|
||||||
s.add(row)
|
|
||||||
s.commit()
|
|
||||||
s.refresh(row)
|
|
||||||
return row
|
|
||||||
|
|
||||||
# -- manual reconciliation (T12) -----------------------------------
|
# -- manual reconciliation (T12) -----------------------------------
|
||||||
|
|
||||||
def list_unmatched(self, *, kind: str = "both") -> dict:
|
def list_unmatched(self, *, kind: str = "both") -> dict:
|
||||||
|
|||||||
@@ -10,26 +10,51 @@ requires the engine to be initialized before any DB access, whereas the
|
|||||||
old in-memory store did not. Adding the init here keeps existing test
|
old in-memory store did not. Adding the init here keeps existing test
|
||||||
modules (test_api.py, test_api_835.py, test_api_gets.py,
|
modules (test_api.py, test_api_835.py, test_api_gets.py,
|
||||||
test_api_parse_persists.py) working unchanged.
|
test_api_parse_persists.py) working unchanged.
|
||||||
|
|
||||||
|
Auth posture: ``CYCLONE_AUTH_DISABLED`` defaults to ``"1"`` here so the
|
||||||
|
existing test suite (700+ tests that don't log in) continues to pass
|
||||||
|
unmodified. Tests in ``test_auth_*`` modules explicitly re-enable auth
|
||||||
|
so they exercise the real ``get_current_user`` / ``matrix_gate`` paths.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Must be set before ``cyclone.api`` is imported so ``get_current_user``
|
||||||
|
# / ``matrix_gate`` see the env var. The module-level ``AUTH_DISABLED``
|
||||||
|
# flag in ``cyclone.auth.deps`` is flipped per-test below.
|
||||||
|
os.environ.setdefault("CYCLONE_AUTH_DISABLED", "1")
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _auto_init_db(tmp_path, monkeypatch):
|
def _auto_init_db(tmp_path, monkeypatch, request):
|
||||||
"""Point CYCLONE_DB_URL at a per-test SQLite file and init the schema.
|
"""Point CYCLONE_DB_URL at a per-test SQLite file and init the schema.
|
||||||
|
|
||||||
Also wires a fresh ``EventBus`` onto ``app.state`` because ``TestClient``
|
Also wires a fresh ``EventBus`` onto ``app.state`` because ``TestClient``
|
||||||
does not invoke the FastAPI lifespan handler unless used as a context
|
does not invoke the FastAPI lifespan handler unless used as a context
|
||||||
manager. The bus is reset between tests so subscribers don't leak.
|
manager. The bus is reset between tests so subscribers don't leak.
|
||||||
|
|
||||||
|
Auth posture is set per-test from the test module's name: legacy
|
||||||
|
tests (any module not starting with ``test_auth``) run with
|
||||||
|
``AUTH_DISABLED=True`` so they hit the API without logging in;
|
||||||
|
``test_auth_*`` modules run with ``AUTH_DISABLED=False`` so they
|
||||||
|
exercise the real ``get_current_user`` / ``matrix_gate`` paths.
|
||||||
"""
|
"""
|
||||||
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
|
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
|
||||||
from cyclone import db
|
from cyclone import db
|
||||||
from cyclone.api import app
|
from cyclone.api import app
|
||||||
|
from cyclone.auth import deps as _auth_deps
|
||||||
from cyclone.pubsub import EventBus
|
from cyclone.pubsub import EventBus
|
||||||
|
|
||||||
|
test_module = request.node.module.__name__
|
||||||
|
if test_module.startswith("test_auth"):
|
||||||
|
_auth_deps.AUTH_DISABLED = False
|
||||||
|
else:
|
||||||
|
_auth_deps.AUTH_DISABLED = True
|
||||||
|
|
||||||
db._reset_for_tests()
|
db._reset_for_tests()
|
||||||
db.init_db()
|
db.init_db()
|
||||||
app.state.event_bus = EventBus()
|
app.state.event_bus = EventBus()
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ PER*IC*Test Contact*EM*test@example.com~
|
|||||||
NM1*40*2*COLORADO MEDICAL ASSISTANCE PROGRAM*****46*COMEDASSISTPROG~
|
NM1*40*2*COLORADO MEDICAL ASSISTANCE PROGRAM*****46*COMEDASSISTPROG~
|
||||||
HL*1**20*1~
|
HL*1**20*1~
|
||||||
PRV*BI*PXC*251E00000X~
|
PRV*BI*PXC*251E00000X~
|
||||||
NM1*85*2*Test Provider Inc*****XX*1993999998~
|
NM1*85*2*Test Provider Inc*****XX*1234567890~
|
||||||
N3*123 Test St~
|
N3*123 Test St~
|
||||||
N4*Denver*CO*80202~
|
N4*Denver*CO*80202~
|
||||||
REF*EI*123456789~
|
REF*EI*123456789~
|
||||||
|
|||||||
-1
@@ -1 +0,0 @@
|
|||||||
ISA*00* *00* *ZZ*SENDER *ZZ*RECEIVER *260520*1750*^*00501*000000001*0*P*:~TA1*000000001*20260520*1750*A*000*20260520~IEA*1*000000001~
|
|
||||||
@@ -51,19 +51,18 @@ def test_migration_0002_creates_acks_table():
|
|||||||
|
|
||||||
def test_migration_latest_idempotent_on_fresh_db():
|
def test_migration_latest_idempotent_on_fresh_db():
|
||||||
"""Re-running the migration on the same DB must be a no-op (PRAGMA
|
"""Re-running the migration on the same DB must be a no-op (PRAGMA
|
||||||
user_version already at the latest version — currently 12 after
|
user_version already at the latest version — currently 9 after
|
||||||
0004-0006 line_reconciliation, 0005 ta1_acks, SP9's 0007
|
0004-0006 line_reconciliation, 0005 ta1_acks, SP9's 0007
|
||||||
providers/payers/clearhouse, SP10's 0008 payer_rejected,
|
providers/payers/clearhouse, SP10's 0008 payer_rejected, and
|
||||||
SP11's 0009 audit_log, SP14's 0010 payer_rejected_acknowledged,
|
SP11's 0009 audit_log)."""
|
||||||
SP16's 0011 processed_inbound_files, SP17's 0012 db_backups)."""
|
|
||||||
with db.engine().begin() as c:
|
with db.engine().begin() as c:
|
||||||
v1 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
|
v1 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
|
||||||
assert v1 == 12
|
assert v1 == 9
|
||||||
# A second run should not raise and should not bump the version.
|
# A second run should not raise and should not bump the version.
|
||||||
db_migrate.run(db.engine())
|
db_migrate.run(db.engine())
|
||||||
with db.engine().begin() as c:
|
with db.engine().begin() as c:
|
||||||
v2 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
|
v2 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
|
||||||
assert v2 == 12
|
assert v2 == 9
|
||||||
|
|
||||||
|
|
||||||
def test_add_ack_persists_row():
|
def test_add_ack_persists_row():
|
||||||
|
|||||||
@@ -31,18 +31,10 @@ def client() -> TestClient:
|
|||||||
|
|
||||||
|
|
||||||
def test_health_endpoint(client: TestClient):
|
def test_health_endpoint(client: TestClient):
|
||||||
"""SP19: health endpoint now returns a subsystem snapshot."""
|
|
||||||
resp = client.get("/api/health")
|
resp = client.get("/api/health")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
# Old contract (status + version) is preserved.
|
assert body == {"status": "ok", "version": __version__}
|
||||||
assert body["status"] == "ok"
|
|
||||||
assert body["version"] == __version__
|
|
||||||
# SP19 additions.
|
|
||||||
assert "db" in body and body["db"].get("ok") is True
|
|
||||||
assert "scheduler" in body
|
|
||||||
assert "pubsub" in body
|
|
||||||
assert "batch" in body
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -1,313 +0,0 @@
|
|||||||
"""SP17 — Admin backup API endpoint tests.
|
|
||||||
|
|
||||||
Covers:
|
|
||||||
- POST /api/admin/backup/create
|
|
||||||
- GET /api/admin/backup/list
|
|
||||||
- GET /api/admin/backup/status
|
|
||||||
- POST /api/admin/backup/{id}/verify
|
|
||||||
- POST /api/admin/backup/{id}/restore/initiate
|
|
||||||
- POST /api/admin/backup/{id}/restore/confirm
|
|
||||||
- POST /api/admin/backup/prune
|
|
||||||
- POST /api/admin/backup/scheduler/{start,stop,tick}
|
|
||||||
|
|
||||||
Each fixture starts a clean DB + BackupService configured with a
|
|
||||||
known passphrase. We deliberately do NOT enable SQLCipher here —
|
|
||||||
the backup layer is independent of SQLCipher encryption at rest.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def _backup_env(tmp_path, monkeypatch):
|
|
||||||
"""Fresh sqlite DB + BackupService with passphrase. Reset module singletons."""
|
|
||||||
from cyclone import db
|
|
||||||
from cyclone import backup_service as svc_mod
|
|
||||||
from cyclone import backup_scheduler as sched_mod
|
|
||||||
from cyclone.db import Batch
|
|
||||||
|
|
||||||
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
|
|
||||||
db._reset_for_tests()
|
|
||||||
db.init_db()
|
|
||||||
|
|
||||||
# Make sure there's at least one row so the backup isn't a no-op.
|
|
||||||
import uuid
|
|
||||||
with db.SessionLocal()() as s:
|
|
||||||
s.add(Batch(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
kind="837P",
|
|
||||||
input_filename="seed.x12",
|
|
||||||
parsed_at=datetime.now(timezone.utc),
|
|
||||||
totals_json=None,
|
|
||||||
validation_json=None,
|
|
||||||
raw_result_json={"envelope": {"control_number": "1"}, "claims": [], "summary": {"passed": 0, "failed": 0, "failed_claim_ids": []}},
|
|
||||||
))
|
|
||||||
s.commit()
|
|
||||||
|
|
||||||
backup_dir = tmp_path / "backups"
|
|
||||||
svc_mod.reset_backup_service_for_tests()
|
|
||||||
sched_mod.reset_backup_scheduler_for_tests()
|
|
||||||
svc = svc_mod.configure_backup_service(
|
|
||||||
backup_dir=backup_dir, passphrase="api-test-pass", retention_days=7,
|
|
||||||
)
|
|
||||||
yield svc, backup_dir
|
|
||||||
sched_mod.reset_backup_scheduler_for_tests()
|
|
||||||
svc_mod.reset_backup_service_for_tests()
|
|
||||||
db._reset_for_tests()
|
|
||||||
|
|
||||||
|
|
||||||
def _client():
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from cyclone.api import app
|
|
||||||
return TestClient(app)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# /backup/create
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_returns_metadata_and_persists_row(_backup_env):
|
|
||||||
svc, backup_dir = _backup_env
|
|
||||||
r = _client().post("/api/admin/backup/create")
|
|
||||||
assert r.status_code == 200, r.text
|
|
||||||
body = r.json()
|
|
||||||
assert body["ok"] is True
|
|
||||||
b = body["backup"]
|
|
||||||
assert b["size_bytes"] > 0
|
|
||||||
assert b["db_fingerprint"].startswith("sha256:")
|
|
||||||
assert b["table_count"] >= 1
|
|
||||||
assert b["created_at"]
|
|
||||||
# File actually exists on disk.
|
|
||||||
assert (backup_dir / b["filename"]).exists()
|
|
||||||
# Sidecar metadata echoed.
|
|
||||||
sc = body["sidecar"]
|
|
||||||
assert sc["kdf"] == "PBKDF2-HMAC-SHA256"
|
|
||||||
assert sc["kdf_iterations"] == 200_000
|
|
||||||
assert sc["cipher"] == "AES-256-GCM"
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_503_when_service_unconfigured(tmp_path, monkeypatch):
|
|
||||||
"""If BackupService was never configured, create returns 503."""
|
|
||||||
from cyclone import db
|
|
||||||
from cyclone import backup_service as svc_mod
|
|
||||||
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
|
|
||||||
db._reset_for_tests()
|
|
||||||
db.init_db()
|
|
||||||
svc_mod.reset_backup_service_for_tests()
|
|
||||||
try:
|
|
||||||
r = _client().post("/api/admin/backup/create")
|
|
||||||
assert r.status_code == 503
|
|
||||||
assert "not configured" in r.json()["detail"].lower()
|
|
||||||
finally:
|
|
||||||
db._reset_for_tests()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# /backup/list
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_returns_newest_first(_backup_env):
|
|
||||||
svc, _ = _backup_env
|
|
||||||
client = _client()
|
|
||||||
client.post("/api/admin/backup/create")
|
|
||||||
client.post("/api/admin/backup/create")
|
|
||||||
r = client.get("/api/admin/backup/list")
|
|
||||||
assert r.status_code == 200
|
|
||||||
body = r.json()
|
|
||||||
assert body["count"] == 2
|
|
||||||
assert body["files"][0]["id"] > body["files"][1]["id"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_filter_by_status(_backup_env):
|
|
||||||
svc, _ = _backup_env
|
|
||||||
client = _client()
|
|
||||||
client.post("/api/admin/backup/create")
|
|
||||||
r = client.get("/api/admin/backup/list?status=ok")
|
|
||||||
assert r.json()["count"] == 1
|
|
||||||
r = client.get("/api/admin/backup/list?status=error")
|
|
||||||
assert r.json()["count"] == 0
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# /backup/status
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_status_returns_counts_and_dirs(_backup_env):
|
|
||||||
svc, backup_dir = _backup_env
|
|
||||||
client = _client()
|
|
||||||
client.post("/api/admin/backup/create")
|
|
||||||
r = client.get("/api/admin/backup/status")
|
|
||||||
assert r.status_code == 200
|
|
||||||
body = r.json()
|
|
||||||
assert body["totals"]["ok"] == 1
|
|
||||||
assert body["backup_dir"] == str(backup_dir)
|
|
||||||
assert body["retention_days"] == 7
|
|
||||||
assert body["last_backup_at"] is not None
|
|
||||||
assert body["last_ok_backup_at"] is not None
|
|
||||||
# The scheduler may or may not be configured depending on lifespan.
|
|
||||||
assert "scheduler" in body
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# /backup/{id}/verify
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_verify_ok_after_create(_backup_env):
|
|
||||||
svc, _ = _backup_env
|
|
||||||
client = _client()
|
|
||||||
cid = client.post("/api/admin/backup/create").json()["backup"]["id"]
|
|
||||||
r = client.post(f"/api/admin/backup/{cid}/verify")
|
|
||||||
assert r.status_code == 200
|
|
||||||
body = r.json()
|
|
||||||
assert body["ok"] is True
|
|
||||||
assert body["expected_fingerprint"] == body["actual_fingerprint"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_verify_detects_tampered_ciphertext(_backup_env):
|
|
||||||
from cyclone import backup as backup_mod
|
|
||||||
svc, backup_dir = _backup_env
|
|
||||||
client = _client()
|
|
||||||
cid = client.post("/api/admin/backup/create").json()["backup"]["id"]
|
|
||||||
fname = svc.list_backups()[0].filename
|
|
||||||
# Flip a bit in the ciphertext.
|
|
||||||
bin_path = backup_dir / fname
|
|
||||||
data = bytearray(bin_path.read_bytes())
|
|
||||||
data[backup_mod.NONCE_LEN + 5] ^= 0x01
|
|
||||||
bin_path.write_bytes(bytes(data))
|
|
||||||
r = client.post(f"/api/admin/backup/{cid}/verify")
|
|
||||||
assert r.status_code == 200
|
|
||||||
assert r.json()["ok"] is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_verify_404_when_unknown_backup(_backup_env):
|
|
||||||
r = _client().post("/api/admin/backup/99999/verify")
|
|
||||||
# The service raises BackupError; the endpoint should return 503 (no svc) or 400
|
|
||||||
# depending on flow. Let's see what happens.
|
|
||||||
assert r.status_code in (400, 404, 503)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# /backup/{id}/restore/{initiate,confirm}
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_restore_two_step_via_api(_backup_env):
|
|
||||||
svc, _ = _backup_env
|
|
||||||
from cyclone.db import Batch
|
|
||||||
import uuid
|
|
||||||
client = _client()
|
|
||||||
cid = client.post("/api/admin/backup/create").json()["backup"]["id"]
|
|
||||||
|
|
||||||
# Mutate the live DB (add another Batch row).
|
|
||||||
with __import__("cyclone").db.SessionLocal()() as s:
|
|
||||||
s.add(Batch(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
kind="837P",
|
|
||||||
input_filename="mutated.x12",
|
|
||||||
parsed_at=datetime.now(timezone.utc),
|
|
||||||
totals_json=None,
|
|
||||||
validation_json=None,
|
|
||||||
raw_result_json={"envelope": {"control_number": "2"}, "claims": [], "summary": {"passed": 0, "failed": 0, "failed_claim_ids": []}},
|
|
||||||
))
|
|
||||||
s.commit()
|
|
||||||
|
|
||||||
# Step 1: initiate.
|
|
||||||
r1 = client.post(f"/api/admin/backup/{cid}/restore/initiate")
|
|
||||||
assert r1.status_code == 200, r1.text
|
|
||||||
body1 = r1.json()
|
|
||||||
assert body1["restore_token"]
|
|
||||||
assert body1["preview"]["backup_table_count"] >= 1
|
|
||||||
assert body1["preview"]["backup_db_fingerprint"] != body1["preview"]["current_db_fingerprint"]
|
|
||||||
|
|
||||||
# Step 2: confirm.
|
|
||||||
r2 = client.post(
|
|
||||||
f"/api/admin/backup/{cid}/restore/confirm",
|
|
||||||
json={"restore_token": body1["restore_token"], "actor": "test"},
|
|
||||||
)
|
|
||||||
assert r2.status_code == 200, r2.text
|
|
||||||
body2 = r2.json()
|
|
||||||
assert body2["ok"] is True
|
|
||||||
assert body2["new_db_fingerprint"] == body1["preview"]["backup_db_fingerprint"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_restore_confirm_requires_token(_backup_env):
|
|
||||||
svc, _ = _backup_env
|
|
||||||
client = _client()
|
|
||||||
cid = client.post("/api/admin/backup/create").json()["backup"]["id"]
|
|
||||||
r = client.post(f"/api/admin/backup/{cid}/restore/confirm", json={})
|
|
||||||
assert r.status_code == 400
|
|
||||||
|
|
||||||
|
|
||||||
def test_restore_confirm_rejects_wrong_token(_backup_env):
|
|
||||||
svc, _ = _backup_env
|
|
||||||
client = _client()
|
|
||||||
cid = client.post("/api/admin/backup/create").json()["backup"]["id"]
|
|
||||||
r = client.post(
|
|
||||||
f"/api/admin/backup/{cid}/restore/confirm",
|
|
||||||
json={"restore_token": "0" * 64},
|
|
||||||
)
|
|
||||||
assert r.status_code == 400
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# /backup/prune
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_prune_deletes_old_backups(_backup_env):
|
|
||||||
svc, _ = _backup_env
|
|
||||||
from cyclone.db import DbBackup
|
|
||||||
client = _client()
|
|
||||||
cid = client.post("/api/admin/backup/create").json()["backup"]["id"]
|
|
||||||
# Age the backup past the retention cutoff.
|
|
||||||
with __import__("cyclone").db.SessionLocal()() as s:
|
|
||||||
row = s.get(DbBackup, cid)
|
|
||||||
row.created_at = datetime.now(timezone.utc) - timedelta(days=30)
|
|
||||||
s.commit()
|
|
||||||
r = client.post("/api/admin/backup/prune")
|
|
||||||
assert r.status_code == 200
|
|
||||||
body = r.json()
|
|
||||||
assert body["ok"] is True
|
|
||||||
assert body["deleted_count"] == 2 # .bin + .meta.json
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# /backup/scheduler/{start,stop,tick}
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_scheduler_endpoints_require_configured_scheduler(_backup_env, monkeypatch):
|
|
||||||
"""Without calling configure_backup_scheduler, the endpoints 503."""
|
|
||||||
svc, _ = _backup_env
|
|
||||||
# We did NOT call configure_backup_scheduler; the lifespan
|
|
||||||
# *might* have called it as a side effect of the TestClient
|
|
||||||
# entering its context. Either way, the scheduler endpoints
|
|
||||||
# need it to be present.
|
|
||||||
client = _client()
|
|
||||||
r = client.post("/api/admin/backup/scheduler/tick")
|
|
||||||
assert r.status_code in (200, 503)
|
|
||||||
|
|
||||||
|
|
||||||
def test_scheduler_tick_when_configured(_backup_env):
|
|
||||||
"""With a configured scheduler, tick runs and returns a result."""
|
|
||||||
from cyclone import backup_scheduler as sched_mod
|
|
||||||
svc, _ = _backup_env
|
|
||||||
sched_mod.configure_backup_scheduler(svc, interval_hours=24.0)
|
|
||||||
try:
|
|
||||||
client = _client()
|
|
||||||
r = client.post("/api/admin/backup/scheduler/tick")
|
|
||||||
assert r.status_code == 200
|
|
||||||
body = r.json()
|
|
||||||
assert body["ok"] is True
|
|
||||||
assert body["tick"]["created"] is not None
|
|
||||||
assert body["tick"]["created"]["id"] >= 1
|
|
||||||
finally:
|
|
||||||
sched_mod.reset_backup_scheduler_for_tests()
|
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
"""Tests for ``GET /api/dashboard/summary`` (SP10 — Dashboard aggregates)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from cyclone.api import app
|
||||||
|
from cyclone.store import store as global_store
|
||||||
|
|
||||||
|
FIXTURE_837 = Path(__file__).parent / "fixtures" / "co_medicaid_837p.txt"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_store():
|
||||||
|
with global_store._lock:
|
||||||
|
global_store._batches.clear()
|
||||||
|
yield
|
||||||
|
with global_store._lock:
|
||||||
|
global_store._batches.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client() -> TestClient:
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def seeded_store():
|
||||||
|
"""One parsed 837P fixture batch."""
|
||||||
|
text = FIXTURE_837.read_text()
|
||||||
|
client = TestClient(app)
|
||||||
|
client.post(
|
||||||
|
"/api/parse-837",
|
||||||
|
files={"file": ("x.txt", text, "text/plain")},
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
JSON = {"Accept": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Empty state
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_summary_empty_when_no_parses(client: TestClient):
|
||||||
|
resp = client.get("/api/dashboard/summary", headers=JSON)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
|
||||||
|
assert body["kpis"] == {
|
||||||
|
"claimCount": 0,
|
||||||
|
"billedTotal": 0.0,
|
||||||
|
"receivedTotal": 0.0,
|
||||||
|
"outstandingAr": 0.0,
|
||||||
|
"deniedCount": 0,
|
||||||
|
"pendingCount": 0,
|
||||||
|
"denialRate": 0.0,
|
||||||
|
}
|
||||||
|
# Default window is 6 months; with no claims every bucket is empty
|
||||||
|
# but still present so the sparkline renders zeros consistently.
|
||||||
|
assert len(body["monthly"]) == 6
|
||||||
|
for bucket in body["monthly"]:
|
||||||
|
assert bucket["count"] == 0
|
||||||
|
assert bucket["billed"] == 0.0
|
||||||
|
assert bucket["received"] == 0.0
|
||||||
|
assert bucket["denied"] == 0
|
||||||
|
assert bucket["ar"] == 0.0
|
||||||
|
assert bucket["denialRate"] == 0.0
|
||||||
|
assert body["topProviders"] == []
|
||||||
|
assert body["recentDenials"] == []
|
||||||
|
assert body["providerCount"] == 0
|
||||||
|
# asOf is a UTC ISO string.
|
||||||
|
assert body["asOf"].endswith("Z")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Populated state — uses the 2-claim co_medicaid fixture
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_summary_after_one_parse(seeded_store):
|
||||||
|
resp = seeded_store.get("/api/dashboard/summary", headers=JSON)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
|
||||||
|
# The fixture has 2 claims, both submitted, billed $85.40 + $155.76.
|
||||||
|
kpis = body["kpis"]
|
||||||
|
assert kpis["claimCount"] == 2
|
||||||
|
assert kpis["billedTotal"] == pytest.approx(85.40 + 155.76)
|
||||||
|
assert kpis["receivedTotal"] == 0.0
|
||||||
|
assert kpis["outstandingAr"] == pytest.approx(85.40 + 155.76)
|
||||||
|
assert kpis["deniedCount"] == 0
|
||||||
|
assert kpis["pendingCount"] == 2
|
||||||
|
assert kpis["denialRate"] == 0.0
|
||||||
|
|
||||||
|
# Default monthly window is 6 months; all claims are in the current
|
||||||
|
# month (parsed today) so the other 5 buckets are zeros.
|
||||||
|
assert len(body["monthly"]) == 6
|
||||||
|
current = body["monthly"][-1]
|
||||||
|
assert current["count"] == 2
|
||||||
|
assert current["billed"] == pytest.approx(85.40 + 155.76)
|
||||||
|
assert current["denied"] == 0
|
||||||
|
|
||||||
|
# The earlier buckets should be empty (count=0, billed=0).
|
||||||
|
for bucket in body["monthly"][:-1]:
|
||||||
|
assert bucket["count"] == 0
|
||||||
|
assert bucket["billed"] == 0.0
|
||||||
|
|
||||||
|
# One distinct provider (TOC, Inc. — NPI 1881068062).
|
||||||
|
assert body["providerCount"] == 1
|
||||||
|
assert len(body["topProviders"]) == 1
|
||||||
|
assert body["topProviders"][0]["npi"] == "1881068062"
|
||||||
|
assert body["topProviders"][0]["claimCount"] == 2
|
||||||
|
|
||||||
|
# No denials in this fixture.
|
||||||
|
assert body["recentDenials"] == []
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# months param
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_summary_months_param(client: TestClient):
|
||||||
|
resp = client.get("/api/dashboard/summary?months=3", headers=JSON)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(resp.json()["monthly"]) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_summary_months_param_min_max(client: TestClient):
|
||||||
|
# Floor is 1.
|
||||||
|
r = client.get("/api/dashboard/summary?months=1", headers=JSON)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert len(r.json()["monthly"]) == 1
|
||||||
|
|
||||||
|
# Ceiling is 24.
|
||||||
|
r = client.get("/api/dashboard/summary?months=24", headers=JSON)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert len(r.json()["monthly"]) == 24
|
||||||
|
|
||||||
|
# Out of range → 422.
|
||||||
|
r = client.get("/api/dashboard/summary?months=0", headers=JSON)
|
||||||
|
assert r.status_code == 422
|
||||||
|
r = client.get("/api/dashboard/summary?months=25", headers=JSON)
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# top_providers param
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_summary_top_providers_param(seeded_store):
|
||||||
|
resp = seeded_store.get(
|
||||||
|
"/api/dashboard/summary?top_providers=1", headers=JSON
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert len(body["topProviders"]) == 1
|
||||||
|
# providerCount is independent of topProviders.
|
||||||
|
assert body["providerCount"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Recent denials only contain denied claims
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_summary_recent_denials_empty_when_no_denied(seeded_store):
|
||||||
|
"""Fixture has 2 submitted claims, 0 denied. recentDenials: []."""
|
||||||
|
body = seeded_store.get("/api/dashboard/summary", headers=JSON).json()
|
||||||
|
assert body["kpis"]["deniedCount"] == 0
|
||||||
|
assert body["recentDenials"] == []
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Monthly buckets walk oldest → newest and the running AR is cumulative
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_summary_monthly_chronological_and_labels(seeded_store):
|
||||||
|
body = seeded_store.get("/api/dashboard/summary", headers=JSON).json()
|
||||||
|
months = body["monthly"]
|
||||||
|
assert len(months) == 6
|
||||||
|
# Labels are short English month names (e.g. "Jan", "Feb").
|
||||||
|
for bucket in months:
|
||||||
|
assert len(bucket["label"]) == 3
|
||||||
|
assert bucket["label"][0].isupper()
|
||||||
|
# `month` strings sort in the same order the buckets appear.
|
||||||
|
keys = [b["month"] for b in months]
|
||||||
|
assert keys == sorted(keys)
|
||||||
|
# Running AR never goes negative (clamped at 0).
|
||||||
|
assert all(b["ar"] >= 0 for b in months)
|
||||||
|
# Today's bucket is the last one and has the running AR equal to the
|
||||||
|
# outstandingAr KPI (no received amounts yet in this fixture).
|
||||||
|
assert months[-1]["ar"] == pytest.approx(body["kpis"]["outstandingAr"])
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Store-level aggregation (called by the endpoint)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_store_get_dashboard_summary_directly():
|
||||||
|
"""Call the Store method without going through the API."""
|
||||||
|
from cyclone.store import store
|
||||||
|
|
||||||
|
summary = store.get_dashboard_summary(months=6, top_providers=4, denials=5)
|
||||||
|
# All claims come from the seeded fixture if any; we just assert the
|
||||||
|
# shape here so the endpoint and Store stay in sync.
|
||||||
|
assert set(summary.keys()) == {
|
||||||
|
"kpis", "monthly", "topProviders", "recentDenials",
|
||||||
|
"providerCount", "asOf",
|
||||||
|
}
|
||||||
|
assert set(summary["kpis"].keys()) == {
|
||||||
|
"claimCount", "billedTotal", "receivedTotal", "outstandingAr",
|
||||||
|
"deniedCount", "pendingCount", "denialRate",
|
||||||
|
}
|
||||||
|
assert set(summary["monthly"][0].keys()) == {
|
||||||
|
"month", "label", "count", "billed", "received", "denied",
|
||||||
|
"ar", "denialRate",
|
||||||
|
}
|
||||||
|
# Default window is 6 months even when called with explicit defaults.
|
||||||
|
assert len(summary["monthly"]) == 6
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
"""SP15 — SQLCipher key rotation API endpoint tests.
|
|
||||||
|
|
||||||
We test the *wiring* of the endpoint:
|
|
||||||
1. Refuses with 400 when encryption is not enabled.
|
|
||||||
2. Refuses with 409 when a rotation is already in flight.
|
|
||||||
3. On success: calls rotate_db_key, updates the Keychain, rebuilds
|
|
||||||
the engine, writes an audit event, and returns the fingerprints.
|
|
||||||
4. On Keychain write failure: returns 503 (DB is rotated, Keychain
|
|
||||||
is stale; operator must restore).
|
|
||||||
|
|
||||||
The actual ``PRAGMA rekey`` mechanics are tested in ``test_db_crypto.py``
|
|
||||||
(see :class:`TestRotateDbKey`); we don't duplicate that here.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
# Skip if sqlcipher3 isn't installed.
|
|
||||||
pytestmark = pytest.mark.skipif(
|
|
||||||
not __import__(
|
|
||||||
"cyclone.db_crypto", fromlist=["is_sqlcipher_available"]
|
|
||||||
).is_sqlcipher_available(),
|
|
||||||
reason="sqlcipher3 not installed",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _stub_rotate_ok(*, url, old_key, new_key) -> dict:
|
|
||||||
"""Return a synthetic RotateKeyResult for endpoint wiring tests."""
|
|
||||||
from cyclone.db_crypto import RotateKeyResult
|
|
||||||
return RotateKeyResult(
|
|
||||||
ok=True,
|
|
||||||
old_fingerprint="aaaa1111",
|
|
||||||
new_fingerprint="bbbb2222",
|
|
||||||
rotated_at=datetime.now(timezone.utc).isoformat(),
|
|
||||||
table_count=12,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestRotateKeyRefusesWhenNotEncrypted:
|
|
||||||
def test_400_when_encryption_disabled(self, tmp_path, monkeypatch):
|
|
||||||
from cyclone import db, db_crypto
|
|
||||||
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/plain.db")
|
|
||||||
db._reset_for_tests()
|
|
||||||
monkeypatch.setattr(db_crypto, "get_secret", lambda account: None)
|
|
||||||
db.init_db()
|
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from cyclone.api import app
|
|
||||||
with TestClient(app) as client:
|
|
||||||
r = client.post("/api/admin/db/rotate-key")
|
|
||||||
assert r.status_code == 400
|
|
||||||
assert "not enabled" in r.json()["detail"]
|
|
||||||
db._reset_for_tests()
|
|
||||||
|
|
||||||
|
|
||||||
class TestRotateKeyEndpointWiring:
|
|
||||||
@pytest.fixture
|
|
||||||
def _fake_encrypted_env(self, tmp_path, monkeypatch):
|
|
||||||
"""Set up: encryption-enabled DB on disk, fake Keychain
|
|
||||||
(read + write), and the engine initialized here.
|
|
||||||
|
|
||||||
With NullPool (see ``cyclone.db._make_engine``), every thread
|
|
||||||
opens its own SQLCipher connection — no cross-thread reuse,
|
|
||||||
no ProgramingError. The endpoint runs on the request thread
|
|
||||||
and verification runs on the test thread; both get fresh
|
|
||||||
per-thread connections transparently.
|
|
||||||
"""
|
|
||||||
from cyclone import db, db_crypto
|
|
||||||
|
|
||||||
db_file = tmp_path / "cyclone.db"
|
|
||||||
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{db_file}")
|
|
||||||
db._reset_for_tests()
|
|
||||||
fake_kc = {db_crypto.KEYCHAIN_ACCOUNT: "old-test-key-1"}
|
|
||||||
monkeypatch.setattr(db_crypto, "get_secret", lambda n: fake_kc.get(n))
|
|
||||||
monkeypatch.setattr("cyclone.secrets.get_secret", lambda n: fake_kc.get(n))
|
|
||||||
monkeypatch.setattr("cyclone.secrets.set_secret",
|
|
||||||
lambda n, v: fake_kc.__setitem__(n, v) or True)
|
|
||||||
# The endpoint's actual rekey is stubbed; the real PRAGMA
|
|
||||||
# rekey mechanics are tested in test_db_crypto.py::TestRotateDbKey.
|
|
||||||
monkeypatch.setattr("cyclone.api._db_crypto.rotate_db_key", _stub_rotate_ok)
|
|
||||||
db.init_db()
|
|
||||||
yield db_file, fake_kc
|
|
||||||
db._reset_for_tests()
|
|
||||||
|
|
||||||
def test_successful_rotation_updates_keychain_and_writes_audit(
|
|
||||||
self, _fake_encrypted_env,
|
|
||||||
):
|
|
||||||
from cyclone import db
|
|
||||||
|
|
||||||
# The fixture stubs rotate_db_key to a no-op success.
|
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from cyclone.api import app
|
|
||||||
with TestClient(app) as client:
|
|
||||||
r = client.post(
|
|
||||||
"/api/admin/db/rotate-key",
|
|
||||||
json={"actor": "alice", "reason": "scheduled"},
|
|
||||||
)
|
|
||||||
assert r.status_code == 200, r.text
|
|
||||||
body = r.json()
|
|
||||||
assert body["ok"] is True
|
|
||||||
assert body["old_fingerprint"] == "aaaa1111"
|
|
||||||
assert body["new_fingerprint"] == "bbbb2222"
|
|
||||||
assert body["table_count"] == 12
|
|
||||||
|
|
||||||
def test_successful_rotation_writes_audit_event(
|
|
||||||
self, _fake_encrypted_env,
|
|
||||||
):
|
|
||||||
from cyclone import db
|
|
||||||
import json as _json
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from cyclone.api import app
|
|
||||||
with TestClient(app) as client:
|
|
||||||
r = client.post("/api/admin/db/rotate-key", json={"actor": "bob"})
|
|
||||||
assert r.status_code == 200
|
|
||||||
|
|
||||||
from cyclone.db import AuditLog
|
|
||||||
with db.SessionLocal()() as session:
|
|
||||||
events = (
|
|
||||||
session.query(AuditLog)
|
|
||||||
.filter(AuditLog.event_type == "db.key_rotated")
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
assert len(events) == 1
|
|
||||||
e = events[0]
|
|
||||||
assert e.entity_type == "database"
|
|
||||||
assert e.entity_id == "cyclone.db"
|
|
||||||
assert e.actor == "bob"
|
|
||||||
payload = _json.loads(e.payload_json)
|
|
||||||
assert payload["old_fingerprint"] == "aaaa1111"
|
|
||||||
assert payload["new_fingerprint"] == "bbbb2222"
|
|
||||||
assert payload["table_count"] == 12
|
|
||||||
|
|
||||||
def test_rotation_rekey_failure_returns_503_and_leaves_keychain_unchanged(
|
|
||||||
self, _fake_encrypted_env, monkeypatch
|
|
||||||
):
|
|
||||||
from cyclone import db_crypto
|
|
||||||
from cyclone import db
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
def _fail_rotate(*, url, old_key, new_key):
|
|
||||||
return db_crypto.RotateKeyResult(
|
|
||||||
ok=False,
|
|
||||||
old_fingerprint=db_crypto.fingerprint(old_key),
|
|
||||||
new_fingerprint=db_crypto.fingerprint(new_key),
|
|
||||||
rotated_at=datetime.now(timezone.utc).isoformat(),
|
|
||||||
reason="simulated PRAGMA rekey failure",
|
|
||||||
)
|
|
||||||
monkeypatch.setattr("cyclone.api._db_crypto.rotate_db_key", _fail_rotate)
|
|
||||||
|
|
||||||
_, fake_kc = _fake_encrypted_env
|
|
||||||
before = dict(fake_kc)
|
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from cyclone.api import app
|
|
||||||
with TestClient(app) as client:
|
|
||||||
r = client.post("/api/admin/db/rotate-key")
|
|
||||||
assert r.status_code == 503
|
|
||||||
body = r.json()["detail"]
|
|
||||||
assert body["ok"] is False
|
|
||||||
assert "simulated" in body["reason"]
|
|
||||||
|
|
||||||
# Keychain wasn't touched.
|
|
||||||
assert fake_kc == before
|
|
||||||
|
|
||||||
# No audit event was written.
|
|
||||||
from cyclone.db import AuditLog
|
|
||||||
with db.SessionLocal()() as session:
|
|
||||||
count = (
|
|
||||||
session.query(AuditLog)
|
|
||||||
.filter(AuditLog.event_type == "db.key_rotated")
|
|
||||||
.count()
|
|
||||||
)
|
|
||||||
assert count == 0
|
|
||||||
|
|
||||||
def test_503_when_keychain_write_fails_after_successful_rekey(
|
|
||||||
self, _fake_encrypted_env, monkeypatch
|
|
||||||
):
|
|
||||||
"""The rekey itself succeeded but the Keychain write failed.
|
|
||||||
The DB is now behind a new key the Keychain doesn't know about.
|
|
||||||
Endpoint must return 503 so the operator can run the manual
|
|
||||||
restore-key command."""
|
|
||||||
from cyclone import db
|
|
||||||
# Override the set_secret at the import-site of the endpoint.
|
|
||||||
monkeypatch.setattr("cyclone.api._secrets.set_secret", lambda n, v: False)
|
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from cyclone.api import app
|
|
||||||
with TestClient(app) as client:
|
|
||||||
r = client.post("/api/admin/db/rotate-key")
|
|
||||||
assert r.status_code == 503
|
|
||||||
body = r.json()["detail"]
|
|
||||||
assert body["ok"] is False
|
|
||||||
assert "keychain" in body["reason"].lower()
|
|
||||||
|
|
||||||
def test_409_when_concurrent_request(self, _fake_encrypted_env, monkeypatch):
|
|
||||||
"""A second concurrent rotation request gets 409 — only one
|
|
||||||
rotation can run at a time (the module-level lock)."""
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"cyclone.api._secrets.set_secret", lambda n, v: True,
|
|
||||||
)
|
|
||||||
from cyclone import api as api_mod
|
|
||||||
api_mod._db_rotate_lock.acquire()
|
|
||||||
try:
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from cyclone.api import app
|
|
||||||
with TestClient(app) as client:
|
|
||||||
r = client.post("/api/admin/db/rotate-key")
|
|
||||||
assert r.status_code == 409
|
|
||||||
assert "in progress" in r.json()["detail"]
|
|
||||||
finally:
|
|
||||||
api_mod._db_rotate_lock.release()
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
"""SP16 — Admin scheduler API endpoint tests.
|
|
||||||
|
|
||||||
The endpoints under /api/admin/scheduler/* are thin wrappers around
|
|
||||||
:class:`cyclone.scheduler.Scheduler`. These tests exercise them via
|
|
||||||
the FastAPI TestClient to confirm wiring (auth-free admin endpoints
|
|
||||||
work, response shapes match, idempotency holds).
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def _stub_scheduler_env(tmp_path, monkeypatch):
|
|
||||||
"""Set up: a stub-mode SFTP block, scheduler configured.
|
|
||||||
|
|
||||||
Yields (staging_dir, scheduler_singleton). We deliberately do
|
|
||||||
NOT enable SQLCipher encryption in this fixture — the scheduler
|
|
||||||
doesn't care about encryption, and patching ``db_crypto.get_secret``
|
|
||||||
here would cause the lifespan handler to rebuild the engine with
|
|
||||||
SQLCipher on a plain-SQLite test file (which raises "file is not
|
|
||||||
a database"). The encryption-at-rest tests live in
|
|
||||||
``test_db_crypto.py``.
|
|
||||||
"""
|
|
||||||
from cyclone import db
|
|
||||||
from cyclone import scheduler as sched_mod
|
|
||||||
from cyclone.providers import SftpBlock
|
|
||||||
|
|
||||||
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
|
|
||||||
db._reset_for_tests()
|
|
||||||
|
|
||||||
staging = tmp_path / "staging"
|
|
||||||
inbound = staging / "ToHPE"
|
|
||||||
inbound.mkdir(parents=True)
|
|
||||||
sftp_block = SftpBlock(
|
|
||||||
host="mft.example.com",
|
|
||||||
port=22,
|
|
||||||
username="test",
|
|
||||||
paths={"outbound": "/FromHPE", "inbound": "/ToHPE"},
|
|
||||||
stub=True,
|
|
||||||
staging_dir=str(staging),
|
|
||||||
poll_seconds=60,
|
|
||||||
auth={"method": "keychain", "secret_ref": "test.password"},
|
|
||||||
)
|
|
||||||
sched_mod.reset_scheduler_for_tests()
|
|
||||||
sched = sched_mod.configure_scheduler(sftp_block, sftp_block_name="t")
|
|
||||||
yield staging, sched
|
|
||||||
sched_mod.reset_scheduler_for_tests()
|
|
||||||
db._reset_for_tests()
|
|
||||||
|
|
||||||
|
|
||||||
def _drop_file(staging: Path, name: str, body: bytes) -> Path:
|
|
||||||
p = staging / "ToHPE" / name
|
|
||||||
p.write_bytes(body)
|
|
||||||
return p
|
|
||||||
|
|
||||||
|
|
||||||
def test_scheduler_status_starts_not_running(_stub_scheduler_env):
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from cyclone.api import app
|
|
||||||
_, sched = _stub_scheduler_env
|
|
||||||
with TestClient(app) as client:
|
|
||||||
r = client.get("/api/admin/scheduler/status")
|
|
||||||
assert r.status_code == 200
|
|
||||||
body = r.json()
|
|
||||||
assert body["running"] is False
|
|
||||||
assert body["poll_interval_seconds"] == 60
|
|
||||||
assert body["sftp_block_name"] == "t"
|
|
||||||
|
|
||||||
|
|
||||||
def test_scheduler_start_then_status_then_stop(_stub_scheduler_env):
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from cyclone.api import app
|
|
||||||
with TestClient(app) as client:
|
|
||||||
r1 = client.post("/api/admin/scheduler/start")
|
|
||||||
assert r1.status_code == 200
|
|
||||||
assert r1.json()["status"]["running"] is True
|
|
||||||
|
|
||||||
r2 = client.get("/api/admin/scheduler/status")
|
|
||||||
assert r2.json()["running"] is True
|
|
||||||
|
|
||||||
r3 = client.post("/api/admin/scheduler/stop")
|
|
||||||
assert r3.status_code == 200
|
|
||||||
assert r3.json()["status"]["running"] is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_scheduler_tick_processes_one_file(_stub_scheduler_env):
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from cyclone.api import app
|
|
||||||
staging, _ = _stub_scheduler_env
|
|
||||||
_drop_file(
|
|
||||||
staging,
|
|
||||||
"TP11525703-837P_M019048402-20260618130000000-1of1_TA1.x12",
|
|
||||||
(Path(__file__).parent / "fixtures" / "minimal_ta1.txt").read_bytes(),
|
|
||||||
)
|
|
||||||
with TestClient(app) as client:
|
|
||||||
r = client.post("/api/admin/scheduler/tick")
|
|
||||||
assert r.status_code == 200
|
|
||||||
body = r.json()
|
|
||||||
assert body["ok"] is True
|
|
||||||
assert body["tick"]["files_seen"] == 1
|
|
||||||
assert body["tick"]["files_processed"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_scheduler_processed_files_lists_history(_stub_scheduler_env):
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from cyclone.api import app
|
|
||||||
staging, _ = _stub_scheduler_env
|
|
||||||
_drop_file(
|
|
||||||
staging,
|
|
||||||
"TP11525703-837P_M019048402-20260618130000000-1of1_TA1.x12",
|
|
||||||
(Path(__file__).parent / "fixtures" / "minimal_ta1.txt").read_bytes(),
|
|
||||||
)
|
|
||||||
with TestClient(app) as client:
|
|
||||||
client.post("/api/admin/scheduler/tick")
|
|
||||||
r = client.get("/api/admin/scheduler/processed-files")
|
|
||||||
assert r.status_code == 200
|
|
||||||
body = r.json()
|
|
||||||
assert body["count"] == 1
|
|
||||||
f = body["files"][0]
|
|
||||||
assert f["status"] == "ok"
|
|
||||||
assert f["parser_used"] == "parse_ta1"
|
|
||||||
assert "TP11525703" in f["name"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_scheduler_processed_files_filters_by_status(_stub_scheduler_env):
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from cyclone.api import app
|
|
||||||
staging, _ = _stub_scheduler_env
|
|
||||||
# Drop a file with a type Cyclone doesn't parse — gets recorded as
|
|
||||||
# "skipped".
|
|
||||||
_drop_file(
|
|
||||||
staging,
|
|
||||||
"TP11525703-837P_M019048402-20260618130000000-1of1_270.x12",
|
|
||||||
b"some bytes",
|
|
||||||
)
|
|
||||||
with TestClient(app) as client:
|
|
||||||
client.post("/api/admin/scheduler/tick")
|
|
||||||
r_all = client.get("/api/admin/scheduler/processed-files")
|
|
||||||
r_skipped = client.get(
|
|
||||||
"/api/admin/scheduler/processed-files?status=skipped",
|
|
||||||
)
|
|
||||||
r_ok = client.get(
|
|
||||||
"/api/admin/scheduler/processed-files?status=ok",
|
|
||||||
)
|
|
||||||
assert r_all.json()["count"] == 1
|
|
||||||
assert r_skipped.json()["count"] == 1
|
|
||||||
assert r_ok.json()["count"] == 0
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
"""Tests for ``GET /api/admin/validate-provider`` (SP20).
|
|
||||||
|
|
||||||
Pure read-only endpoint — runs the local NPI Luhn + EIN format checks.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from cyclone.api import app
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def client() -> TestClient:
|
|
||||||
return TestClient(app)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Both fields populated
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_provider_both_valid(client: TestClient):
|
|
||||||
resp = client.get("/api/admin/validate-provider", params={
|
|
||||||
"npi": "1234567893", # CMS-published valid NPI
|
|
||||||
"tax_id": "72-1587149", # Touch of Care EIN
|
|
||||||
})
|
|
||||||
assert resp.status_code == 200
|
|
||||||
body = resp.json()
|
|
||||||
assert body["npi"]["valid"] is True
|
|
||||||
assert body["npi"]["skipped"] is False
|
|
||||||
assert body["tax_id"]["valid"] is True
|
|
||||||
assert body["tax_id"]["normalized"] == "721587149"
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_provider_both_invalid(client: TestClient):
|
|
||||||
resp = client.get("/api/admin/validate-provider", params={
|
|
||||||
"npi": "1234567890", # format OK but Luhn fails
|
|
||||||
"tax_id": "00-1234567", # reserved prefix
|
|
||||||
})
|
|
||||||
assert resp.status_code == 200
|
|
||||||
body = resp.json()
|
|
||||||
assert body["npi"]["valid"] is False
|
|
||||||
assert body["tax_id"]["valid"] is False
|
|
||||||
assert body["tax_id"]["normalized"] == "001234567"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Param omission → skipped
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_provider_skips_missing_npi(client: TestClient):
|
|
||||||
resp = client.get("/api/admin/validate-provider", params={"tax_id": "721587149"})
|
|
||||||
assert resp.status_code == 200
|
|
||||||
body = resp.json()
|
|
||||||
assert body["npi"]["skipped"] is True
|
|
||||||
assert body["npi"]["valid"] is None
|
|
||||||
assert body["tax_id"]["valid"] is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_provider_skips_missing_tax_id(client: TestClient):
|
|
||||||
resp = client.get("/api/admin/validate-provider", params={"npi": "1234567893"})
|
|
||||||
assert resp.status_code == 200
|
|
||||||
body = resp.json()
|
|
||||||
assert body["tax_id"]["skipped"] is True
|
|
||||||
assert body["tax_id"]["valid"] is None
|
|
||||||
assert body["tax_id"]["normalized"] is None
|
|
||||||
assert body["npi"]["valid"] is True
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""Audit log entries record the acting user_id.
|
||||||
|
|
||||||
|
Schema: the ``audit_log`` table has a ``user_id INTEGER`` column added
|
||||||
|
by migration 0011; the SQLAlchemy ``AuditLog`` model itself does not
|
||||||
|
declare it yet, so ``append_event`` cannot pass it through. These tests
|
||||||
|
fail before the model + dataclass are updated, and pass after.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
|
||||||
|
from cyclone.audit_log import AuditEvent, append_event
|
||||||
|
from cyclone.db import AuditLog, SessionLocal, User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(AuditLog))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
yield
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(AuditLog))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_append_event_accepts_user_id_kwarg():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
append_event(
|
||||||
|
db,
|
||||||
|
AuditEvent(
|
||||||
|
event_type="test",
|
||||||
|
entity_type="x",
|
||||||
|
entity_id="y",
|
||||||
|
user_id=42,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
row = db.execute(select(AuditLog)).scalars().one()
|
||||||
|
assert row.user_id == 42
|
||||||
|
|
||||||
|
|
||||||
|
def test_append_event_without_user_id_is_null():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
append_event(
|
||||||
|
db,
|
||||||
|
AuditEvent(
|
||||||
|
event_type="test",
|
||||||
|
entity_type="x",
|
||||||
|
entity_id="y",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
row = db.execute(select(AuditLog)).scalars().one()
|
||||||
|
assert row.user_id is None
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Admin-only user management endpoints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import delete
|
||||||
|
|
||||||
|
from cyclone.api import app
|
||||||
|
from cyclone.auth import users
|
||||||
|
from cyclone.db import Session as DbSession
|
||||||
|
from cyclone.db import SessionLocal, User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
yield
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def admin_client():
|
||||||
|
client = TestClient(app)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="root", password="rootpassword1", role="admin")
|
||||||
|
login = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "root", "password": "rootpassword1"},
|
||||||
|
)
|
||||||
|
assert login.status_code == 200
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def user_client():
|
||||||
|
client = TestClient(app)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="plain", password="plainpassword1", role="user")
|
||||||
|
login = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "plain", "password": "plainpassword1"},
|
||||||
|
)
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_users_as_admin(admin_client):
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="alice", password="hunter2hunter2", role="user")
|
||||||
|
resp = admin_client.get("/api/admin/users")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
usernames = {u["username"] for u in body}
|
||||||
|
assert {"root", "alice"} <= usernames
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_users_as_nonadmin_returns_403(user_client):
|
||||||
|
resp = user_client.get("/api/admin/users")
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_user_as_admin(admin_client):
|
||||||
|
resp = admin_client.post(
|
||||||
|
"/api/admin/users",
|
||||||
|
json={"username": "newbie", "password": "newbiepassword1", "role": "viewer"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert resp.json()["username"] == "newbie"
|
||||||
|
assert resp.json()["role"] == "viewer"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_user_rejects_invalid_role(admin_client):
|
||||||
|
resp = admin_client.post(
|
||||||
|
"/api/admin/users",
|
||||||
|
json={"username": "badrole", "password": "hunter2hunter2", "role": "owner"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_user_role_and_password(admin_client):
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.create(db, username="subject", password="hunter2hunter2", role="viewer")
|
||||||
|
resp = admin_client.patch(
|
||||||
|
f"/api/admin/users/{u.id}",
|
||||||
|
json={"role": "user", "password": "newpassword1"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["role"] == "user"
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_cannot_demote_self(admin_client):
|
||||||
|
me = admin_client.get("/api/auth/me").json()
|
||||||
|
resp = admin_client.patch(
|
||||||
|
f"/api/admin/users/{me['id']}",
|
||||||
|
json={"role": "viewer"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Bootstrap admin user on backend startup."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
|
||||||
|
from cyclone.auth import bootstrap, users
|
||||||
|
from cyclone.auth.deps import AUTH_DISABLED
|
||||||
|
from cyclone.auth.permissions import Role
|
||||||
|
from cyclone.db import Session as DbSession
|
||||||
|
from cyclone.db import SessionLocal, User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear(monkeypatch):
|
||||||
|
# Reset the bootstrap-side AUTH_DISABLED flag so tests don't leak state
|
||||||
|
# into each other. The conftest fixture flips this back to True at the
|
||||||
|
# start of every test, but bootstrap.run() mutates this module-level
|
||||||
|
# value, so we restore it here too.
|
||||||
|
monkeypatch.setattr("cyclone.auth.deps.AUTH_DISABLED", False)
|
||||||
|
# conftest sets CYCLONE_AUTH_DISABLED=1 at module import. Drop it
|
||||||
|
# by default so tests exercise the real bootstrap path; the
|
||||||
|
# AUTH_DISABLED-specific test re-sets it explicitly.
|
||||||
|
monkeypatch.delenv("CYCLONE_AUTH_DISABLED", raising=False)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
yield
|
||||||
|
monkeypatch.setattr("cyclone.auth.deps.AUTH_DISABLED", False)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_creates_admin_when_users_empty_and_env_set(monkeypatch):
|
||||||
|
monkeypatch.setenv("CYCLONE_ADMIN_USERNAME", "firstadmin")
|
||||||
|
monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD", "firstadminpw1")
|
||||||
|
bootstrap.run()
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = db.execute(select(User).where(User.username == "firstadmin")).scalar_one()
|
||||||
|
assert u.role == Role.ADMIN.value
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_noop_when_users_exist(monkeypatch):
|
||||||
|
monkeypatch.setenv("CYCLONE_ADMIN_USERNAME", "ignored")
|
||||||
|
monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD", "ignoredignored1")
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="existing", password="hunter2hunter2", role="admin")
|
||||||
|
bootstrap.run()
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
all_users = db.execute(select(User)).scalars().all()
|
||||||
|
usernames = {u.username for u in all_users}
|
||||||
|
assert usernames == {"existing"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_refuses_to_run_without_env(monkeypatch):
|
||||||
|
monkeypatch.delenv("CYCLONE_ADMIN_USERNAME", raising=False)
|
||||||
|
monkeypatch.delenv("CYCLONE_ADMIN_PASSWORD", raising=False)
|
||||||
|
with pytest.raises(RuntimeError, match="CYCLONE_ADMIN_USERNAME"):
|
||||||
|
bootstrap.run()
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_rejects_short_password(monkeypatch):
|
||||||
|
monkeypatch.setenv("CYCLONE_ADMIN_USERNAME", "weak")
|
||||||
|
monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD", "short")
|
||||||
|
with pytest.raises(RuntimeError, match="12 characters"):
|
||||||
|
bootstrap.run()
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_skips_when_auth_disabled(monkeypatch):
|
||||||
|
monkeypatch.setenv("CYCLONE_AUTH_DISABLED", "1")
|
||||||
|
# Even without env vars, bootstrap should NOT raise when AUTH_DISABLED=1.
|
||||||
|
bootstrap.run()
|
||||||
|
# And it must flip the deps flag so the API skips auth checks.
|
||||||
|
from cyclone.auth import deps as _deps
|
||||||
|
assert _deps.AUTH_DISABLED is True
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""Login rate limit: 5 fails / 5 min per username."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import delete
|
||||||
|
|
||||||
|
from cyclone.api import app
|
||||||
|
from cyclone.auth import rate_limit, users
|
||||||
|
from cyclone.db import Session as DbSession
|
||||||
|
from cyclone.db import SessionLocal, User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
# Reset the in-memory rate-limit counter so a previous test's 5
|
||||||
|
# failures don't poison this test. The plan recipe calls this out.
|
||||||
|
rate_limit.reset("victim")
|
||||||
|
yield
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
rate_limit.reset("victim")
|
||||||
|
|
||||||
|
|
||||||
|
def test_5_fails_then_429():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="victim", password="hunter2hunter2", role="user")
|
||||||
|
client = TestClient(app)
|
||||||
|
for _ in range(5):
|
||||||
|
r = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "victim", "password": "WRONG"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 401
|
||||||
|
# 6th should be 429.
|
||||||
|
r = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "victim", "password": "WRONG"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 429
|
||||||
|
assert "Retry-After" in r.headers
|
||||||
|
# And the module-level helper confirms we're now over the threshold.
|
||||||
|
assert rate_limit.check("victim") > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_successful_login_resets_counter():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="victim", password="hunter2hunter2", role="user")
|
||||||
|
client = TestClient(app)
|
||||||
|
for _ in range(4):
|
||||||
|
r = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "victim", "password": "WRONG"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 401
|
||||||
|
# Successful login — should reset the counter.
|
||||||
|
r = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "victim", "password": "hunter2hunter2"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
# Counter reset — 5 more fails allowed.
|
||||||
|
for _ in range(5):
|
||||||
|
r = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "victim", "password": "WRONG"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 401
|
||||||
|
# 6th is now throttled.
|
||||||
|
r = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "victim", "password": "WRONG"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 429
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""API tests for /api/auth/login, /api/auth/logout, /api/auth/me."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import delete
|
||||||
|
|
||||||
|
from cyclone.api import app
|
||||||
|
from cyclone.auth import users
|
||||||
|
from cyclone.db import Session as DbSession
|
||||||
|
from cyclone.db import SessionLocal, User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
yield
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def seeded_admin(client):
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="admin", password="adminpassword1", role="admin")
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_success_returns_user_and_cookie(client):
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="alice", password="hunter2hunter2", role="user")
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "alice", "password": "hunter2hunter2"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["username"] == "alice"
|
||||||
|
assert body["role"] == "user"
|
||||||
|
assert "password_hash" not in body
|
||||||
|
assert "cyclone_session" in resp.cookies
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_bad_password_returns_401(client):
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="bob", password="hunter2hunter2", role="user")
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "bob", "password": "WRONG"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert resp.json()["error"] == "invalid_credentials"
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_unknown_user_returns_401(client):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "ghost", "password": "whatever"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert resp.json()["error"] == "invalid_credentials"
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_disabled_user_returns_403(client):
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.create(db, username="carol", password="hunter2hunter2", role="user")
|
||||||
|
users.disable(db, u.id)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "carol", "password": "hunter2hunter2"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
assert resp.json()["error"] == "account_disabled"
|
||||||
|
|
||||||
|
|
||||||
|
def test_logout_clears_session_and_cookie(seeded_admin):
|
||||||
|
login = seeded_admin.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "admin", "password": "adminpassword1"},
|
||||||
|
)
|
||||||
|
cookie = login.cookies.get("cyclone_session")
|
||||||
|
assert cookie
|
||||||
|
resp = seeded_admin.post("/api/auth/logout", cookies={"cyclone_session": cookie})
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
def test_me_returns_current_user(seeded_admin):
|
||||||
|
login = seeded_admin.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "admin", "password": "adminpassword1"},
|
||||||
|
)
|
||||||
|
cookie = login.cookies.get("cyclone_session")
|
||||||
|
resp = seeded_admin.get("/api/auth/me", cookies={"cyclone_session": cookie})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["username"] == "admin"
|
||||||
|
|
||||||
|
|
||||||
|
def test_me_without_cookie_returns_401(seeded_admin):
|
||||||
|
resp = seeded_admin.get("/api/auth/me")
|
||||||
|
assert resp.status_code == 401
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Unit tests for cyclone.auth.sessions — Session create/validate/expire/touch."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import delete
|
||||||
|
|
||||||
|
from cyclone.auth import sessions, users
|
||||||
|
from cyclone.db import Session as DbSession
|
||||||
|
from cyclone.db import SessionLocal, User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
yield
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_user():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
return users.create(db, username="sessuser", password="hunter2hunter2", role="user")
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_session_returns_id_and_session():
|
||||||
|
user = _make_user()
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
sid, sess = sessions.create(db, user_id=user.id)
|
||||||
|
assert len(sid) >= 32
|
||||||
|
assert sess.user_id == user.id
|
||||||
|
assert sess.expires_at > datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_valid_returns_session_for_active():
|
||||||
|
user = _make_user()
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
sid, _ = sessions.create(db, user_id=user.id)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
got = sessions.get_valid(db, sid)
|
||||||
|
assert got is not None
|
||||||
|
assert got.user_id == user.id
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_valid_returns_none_for_missing():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
assert sessions.get_valid(db, "does-not-exist") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_valid_returns_none_for_expired():
|
||||||
|
user = _make_user()
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
sid, _ = sessions.create(db, user_id=user.id)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
sess = sessions.get_valid(db, sid)
|
||||||
|
sess.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
|
||||||
|
db.commit()
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
assert sessions.get_valid(db, sid) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_removes_session():
|
||||||
|
user = _make_user()
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
sid, _ = sessions.create(db, user_id=user.id)
|
||||||
|
sessions.delete(db, sid)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
assert sessions.get_valid(db, sid) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_touch_extends_expiry():
|
||||||
|
user = _make_user()
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
sid, sess = sessions.create(db, user_id=user.id)
|
||||||
|
original_expiry = sess.expires_at
|
||||||
|
sessions.touch(db, sid)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
refreshed = sessions.get_valid(db, sid)
|
||||||
|
assert refreshed.expires_at >= original_expiry
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""Unit tests for cyclone.auth.users — User CRUD + bcrypt hashing."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import delete
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
from cyclone.auth import users
|
||||||
|
from cyclone.db import SessionLocal, User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear_users():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
yield
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_hash_password_returns_bcrypt():
|
||||||
|
h = users.hash_password("hunter2hunter2")
|
||||||
|
assert h.startswith("$2")
|
||||||
|
|
||||||
|
|
||||||
|
def test_hash_password_produces_unique_salts():
|
||||||
|
a = users.hash_password("same-password")
|
||||||
|
b = users.hash_password("same-password")
|
||||||
|
assert a != b
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_password_correct():
|
||||||
|
h = users.hash_password("hunter2hunter2")
|
||||||
|
assert users.verify_password("hunter2hunter2", h) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_password_incorrect():
|
||||||
|
h = users.hash_password("hunter2hunter2")
|
||||||
|
assert users.verify_password("WRONG", h) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_user_persists_with_hashed_password():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.create(db, username="alice", password="hunter2hunter2", role="admin")
|
||||||
|
assert u.id is not None
|
||||||
|
assert u.username == "alice"
|
||||||
|
assert u.role == "admin"
|
||||||
|
assert u.disabled_at is None
|
||||||
|
assert u.password_hash != "hunter2hunter2"
|
||||||
|
assert u.password_hash.startswith("$2")
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_user_rejects_duplicate_username():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="bob", password="hunter2hunter2", role="user")
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
users.create(db, username="bob", password="anotherone", role="user")
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_by_username_returns_user():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="carol", password="hunter2hunter2", role="viewer")
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.get_by_username(db, "carol")
|
||||||
|
assert u is not None
|
||||||
|
assert u.username == "carol"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_by_username_returns_none_for_missing():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
assert users.get_by_username(db, "ghost") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_disable_user_sets_disabled_at():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.create(db, username="dave", password="hunter2hunter2", role="user")
|
||||||
|
users.disable(db, u.id)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
refreshed = users.get_by_username(db, "dave")
|
||||||
|
assert refreshed.disabled_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_to_public_shape_omits_password_hash():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.create(db, username="eve", password="hunter2hunter2", role="admin")
|
||||||
|
shape = users.to_public(u)
|
||||||
|
assert "password_hash" not in shape
|
||||||
|
assert shape["username"] == "eve"
|
||||||
|
assert shape["role"] == "admin"
|
||||||
|
assert "id" in shape and "createdAt" in shape
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_password_actually_rehashes_and_verifies():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.create(db, username="frank", password="hunter2hunter2", role="user")
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.update_password(db, u.id, "newpassword1")
|
||||||
|
# Old password no longer verifies.
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
refreshed = users.get_by_username(db, "frank")
|
||||||
|
assert not users.verify_password("hunter2hunter2", refreshed.password_hash)
|
||||||
|
assert users.verify_password("newpassword1", refreshed.password_hash)
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
"""SP17 — low-level backup crypto tests.
|
|
||||||
|
|
||||||
Pure-Python, no DB. Covers key derivation determinism, encrypt /
|
|
||||||
decrypt round-trip, tampered-ciphertext failure, wrong-passphrase
|
|
||||||
failure, and the sidecar JSON format.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from cyclone import backup as backup_mod
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Key derivation
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_derive_key_is_deterministic():
|
|
||||||
salt = os.urandom(16)
|
|
||||||
k1 = backup_mod.derive_key("correct horse battery staple", salt)
|
|
||||||
k2 = backup_mod.derive_key("correct horse battery staple", salt)
|
|
||||||
assert k1 == k2
|
|
||||||
assert len(k1) == backup_mod.KEY_LEN == 32
|
|
||||||
|
|
||||||
|
|
||||||
def test_derive_key_different_salts_produce_different_keys():
|
|
||||||
"""Salt is what makes the same passphrase produce different keys."""
|
|
||||||
k1 = backup_mod.derive_key("hunter2", os.urandom(16))
|
|
||||||
k2 = backup_mod.derive_key("hunter2", os.urandom(16))
|
|
||||||
assert k1 != k2
|
|
||||||
|
|
||||||
|
|
||||||
def test_derive_key_different_passphrases_produce_different_keys():
|
|
||||||
salt = os.urandom(16)
|
|
||||||
k1 = backup_mod.derive_key("a", salt)
|
|
||||||
k2 = backup_mod.derive_key("b", salt)
|
|
||||||
assert k1 != k2
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Encrypt / decrypt round-trip
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_encrypt_decrypt_roundtrip():
|
|
||||||
key = os.urandom(32)
|
|
||||||
plaintext = b"hello cyclone backup " * 1000
|
|
||||||
blob = backup_mod.encrypt(plaintext, key)
|
|
||||||
assert len(blob) == backup_mod.NONCE_LEN + len(plaintext) + 16 # tag
|
|
||||||
out = backup_mod.decrypt(blob, key)
|
|
||||||
assert out == plaintext
|
|
||||||
|
|
||||||
|
|
||||||
def test_encrypt_decrypt_empty_plaintext():
|
|
||||||
"""Edge case: zero-byte payload still produces nonce + tag."""
|
|
||||||
key = os.urandom(32)
|
|
||||||
blob = backup_mod.encrypt(b"", key)
|
|
||||||
out = backup_mod.decrypt(blob, key)
|
|
||||||
assert out == b""
|
|
||||||
|
|
||||||
|
|
||||||
def test_decrypt_with_wrong_key_raises():
|
|
||||||
plaintext = b"some bytes"
|
|
||||||
key1 = os.urandom(32)
|
|
||||||
key2 = os.urandom(32)
|
|
||||||
blob = backup_mod.encrypt(plaintext, key1)
|
|
||||||
with pytest.raises(backup_mod.BackupDecryptError):
|
|
||||||
backup_mod.decrypt(blob, key2)
|
|
||||||
|
|
||||||
|
|
||||||
def test_decrypt_tampered_ciphertext_raises():
|
|
||||||
"""Flipping a single ciphertext byte must fail GCM auth."""
|
|
||||||
key = os.urandom(32)
|
|
||||||
blob = backup_mod.encrypt(b"a" * 200, key)
|
|
||||||
tampered = bytearray(blob)
|
|
||||||
# Flip a bit somewhere in the ciphertext region (past the nonce).
|
|
||||||
tampered[backup_mod.NONCE_LEN + 5] ^= 0x01
|
|
||||||
with pytest.raises(backup_mod.BackupDecryptError):
|
|
||||||
backup_mod.decrypt(bytes(tampered), key)
|
|
||||||
|
|
||||||
|
|
||||||
def test_decrypt_truncated_blob_raises():
|
|
||||||
key = os.urandom(32)
|
|
||||||
blob = backup_mod.encrypt(b"x" * 100, key)
|
|
||||||
with pytest.raises(backup_mod.BackupDecryptError):
|
|
||||||
# Strip the GCM tag.
|
|
||||||
backup_mod.decrypt(blob[: -16], key)
|
|
||||||
|
|
||||||
|
|
||||||
def test_encrypt_with_wrong_key_length_raises():
|
|
||||||
with pytest.raises(backup_mod.BackupError):
|
|
||||||
backup_mod.encrypt(b"data", b"short") # not 32 bytes
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Fingerprint
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_fingerprint_format_and_stability():
|
|
||||||
fp = backup_mod.fingerprint(b"hello")
|
|
||||||
assert fp.startswith("sha256:")
|
|
||||||
assert len(fp) == len("sha256:") + 64
|
|
||||||
assert fp == backup_mod.fingerprint(b"hello")
|
|
||||||
assert fp != backup_mod.fingerprint(b"hellp")
|
|
||||||
|
|
||||||
|
|
||||||
def test_fingerprint_file_matches_fingerprint_bytes(tmp_path):
|
|
||||||
p = tmp_path / "data.bin"
|
|
||||||
p.write_bytes(b"\x00\x01\x02" * 100)
|
|
||||||
assert backup_mod.fingerprint_file(p) == backup_mod.fingerprint(p.read_bytes())
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Sidecar
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_sidecar_round_trip_json():
|
|
||||||
sc = backup_mod.Sidecar(
|
|
||||||
format_version="v1",
|
|
||||||
created_at="2026-06-21T15:30:00+00:00",
|
|
||||||
db_fingerprint="sha256:" + "a" * 64,
|
|
||||||
table_count=11,
|
|
||||||
size_bytes=1024,
|
|
||||||
kdf="PBKDF2-HMAC-SHA256",
|
|
||||||
kdf_iterations=200_000,
|
|
||||||
cipher="AES-256-GCM",
|
|
||||||
key_fingerprint="sha256:" + "b" * 64,
|
|
||||||
)
|
|
||||||
text = sc.to_json()
|
|
||||||
parsed = json.loads(text)
|
|
||||||
assert parsed["format_version"] == "v1"
|
|
||||||
assert parsed["encryption"]["kdf_iterations"] == 200_000
|
|
||||||
sc2 = backup_mod.Sidecar.from_json(text)
|
|
||||||
assert sc2 == sc
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Filenames
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_backup_filename_format():
|
|
||||||
"""The timestamp prefix is fixed; the suffix is random per call."""
|
|
||||||
import re
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
ts = datetime(2026, 6, 21, 15, 30, 0, tzinfo=timezone.utc)
|
|
||||||
name = backup_mod.backup_filename(ts)
|
|
||||||
assert re.match(r"^cyclone-backup-20260621T153000Z-[0-9a-f]{8}\.bin$", name), name
|
|
||||||
|
|
||||||
|
|
||||||
def test_backup_filename_random_suffix_avoids_collisions():
|
|
||||||
"""Two calls in the same second get different filenames."""
|
|
||||||
a = backup_mod.backup_filename()
|
|
||||||
b = backup_mod.backup_filename()
|
|
||||||
assert a != b
|
|
||||||
|
|
||||||
|
|
||||||
def test_sidecar_filename_appends_meta_json():
|
|
||||||
assert backup_mod.sidecar_filename("foo.bin") == "foo.bin.meta.json"
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
"""SP17 — BackupScheduler unit tests.
|
|
||||||
|
|
||||||
Exercises the asyncio tick / start / stop loop without spinning up
|
|
||||||
the FastAPI app. The scheduler wraps a real BackupService against
|
|
||||||
a real on-disk sqlite DB.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from cyclone import backup_service as svc_mod
|
|
||||||
from cyclone import backup_scheduler as sched_mod
|
|
||||||
from cyclone import db
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def fresh_db(tmp_path, monkeypatch):
|
|
||||||
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
|
|
||||||
db._reset_for_tests()
|
|
||||||
db.init_db()
|
|
||||||
from cyclone.db import Batch
|
|
||||||
import uuid
|
|
||||||
with db.SessionLocal()() as s:
|
|
||||||
s.add(Batch(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
kind="837P",
|
|
||||||
input_filename="seed.x12",
|
|
||||||
parsed_at=datetime.now(timezone.utc),
|
|
||||||
totals_json=None,
|
|
||||||
validation_json=None,
|
|
||||||
raw_result_json={"envelope": {"control_number": "1"}, "claims": [], "summary": {"passed": 0, "failed": 0, "failed_claim_ids": []}},
|
|
||||||
))
|
|
||||||
s.commit()
|
|
||||||
yield
|
|
||||||
db._reset_for_tests()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def backup_svc(tmp_path):
|
|
||||||
return svc_mod.BackupService(
|
|
||||||
backup_dir=tmp_path / "backups",
|
|
||||||
passphrase="test-pass",
|
|
||||||
retention_days=7,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# tick
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_tick_creates_backup_and_audits_it(fresh_db, backup_svc):
|
|
||||||
sched = sched_mod.BackupScheduler(backup_svc, interval_hours=24.0)
|
|
||||||
result = await sched.tick()
|
|
||||||
assert result.ok
|
|
||||||
assert result.created is not None
|
|
||||||
assert result.error is None
|
|
||||||
assert len(backup_svc.list_backups()) == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_tick_creates_audit_event(fresh_db, backup_svc):
|
|
||||||
"""db.backup_created audit event is written (SP11 hash chain)."""
|
|
||||||
sched = sched_mod.BackupScheduler(backup_svc, interval_hours=24.0)
|
|
||||||
await sched.tick()
|
|
||||||
with db.SessionLocal()() as s:
|
|
||||||
from cyclone.db import AuditLog
|
|
||||||
rows = (
|
|
||||||
s.query(AuditLog)
|
|
||||||
.filter(AuditLog.event_type == "db.backup_created")
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
assert len(rows) == 1
|
|
||||||
assert "backup_id" in rows[0].payload_json
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_tick_handles_create_failure_without_crashing(fresh_db, backup_svc, monkeypatch):
|
|
||||||
"""If create_now raises, tick records the error and continues."""
|
|
||||||
def boom():
|
|
||||||
raise RuntimeError("simulated failure")
|
|
||||||
monkeypatch.setattr(backup_svc, "create_now", boom)
|
|
||||||
sched = sched_mod.BackupScheduler(backup_svc, interval_hours=24.0)
|
|
||||||
result = await sched.tick()
|
|
||||||
assert result.error is not None
|
|
||||||
assert "simulated failure" in result.error
|
|
||||||
# Audit event written for the failure.
|
|
||||||
with db.SessionLocal()() as s:
|
|
||||||
from cyclone.db import AuditLog
|
|
||||||
rows = (
|
|
||||||
s.query(AuditLog)
|
|
||||||
.filter(AuditLog.event_type == "db.backup_failed")
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
assert len(rows) == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_tick_prunes_old_backups_and_audits(fresh_db, backup_svc):
|
|
||||||
"""A tick prunes backups past retention and writes a db.backup_pruned event."""
|
|
||||||
from cyclone.db import DbBackup
|
|
||||||
|
|
||||||
# Take an initial backup.
|
|
||||||
initial = backup_svc.create_now()
|
|
||||||
# Age it past retention.
|
|
||||||
with db.SessionLocal()() as s:
|
|
||||||
row = s.get(DbBackup, initial.backup.id)
|
|
||||||
row.created_at = datetime.now(timezone.utc) - timedelta(days=30)
|
|
||||||
s.commit()
|
|
||||||
|
|
||||||
sched = sched_mod.BackupScheduler(backup_svc, interval_hours=24.0)
|
|
||||||
result = await sched.tick()
|
|
||||||
assert result.ok # create_now succeeded even though prune removed old
|
|
||||||
assert len(result.pruned_paths) == 2 # .bin + .meta.json
|
|
||||||
|
|
||||||
with db.SessionLocal()() as s:
|
|
||||||
from cyclone.db import AuditLog
|
|
||||||
pruned_events = (
|
|
||||||
s.query(AuditLog)
|
|
||||||
.filter(AuditLog.event_type == "db.backup_pruned")
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
assert len(pruned_events) == 1
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# start / stop / is_running
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_start_then_stop(fresh_db, backup_svc):
|
|
||||||
sched = sched_mod.BackupScheduler(backup_svc, interval_hours=24.0)
|
|
||||||
assert not sched.is_running()
|
|
||||||
await sched.start()
|
|
||||||
assert sched.is_running()
|
|
||||||
# Don't wait for the staggered first tick; just stop.
|
|
||||||
await sched.stop()
|
|
||||||
assert not sched.is_running()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_double_start_is_idempotent(fresh_db, backup_svc):
|
|
||||||
sched = sched_mod.BackupScheduler(backup_svc, interval_hours=24.0)
|
|
||||||
await sched.start()
|
|
||||||
await sched.start() # no-op
|
|
||||||
assert sched.is_running()
|
|
||||||
await sched.stop()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_concurrent_ticks_are_coalesced(fresh_db, backup_svc):
|
|
||||||
"""Two tick() calls in flight — second waits for first."""
|
|
||||||
sched = sched_mod.BackupScheduler(backup_svc, interval_hours=24.0)
|
|
||||||
r1, r2 = await asyncio.gather(sched.tick(), sched.tick())
|
|
||||||
# Both should succeed and produce a single backup (the second
|
|
||||||
# call returned the first call's result, or ran back-to-back
|
|
||||||
# and produced a second backup — both are valid coalescings).
|
|
||||||
assert r1 is not None
|
|
||||||
assert r2 is not None
|
|
||||||
# No matter the order, exactly 1 backup should exist OR 2 if they
|
|
||||||
# ran sequentially. The point of coalescing is no-overlap, so
|
|
||||||
# both should be ok=True.
|
|
||||||
assert r1.ok
|
|
||||||
assert r2.ok
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# status
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_status_snapshot(fresh_db, backup_svc):
|
|
||||||
sched = sched_mod.BackupScheduler(backup_svc, interval_hours=12.0)
|
|
||||||
snap = sched.status()
|
|
||||||
assert snap.running is False
|
|
||||||
assert snap.interval_hours == 12.0
|
|
||||||
assert snap.backup_dir == str(backup_svc.backup_dir)
|
|
||||||
assert snap.retention_days == 7
|
|
||||||
assert snap.tick_count == 0
|
|
||||||
assert snap.last_tick is None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Module-level singleton
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_module_singleton_round_trip(fresh_db, tmp_path):
|
|
||||||
sched_mod.reset_backup_scheduler_for_tests()
|
|
||||||
svc = svc_mod.BackupService(tmp_path / "b", passphrase="x", retention_days=1)
|
|
||||||
sched = sched_mod.configure_backup_scheduler(svc, interval_hours=1)
|
|
||||||
assert sched_mod.get_backup_scheduler() is sched
|
|
||||||
# Second configure is a no-op.
|
|
||||||
assert sched_mod.configure_backup_scheduler(svc) is sched
|
|
||||||
sched_mod.reset_backup_scheduler_for_tests()
|
|
||||||
|
|
||||||
|
|
||||||
def test_module_singleton_get_raises_when_unset():
|
|
||||||
sched_mod.reset_backup_scheduler_for_tests()
|
|
||||||
with pytest.raises(RuntimeError):
|
|
||||||
sched_mod.get_backup_scheduler()
|
|
||||||
@@ -1,400 +0,0 @@
|
|||||||
"""SP17 — BackupService integration tests.
|
|
||||||
|
|
||||||
Exercises the full create / list / verify / restore / prune flow
|
|
||||||
against a real on-disk SQLite file (no SQLCipher, no Keychain). We
|
|
||||||
inject the passphrase directly into the BackupService constructor.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from cyclone import backup as backup_mod
|
|
||||||
from cyclone import backup_service as svc_mod
|
|
||||||
from cyclone import db
|
|
||||||
from cyclone.backup import BackupError
|
|
||||||
from cyclone.backup_service import (
|
|
||||||
BackupService,
|
|
||||||
STATUS_ERROR,
|
|
||||||
STATUS_OK,
|
|
||||||
STATUS_PENDING,
|
|
||||||
STATUS_PRUNED,
|
|
||||||
configure_backup_service,
|
|
||||||
get_backup_service,
|
|
||||||
reset_backup_service_for_tests,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Fixtures
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def fresh_db(tmp_path, monkeypatch):
|
|
||||||
"""Fresh sqlite DB; init_db + create tables; yield the path."""
|
|
||||||
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
|
|
||||||
db._reset_for_tests()
|
|
||||||
db.init_db()
|
|
||||||
yield tmp_path / "test.db"
|
|
||||||
db._reset_for_tests()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def backup_svc(fresh_db, tmp_path):
|
|
||||||
"""A BackupService rooted in a temp backup directory."""
|
|
||||||
backup_dir = tmp_path / "backups"
|
|
||||||
return BackupService(
|
|
||||||
backup_dir=backup_dir,
|
|
||||||
passphrase="test-passphrase-123",
|
|
||||||
retention_days=7,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _make_a_row(s: "sa.orm.Session") -> None:
|
|
||||||
"""Insert one minimal Batch row so the DB has a real schema + content.
|
|
||||||
|
|
||||||
Bypasses the Claim model (which has many NOT NULL columns tied to
|
|
||||||
BatchRecord lifecycle) and just writes a Batch directly — the
|
|
||||||
backup flow doesn't care which tables exist, only that there
|
|
||||||
are some.
|
|
||||||
"""
|
|
||||||
from cyclone.db import Batch
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from decimal import Decimal
|
|
||||||
s.add(Batch(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
kind="837P",
|
|
||||||
input_filename="test.x12",
|
|
||||||
parsed_at=datetime.now(timezone.utc),
|
|
||||||
totals_json=None,
|
|
||||||
validation_json=None,
|
|
||||||
raw_result_json={
|
|
||||||
"envelope": {"control_number": "1"},
|
|
||||||
"claims": [],
|
|
||||||
"summary": {"passed": 0, "failed": 0, "failed_claim_ids": []},
|
|
||||||
},
|
|
||||||
))
|
|
||||||
s.commit()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# create_now
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_now_writes_encrypted_blob_and_sidecar(fresh_db, backup_svc):
|
|
||||||
from cyclone.db import DbBackup
|
|
||||||
# Add a claim so the DB has content + table_count > 0.
|
|
||||||
with db.SessionLocal()() as s:
|
|
||||||
_make_a_row(s)
|
|
||||||
|
|
||||||
result = backup_svc.create_now()
|
|
||||||
record = result.backup
|
|
||||||
sidecar = result.sidecar
|
|
||||||
|
|
||||||
assert record.status == STATUS_OK
|
|
||||||
assert record.size_bytes > 0
|
|
||||||
assert record.db_fingerprint.startswith("sha256:")
|
|
||||||
assert record.table_count >= 1
|
|
||||||
assert record.completed_at is not None
|
|
||||||
|
|
||||||
# The .bin file exists, is non-trivial size, and does NOT look
|
|
||||||
# like a SQLite header (which is the whole point of encryption).
|
|
||||||
bin_path = backup_svc.backup_dir / record.filename
|
|
||||||
assert bin_path.exists()
|
|
||||||
blob = bin_path.read_bytes()
|
|
||||||
assert blob[:6] != b"SQLite" # not a plaintext SQLite file
|
|
||||||
|
|
||||||
# Sidecar exists and round-trips.
|
|
||||||
meta_path = backup_svc.backup_dir / backup_mod.sidecar_filename(record.filename)
|
|
||||||
assert meta_path.exists()
|
|
||||||
parsed = backup_mod.Sidecar.from_json(meta_path.read_text())
|
|
||||||
assert parsed.db_fingerprint == record.db_fingerprint
|
|
||||||
assert parsed.table_count == record.table_count
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_now_marks_error_on_db_failure(fresh_db, tmp_path, monkeypatch):
|
|
||||||
"""If SQLite .backup() raises, the row is marked error + files cleaned."""
|
|
||||||
backup_dir = tmp_path / "backups"
|
|
||||||
svc = BackupService(backup_dir=backup_dir, passphrase="x", retention_days=7)
|
|
||||||
|
|
||||||
# Force the .backup() call to fail by patching sqlite3.connect to raise.
|
|
||||||
import sqlite3 as _sqlite3
|
|
||||||
real_connect = _sqlite3.connect
|
|
||||||
def boom(path):
|
|
||||||
raise RuntimeError("simulated disk failure")
|
|
||||||
monkeypatch.setattr(_sqlite3, "connect", boom)
|
|
||||||
# But we also need to make sure engine.raw_connection().driver_connection
|
|
||||||
# is reachable — it's still using real_connect via the engine's
|
|
||||||
# internals. So patch at the higher level: the BackupService's
|
|
||||||
# _sqlite_backup_to.
|
|
||||||
monkeypatch.setattr(svc, "_sqlite_backup_to",
|
|
||||||
lambda p: (_ for _ in ()).throw(RuntimeError("boom")))
|
|
||||||
|
|
||||||
with pytest.raises(RuntimeError, match="boom"):
|
|
||||||
svc.create_now()
|
|
||||||
|
|
||||||
rows = svc.list_backups()
|
|
||||||
assert len(rows) == 1
|
|
||||||
assert rows[0].status == STATUS_ERROR
|
|
||||||
assert "boom" in rows[0].error_message
|
|
||||||
# No files left in the backup dir.
|
|
||||||
assert list(backup_dir.iterdir()) == []
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# list_backups
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_backups_orders_newest_first(fresh_db, backup_svc):
|
|
||||||
with db.SessionLocal()() as s:
|
|
||||||
_make_a_row(s)
|
|
||||||
r1 = backup_svc.create_now()
|
|
||||||
r2 = backup_svc.create_now()
|
|
||||||
rows = backup_svc.list_backups()
|
|
||||||
assert [r.id for r in rows] == [r2.backup.id, r1.backup.id]
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_backups_filter_by_status(fresh_db, backup_svc):
|
|
||||||
with db.SessionLocal()() as s:
|
|
||||||
_make_a_row(s)
|
|
||||||
backup_svc.create_now()
|
|
||||||
rows = backup_svc.list_backups(status=STATUS_OK)
|
|
||||||
assert all(r.status == STATUS_OK for r in rows)
|
|
||||||
rows = backup_svc.list_backups(status=STATUS_PENDING)
|
|
||||||
assert rows == []
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# verify
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_verify_ok_after_create(fresh_db, backup_svc):
|
|
||||||
with db.SessionLocal()() as s:
|
|
||||||
_make_a_row(s)
|
|
||||||
r = backup_svc.create_now()
|
|
||||||
v = backup_svc.verify(r.backup.id)
|
|
||||||
assert v.ok
|
|
||||||
assert v.expected_fingerprint == v.actual_fingerprint
|
|
||||||
assert v.table_count >= 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_verify_detects_tampered_ciphertext(fresh_db, backup_svc):
|
|
||||||
with db.SessionLocal()() as s:
|
|
||||||
_make_a_row(s)
|
|
||||||
r = backup_svc.create_now()
|
|
||||||
bin_path = backup_svc.backup_dir / r.backup.filename
|
|
||||||
# Flip a bit in the middle of the encrypted blob.
|
|
||||||
data = bytearray(bin_path.read_bytes())
|
|
||||||
idx = backup_mod.NONCE_LEN + 5
|
|
||||||
data[idx] ^= 0x01
|
|
||||||
bin_path.write_bytes(bytes(data))
|
|
||||||
|
|
||||||
v = backup_svc.verify(r.backup.id)
|
|
||||||
assert not v.ok
|
|
||||||
assert "decryption failed" in (v.reason or "")
|
|
||||||
|
|
||||||
|
|
||||||
def test_verify_handles_missing_file(fresh_db, backup_svc):
|
|
||||||
with db.SessionLocal()() as s:
|
|
||||||
_make_a_row(s)
|
|
||||||
r = backup_svc.create_now()
|
|
||||||
(backup_svc.backup_dir / r.backup.filename).unlink()
|
|
||||||
v = backup_svc.verify(r.backup.id)
|
|
||||||
assert not v.ok
|
|
||||||
assert "missing" in (v.reason or "")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# restore — two-step
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_restore_two_step_round_trip(fresh_db, backup_svc, tmp_path):
|
|
||||||
"""Create a backup, mutate the live DB, restore, confirm mutation gone."""
|
|
||||||
from cyclone.db import Batch
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
# 1. Backup a DB with one Batch row.
|
|
||||||
with db.SessionLocal()() as s:
|
|
||||||
_make_a_row(s)
|
|
||||||
snap = backup_svc.create_now()
|
|
||||||
|
|
||||||
# 2. Mutate the live DB (add another Batch row).
|
|
||||||
with db.SessionLocal()() as s:
|
|
||||||
s.add(Batch(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
kind="837P",
|
|
||||||
input_filename="mutated.x12",
|
|
||||||
parsed_at=datetime.now(timezone.utc),
|
|
||||||
totals_json=None,
|
|
||||||
validation_json=None,
|
|
||||||
raw_result_json={"envelope": {"control_number": "2"}, "claims": [], "summary": {"passed": 0, "failed": 0, "failed_claim_ids": []}},
|
|
||||||
))
|
|
||||||
s.commit()
|
|
||||||
with db.SessionLocal()() as s:
|
|
||||||
assert s.query(Batch).count() == 2
|
|
||||||
|
|
||||||
# 3. Initiate restore.
|
|
||||||
init = backup_svc.restore_initiate(snap.backup.id)
|
|
||||||
assert init.table_count >= 1
|
|
||||||
assert init.current_db_fingerprint != init.db_fingerprint # live != backup now
|
|
||||||
assert init.restore_token and len(init.restore_token) == 64
|
|
||||||
|
|
||||||
# 4. Confirm restore.
|
|
||||||
result = backup_svc.restore_confirm(snap.backup.id, init.restore_token)
|
|
||||||
assert result.new_db_fingerprint == init.db_fingerprint
|
|
||||||
|
|
||||||
# 5. The live DB now reflects the snapshot (1 row, not 2).
|
|
||||||
with db.SessionLocal()() as s:
|
|
||||||
assert s.query(Batch).count() == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_restore_initiate_rejects_non_ok_backup(fresh_db, backup_svc, tmp_path, monkeypatch):
|
|
||||||
"""A backup row with status='error' cannot be restored."""
|
|
||||||
with db.SessionLocal()() as s:
|
|
||||||
_make_a_row(s)
|
|
||||||
r = backup_svc.create_now()
|
|
||||||
# Force the row to error.
|
|
||||||
from cyclone.db import DbBackup
|
|
||||||
with db.SessionLocal()() as session:
|
|
||||||
row = session.get(DbBackup, r.backup.id)
|
|
||||||
row.status = STATUS_ERROR
|
|
||||||
row.error_message = "simulated"
|
|
||||||
session.commit()
|
|
||||||
with pytest.raises(BackupError, match="only 'ok' backups"):
|
|
||||||
backup_svc.restore_initiate(r.backup.id)
|
|
||||||
|
|
||||||
|
|
||||||
def test_restore_confirm_rejects_wrong_token(fresh_db, backup_svc):
|
|
||||||
with db.SessionLocal()() as s:
|
|
||||||
_make_a_row(s)
|
|
||||||
r = backup_svc.create_now()
|
|
||||||
init = backup_svc.restore_initiate(r.backup.id)
|
|
||||||
with pytest.raises(BackupError, match="not found"):
|
|
||||||
backup_svc.restore_confirm(r.backup.id, "0" * 64)
|
|
||||||
|
|
||||||
|
|
||||||
def test_restore_confirm_rejects_expired_token(fresh_db, backup_svc, monkeypatch):
|
|
||||||
"""A token whose expires_at is in the past is rejected."""
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
with db.SessionLocal()() as s:
|
|
||||||
_make_a_row(s)
|
|
||||||
r = backup_svc.create_now()
|
|
||||||
init = backup_svc.restore_initiate(r.backup.id)
|
|
||||||
# Manually age the token past its expiry.
|
|
||||||
with backup_svc._lock:
|
|
||||||
backup_svc._pending_restores[init.restore_token] = (
|
|
||||||
init.backup_id,
|
|
||||||
datetime.now(timezone.utc) - timedelta(seconds=1),
|
|
||||||
)
|
|
||||||
with pytest.raises(BackupError, match="expired"):
|
|
||||||
backup_svc.restore_confirm(r.backup.id, init.restore_token)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# prune
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_prune_deletes_files_and_marks_status(fresh_db, backup_svc):
|
|
||||||
with db.SessionLocal()() as s:
|
|
||||||
_make_a_row(s)
|
|
||||||
r1 = backup_svc.create_now()
|
|
||||||
# The retention cutoff is 7 days from now. Move the row's created_at
|
|
||||||
# back 30 days so it's definitely past retention.
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from cyclone.db import DbBackup
|
|
||||||
with db.SessionLocal()() as session:
|
|
||||||
row = session.get(DbBackup, r1.backup.id)
|
|
||||||
row.created_at = datetime.now(timezone.utc) - timedelta(days=30)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
deleted = backup_svc.prune()
|
|
||||||
assert len(deleted) == 2 # .bin + .meta.json
|
|
||||||
rows = backup_svc.list_backups()
|
|
||||||
assert rows[0].status == STATUS_PRUNED
|
|
||||||
|
|
||||||
|
|
||||||
def test_prune_keeps_recent_backups(fresh_db, backup_svc):
|
|
||||||
with db.SessionLocal()() as s:
|
|
||||||
_make_a_row(s)
|
|
||||||
backup_svc.create_now()
|
|
||||||
deleted = backup_svc.prune()
|
|
||||||
assert deleted == []
|
|
||||||
rows = backup_svc.list_backups()
|
|
||||||
assert rows[0].status == STATUS_OK
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# status
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_status_reports_counts(fresh_db, backup_svc):
|
|
||||||
with db.SessionLocal()() as s:
|
|
||||||
_make_a_row(s)
|
|
||||||
backup_svc.create_now()
|
|
||||||
snap = backup_svc.status()
|
|
||||||
assert snap["totals"]["ok"] == 1
|
|
||||||
assert snap["totals"]["all"] == 1
|
|
||||||
assert snap["backup_dir"] == str(backup_svc.backup_dir)
|
|
||||||
assert snap["retention_days"] == 7
|
|
||||||
assert snap["used_fallback_key"] is False
|
|
||||||
assert snap["last_backup_at"] is not None
|
|
||||||
assert snap["last_ok_backup_at"] is not None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Fallback key
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_fallback_key_used_when_no_passphrase(fresh_db, tmp_path):
|
|
||||||
"""If no passphrase AND no SQLCipher, refuse. Otherwise fallback + warn."""
|
|
||||||
backup_dir = tmp_path / "backups"
|
|
||||||
svc = BackupService(backup_dir=backup_dir, passphrase=None, retention_days=7)
|
|
||||||
# No SQLCipher key either → BackupError.
|
|
||||||
with pytest.raises(BackupError, match="no backup passphrase"):
|
|
||||||
svc._ensure_key()
|
|
||||||
|
|
||||||
|
|
||||||
def test_key_fingerprint_changes_per_passphrase(fresh_db, tmp_path):
|
|
||||||
"""Two services with different passphrases have different key fingerprints."""
|
|
||||||
s1 = BackupService(tmp_path / "b1", passphrase="alpha", retention_days=1)
|
|
||||||
s2 = BackupService(tmp_path / "b2", passphrase="beta", retention_days=1)
|
|
||||||
# Force key derivation.
|
|
||||||
s1._ensure_key()
|
|
||||||
s2._ensure_key()
|
|
||||||
assert s1.key_fingerprint != s2.key_fingerprint
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Module-level singleton
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_module_singleton_round_trip(fresh_db, tmp_path):
|
|
||||||
reset_backup_service_for_tests()
|
|
||||||
svc = configure_backup_service(
|
|
||||||
tmp_path / "backups", passphrase="x", retention_days=1,
|
|
||||||
)
|
|
||||||
assert get_backup_service() is svc
|
|
||||||
# Second configure is a no-op (returns existing).
|
|
||||||
assert configure_backup_service(
|
|
||||||
tmp_path / "backups2", passphrase="y", retention_days=2,
|
|
||||||
) is svc
|
|
||||||
reset_backup_service_for_tests()
|
|
||||||
|
|
||||||
|
|
||||||
def test_module_singleton_get_raises_when_unset():
|
|
||||||
reset_backup_service_for_tests()
|
|
||||||
with pytest.raises(RuntimeError):
|
|
||||||
get_backup_service()
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
"""SP17 — `cyclone backup` CLI subcommand tests.
|
|
||||||
|
|
||||||
Uses Click's CliRunner + monkeypatching of Keychain + DB env so the
|
|
||||||
subcommands can run without the operator's machine state.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from click.testing import CliRunner
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def _cli_env(tmp_path, monkeypatch):
|
|
||||||
"""Fresh sqlite DB + in-memory Keychain stub."""
|
|
||||||
from cyclone import db
|
|
||||||
from cyclone import backup_service as svc_mod
|
|
||||||
from cyclone import secrets as secrets_mod
|
|
||||||
from cyclone.db import Batch
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
|
|
||||||
db._reset_for_tests()
|
|
||||||
db.init_db()
|
|
||||||
|
|
||||||
with db.SessionLocal()() as s:
|
|
||||||
s.add(Batch(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
kind="837P",
|
|
||||||
input_filename="seed.x12",
|
|
||||||
parsed_at=datetime.now(timezone.utc),
|
|
||||||
totals_json=None,
|
|
||||||
validation_json=None,
|
|
||||||
raw_result_json={"envelope": {"control_number": "1"}, "claims": [], "summary": {"passed": 0, "failed": 0, "failed_claim_ids": []}},
|
|
||||||
))
|
|
||||||
s.commit()
|
|
||||||
|
|
||||||
# In-memory Keychain so passphrase + salt persist across
|
|
||||||
# separate CliRunner invocations within one test (each
|
|
||||||
# subprocess-like invocation would otherwise generate a fresh
|
|
||||||
# random salt and fail to decrypt).
|
|
||||||
store: dict[str, str] = {}
|
|
||||||
store[svc_mod.KEYCHAIN_BACKUP_PASSPHRASE_ACCOUNT] = "cli-test-passphrase"
|
|
||||||
# Pre-populate a stable salt so the very first invocation
|
|
||||||
# doesn't generate a new random one (which the next invocation
|
|
||||||
# would then fail to reproduce).
|
|
||||||
store[svc_mod.KEYCHAIN_BACKUP_SALT_ACCOUNT] = "0123456789abcdef0123456789abcdef"
|
|
||||||
|
|
||||||
def _get(name):
|
|
||||||
return store.get(name)
|
|
||||||
def _set(name, value):
|
|
||||||
store[name] = value
|
|
||||||
return True
|
|
||||||
|
|
||||||
monkeypatch.setattr(secrets_mod, "get_secret", _get)
|
|
||||||
monkeypatch.setattr(secrets_mod, "set_secret", _set)
|
|
||||||
|
|
||||||
backup_dir = tmp_path / "backups"
|
|
||||||
monkeypatch.setenv("CYCLONE_BACKUP_DIR", str(backup_dir))
|
|
||||||
monkeypatch.setenv("CYCLONE_BACKUP_RETENTION_DAYS", "7")
|
|
||||||
|
|
||||||
yield backup_dir
|
|
||||||
db._reset_for_tests()
|
|
||||||
|
|
||||||
|
|
||||||
def _run(args, env):
|
|
||||||
from cyclone.cli import main
|
|
||||||
runner = CliRunner()
|
|
||||||
return runner.invoke(main, args, catch_exceptions=False)
|
|
||||||
|
|
||||||
|
|
||||||
def test_backup_create_list_verify_status(_cli_env):
|
|
||||||
"""Happy path: create → list → verify → status."""
|
|
||||||
backup_dir = _cli_env
|
|
||||||
|
|
||||||
# create
|
|
||||||
r = _run(["backup", "create"], _cli_env)
|
|
||||||
assert r.exit_code == 0, r.output
|
|
||||||
assert "created backup id=" in r.output
|
|
||||||
|
|
||||||
# list
|
|
||||||
r = _run(["backup", "list"], _cli_env)
|
|
||||||
assert r.exit_code == 0, r.output
|
|
||||||
assert ".bin" in r.output
|
|
||||||
|
|
||||||
# verify (we don't know the id, parse it from the list output)
|
|
||||||
import re
|
|
||||||
m = re.search(r"^\s*(\d+)\s+ok\s+", r.output, re.MULTILINE)
|
|
||||||
assert m, r.output
|
|
||||||
backup_id = int(m.group(1))
|
|
||||||
r = _run(["backup", "verify", str(backup_id)], _cli_env)
|
|
||||||
assert r.exit_code == 0, r.output
|
|
||||||
assert r.output.startswith("OK:")
|
|
||||||
|
|
||||||
# status
|
|
||||||
r = _run(["backup", "status"], _cli_env)
|
|
||||||
assert r.exit_code == 0, r.output
|
|
||||||
assert '"totals"' in r.output
|
|
||||||
assert '"ok": 1' in r.output
|
|
||||||
|
|
||||||
|
|
||||||
def test_backup_verify_fails_on_tampered_ciphertext(_cli_env):
|
|
||||||
from cyclone import backup as backup_mod
|
|
||||||
from cyclone import backup_service as svc_mod
|
|
||||||
from cyclone import secrets as secrets_mod
|
|
||||||
|
|
||||||
# Create a backup.
|
|
||||||
r = _run(["backup", "create"], _cli_env)
|
|
||||||
assert r.exit_code == 0
|
|
||||||
# Tamper.
|
|
||||||
bin_path = next(_cli_env.glob("*.bin"))
|
|
||||||
data = bytearray(bin_path.read_bytes())
|
|
||||||
data[backup_mod.NONCE_LEN + 5] ^= 0x01
|
|
||||||
bin_path.write_bytes(bytes(data))
|
|
||||||
|
|
||||||
# Verify should fail.
|
|
||||||
r = _run(["backup", "verify", "1"], _cli_env)
|
|
||||||
assert r.exit_code == 1
|
|
||||||
assert "FAIL" in r.output
|
|
||||||
|
|
||||||
|
|
||||||
def test_backup_restore_requires_yes_flag(_cli_env):
|
|
||||||
"""Without --yes, an interactive confirm blocks and the command aborts."""
|
|
||||||
r = _run(["backup", "create"], _cli_env)
|
|
||||||
assert r.exit_code == 0
|
|
||||||
# Click's runner auto-declines the confirm prompt; expect abort.
|
|
||||||
r = _run(["backup", "restore", "1"], _cli_env, )
|
|
||||||
# CliRunner auto-aborts confirm prompts by default → exit code != 0.
|
|
||||||
assert r.exit_code != 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_backup_prune_aborts_without_yes(_cli_env):
|
|
||||||
r = _run(["backup", "create"], _cli_env)
|
|
||||||
assert r.exit_code == 0
|
|
||||||
# Same auto-abort for the prune confirm.
|
|
||||||
r = _run(["backup", "prune"], _cli_env)
|
|
||||||
assert r.exit_code != 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_backup_init_passphrase_rejects_short(_cli_env):
|
|
||||||
"""init-passphrase enforces a 12-char minimum."""
|
|
||||||
r = _run(["backup", "init-passphrase", "--passphrase", "short"], _cli_env)
|
|
||||||
assert r.exit_code != 0
|
|
||||||
assert "12 characters" in r.output
|
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""CLI: python -m cyclone users {create,list,disable,reset-password,set-role}.
|
||||||
|
|
||||||
|
Uses Click's CliRunner (matches the existing parse-837/parse-835 CLI tests).
|
||||||
|
The full CLI group lives in ``cyclone.cli.main`` — we exercise the
|
||||||
|
``users`` subgroup through the top-level ``main`` so the wiring is real.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
|
||||||
|
from cyclone.auth import users
|
||||||
|
from cyclone.cli import main as cli_main
|
||||||
|
from cyclone.db import Session as DbSession
|
||||||
|
from cyclone.db import SessionLocal, User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
yield
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_user_via_cli():
|
||||||
|
from click.testing import CliRunner
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
cli_main,
|
||||||
|
[
|
||||||
|
"users", "create", "cli-user",
|
||||||
|
"--role", "viewer",
|
||||||
|
"--password", "clipassword1",
|
||||||
|
],
|
||||||
|
input="", # don't prompt
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.get_by_username(db, "cli-user")
|
||||||
|
assert u is not None
|
||||||
|
assert u.role == "viewer"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_users_via_cli():
|
||||||
|
from click.testing import CliRunner
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="listed", password="hunter2hunter2", role="user")
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli_main, ["users", "list"])
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert "listed" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_disable_user_via_cli():
|
||||||
|
from click.testing import CliRunner
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="todie", password="hunter2hunter2", role="user")
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli_main, ["users", "disable", "todie"])
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.get_by_username(db, "todie")
|
||||||
|
assert u.disabled_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_password_via_cli():
|
||||||
|
from click.testing import CliRunner
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="pwchange", password="oldpassword1", role="user")
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
cli_main,
|
||||||
|
[
|
||||||
|
"users", "reset-password", "pwchange",
|
||||||
|
"--password", "newpassword1",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.get_by_username(db, "pwchange")
|
||||||
|
assert users.verify_password("newpassword1", u.password_hash)
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_role_via_cli():
|
||||||
|
from click.testing import CliRunner
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="promote", password="hunter2hunter2", role="viewer")
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
cli_main,
|
||||||
|
["users", "set-role", "promote", "--role", "admin"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.get_by_username(db, "promote")
|
||||||
|
assert u.role == "admin"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_rejects_short_password():
|
||||||
|
from click.testing import CliRunner
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
cli_main,
|
||||||
|
[
|
||||||
|
"users", "create", "weak",
|
||||||
|
"--role", "viewer",
|
||||||
|
"--password", "short",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
# Click surfaces validation failures with a non-zero exit code.
|
||||||
|
assert result.exit_code != 0, result.output
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
rows = db.execute(select(User).where(User.username == "weak")).scalars().all()
|
||||||
|
assert rows == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_disable_unknown_user_exits_nonzero():
|
||||||
|
from click.testing import CliRunner
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli_main, ["users", "disable", "ghost"])
|
||||||
|
assert result.exit_code != 0, result.output
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
"""Tests for ``cyclone validate-npi`` + ``cyclone validate-tax-id`` (SP20).
|
|
||||||
|
|
||||||
CLI smoke tests — verify exit codes (0 = valid, 1 = invalid) and that
|
|
||||||
the help text references the new subcommands. We don't pipe the value
|
|
||||||
into shared logs (NPI / EIN are PHI / PII); the CliRunner captures it.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
from cyclone.cli import main
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# validate-npi
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_cli_validate_npi_valid_exits_zero():
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["validate-npi", "1234567893"])
|
|
||||||
assert result.exit_code == 0, result.output
|
|
||||||
assert "OK" in result.output
|
|
||||||
|
|
||||||
|
|
||||||
def test_cli_validate_npi_bad_checksum_exits_one():
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["validate-npi", "1234567890"])
|
|
||||||
assert result.exit_code == 1
|
|
||||||
assert "INVALID" in result.output
|
|
||||||
|
|
||||||
|
|
||||||
def test_cli_validate_npi_wrong_length_exits_one():
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["validate-npi", "12345"])
|
|
||||||
assert result.exit_code == 1
|
|
||||||
assert "INVALID" in result.output
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# validate-tax-id
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_cli_validate_tax_id_formatted_exits_zero():
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["validate-tax-id", "72-1587149"])
|
|
||||||
assert result.exit_code == 0, result.output
|
|
||||||
assert "721587149" in result.output # normalized form echoed
|
|
||||||
|
|
||||||
|
|
||||||
def test_cli_validate_tax_id_unformatted_exits_zero():
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["validate-tax-id", "721587149"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_cli_validate_tax_id_reserved_prefix_exits_one():
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["validate-tax-id", "00-1234567"])
|
|
||||||
assert result.exit_code == 1
|
|
||||||
assert "reserved" in result.output.lower()
|
|
||||||
|
|
||||||
|
|
||||||
def test_cli_validate_tax_id_malformed_exits_one():
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["validate-tax-id", "not-an-ein"])
|
|
||||||
assert result.exit_code == 1
|
|
||||||
assert "9-digit" in result.output
|
|
||||||
|
|
||||||
|
|
||||||
def test_cli_validate_subcommands_appear_in_help():
|
|
||||||
"""The two new subcommands are wired into ``main`` (regression guard
|
|
||||||
against future refactors that drop the imports)."""
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--help"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "validate-npi" in result.output
|
|
||||||
assert "validate-tax-id" in result.output
|
|
||||||
@@ -173,120 +173,3 @@ class TestMakeSqlcipherConnectCreator:
|
|||||||
result = conn.execute("SELECT x FROM t").fetchone()
|
result = conn.execute("SELECT x FROM t").fetchone()
|
||||||
assert result[0] == 42
|
assert result[0] == 42
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# SP15: Key generation + fingerprint
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
|
|
||||||
class TestGenerateDbKey:
|
|
||||||
def test_returns_64_char_hex(self):
|
|
||||||
"""A 256-bit key hex-encodes to 64 characters."""
|
|
||||||
key = db_crypto.generate_db_key()
|
|
||||||
assert len(key) == 64
|
|
||||||
int(key, 16) # parses as hex (raises if not)
|
|
||||||
|
|
||||||
def test_two_calls_return_different_keys(self):
|
|
||||||
"""Distinct calls produce cryptographically distinct keys."""
|
|
||||||
keys = {db_crypto.generate_db_key() for _ in range(8)}
|
|
||||||
assert len(keys) == 8
|
|
||||||
|
|
||||||
|
|
||||||
class TestFingerprint:
|
|
||||||
def test_deterministic(self):
|
|
||||||
assert db_crypto.fingerprint("abc") == db_crypto.fingerprint("abc")
|
|
||||||
|
|
||||||
def test_different_inputs_yield_different_fingerprints(self):
|
|
||||||
assert db_crypto.fingerprint("abc") != db_crypto.fingerprint("xyz")
|
|
||||||
|
|
||||||
def test_eight_chars(self):
|
|
||||||
assert len(db_crypto.fingerprint("anything")) == 8
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# SP15: rotate_db_key (in-place rekey via PRAGMA rekey)
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
|
|
||||||
@pytestmark_sqlcipher
|
|
||||||
class TestRotateDbKey:
|
|
||||||
def _create_encrypted_db(self, tmp_path: Path, key: str) -> Path:
|
|
||||||
"""Create a small SQLCipher DB with two tables."""
|
|
||||||
import sqlcipher3
|
|
||||||
db_file = tmp_path / "rotate.db"
|
|
||||||
conn = sqlcipher3.connect(str(db_file))
|
|
||||||
conn.execute(f'PRAGMA key = "{key}"')
|
|
||||||
conn.execute("CREATE TABLE accounts (id INTEGER PRIMARY KEY, name TEXT)")
|
|
||||||
conn.execute("CREATE TABLE balances (acct_id INTEGER, amt REAL)")
|
|
||||||
conn.execute("INSERT INTO accounts VALUES (1, 'alice'), (2, 'bob')")
|
|
||||||
conn.execute("INSERT INTO balances VALUES (1, 100.5), (2, 250.75)")
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
return db_file
|
|
||||||
|
|
||||||
def test_rotate_changes_key_preserves_data(self, tmp_path: Path):
|
|
||||||
"""The core SP15 contract: rekey with a new key, data survives."""
|
|
||||||
db_file = self._create_encrypted_db(tmp_path, "old-key-aaaa")
|
|
||||||
url = f"sqlite:///{db_file}"
|
|
||||||
result = db_crypto.rotate_db_key(
|
|
||||||
url=url, old_key="old-key-aaaa", new_key="new-key-bbbb",
|
|
||||||
)
|
|
||||||
assert result.ok, f"rotate failed: {result.reason}"
|
|
||||||
assert result.old_fingerprint == db_crypto.fingerprint("old-key-aaaa")
|
|
||||||
assert result.new_fingerprint == db_crypto.fingerprint("new-key-bbbb")
|
|
||||||
assert result.table_count == 2 # accounts + balances
|
|
||||||
|
|
||||||
# Open with the new key; data is intact.
|
|
||||||
import sqlcipher3
|
|
||||||
conn = sqlcipher3.connect(str(db_file))
|
|
||||||
conn.execute(f'PRAGMA key = "new-key-bbbb"')
|
|
||||||
rows = conn.execute("SELECT id, name FROM accounts ORDER BY id").fetchall()
|
|
||||||
assert rows == [(1, "alice"), (2, "bob")]
|
|
||||||
assert conn.execute("SELECT amt FROM balances WHERE acct_id = 2").fetchone()[0] == 250.75
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def test_old_key_no_longer_opens_db(self, tmp_path: Path):
|
|
||||||
"""After rekey, the old key must not be able to open the DB."""
|
|
||||||
import sqlcipher3
|
|
||||||
db_file = self._create_encrypted_db(tmp_path, "old-key")
|
|
||||||
url = f"sqlite:///{db_file}"
|
|
||||||
result = db_crypto.rotate_db_key(
|
|
||||||
url=url, old_key="old-key", new_key="new-key",
|
|
||||||
)
|
|
||||||
assert result.ok
|
|
||||||
|
|
||||||
# Old key raises on first query.
|
|
||||||
conn = sqlcipher3.connect(str(db_file))
|
|
||||||
conn.execute(f'PRAGMA key = "old-key"')
|
|
||||||
with pytest.raises(Exception) as exc_info:
|
|
||||||
conn.execute("SELECT * FROM accounts").fetchall()
|
|
||||||
msg = str(exc_info.value).lower()
|
|
||||||
assert "not a database" in msg or "file is encrypted" in msg
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def test_wrong_old_key_reports_helpful_reason(self, tmp_path: Path):
|
|
||||||
"""If the operator types the wrong old key, the rekey fails clean."""
|
|
||||||
db_file = self._create_encrypted_db(tmp_path, "correct-old")
|
|
||||||
url = f"sqlite:///{db_file}"
|
|
||||||
result = db_crypto.rotate_db_key(
|
|
||||||
url=url, old_key="WRONG-OLD-KEY", new_key="new",
|
|
||||||
)
|
|
||||||
assert result.ok is False
|
|
||||||
assert "old key did not open" in result.reason.lower()
|
|
||||||
|
|
||||||
def test_in_memory_url_is_rejected(self):
|
|
||||||
"""In-memory DBs cannot be rekeyed (nothing to persist)."""
|
|
||||||
result = db_crypto.rotate_db_key(
|
|
||||||
url="sqlite:///:memory:", old_key="a", new_key="b",
|
|
||||||
)
|
|
||||||
assert result.ok is False
|
|
||||||
assert "file-backed" in result.reason.lower() or "in-memory" in result.reason.lower()
|
|
||||||
|
|
||||||
def test_missing_db_file_is_rejected(self, tmp_path: Path):
|
|
||||||
result = db_crypto.rotate_db_key(
|
|
||||||
url=f"sqlite:///{tmp_path}/does-not-exist.db",
|
|
||||||
old_key="a", new_key="b",
|
|
||||||
)
|
|
||||||
assert result.ok is False
|
|
||||||
assert "not found" in result.reason.lower()
|
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Spot-check that existing endpoints now require auth (when AUTH_DISABLED is not set).
|
||||||
|
|
||||||
|
The conftest in ``tests/conftest.py`` flips ``AUTH_DISABLED=True`` for
|
||||||
|
every test module that does NOT start with ``test_auth`` — this module
|
||||||
|
deliberately is NOT named ``test_auth_*`` so it inherits the default
|
||||||
|
disabled posture... wait, that's the opposite of what we want.
|
||||||
|
|
||||||
|
This module is named ``test_existing_endpoints_require_auth`` so the
|
||||||
|
conftest will treat it as a legacy test and set ``AUTH_DISABLED=True``,
|
||||||
|
bypassing the auth check entirely. To actually verify the gate, this
|
||||||
|
test's ``client`` fixture flips ``AUTH_DISABLED`` to ``False``
|
||||||
|
*just for this test*, then restores it afterwards. This way the
|
||||||
|
rest of the suite still sees the disabled posture and existing
|
||||||
|
tests keep passing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import delete
|
||||||
|
|
||||||
|
from cyclone.api import app
|
||||||
|
from cyclone.auth import deps as _auth_deps
|
||||||
|
from cyclone.auth.deps import AUTH_DISABLED
|
||||||
|
from cyclone.db import Session as DbSession
|
||||||
|
from cyclone.db import SessionLocal, User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
yield
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
# Force AUTH_DISABLED=False for these tests so the dep actually checks the cookie.
|
||||||
|
original = AUTH_DISABLED
|
||||||
|
_auth_deps.AUTH_DISABLED = False
|
||||||
|
try:
|
||||||
|
yield TestClient(app)
|
||||||
|
finally:
|
||||||
|
_auth_deps.AUTH_DISABLED = original
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("method,path", [
|
||||||
|
("GET", "/api/claims"),
|
||||||
|
("GET", "/api/remittances"),
|
||||||
|
("GET", "/api/providers"),
|
||||||
|
("GET", "/api/batches"),
|
||||||
|
("GET", "/api/dashboard/summary"),
|
||||||
|
("GET", "/api/activity"),
|
||||||
|
])
|
||||||
|
def test_existing_get_endpoints_require_auth(client, method, path):
|
||||||
|
resp = client.request(method, path)
|
||||||
|
assert resp.status_code == 401, f"{method} {path} returned {resp.status_code}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_is_public(client):
|
||||||
|
"""``/api/health`` is the public healthcheck and must remain reachable."""
|
||||||
|
resp = client.get("/api/health")
|
||||||
|
assert resp.status_code != 401, f"/api/health returned {resp.status_code}"
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
"""SP14 — Lane filter drops acknowledged payer-rejected claims.
|
|
||||||
|
|
||||||
The Payer-Rejected Inbox lane must not include claims the operator
|
|
||||||
has already acknowledged. This is the working-surface UX: acknowledged
|
|
||||||
claims drop out so the operator only sees new rejections.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from cyclone import db
|
|
||||||
from cyclone.db import Batch, Claim, ClaimState
|
|
||||||
from cyclone.inbox_lanes import compute_lanes
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _setup(tmp_path, monkeypatch):
|
|
||||||
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
|
|
||||||
db._reset_for_tests()
|
|
||||||
db.init_db()
|
|
||||||
|
|
||||||
|
|
||||||
def _seed(*, acked: bool = False) -> str:
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
cid = "C1"
|
|
||||||
with db.SessionLocal()() as session:
|
|
||||||
batch = Batch(
|
|
||||||
id="b-1", kind="837p",
|
|
||||||
input_filename="t.x12", parsed_at=now,
|
|
||||||
)
|
|
||||||
session.add(batch)
|
|
||||||
session.flush()
|
|
||||||
claim = Claim(
|
|
||||||
id=cid, batch_id=batch.id, patient_control_number=cid,
|
|
||||||
state=ClaimState.SUBMITTED, charge_amount=100,
|
|
||||||
payer_rejected_at=now,
|
|
||||||
payer_rejected_status_code="A7",
|
|
||||||
payer_rejected_reason="invalid dx",
|
|
||||||
)
|
|
||||||
if acked:
|
|
||||||
claim.payer_rejected_acknowledged_at = now
|
|
||||||
claim.payer_rejected_acknowledged_actor = "operator"
|
|
||||||
session.add(claim)
|
|
||||||
session.commit()
|
|
||||||
return cid
|
|
||||||
|
|
||||||
|
|
||||||
def test_unacknowledged_claim_appears_in_lane():
|
|
||||||
_seed(acked=False)
|
|
||||||
with db.SessionLocal()() as session:
|
|
||||||
lanes = compute_lanes(session, dismissed_pairs=set())
|
|
||||||
assert len(lanes.payer_rejected) == 1
|
|
||||||
assert lanes.payer_rejected[0]["id"] == "C1"
|
|
||||||
|
|
||||||
|
|
||||||
def test_acknowledged_claim_drops_out_of_lane():
|
|
||||||
_seed(acked=True)
|
|
||||||
with db.SessionLocal()() as session:
|
|
||||||
lanes = compute_lanes(session, dismissed_pairs=set())
|
|
||||||
assert lanes.payer_rejected == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_mix_of_acked_and_unacked():
|
|
||||||
"""Only the unacknowledged one shows up."""
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
with db.SessionLocal()() as session:
|
|
||||||
batch = Batch(
|
|
||||||
id="b-1", kind="837p",
|
|
||||||
input_filename="t.x12", parsed_at=now,
|
|
||||||
)
|
|
||||||
session.add(batch)
|
|
||||||
session.flush()
|
|
||||||
for i, acked in enumerate([True, False, True, False]):
|
|
||||||
claim = Claim(
|
|
||||||
id=f"C{i}", batch_id=batch.id,
|
|
||||||
patient_control_number=f"PCN-{i}",
|
|
||||||
state=ClaimState.SUBMITTED, charge_amount=100,
|
|
||||||
payer_rejected_at=now,
|
|
||||||
payer_rejected_status_code="A7",
|
|
||||||
)
|
|
||||||
if acked:
|
|
||||||
claim.payer_rejected_acknowledged_at = now
|
|
||||||
session.add(claim)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
with db.SessionLocal()() as session:
|
|
||||||
lanes = compute_lanes(session, dismissed_pairs=set())
|
|
||||||
ids = [r["id"] for r in lanes.payer_rejected]
|
|
||||||
assert sorted(ids) == ["C1", "C3"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_lane_row_carries_ack_fields_for_forward_compat():
|
|
||||||
"""The row payload still carries the ack fields (all null on the lane)
|
|
||||||
so future views that want a 'Recently acknowledged' section don't need
|
|
||||||
a schema change."""
|
|
||||||
_seed(acked=False)
|
|
||||||
with db.SessionLocal()() as session:
|
|
||||||
lanes = compute_lanes(session, dismissed_pairs=set())
|
|
||||||
row = lanes.payer_rejected[0]
|
|
||||||
assert "payer_rejected_acknowledged_at" in row
|
|
||||||
assert row["payer_rejected_acknowledged_at"] is None
|
|
||||||
assert "payer_rejected_acknowledged_actor" in row
|
|
||||||
assert row["payer_rejected_acknowledged_actor"] is None
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
"""SP18 — JsonFormatter + CycloneDevFormatter tests.
|
|
||||||
|
|
||||||
Covers the structural shape of log records, exception handling,
|
|
||||||
and the ``extra`` kwarg passthrough.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from cyclone.logging_config import (
|
|
||||||
CycloneDevFormatter,
|
|
||||||
JsonFormatter,
|
|
||||||
setup_logging,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _make_record(
|
|
||||||
msg: str = "hello",
|
|
||||||
args: tuple = (),
|
|
||||||
level: int = logging.INFO,
|
|
||||||
name: str = "test.logger",
|
|
||||||
extras: dict | None = None,
|
|
||||||
exc_info=None,
|
|
||||||
) -> logging.LogRecord:
|
|
||||||
record = logging.getLogger(name).makeRecord(
|
|
||||||
name=name,
|
|
||||||
level=level,
|
|
||||||
fn="t.py",
|
|
||||||
lno=1,
|
|
||||||
msg=msg,
|
|
||||||
args=args,
|
|
||||||
exc_info=exc_info,
|
|
||||||
)
|
|
||||||
if extras:
|
|
||||||
for k, v in extras.items():
|
|
||||||
setattr(record, k, v)
|
|
||||||
return record
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# JsonFormatter
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_json_formatter_basic_shape():
|
|
||||||
f = JsonFormatter()
|
|
||||||
line = f.format(_make_record(msg="hello %s", args=("cyclone",)))
|
|
||||||
parsed = json.loads(line)
|
|
||||||
assert parsed["level"] == "INFO"
|
|
||||||
assert parsed["logger"] == "test.logger"
|
|
||||||
assert parsed["msg"] == "hello cyclone"
|
|
||||||
assert "ts" in parsed
|
|
||||||
# ts must be ISO 8601 with milliseconds + Z suffix.
|
|
||||||
assert parsed["ts"].endswith("Z") or "+" in parsed["ts"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_json_formatter_includes_extras():
|
|
||||||
f = JsonFormatter()
|
|
||||||
line = f.format(_make_record(
|
|
||||||
msg="processed",
|
|
||||||
extras={"input_filename": "foo.x12", "parser_kind": "parse_999", "claims": 3},
|
|
||||||
))
|
|
||||||
parsed = json.loads(line)
|
|
||||||
assert parsed["extra"] == {
|
|
||||||
"input_filename": "foo.x12", "parser_kind": "parse_999", "claims": 3,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_json_formatter_handles_exception_info():
|
|
||||||
f = JsonFormatter()
|
|
||||||
try:
|
|
||||||
raise ValueError("boom")
|
|
||||||
except ValueError:
|
|
||||||
import sys
|
|
||||||
rec = _make_record(msg="oops", exc_info=sys.exc_info())
|
|
||||||
line = f.format(rec)
|
|
||||||
parsed = json.loads(line)
|
|
||||||
assert "traceback" in parsed
|
|
||||||
assert "ValueError: boom" in parsed["traceback"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_json_formatter_no_extras_key_when_none():
|
|
||||||
f = JsonFormatter()
|
|
||||||
line = f.format(_make_record(msg="plain"))
|
|
||||||
parsed = json.loads(line)
|
|
||||||
assert "extra" not in parsed
|
|
||||||
|
|
||||||
|
|
||||||
def test_json_formatter_handles_non_serializable_extras():
|
|
||||||
"""Non-JSON-serializable extras go through ``default=str``."""
|
|
||||||
class Opaque:
|
|
||||||
def __str__(self):
|
|
||||||
return "opaque-string"
|
|
||||||
f = JsonFormatter()
|
|
||||||
line = f.format(_make_record(msg="x", extras={"thing": Opaque()}))
|
|
||||||
parsed = json.loads(line)
|
|
||||||
assert parsed["extra"]["thing"] == "opaque-string"
|
|
||||||
|
|
||||||
|
|
||||||
def test_json_formatter_preserves_warning_level():
|
|
||||||
f = JsonFormatter()
|
|
||||||
line = f.format(_make_record(msg="careful", level=logging.WARNING))
|
|
||||||
parsed = json.loads(line)
|
|
||||||
assert parsed["level"] == "WARNING"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# CycloneDevFormatter
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_dev_formatter_basic_shape():
|
|
||||||
f = CycloneDevFormatter()
|
|
||||||
line = f.format(_make_record(msg="hello %s", args=("cyclone",)))
|
|
||||||
assert "INFO" in line
|
|
||||||
assert "test.logger" in line
|
|
||||||
assert "hello cyclone" in line
|
|
||||||
|
|
||||||
|
|
||||||
def test_dev_formatter_includes_extras():
|
|
||||||
f = CycloneDevFormatter()
|
|
||||||
line = f.format(_make_record(
|
|
||||||
msg="processed",
|
|
||||||
extras={"input_filename": "foo.x12", "claims": 3},
|
|
||||||
))
|
|
||||||
assert "input_filename='foo.x12'" in line
|
|
||||||
assert "claims=3" in line
|
|
||||||
|
|
||||||
|
|
||||||
def test_dev_formatter_handles_exception():
|
|
||||||
f = CycloneDevFormatter()
|
|
||||||
try:
|
|
||||||
raise RuntimeError("nope")
|
|
||||||
except RuntimeError:
|
|
||||||
import sys
|
|
||||||
rec = _make_record(msg="oops", exc_info=sys.exc_info())
|
|
||||||
line = f.format(rec)
|
|
||||||
assert "RuntimeError: nope" in line
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# setup_logging (light — deeper coverage in test_logging_setup.py)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_setup_logging_attaches_handler_to_root():
|
|
||||||
setup_logging(level="DEBUG", json_format=True)
|
|
||||||
root = logging.getLogger()
|
|
||||||
assert len(root.handlers) >= 1
|
|
||||||
assert isinstance(root.handlers[0].formatter, JsonFormatter)
|
|
||||||
|
|
||||||
|
|
||||||
def test_setup_logging_is_idempotent():
|
|
||||||
"""Re-calling clears handlers; the formatter toggle takes effect."""
|
|
||||||
setup_logging(level="INFO", json_format=True)
|
|
||||||
setup_logging(level="INFO", json_format=False)
|
|
||||||
root = logging.getLogger()
|
|
||||||
assert len(root.handlers) == 1
|
|
||||||
assert isinstance(root.handlers[0].formatter, CycloneDevFormatter)
|
|
||||||
# Reset back to JSON for the rest of the test suite.
|
|
||||||
setup_logging(level="INFO", json_format=True)
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
"""SP18 — PII scrubber tests.
|
|
||||||
|
|
||||||
Covers each PHI pattern, the false-positive guard, and the
|
|
||||||
disable toggle.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from cyclone.logging_config import (
|
|
||||||
PiiScrubber,
|
|
||||||
get_scrubber,
|
|
||||||
setup_logging,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _make_record(msg: str, extras: dict | None = None) -> logging.LogRecord:
|
|
||||||
record = logging.getLogger("test.scrub").makeRecord(
|
|
||||||
name="test.scrub", level=logging.INFO, fn="t.py", lno=1,
|
|
||||||
msg=msg, args=(), exc_info=None,
|
|
||||||
)
|
|
||||||
if extras:
|
|
||||||
for k, v in extras.items():
|
|
||||||
setattr(record, k, v)
|
|
||||||
return record
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _reset_scrubber():
|
|
||||||
"""Make sure the scrubber is enabled + on the root after each test."""
|
|
||||||
yield
|
|
||||||
get_scrubber().enable()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# NPI
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_scrubs_ten_digit_npi_in_message():
|
|
||||||
scrubber = PiiScrubber()
|
|
||||||
rec = _make_record("processed claim with npi 1881068062 ok")
|
|
||||||
assert scrubber.filter(rec) is True
|
|
||||||
assert rec.getMessage() == "processed claim with npi <redacted:npi> ok"
|
|
||||||
|
|
||||||
|
|
||||||
def test_scrubs_npi_in_extras():
|
|
||||||
scrubber = PiiScrubber()
|
|
||||||
rec = _make_record("ok", extras={"provider_npi": "1881068062"})
|
|
||||||
scrubber.filter(rec)
|
|
||||||
assert rec.provider_npi == "<redacted:npi>"
|
|
||||||
|
|
||||||
|
|
||||||
def test_does_not_scrub_short_numbers():
|
|
||||||
scrubber = PiiScrubber()
|
|
||||||
rec = _make_record("processed 5 claims in 2 batches")
|
|
||||||
scrubber.filter(rec)
|
|
||||||
assert rec.getMessage() == "processed 5 claims in 2 batches"
|
|
||||||
|
|
||||||
|
|
||||||
def test_does_not_scrub_eleven_digit_numbers():
|
|
||||||
"""11+ digit numbers aren't NPIs — leave them alone."""
|
|
||||||
scrubber = PiiScrubber()
|
|
||||||
rec = _make_record("control number 12345678901")
|
|
||||||
scrubber.filter(rec)
|
|
||||||
assert rec.getMessage() == "control number 12345678901"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# SSN
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_scrubs_dashed_ssn_in_message():
|
|
||||||
scrubber = PiiScrubber()
|
|
||||||
rec = _make_record("ssn=123-45-6789 detected")
|
|
||||||
scrubber.filter(rec)
|
|
||||||
assert rec.getMessage() == "ssn=<redacted:ssn> detected"
|
|
||||||
|
|
||||||
|
|
||||||
def test_scrubs_undashed_ssn_at_phrase_boundary():
|
|
||||||
"""A bare 9-digit number is ambiguous — we scrub it when followed
|
|
||||||
by whitespace/punctuation/closing paren/brace/comma (so we don't
|
|
||||||
hit claim control numbers or zip codes mid-sentence)."""
|
|
||||||
scrubber = PiiScrubber()
|
|
||||||
rec = _make_record("ssn 123456789 on file")
|
|
||||||
scrubber.filter(rec)
|
|
||||||
assert "<redacted:ssn>" in rec.getMessage()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# DOB
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_scrubs_dob_field():
|
|
||||||
scrubber = PiiScrubber()
|
|
||||||
rec = _make_record("dob=1980-04-12 verified")
|
|
||||||
scrubber.filter(rec)
|
|
||||||
assert rec.getMessage() == "dob=<redacted:dob> verified"
|
|
||||||
|
|
||||||
|
|
||||||
def test_scrubs_dob_in_extras():
|
|
||||||
scrubber = PiiScrubber()
|
|
||||||
rec = _make_record("ok", extras={"date_of_birth": "1980-04-12"})
|
|
||||||
scrubber.filter(rec)
|
|
||||||
assert rec.date_of_birth == "<redacted:dob>"
|
|
||||||
|
|
||||||
|
|
||||||
def test_does_not_scrub_bare_iso_date():
|
|
||||||
"""A YYYY-MM-DD without a dob= prefix isn't necessarily PHI."""
|
|
||||||
scrubber = PiiScrubber()
|
|
||||||
rec = _make_record("parsed at 2026-06-21")
|
|
||||||
scrubber.filter(rec)
|
|
||||||
assert rec.getMessage() == "parsed at 2026-06-21"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Patient name
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_scrubs_patient_name_field():
|
|
||||||
scrubber = PiiScrubber()
|
|
||||||
rec = _make_record('patient_name="John Doe" verified')
|
|
||||||
scrubber.filter(rec)
|
|
||||||
assert "John Doe" not in rec.getMessage()
|
|
||||||
assert "<redacted:patient_name>" in rec.getMessage()
|
|
||||||
|
|
||||||
|
|
||||||
def test_does_not_scrub_random_words():
|
|
||||||
scrubber = PiiScrubber()
|
|
||||||
rec = _make_record("the parser ran successfully")
|
|
||||||
scrubber.filter(rec)
|
|
||||||
assert rec.getMessage() == "the parser ran successfully"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Disable toggle
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_scrubber_disabled_leaves_message_intact():
|
|
||||||
scrubber = PiiScrubber()
|
|
||||||
scrubber.disable()
|
|
||||||
rec = _make_record("npi 1881068062 ok")
|
|
||||||
scrubber.filter(rec)
|
|
||||||
assert rec.getMessage() == "npi 1881068062 ok"
|
|
||||||
|
|
||||||
|
|
||||||
def test_scrubber_does_not_crash_on_unusual_records():
|
|
||||||
"""Even with weird attribute combinations the filter returns True."""
|
|
||||||
scrubber = PiiScrubber()
|
|
||||||
rec = _make_record("ok", extras={"weird": object()})
|
|
||||||
assert scrubber.filter(rec) is True
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
"""SP18 — ``setup_logging`` entry-point tests.
|
|
||||||
|
|
||||||
Covers level resolution, handler attachment, env-var overrides, and
|
|
||||||
the idempotent re-setup behavior used by the FastAPI lifespan and
|
|
||||||
the CLI's ``main()``.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from cyclone.logging_config import (
|
|
||||||
CycloneDevFormatter,
|
|
||||||
JsonFormatter,
|
|
||||||
PiiScrubber,
|
|
||||||
setup_logging,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _reset_root_logger():
|
|
||||||
"""Strip our handlers + filters before each test so setup runs clean."""
|
|
||||||
root = logging.getLogger()
|
|
||||||
for h in list(root.handlers):
|
|
||||||
root.removeHandler(h)
|
|
||||||
for flt in list(root.filters):
|
|
||||||
if isinstance(flt, PiiScrubber):
|
|
||||||
root.removeFilter(flt)
|
|
||||||
yield
|
|
||||||
for h in list(root.handlers):
|
|
||||||
root.removeHandler(h)
|
|
||||||
for flt in list(root.filters):
|
|
||||||
if isinstance(flt, PiiScrubber):
|
|
||||||
root.removeFilter(flt)
|
|
||||||
|
|
||||||
|
|
||||||
def test_setup_respects_level_string():
|
|
||||||
setup_logging(level="DEBUG", json_format=True)
|
|
||||||
assert logging.getLogger().level == logging.DEBUG
|
|
||||||
setup_logging(level="WARNING", json_format=True)
|
|
||||||
assert logging.getLogger().level == logging.WARNING
|
|
||||||
|
|
||||||
|
|
||||||
def test_setup_attaches_rotating_file_handler(tmp_path: Path):
|
|
||||||
log_file = tmp_path / "cyclone.log"
|
|
||||||
setup_logging(level="INFO", log_file=str(log_file), json_format=True)
|
|
||||||
root = logging.getLogger()
|
|
||||||
assert len(root.handlers) == 1
|
|
||||||
h = root.handlers[0]
|
|
||||||
# RotatingFileHandler has ``baseFilename`` attr.
|
|
||||||
assert hasattr(h, "baseFilename")
|
|
||||||
assert Path(h.baseFilename).name == "cyclone.log"
|
|
||||||
|
|
||||||
|
|
||||||
def test_setup_defaults_to_json_formatter():
|
|
||||||
setup_logging(level="INFO")
|
|
||||||
root = logging.getLogger()
|
|
||||||
assert isinstance(root.handlers[0].formatter, JsonFormatter)
|
|
||||||
|
|
||||||
|
|
||||||
def test_setup_dev_toggle_uses_dev_formatter():
|
|
||||||
setup_logging(level="INFO", json_format=False)
|
|
||||||
root = logging.getLogger()
|
|
||||||
assert isinstance(root.handlers[0].formatter, CycloneDevFormatter)
|
|
||||||
|
|
||||||
|
|
||||||
def test_setup_idempotent_re_setup_replaces_handlers():
|
|
||||||
"""Re-calling setup_logging clears the previous handler(s)."""
|
|
||||||
setup_logging(level="INFO", json_format=True)
|
|
||||||
setup_logging(level="DEBUG", json_format=False)
|
|
||||||
root = logging.getLogger()
|
|
||||||
assert len(root.handlers) == 1
|
|
||||||
assert isinstance(root.handlers[0].formatter, CycloneDevFormatter)
|
|
||||||
assert root.level == logging.DEBUG
|
|
||||||
|
|
||||||
|
|
||||||
def test_setup_quietens_noisy_third_party_loggers():
|
|
||||||
setup_logging(level="DEBUG", json_format=True)
|
|
||||||
for noisy in ("urllib3", "paramiko", "sqlalchemy.engine"):
|
|
||||||
assert logging.getLogger(noisy).level >= logging.WARNING
|
|
||||||
|
|
||||||
|
|
||||||
def test_setup_attaches_pii_scrubber_by_default():
|
|
||||||
setup_logging(level="INFO", json_format=True)
|
|
||||||
root = logging.getLogger()
|
|
||||||
assert any(isinstance(f, PiiScrubber) for f in root.filters)
|
|
||||||
|
|
||||||
|
|
||||||
def test_setup_honors_scrub_pii_false():
|
|
||||||
setup_logging(level="INFO", json_format=True, scrub_pii=False)
|
|
||||||
root = logging.getLogger()
|
|
||||||
# Scrubber is still attached but disabled.
|
|
||||||
scrubbers = [f for f in root.filters if isinstance(f, PiiScrubber)]
|
|
||||||
assert len(scrubbers) == 1
|
|
||||||
assert scrubbers[0]._enabled is False # noqa: SLF001
|
|
||||||
|
|
||||||
|
|
||||||
def test_setup_honors_env_var_no_pii_scrub(monkeypatch):
|
|
||||||
monkeypatch.setenv("CYCLONE_LOG_NO_PII_SCRUB", "1")
|
|
||||||
setup_logging(level="INFO", json_format=True)
|
|
||||||
scrubbers = [f for f in logging.getLogger().filters if isinstance(f, PiiScrubber)]
|
|
||||||
assert scrubbers and scrubbers[0]._enabled is False # noqa: SLF001
|
|
||||||
|
|
||||||
|
|
||||||
def test_setup_emits_json_to_stderr_by_default(caplog):
|
|
||||||
"""Records emitted after setup flow through JsonFormatter."""
|
|
||||||
setup_logging(level="INFO", json_format=True)
|
|
||||||
logger = logging.getLogger("cyclone.test_setup")
|
|
||||||
logger.info("hello %s", "world", extra={"x": 1})
|
|
||||||
# Cyclone attaches the handler to root, not the named logger; caplog
|
|
||||||
# won't capture unless we propagate (which is the default).
|
|
||||||
# Just assert the handler is on root and would format correctly.
|
|
||||||
root = logging.getLogger()
|
|
||||||
h = root.handlers[0]
|
|
||||||
record = logger.makeRecord(
|
|
||||||
name="cyclone.test_setup", level=logging.INFO, fn="t.py", lno=1,
|
|
||||||
msg="hello %s", args=("world",), exc_info=None,
|
|
||||||
)
|
|
||||||
record.x = 1
|
|
||||||
formatted = h.formatter.format(record)
|
|
||||||
import json as _json
|
|
||||||
parsed = _json.loads(formatted)
|
|
||||||
assert parsed["msg"] == "hello world"
|
|
||||||
assert parsed["extra"]["x"] == 1
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
"""Tests for ``cyclone.npi`` — NPI checksum + Tax ID (EIN) format validation.
|
|
||||||
|
|
||||||
Pure local validation, no NPPES calls. Covers the NPPES-published Luhn
|
|
||||||
checksum (with prefix ``80840``) and a small set of obvious EIN typo cases.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from cyclone.npi import (
|
|
||||||
_luhn_check_digit,
|
|
||||||
is_valid_npi,
|
|
||||||
is_valid_tax_id,
|
|
||||||
npi_checksum,
|
|
||||||
normalize_tax_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Luhn internals
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_luhn_check_digit_known_sequence():
|
|
||||||
"""Standard Luhn for the empty body returns 0.
|
|
||||||
|
|
||||||
With no digits the sum is 0, so (10 - 0 % 10) % 10 = 0.
|
|
||||||
"""
|
|
||||||
assert _luhn_check_digit("") == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_luhn_check_digit_single_digit():
|
|
||||||
"""For a single body digit the check digit is the standard Luhn value.
|
|
||||||
|
|
||||||
With the corrected "double at rightmost" pattern: "0" doubles to 0
|
|
||||||
(total=0, check=0); "1" doubles to 2 (total=2, check=(10-2)%10=8).
|
|
||||||
"""
|
|
||||||
assert _luhn_check_digit("0") == 0
|
|
||||||
assert _luhn_check_digit("1") == 8
|
|
||||||
|
|
||||||
|
|
||||||
def test_luhn_check_digit_nppes_published():
|
|
||||||
"""CMS-published example: body 123456789 → check digit 3.
|
|
||||||
|
|
||||||
Per https://www.cms.gov/.../NPIcheckdigit.pdf the Luhn sum of the
|
|
||||||
prefixed body ``80840123456789`` is 67, so check digit = (10-7)%10 = 3,
|
|
||||||
giving the full NPI ``1234567893``.
|
|
||||||
"""
|
|
||||||
assert _luhn_check_digit("80840123456789") == 3
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# npi_checksum
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_npi_checksum_cms_published_body():
|
|
||||||
"""For NPI body 123456789 the check digit is 3 → NPI 1234567893.
|
|
||||||
|
|
||||||
Confirmed against the CMS-published NPI Luhn example.
|
|
||||||
"""
|
|
||||||
assert npi_checksum("123456789") == 3
|
|
||||||
|
|
||||||
|
|
||||||
def test_npi_checksum_rejects_non_digits():
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
npi_checksum("12345abc6") # letter in body
|
|
||||||
|
|
||||||
|
|
||||||
def test_npi_checksum_rejects_wrong_length():
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
npi_checksum("12345") # only 5 digits
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
npi_checksum("1234567890") # 10 digits — includes the check digit
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# is_valid_npi
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_valid_npi_nppes_sample_is_true():
|
|
||||||
"""The CMS-published example NPI 1234567893 must validate."""
|
|
||||||
assert is_valid_npi("1234567893") is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_valid_npi_off_by_one_is_false():
|
|
||||||
assert is_valid_npi("1234567894") is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_valid_npi_all_zeros_is_false():
|
|
||||||
"""All zeros fails the Luhn check."""
|
|
||||||
assert is_valid_npi("0000000000") is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_valid_npi_rejects_empty():
|
|
||||||
assert is_valid_npi("") is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_valid_npi_rejects_none():
|
|
||||||
assert is_valid_npi(None) is False # type: ignore[arg-type]
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_valid_npi_rejects_non_string():
|
|
||||||
assert is_valid_npi(1234567890) is False # type: ignore[arg-type]
|
|
||||||
assert is_valid_npi(["1881068062"]) is False # type: ignore[arg-type]
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_valid_npi_rejects_short():
|
|
||||||
assert is_valid_npi("123456789") is False # 9 digits
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_valid_npi_rejects_long():
|
|
||||||
assert is_valid_npi("12345678901") is False # 11 digits
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_valid_npi_rejects_non_digits():
|
|
||||||
assert is_valid_npi("188106806X") is False
|
|
||||||
assert is_valid_npi("18810 68062") is False # space
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_valid_npi_all_ones_fails_luhn():
|
|
||||||
"""1111111111 has all-1 sum: alternating double = 1,2,1,2,..., 1+2=3 then
|
|
||||||
collapse. Total for 10 digits body (body=9 of all 1's, sum doubled):
|
|
||||||
Position from right (i=0..8): 1, 1, 1, 1, 1, 1, 1, 1, 1
|
|
||||||
i even (not doubled): 1, 1, 1, 1, 1 → 5
|
|
||||||
i odd (doubled): 1*2=2, 1*2=2, 1*2=2, 1*2=2 → 8
|
|
||||||
Total body = 13. Body alone has check digit (10 - 13 % 10) % 10 = 7.
|
|
||||||
So full NPI 1111111111's body (9 ones) check = 7, and 1 != 7 → invalid.
|
|
||||||
"""
|
|
||||||
assert is_valid_npi("1111111111") is False
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# is_valid_tax_id / normalize_tax_id
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_valid_tax_id_touch_of_care_true():
|
|
||||||
"""The operator's reference EIN — Touch of Care Family Practice."""
|
|
||||||
assert is_valid_tax_id("72-1587149") is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_valid_tax_id_unformatted_true():
|
|
||||||
assert is_valid_tax_id("721587149") is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_valid_tax_id_00_prefix_rejected():
|
|
||||||
"""``00`` is reserved / never assigned by the IRS."""
|
|
||||||
assert is_valid_tax_id("00-1234567") is False
|
|
||||||
assert is_valid_tax_id("001234567") is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_valid_tax_id_07_prefix_rejected():
|
|
||||||
"""``07`` is a campus prefix reserved for future use."""
|
|
||||||
assert is_valid_tax_id("07-1234567") is False
|
|
||||||
assert is_valid_tax_id("071234567") is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_valid_tax_id_8x_prefix_rejected():
|
|
||||||
"""``80``–``89`` is the IRS Pension Plan Branch — never assigned otherwise."""
|
|
||||||
assert is_valid_tax_id("80-1234567") is False
|
|
||||||
assert is_valid_tax_id("89-1234567") is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_valid_tax_id_rejects_malformed():
|
|
||||||
assert is_valid_tax_id("not-an-ein") is False
|
|
||||||
assert is_valid_tax_id("12345") is False
|
|
||||||
assert is_valid_tax_id("1234567890") is False # 10 digits
|
|
||||||
assert is_valid_tax_id("12-345678") is False # 8 digits after hyphen
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_valid_tax_id_rejects_none_and_non_string():
|
|
||||||
assert is_valid_tax_id(None) is False
|
|
||||||
assert is_valid_tax_id(721587149) is False # type: ignore[arg-type]
|
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_tax_id_returns_plain_form():
|
|
||||||
assert normalize_tax_id("72-1587149") == "721587149"
|
|
||||||
assert normalize_tax_id("721587149") == "721587149"
|
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_tax_id_strips_whitespace():
|
|
||||||
assert normalize_tax_id(" 72-1587149 ") == "721587149"
|
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_tax_id_returns_none_for_invalid():
|
|
||||||
assert normalize_tax_id("not-an-ein") is None
|
|
||||||
assert normalize_tax_id(None) is None
|
|
||||||
assert normalize_tax_id("") is None
|
|
||||||
assert normalize_tax_id("12-345678") is None # wrong digit count
|
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_tax_id_keeps_reserved_prefix():
|
|
||||||
"""normalize_tax_id is a *structural* normalizer — it doesn't reject
|
|
||||||
reserved prefixes. That's ``is_valid_tax_id``'s job. Operators who
|
|
||||||
want to store 00-prefixed EINs as placeholders still get a clean
|
|
||||||
9-digit string."""
|
|
||||||
assert normalize_tax_id("00-1234567") == "001234567"
|
|
||||||
@@ -14,7 +14,7 @@ def test_parse_minimal_fixture_returns_one_claim():
|
|||||||
assert len(result.claims) == 1
|
assert len(result.claims) == 1
|
||||||
claim = result.claims[0]
|
claim = result.claims[0]
|
||||||
assert claim.claim_id == "CLM001"
|
assert claim.claim_id == "CLM001"
|
||||||
assert claim.billing_provider.npi == "1993999998"
|
assert claim.billing_provider.npi == "1234567890"
|
||||||
assert claim.subscriber.last_name == "Doe"
|
assert claim.subscriber.last_name == "Doe"
|
||||||
assert claim.subscriber.first_name == "John"
|
assert claim.subscriber.first_name == "John"
|
||||||
assert claim.subscriber.member_id == "ABC123"
|
assert claim.subscriber.member_id == "ABC123"
|
||||||
|
|||||||
@@ -1,168 +0,0 @@
|
|||||||
"""SP14 — Payer-Rejected acknowledge endpoint tests.
|
|
||||||
|
|
||||||
The endpoint marks payer-rejected claims as acknowledged so the
|
|
||||||
working-surface lane query filters them out. The original
|
|
||||||
payer_rejected_* fields stay intact (SP11 audit).
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from cyclone import db
|
|
||||||
from cyclone.db import Claim, ClaimState
|
|
||||||
from cyclone.audit_log import verify_chain
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _setup(tmp_path, monkeypatch):
|
|
||||||
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
|
|
||||||
db._reset_for_tests()
|
|
||||||
db.init_db()
|
|
||||||
|
|
||||||
|
|
||||||
def _make_claim(claim_id: str, *, payer_rejected: bool = True) -> None:
|
|
||||||
"""Insert a minimal claim with optional payer_rejected markers."""
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from cyclone.db import Batch
|
|
||||||
with db.SessionLocal()() as session:
|
|
||||||
batch = Batch(
|
|
||||||
id=f"b-{claim_id}",
|
|
||||||
kind="837p",
|
|
||||||
input_filename="t.x12",
|
|
||||||
parsed_at=datetime.now(timezone.utc),
|
|
||||||
)
|
|
||||||
session.add(batch)
|
|
||||||
session.flush()
|
|
||||||
claim = Claim(
|
|
||||||
id=claim_id,
|
|
||||||
batch_id=batch.id,
|
|
||||||
patient_control_number=claim_id,
|
|
||||||
state=ClaimState.SUBMITTED,
|
|
||||||
charge_amount=100,
|
|
||||||
)
|
|
||||||
if payer_rejected:
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
claim.payer_rejected_at = now
|
|
||||||
claim.payer_rejected_reason = "invalid diagnosis code"
|
|
||||||
claim.payer_rejected_status_code = "A7"
|
|
||||||
session.add(claim)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def test_acknowledge_marks_unacknowledged_claim():
|
|
||||||
_make_claim("C1", payer_rejected=True)
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from cyclone.api import app
|
|
||||||
with TestClient(app) as client:
|
|
||||||
r = client.post(
|
|
||||||
"/api/inbox/payer-rejected/acknowledge",
|
|
||||||
json={"claim_ids": ["C1"], "actor": "operator"},
|
|
||||||
)
|
|
||||||
assert r.status_code == 200, r.text
|
|
||||||
body = r.json()
|
|
||||||
assert body["ok"] is True
|
|
||||||
assert body["transitioned"] == 1
|
|
||||||
assert body["already_acked"] == 0
|
|
||||||
assert body["not_found"] == 0
|
|
||||||
assert body["not_rejected"] == 0
|
|
||||||
|
|
||||||
with db.SessionLocal()() as session:
|
|
||||||
c = session.get(Claim, "C1")
|
|
||||||
assert c.payer_rejected_acknowledged_at is not None
|
|
||||||
assert c.payer_rejected_acknowledged_actor == "operator"
|
|
||||||
# Original fields stay intact for audit.
|
|
||||||
assert c.payer_rejected_at is not None
|
|
||||||
assert c.payer_rejected_status_code == "A7"
|
|
||||||
assert c.payer_rejected_reason == "invalid diagnosis code"
|
|
||||||
|
|
||||||
|
|
||||||
def test_acknowledge_is_idempotent():
|
|
||||||
_make_claim("C1", payer_rejected=True)
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from cyclone.api import app
|
|
||||||
with TestClient(app) as client:
|
|
||||||
r1 = client.post(
|
|
||||||
"/api/inbox/payer-rejected/acknowledge",
|
|
||||||
json={"claim_ids": ["C1"]},
|
|
||||||
)
|
|
||||||
assert r1.json()["transitioned"] == 1
|
|
||||||
r2 = client.post(
|
|
||||||
"/api/inbox/payer-rejected/acknowledge",
|
|
||||||
json={"claim_ids": ["C1"]},
|
|
||||||
)
|
|
||||||
assert r2.json()["transitioned"] == 0
|
|
||||||
assert r2.json()["already_acked"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_acknowledge_skips_non_payer_rejected_claims():
|
|
||||||
_make_claim("C1", payer_rejected=False)
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from cyclone.api import app
|
|
||||||
with TestClient(app) as client:
|
|
||||||
r = client.post(
|
|
||||||
"/api/inbox/payer-rejected/acknowledge",
|
|
||||||
json={"claim_ids": ["C1"]},
|
|
||||||
)
|
|
||||||
assert r.status_code == 200
|
|
||||||
body = r.json()
|
|
||||||
assert body["transitioned"] == 0
|
|
||||||
assert body["not_rejected"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_acknowledge_counts_missing_ids():
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from cyclone.api import app
|
|
||||||
with TestClient(app) as client:
|
|
||||||
r = client.post(
|
|
||||||
"/api/inbox/payer-rejected/acknowledge",
|
|
||||||
json={"claim_ids": ["nope"]},
|
|
||||||
)
|
|
||||||
assert r.status_code == 200
|
|
||||||
body = r.json()
|
|
||||||
assert body["transitioned"] == 0
|
|
||||||
assert body["not_found"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_acknowledge_empty_claim_ids_400():
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from cyclone.api import app
|
|
||||||
with TestClient(app) as client:
|
|
||||||
r = client.post(
|
|
||||||
"/api/inbox/payer-rejected/acknowledge",
|
|
||||||
json={"claim_ids": []},
|
|
||||||
)
|
|
||||||
assert r.status_code == 400
|
|
||||||
|
|
||||||
|
|
||||||
def test_acknowledge_writes_audit_event():
|
|
||||||
_make_claim("C1", payer_rejected=True)
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from cyclone.api import app
|
|
||||||
with TestClient(app) as client:
|
|
||||||
r = client.post(
|
|
||||||
"/api/inbox/payer-rejected/acknowledge",
|
|
||||||
json={"claim_ids": ["C1"], "actor": "alice"},
|
|
||||||
)
|
|
||||||
assert r.status_code == 200
|
|
||||||
|
|
||||||
# SP11: the chain is intact and includes the new event.
|
|
||||||
with db.SessionLocal()() as session:
|
|
||||||
result = verify_chain(session)
|
|
||||||
assert result.ok, f"chain broken: {result.reason} at {result.first_bad_id}"
|
|
||||||
|
|
||||||
# And the event itself is queryable.
|
|
||||||
import json as _json
|
|
||||||
from cyclone.db import AuditLog
|
|
||||||
with db.SessionLocal()() as session:
|
|
||||||
events = (
|
|
||||||
session.query(AuditLog)
|
|
||||||
.filter(AuditLog.event_type == "claim.payer_rejected_acknowledged")
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
assert len(events) == 1
|
|
||||||
e = events[0]
|
|
||||||
assert e.entity_type == "claim"
|
|
||||||
assert e.entity_id == "C1"
|
|
||||||
assert e.actor == "alice"
|
|
||||||
payload = _json.loads(e.payload_json) if e.payload_json else {}
|
|
||||||
assert payload["payer_rejected_status_code"] == "A7"
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
"""Tests for GET /api/payers/{payer_id}/summary (SP21 Task 1.5).
|
|
||||||
|
|
||||||
The endpoint is the payer-level aggregate that the drill-down UI's
|
|
||||||
"Payer → Claims" panel hangs off. It returns billed/received totals,
|
|
||||||
denial rate, and the top 5 NPIs by claim volume for one payer_id,
|
|
||||||
cached in-process for 60s.
|
|
||||||
|
|
||||||
The minimal 837P fixture ships one CLM with ``payer_id="SKCO0"``,
|
|
||||||
charge_amount=100.00; the minimal 835 carries one CLP for the same
|
|
||||||
claim with total_paid=85.00. So ``/api/payers/SKCO0/summary`` returns
|
|
||||||
``claim_count >= 1`` after both files are ingested.
|
|
||||||
|
|
||||||
Note: the spec calls this ``payer_id`` (the X12 NM1*PR*PI qualifier,
|
|
||||||
e.g. ``SKCO0``). It is NOT the configured payer name from
|
|
||||||
``config/payers.yaml``. The filter key in the store layer is
|
|
||||||
``Claim.payer_id`` — not the ``payer=`` substring filter used by
|
|
||||||
``/api/claims?payer=...``.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from cyclone import api as api_mod
|
|
||||||
from cyclone.api import app
|
|
||||||
|
|
||||||
FIXTURE_837 = Path(__file__).parent / "fixtures" / "minimal_837p.txt"
|
|
||||||
FIXTURE_835 = Path(__file__).parent / "fixtures" / "minimal_835.txt"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _clear_summary_cache():
|
|
||||||
"""Wipe the in-process payer-summary cache between tests.
|
|
||||||
|
|
||||||
conftest resets the DB per test but the cache is module-level
|
|
||||||
state on ``cyclone.api``. Without this clear, a stale payload
|
|
||||||
from a previous test's seed would leak into a later test's first
|
|
||||||
call — masking recompute behavior. The 60s TTL is the only
|
|
||||||
invalidation story today (see api.py docstring on the endpoint).
|
|
||||||
"""
|
|
||||||
api_mod._clear_summary_cache()
|
|
||||||
yield
|
|
||||||
api_mod._clear_summary_cache()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def client() -> TestClient:
|
|
||||||
return TestClient(app)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def seeded_db(client: TestClient):
|
|
||||||
"""Ingest one minimal 837P + one minimal 835.
|
|
||||||
|
|
||||||
Both fixtures carry ``payer_id="SKCO0"`` so the summary endpoint
|
|
||||||
has something to aggregate. ``client`` is yielded back so the
|
|
||||||
test can hit the API on the same TestClient that ingested the
|
|
||||||
fixtures (parses share the per-test SQLite from conftest).
|
|
||||||
"""
|
|
||||||
text_837 = FIXTURE_837.read_text()
|
|
||||||
text_835 = FIXTURE_835.read_text()
|
|
||||||
r837 = client.post(
|
|
||||||
"/api/parse-837",
|
|
||||||
files={"file": ("x.txt", text_837, "text/plain")},
|
|
||||||
headers={"Accept": "application/json"},
|
|
||||||
)
|
|
||||||
assert r837.status_code == 200, r837.text
|
|
||||||
r835 = client.post(
|
|
||||||
"/api/parse-835",
|
|
||||||
files={"file": ("era.txt", text_835, "text/plain")},
|
|
||||||
headers={"Accept": "application/json"},
|
|
||||||
)
|
|
||||||
assert r835.status_code == 200, r835.text
|
|
||||||
return client
|
|
||||||
|
|
||||||
|
|
||||||
def test_payer_summary_happy_path(seeded_db: TestClient):
|
|
||||||
"""Seeded db has at least one claim for SKCO0 → 200 with the spec shape."""
|
|
||||||
resp = seeded_db.get("/api/payers/SKCO0/summary")
|
|
||||||
assert resp.status_code == 200, resp.text
|
|
||||||
data = resp.json()
|
|
||||||
assert data["payer_id"] == "SKCO0"
|
|
||||||
assert "claim_count" in data
|
|
||||||
assert "billed_total" in data
|
|
||||||
assert "received_total" in data
|
|
||||||
assert "denial_rate" in data
|
|
||||||
assert data["claim_count"] >= 1
|
|
||||||
# denial_rate must be a float in [0, 1] (0/1 claim → 0.0).
|
|
||||||
assert isinstance(data["denial_rate"], (int, float))
|
|
||||||
assert 0.0 <= data["denial_rate"] <= 1.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_payer_summary_unknown_payer_returns_404(client: TestClient):
|
|
||||||
resp = client.get("/api/payers/DOES_NOT_EXIST/summary")
|
|
||||||
assert resp.status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
def test_payer_summary_caches_then_invalidates(seeded_db: TestClient):
|
|
||||||
"""Two back-to-back calls return identical payloads (in-process cache)."""
|
|
||||||
resp1 = seeded_db.get("/api/payers/SKCO0/summary")
|
|
||||||
resp2 = seeded_db.get("/api/payers/SKCO0/summary")
|
|
||||||
assert resp1.status_code == 200
|
|
||||||
assert resp2.status_code == 200
|
|
||||||
assert resp1.json() == resp2.json()
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
"""Tests for the extended GET /api/config/providers/{npi} (SP21 Task 1.6).
|
|
||||||
|
|
||||||
The endpoint gains two new top-level arrays for the drill-down panel:
|
|
||||||
``recent_claims`` (top 10 by submission date desc) and ``recent_activity``
|
|
||||||
(top 10 by ``ts`` desc, joined to claims by ``claim_id`` because
|
|
||||||
``ActivityEvent`` has no direct ``provider_npi`` column).
|
|
||||||
|
|
||||||
Existing SP9 fields (``label``, ``legal_name``, ``tax_id``,
|
|
||||||
``address_line1``, ``city``, ``state``, ``zip``, etc.) must remain
|
|
||||||
present — the new arrays are additive only.
|
|
||||||
|
|
||||||
The fixtures in ``fixtures/minimal_837p.txt`` and ``fixtures/minimal_835.txt``
|
|
||||||
pair up to a single claim with ``provider_npi='1993999998'``. That NPI is
|
|
||||||
NOT in the seeded provider set (Montrose/Delta/Salida → 1881068062/1851446637/
|
|
||||||
1467507269). For the tests we hit the seeded Montrose NPI, which is the
|
|
||||||
canonical SP9 fixture NPI. The arrays come back as empty lists — the
|
|
||||||
contract under test is the *shape* (array, ≤10) and the *backwards compat*
|
|
||||||
of the existing fields; the data-path itself is exercised by the existing
|
|
||||||
ingestion path that backs ``/api/claims``.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from cyclone.api import app
|
|
||||||
from cyclone.providers import Provider
|
|
||||||
from cyclone.store import store
|
|
||||||
|
|
||||||
FIXTURE_837 = Path(__file__).parent / "fixtures" / "minimal_837p.txt"
|
|
||||||
FIXTURE_835 = Path(__file__).parent / "fixtures" / "minimal_835.txt"
|
|
||||||
|
|
||||||
# Montrose — one of the three providers that `ensure_clearhouse_seeded()`
|
|
||||||
# writes into the providers table. Using a seeded NPI means the endpoint
|
|
||||||
# won't 404; the seeded claims (provider_npi='1993999998') won't appear
|
|
||||||
# under this NPI, so recent_claims/activity are expected empty lists.
|
|
||||||
MONTROSE_NPI = "1881068062"
|
|
||||||
|
|
||||||
# NPI the minimal 837P fixture bills under. NOT in the default seed —
|
|
||||||
# registering it before ingest is required to pass R204 (NPI must exist
|
|
||||||
# in the providers table).
|
|
||||||
TEST_837_NPI = "1993999998"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def client() -> TestClient:
|
|
||||||
return TestClient(app)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def seeded_db(client: TestClient):
|
|
||||||
"""Seed the clearhouse + ingest the minimal 837P/835 fixtures.
|
|
||||||
|
|
||||||
Mirrors the Task 1.5 ``seeded_db`` pattern: seed → ingest → hand the
|
|
||||||
client back so the test hits the same TestClient.
|
|
||||||
|
|
||||||
The 837 fixture bills under ``TEST_837_NPI``; without first registering
|
|
||||||
that provider the parser's R204 rule rejects the claim with HTTP 422.
|
|
||||||
"""
|
|
||||||
store.ensure_clearhouse_seeded()
|
|
||||||
test_provider = Provider(
|
|
||||||
npi=TEST_837_NPI,
|
|
||||||
label="Test Provider",
|
|
||||||
legal_name="Test Provider Inc",
|
|
||||||
tax_id="123456789",
|
|
||||||
taxonomy_code="207R00000X",
|
|
||||||
address_line1="123 Test St",
|
|
||||||
city="Denver",
|
|
||||||
state="CO",
|
|
||||||
zip="80202",
|
|
||||||
created_at=datetime.now(timezone.utc),
|
|
||||||
updated_at=datetime.now(timezone.utc),
|
|
||||||
)
|
|
||||||
store.upsert_provider(test_provider)
|
|
||||||
text_837 = FIXTURE_837.read_text()
|
|
||||||
text_835 = FIXTURE_835.read_text()
|
|
||||||
r837 = client.post(
|
|
||||||
"/api/parse-837",
|
|
||||||
files={"file": ("x.txt", text_837, "text/plain")},
|
|
||||||
headers={"Accept": "application/json"},
|
|
||||||
)
|
|
||||||
assert r837.status_code == 200, r837.text
|
|
||||||
r835 = client.post(
|
|
||||||
"/api/parse-835",
|
|
||||||
files={"file": ("era.txt", text_835, "text/plain")},
|
|
||||||
headers={"Accept": "application/json"},
|
|
||||||
)
|
|
||||||
assert r835.status_code == 200, r835.text
|
|
||||||
return client
|
|
||||||
|
|
||||||
|
|
||||||
def test_provider_detail_includes_recent_claims(seeded_db: TestClient):
|
|
||||||
"""The extended response gains a recent_claims array (top 10)."""
|
|
||||||
resp = seeded_db.get(f"/api/config/providers/{MONTROSE_NPI}")
|
|
||||||
assert resp.status_code == 200, resp.text
|
|
||||||
data = resp.json()
|
|
||||||
assert "recent_claims" in data
|
|
||||||
assert isinstance(data["recent_claims"], list)
|
|
||||||
assert len(data["recent_claims"]) <= 10
|
|
||||||
|
|
||||||
|
|
||||||
def test_provider_detail_includes_recent_activity(seeded_db: TestClient):
|
|
||||||
"""The extended response gains a recent_activity array (top 10)."""
|
|
||||||
resp = seeded_db.get(f"/api/config/providers/{MONTROSE_NPI}")
|
|
||||||
assert resp.status_code == 200, resp.text
|
|
||||||
data = resp.json()
|
|
||||||
assert "recent_activity" in data
|
|
||||||
assert isinstance(data["recent_activity"], list)
|
|
||||||
assert len(data["recent_activity"]) <= 10
|
|
||||||
|
|
||||||
|
|
||||||
def test_provider_detail_backwards_compat(seeded_db: TestClient):
|
|
||||||
"""All SP9 fields still present; new arrays don't break the contract.
|
|
||||||
|
|
||||||
The Provider Pydantic model (backend/src/cyclone/providers.py)
|
|
||||||
serializes snake_case fields — that's what the wire carries. The
|
|
||||||
TS ``Provider`` interface in ``src/types/index.ts`` is the
|
|
||||||
in-memory sample shape and intentionally diverges; the contract
|
|
||||||
being verified here is the API's actual payload.
|
|
||||||
"""
|
|
||||||
resp = seeded_db.get(f"/api/config/providers/{MONTROSE_NPI}")
|
|
||||||
assert resp.status_code == 200, resp.text
|
|
||||||
data = resp.json()
|
|
||||||
for key in (
|
|
||||||
"npi",
|
|
||||||
"label",
|
|
||||||
"legal_name",
|
|
||||||
"tax_id",
|
|
||||||
"taxonomy_code",
|
|
||||||
"address_line1",
|
|
||||||
"city",
|
|
||||||
"state",
|
|
||||||
"zip",
|
|
||||||
"is_active",
|
|
||||||
):
|
|
||||||
assert key in data, f"missing field {key}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_provider_detail_includes_orphan_remit_received(seeded_db: TestClient):
|
|
||||||
"""Regression: the ``remit_received`` ActivityEvent is recorded at 835
|
|
||||||
ingest with ``claim_id=None`` (``store.add`` lines 999-1003) — the
|
|
||||||
remittance hasn't been matched to a claim yet. The original
|
|
||||||
``ActivityEvent.claim_id IN (claim_ids)`` filter misses it because
|
|
||||||
the orphan's claim_id is NULL.
|
|
||||||
|
|
||||||
Once reconciliation (auto or manual) populates
|
|
||||||
``Remittance.claim_id``, the activity filter must surface the event
|
|
||||||
via the ``Remittance.claim_id IN (claim_ids)`` branch of the OR.
|
|
||||||
Without that branch, a provider's activity feed appears to freeze
|
|
||||||
the moment an 835 lands — the most common activity, invisible.
|
|
||||||
|
|
||||||
Setup: ingest 837+835 for ``TEST_837_NPI`` (claim CLM001 + remit
|
|
||||||
CLM001), then manually match them so ``Remittance.claim_id`` is
|
|
||||||
populated. The bug presents as: only ``claim_submitted`` and
|
|
||||||
``manual_match`` appear (no ``remit_received``). The fix surfaces
|
|
||||||
all three.
|
|
||||||
"""
|
|
||||||
# Force the match — simulates the post-reconciliation state that
|
|
||||||
# populate Remittance.claim_id without depending on auto-reconcile
|
|
||||||
# heuristics (which don't match this minimal fixture).
|
|
||||||
match_resp = seeded_db.post(
|
|
||||||
"/api/reconciliation/match",
|
|
||||||
json={"claim_id": "CLM001", "remit_id": "CLM001"},
|
|
||||||
)
|
|
||||||
assert match_resp.status_code == 200, match_resp.text
|
|
||||||
|
|
||||||
resp = seeded_db.get(f"/api/config/providers/{TEST_837_NPI}")
|
|
||||||
assert resp.status_code == 200, resp.text
|
|
||||||
data = resp.json()
|
|
||||||
kinds = {event["kind"] for event in data["recent_activity"]}
|
|
||||||
assert "remit_received" in kinds, (
|
|
||||||
f"expected remit_received in recent_activity (orphan remits "
|
|
||||||
f"must surface via the Remittance join), got kinds={sorted(kinds)}"
|
|
||||||
)
|
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
"""SP16 — Inbound MFT polling scheduler tests.
|
|
||||||
|
|
||||||
We test the Scheduler class with a fake ``SftpClient`` factory that
|
|
||||||
returns files we drop on disk (the SFTP stub already does this; we
|
|
||||||
just need to control which files appear between ticks). The handlers
|
|
||||||
themselves (999/835/277CA/TA1) are exercised through real parsers
|
|
||||||
using the fixtures in ``tests/fixtures/``.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Iterable
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from cyclone import db, scheduler as sched_mod
|
|
||||||
from cyclone.db import ProcessedInboundFile
|
|
||||||
from cyclone.providers import SftpBlock
|
|
||||||
from cyclone.scheduler import (
|
|
||||||
HANDLERS,
|
|
||||||
ROUTED_FILE_TYPES,
|
|
||||||
Scheduler,
|
|
||||||
STATUS_ERROR,
|
|
||||||
STATUS_OK,
|
|
||||||
STATUS_SKIPPED,
|
|
||||||
TickResult,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ----- fixtures -----------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def sftp_block(tmp_path):
|
|
||||||
staging = tmp_path / "staging"
|
|
||||||
inbound_dir = staging / "ToHPE"
|
|
||||||
inbound_dir.mkdir(parents=True)
|
|
||||||
return SftpBlock(
|
|
||||||
host="mft.example.com",
|
|
||||||
port=22,
|
|
||||||
username="test",
|
|
||||||
paths={
|
|
||||||
"outbound": "/FromHPE",
|
|
||||||
"inbound": "/ToHPE",
|
|
||||||
},
|
|
||||||
stub=True,
|
|
||||||
staging_dir=str(staging),
|
|
||||||
poll_seconds=60,
|
|
||||||
auth={"method": "keychain", "secret_ref": "test.password"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def _drop_file(sftp_block):
|
|
||||||
"""Helper: drop a named file in the inbound dir. Returns the path."""
|
|
||||||
inbound_dir = Path(sftp_block.staging_dir) / "ToHPE"
|
|
||||||
|
|
||||||
def _drop(name: str, body: bytes) -> Path:
|
|
||||||
inbound_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
p = inbound_dir / name
|
|
||||||
p.write_bytes(body)
|
|
||||||
return p
|
|
||||||
|
|
||||||
return _drop
|
|
||||||
|
|
||||||
|
|
||||||
def _make_scheduler(sftp_block, tmp_path) -> Scheduler:
|
|
||||||
"""Build a Scheduler wired to the real (stub) SftpClient."""
|
|
||||||
sched = Scheduler(
|
|
||||||
sftp_block,
|
|
||||||
poll_interval_seconds=60,
|
|
||||||
sftp_block_name="test-block",
|
|
||||||
# Use the real SftpClient — it reads from the stub staging dir.
|
|
||||||
sftp_client_factory=None,
|
|
||||||
)
|
|
||||||
return sched
|
|
||||||
|
|
||||||
|
|
||||||
def _load_999_text() -> str:
|
|
||||||
return (Path(__file__).parent / "fixtures" / "minimal_999.txt").read_text()
|
|
||||||
|
|
||||||
|
|
||||||
def _load_835_text() -> str:
|
|
||||||
return (Path(__file__).parent / "fixtures" / "minimal_835.txt").read_text()
|
|
||||||
|
|
||||||
|
|
||||||
def _load_277ca_text() -> str:
|
|
||||||
return (Path(__file__).parent / "fixtures" / "minimal_277ca.txt").read_text()
|
|
||||||
|
|
||||||
|
|
||||||
def _load_ta1_text() -> str:
|
|
||||||
return (Path(__file__).parent / "fixtures" / "minimal_ta1.txt").read_text()
|
|
||||||
|
|
||||||
|
|
||||||
# ----- tests --------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestSchedulerStatus:
|
|
||||||
def test_not_running_by_default(self, sftp_block):
|
|
||||||
sched = _make_scheduler(sftp_block, Path("/tmp"))
|
|
||||||
st = sched.status()
|
|
||||||
assert st.running is False
|
|
||||||
assert st.poll_count == 0
|
|
||||||
assert st.last_poll_at is None
|
|
||||||
|
|
||||||
def test_running_after_start(self, sftp_block):
|
|
||||||
async def _go():
|
|
||||||
sched = _make_scheduler(sftp_block, Path("/tmp"))
|
|
||||||
await sched.start()
|
|
||||||
try:
|
|
||||||
assert sched.is_running() is True
|
|
||||||
finally:
|
|
||||||
await sched.stop()
|
|
||||||
asyncio.run(_go())
|
|
||||||
|
|
||||||
|
|
||||||
class TestTickOnEmptyInbox:
|
|
||||||
def test_tick_with_no_files_records_zero(self, sftp_block):
|
|
||||||
async def _go():
|
|
||||||
sched = _make_scheduler(sftp_block, Path("/tmp"))
|
|
||||||
result = await sched.tick()
|
|
||||||
assert isinstance(result, TickResult)
|
|
||||||
assert result.files_seen == 0
|
|
||||||
assert result.files_processed == 0
|
|
||||||
assert result.files_skipped == 0
|
|
||||||
assert result.files_errored == 0
|
|
||||||
assert result.finished_at is not None
|
|
||||||
assert result.errors == []
|
|
||||||
asyncio.run(_go())
|
|
||||||
|
|
||||||
|
|
||||||
class TestTickRoutesFiles:
|
|
||||||
def test_999_file_processed(self, sftp_block, _drop_file):
|
|
||||||
async def _go():
|
|
||||||
_drop_file("TP11525703-837P_M019048402-20260618130000000-1of1_999.x12",
|
|
||||||
_load_999_text().encode("utf-8"))
|
|
||||||
sched = _make_scheduler(sftp_block, Path("/tmp"))
|
|
||||||
result = await sched.tick()
|
|
||||||
assert result.files_seen == 1
|
|
||||||
assert result.files_processed == 1
|
|
||||||
assert result.files_errored == 0
|
|
||||||
|
|
||||||
with db.SessionLocal()() as session:
|
|
||||||
rows = (
|
|
||||||
session.query(ProcessedInboundFile)
|
|
||||||
.filter_by(name="TP11525703-837P_M019048402-20260618130000000-1of1_999.x12")
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
assert len(rows) == 1
|
|
||||||
assert rows[0].status == STATUS_OK
|
|
||||||
assert rows[0].parser_used == "parse_999"
|
|
||||||
asyncio.run(_go())
|
|
||||||
|
|
||||||
def test_ta1_file_processed(self, sftp_block, _drop_file):
|
|
||||||
async def _go():
|
|
||||||
_drop_file("TP11525703-837P_M019048402-20260618130000000-1of1_TA1.x12",
|
|
||||||
_load_ta1_text().encode("utf-8"))
|
|
||||||
sched = _make_scheduler(sftp_block, Path("/tmp"))
|
|
||||||
result = await sched.tick()
|
|
||||||
assert result.files_processed == 1, result.errors
|
|
||||||
|
|
||||||
with db.SessionLocal()() as session:
|
|
||||||
row = (
|
|
||||||
session.query(ProcessedInboundFile)
|
|
||||||
.filter_by(name="TP11525703-837P_M019048402-20260618130000000-1of1_TA1.x12")
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
assert row is not None
|
|
||||||
assert row.status == STATUS_OK
|
|
||||||
assert row.parser_used == "parse_ta1"
|
|
||||||
asyncio.run(_go())
|
|
||||||
|
|
||||||
def test_unknown_file_type_marked_skipped(self, sftp_block, _drop_file):
|
|
||||||
async def _go():
|
|
||||||
# 270 (eligibility request) is in the HCPF allowed set but
|
|
||||||
# NOT in ROUTED_FILE_TYPES — Cyclone doesn't have a 270 parser.
|
|
||||||
_drop_file("TP11525703-837P_M019048402-20260618130000000-1of1_270.x12",
|
|
||||||
b"some bytes")
|
|
||||||
sched = _make_scheduler(sftp_block, Path("/tmp"))
|
|
||||||
result = await sched.tick()
|
|
||||||
assert result.files_skipped == 1
|
|
||||||
assert result.files_processed == 0
|
|
||||||
|
|
||||||
with db.SessionLocal()() as session:
|
|
||||||
row = (
|
|
||||||
session.query(ProcessedInboundFile)
|
|
||||||
.filter_by(name="TP11525703-837P_M019048402-20260618130000000-1of1_270.x12")
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
assert row is not None
|
|
||||||
assert row.status == STATUS_SKIPPED
|
|
||||||
assert row.error_message and "270" in row.error_message
|
|
||||||
asyncio.run(_go())
|
|
||||||
|
|
||||||
def test_filename_not_matching_hcpf_marked_skipped(self, sftp_block, _drop_file):
|
|
||||||
async def _go():
|
|
||||||
_drop_file("random.txt", b"garbage")
|
|
||||||
sched = _make_scheduler(sftp_block, Path("/tmp"))
|
|
||||||
result = await sched.tick()
|
|
||||||
assert result.files_skipped == 1
|
|
||||||
asyncio.run(_go())
|
|
||||||
|
|
||||||
def test_parse_error_marked_error(self, sftp_block, _drop_file):
|
|
||||||
async def _go():
|
|
||||||
# Valid filename but malformed body — parser raises.
|
|
||||||
_drop_file("TP11525703-837P_M019048402-20260618130000000-1of1_999.x12",
|
|
||||||
b"this is not a 999 file at all")
|
|
||||||
sched = _make_scheduler(sftp_block, Path("/tmp"))
|
|
||||||
result = await sched.tick()
|
|
||||||
assert result.files_errored == 1
|
|
||||||
assert result.files_processed == 0
|
|
||||||
|
|
||||||
with db.SessionLocal()() as session:
|
|
||||||
row = (
|
|
||||||
session.query(ProcessedInboundFile)
|
|
||||||
.filter_by(name="TP11525703-837P_M019048402-20260618130000000-1of1_999.x12")
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
assert row.status == STATUS_ERROR
|
|
||||||
assert row.error_message
|
|
||||||
asyncio.run(_go())
|
|
||||||
|
|
||||||
|
|
||||||
class TestTickIdempotent:
|
|
||||||
def test_second_tick_does_not_reprocess(self, sftp_block, _drop_file):
|
|
||||||
async def _go():
|
|
||||||
_drop_file("TP11525703-837P_M019048402-20260618130000000-1of1_TA1.x12",
|
|
||||||
_load_ta1_text().encode("utf-8"))
|
|
||||||
sched = _make_scheduler(sftp_block, Path("/tmp"))
|
|
||||||
r1 = await sched.tick()
|
|
||||||
r2 = await sched.tick()
|
|
||||||
assert r1.files_processed == 1
|
|
||||||
assert r2.files_seen == 1 # still lists it
|
|
||||||
assert r2.files_processed == 0 # but skips — already done
|
|
||||||
asyncio.run(_go())
|
|
||||||
|
|
||||||
|
|
||||||
class TestSchedulerStartStop:
|
|
||||||
def test_start_then_stop_returns_to_not_running(self, sftp_block):
|
|
||||||
async def _go():
|
|
||||||
sched = _make_scheduler(sftp_block, Path("/tmp"))
|
|
||||||
await sched.start()
|
|
||||||
assert sched.is_running()
|
|
||||||
await sched.stop()
|
|
||||||
assert not sched.is_running()
|
|
||||||
asyncio.run(_go())
|
|
||||||
|
|
||||||
def test_double_start_is_idempotent(self, sftp_block):
|
|
||||||
async def _go():
|
|
||||||
sched = _make_scheduler(sftp_block, Path("/tmp"))
|
|
||||||
await sched.start()
|
|
||||||
await sched.start() # no-op
|
|
||||||
assert sched.is_running()
|
|
||||||
await sched.stop()
|
|
||||||
asyncio.run(_go())
|
|
||||||
|
|
||||||
def test_stop_when_not_running_is_safe(self, sftp_block):
|
|
||||||
async def _go():
|
|
||||||
sched = _make_scheduler(sftp_block, Path("/tmp"))
|
|
||||||
await sched.stop() # no-op
|
|
||||||
asyncio.run(_go())
|
|
||||||
|
|
||||||
|
|
||||||
class TestModuleSingleton:
|
|
||||||
def test_get_scheduler_raises_if_not_configured(self):
|
|
||||||
sched_mod.reset_scheduler_for_tests()
|
|
||||||
with pytest.raises(RuntimeError, match="not configured"):
|
|
||||||
sched_mod.get_scheduler()
|
|
||||||
|
|
||||||
def test_configure_then_get_returns_same_instance(self, sftp_block):
|
|
||||||
sched_mod.reset_scheduler_for_tests()
|
|
||||||
s = sched_mod.configure_scheduler(sftp_block, sftp_block_name="t")
|
|
||||||
try:
|
|
||||||
assert sched_mod.get_scheduler() is s
|
|
||||||
finally:
|
|
||||||
sched_mod.reset_scheduler_for_tests()
|
|
||||||
|
|
||||||
|
|
||||||
class TestRoutedFileTypes:
|
|
||||||
"""Frozen-set guards — adding a new routed type without updating
|
|
||||||
the dispatch table would silently skip every inbound file of that
|
|
||||||
type. These tests are the regression net."""
|
|
||||||
|
|
||||||
def test_handlers_cover_all_routed_types(self):
|
|
||||||
assert set(HANDLERS.keys()) == ROUTED_FILE_TYPES
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
"""SP19 — Security middleware + health probe tests.
|
|
||||||
|
|
||||||
Covers each middleware in isolation (using FastAPI's TestClient with a
|
|
||||||
minimal app) and the integration into the real ``cyclone.api`` app
|
|
||||||
(headers present on every response, /api/health returns the rich
|
|
||||||
snapshot).
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from typing import Any, Callable
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi import FastAPI, Request
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
from cyclone.security import (
|
|
||||||
DEFAULT_MAX_BODY_BYTES,
|
|
||||||
DEFAULT_RATE_LIMIT_PER_MIN,
|
|
||||||
BodySizeLimitMiddleware,
|
|
||||||
RateLimitMiddleware,
|
|
||||||
SecurityHeadersMiddleware,
|
|
||||||
get_health_snapshot,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Test apps — built inline per-test to avoid state pollution between tests.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _echo_app_with(
|
|
||||||
*middlewares: Callable[..., Any],
|
|
||||||
) -> FastAPI:
|
|
||||||
"""Build a tiny FastAPI app with the given middleware chain.
|
|
||||||
|
|
||||||
``middlewares`` are listed outermost-first (the first element
|
|
||||||
runs first on the request). Starlette's ``add_middleware``
|
|
||||||
*prepends*, so we add in reverse to preserve "outermost first"
|
|
||||||
in the public API.
|
|
||||||
"""
|
|
||||||
app = FastAPI()
|
|
||||||
|
|
||||||
@app.post("/echo")
|
|
||||||
async def echo(request: Request):
|
|
||||||
body = await request.body()
|
|
||||||
return {"received_bytes": len(body)}
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
|
||||||
async def h():
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
for mw in reversed(middlewares):
|
|
||||||
app.add_middleware(mw)
|
|
||||||
return app
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# BodySizeLimitMiddleware
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_body_size_accepts_under_limit():
|
|
||||||
"""500 bytes is under the 1024-byte limit; body passes through."""
|
|
||||||
app = _echo_app_with(
|
|
||||||
SecurityHeadersMiddleware,
|
|
||||||
lambda a: BodySizeLimitMiddleware(a, max_bytes=1024),
|
|
||||||
)
|
|
||||||
client = TestClient(app)
|
|
||||||
resp = client.post("/echo", content=b"x" * 500)
|
|
||||||
assert resp.status_code == 200, resp.text
|
|
||||||
assert resp.json() == {"received_bytes": 500}
|
|
||||||
|
|
||||||
|
|
||||||
def test_body_size_rejects_over_content_length():
|
|
||||||
"""A 200-byte body against a 100-byte cap returns 413."""
|
|
||||||
app = _echo_app_with(
|
|
||||||
SecurityHeadersMiddleware,
|
|
||||||
lambda a: BodySizeLimitMiddleware(a, max_bytes=100),
|
|
||||||
)
|
|
||||||
client = TestClient(app)
|
|
||||||
resp = client.post("/echo", content=b"x" * 200)
|
|
||||||
assert resp.status_code == 413
|
|
||||||
body = resp.json()
|
|
||||||
assert body["error"] == "body_too_large"
|
|
||||||
assert "100" in body["detail"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_body_size_default_is_50mb():
|
|
||||||
"""The default cap is 50 MB so even large prodfiles fit."""
|
|
||||||
assert DEFAULT_MAX_BODY_BYTES == 50 * 1024 * 1024
|
|
||||||
|
|
||||||
|
|
||||||
def test_body_size_rejects_bad_content_length():
|
|
||||||
"""A non-integer Content-Length is rejected as 400, not 500."""
|
|
||||||
sent: list = []
|
|
||||||
inner_app = FastAPI()
|
|
||||||
|
|
||||||
@inner_app.post("/echo")
|
|
||||||
async def echo(request: Request):
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
app_instance = BodySizeLimitMiddleware(inner_app, max_bytes=100)
|
|
||||||
|
|
||||||
async def fake_receive():
|
|
||||||
return {"type": "http.request", "body": b"", "more_body": False}
|
|
||||||
|
|
||||||
async def fake_send(msg):
|
|
||||||
sent.append(msg)
|
|
||||||
|
|
||||||
scope = {
|
|
||||||
"type": "http",
|
|
||||||
"method": "POST",
|
|
||||||
"path": "/echo",
|
|
||||||
"headers": [(b"content-length", b"not-a-number")],
|
|
||||||
"query_string": b"",
|
|
||||||
}
|
|
||||||
asyncio.run(app_instance(scope, fake_receive, fake_send))
|
|
||||||
start = next(m for m in sent if m["type"] == "http.response.start")
|
|
||||||
assert start["status"] == 400
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# RateLimitMiddleware
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_rate_limit_default_is_300_per_min():
|
|
||||||
assert DEFAULT_RATE_LIMIT_PER_MIN == 300
|
|
||||||
|
|
||||||
|
|
||||||
def test_rate_limit_allows_under_threshold():
|
|
||||||
"""A few requests under the per-minute limit are allowed."""
|
|
||||||
app = _echo_app_with(
|
|
||||||
SecurityHeadersMiddleware,
|
|
||||||
lambda a: RateLimitMiddleware(a, per_minute=5),
|
|
||||||
)
|
|
||||||
client = TestClient(app)
|
|
||||||
for _ in range(5):
|
|
||||||
resp = client.post("/echo", content=b"x")
|
|
||||||
assert resp.status_code == 200, resp.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_rate_limit_blocks_over_threshold():
|
|
||||||
"""The 6th request within a 60s window is rate-limited."""
|
|
||||||
app = _echo_app_with(
|
|
||||||
SecurityHeadersMiddleware,
|
|
||||||
lambda a: RateLimitMiddleware(a, per_minute=3),
|
|
||||||
)
|
|
||||||
client = TestClient(app)
|
|
||||||
for _ in range(3):
|
|
||||||
assert client.post("/echo", content=b"x").status_code == 200
|
|
||||||
resp = client.post("/echo", content=b"x")
|
|
||||||
assert resp.status_code == 429
|
|
||||||
assert resp.json()["error"] == "rate_limited"
|
|
||||||
|
|
||||||
|
|
||||||
def test_rate_limit_exempts_health_probes():
|
|
||||||
"""A load balancer hammering /api/health should not trip the limiter."""
|
|
||||||
app = _echo_app_with(
|
|
||||||
SecurityHeadersMiddleware,
|
|
||||||
lambda a: RateLimitMiddleware(a, per_minute=2),
|
|
||||||
)
|
|
||||||
client = TestClient(app)
|
|
||||||
for _ in range(20):
|
|
||||||
assert client.get("/api/health").status_code == 200
|
|
||||||
assert client.post("/echo", content=b"x").status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# SecurityHeadersMiddleware
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_security_headers_present_on_200():
|
|
||||||
app = _echo_app_with(SecurityHeadersMiddleware)
|
|
||||||
client = TestClient(app)
|
|
||||||
resp = client.get("/api/health")
|
|
||||||
assert resp.headers["X-Content-Type-Options"] == "nosniff"
|
|
||||||
assert resp.headers["X-Frame-Options"] == "DENY"
|
|
||||||
assert resp.headers["Referrer-Policy"] == "same-origin"
|
|
||||||
assert "default-src 'none'" in resp.headers["Content-Security-Policy"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_security_headers_present_on_error_response():
|
|
||||||
"""413/429 still carry the security headers."""
|
|
||||||
# Define a tiny factory so Starlette can introspect it as a class.
|
|
||||||
class _BoundBodySize(BodySizeLimitMiddleware):
|
|
||||||
def __init__(self, app): # type: ignore[no-untyped-def]
|
|
||||||
super().__init__(app, max_bytes=10)
|
|
||||||
|
|
||||||
app = _echo_app_with(SecurityHeadersMiddleware, _BoundBodySize)
|
|
||||||
client = TestClient(app)
|
|
||||||
resp = client.post("/echo", content=b"x" * 100)
|
|
||||||
assert resp.status_code == 413, resp.text
|
|
||||||
assert resp.headers["X-Content-Type-Options"] == "nosniff"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# get_health_snapshot
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_health_snapshot_basic_shape():
|
|
||||||
snap = get_health_snapshot()
|
|
||||||
d = snap.to_dict()
|
|
||||||
assert "status" in d
|
|
||||||
assert "version" in d
|
|
||||||
assert "db" in d
|
|
||||||
assert "scheduler" in d
|
|
||||||
assert "pubsub" in d
|
|
||||||
assert "batch" in d
|
|
||||||
|
|
||||||
|
|
||||||
def test_health_snapshot_db_ok_in_tests():
|
|
||||||
"""The conftest DB fixture is live; ``SELECT 1`` works."""
|
|
||||||
snap = get_health_snapshot()
|
|
||||||
assert snap.db.get("ok") is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_health_snapshot_handles_no_scheduler():
|
|
||||||
"""Without a configured scheduler, the snapshot reports gracefully."""
|
|
||||||
snap = get_health_snapshot()
|
|
||||||
sched = snap.scheduler
|
|
||||||
assert "running" in sched or "configured" in sched
|
|
||||||
@@ -72,23 +72,7 @@ def test_stub_get_secret_returns_stub_when_keychain_empty(sftp_block):
|
|||||||
assert secret == "<stub-secret>"
|
assert secret == "<stub-secret>"
|
||||||
|
|
||||||
|
|
||||||
def test_stub_read_file_returns_bytes(sftp_block, tmp_path):
|
def test_stub_read_file_raises(sftp_block):
|
||||||
"""SP16: the stub read_file returns bytes from the staging dir.
|
|
||||||
|
|
||||||
Lets the inbound scheduler exercise the same code path on a
|
|
||||||
workstation without a real MFT connection.
|
|
||||||
"""
|
|
||||||
inbound = tmp_path / "staging" / "ToHPE"
|
|
||||||
inbound.mkdir(parents=True)
|
|
||||||
(inbound / "TP11525703-837P_M019048402-20260618130000000-1of1_TA1.x12").write_bytes(
|
|
||||||
b"hello-world",
|
|
||||||
)
|
|
||||||
client = SftpClient(sftp_block)
|
client = SftpClient(sftp_block)
|
||||||
body = client.read_file("/ToHPE/TP11525703-837P_M019048402-20260618130000000-1of1_TA1.x12")
|
with pytest.raises(RuntimeError, match="Stub SFTP cannot read"):
|
||||||
assert body == b"hello-world"
|
client.read_file("/x.x12")
|
||||||
|
|
||||||
|
|
||||||
def test_stub_read_file_missing_raises(sftp_block):
|
|
||||||
client = SftpClient(sftp_block)
|
|
||||||
with pytest.raises(FileNotFoundError):
|
|
||||||
client.read_file("/ToHPE/does-not-exist.x12")
|
|
||||||
|
|||||||
@@ -100,53 +100,6 @@ def test_r020_npi_must_be_ten_digits():
|
|||||||
assert any(i.rule == "R020_npi_format" for i in report.errors)
|
assert any(i.rule == "R020_npi_format" for i in report.errors)
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
# R021 — NPI Luhn checksum (SP20)
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
|
|
||||||
def test_r021_npi_checksum_valid_passes_silently():
|
|
||||||
"""A valid Luhn NPI (CMS-published 1234567893) yields no R021 issue."""
|
|
||||||
cfg = PayerConfig.co_medicaid()
|
|
||||||
claim = _build_claim()
|
|
||||||
claim.billing_provider.npi = "1234567893"
|
|
||||||
report = validate(claim, cfg)
|
|
||||||
assert not any(i.rule == "R021_npi_checksum" for i in report.errors + report.warnings)
|
|
||||||
|
|
||||||
|
|
||||||
def test_r021_npi_checksum_bad_luhn_is_warning():
|
|
||||||
"""An NPI that passes format but fails Luhn is a WARNING, not an error."""
|
|
||||||
cfg = PayerConfig.co_medicaid()
|
|
||||||
claim = _build_claim()
|
|
||||||
claim.billing_provider.npi = "1234567890" # right length, wrong check digit
|
|
||||||
report = validate(claim, cfg)
|
|
||||||
assert report.passed is True # WARNINGs don't fail the report
|
|
||||||
assert any(i.rule == "R021_npi_checksum" and i.severity == "warning" for i in report.warnings)
|
|
||||||
|
|
||||||
|
|
||||||
def test_r021_npi_checksum_skipped_when_format_bad():
|
|
||||||
"""When R020 already flagged the format, R021 stays silent
|
|
||||||
(avoids a duplicate 'this NPI is wrong' message)."""
|
|
||||||
cfg = PayerConfig.co_medicaid()
|
|
||||||
claim = _build_claim()
|
|
||||||
claim.billing_provider.npi = "12345" # wrong length
|
|
||||||
report = validate(claim, cfg)
|
|
||||||
# R020 errors as expected; R021 stays quiet.
|
|
||||||
assert any(i.rule == "R020_npi_format" for i in report.errors)
|
|
||||||
assert not any(i.rule == "R021_npi_checksum" for i in report.errors + report.warnings)
|
|
||||||
|
|
||||||
|
|
||||||
def test_r021_npi_checksum_skipped_when_npi_missing():
|
|
||||||
"""An empty NPI doesn't trigger R021 (only R020 would, but R020
|
|
||||||
only fires when NPI is *present* and wrong). R021 must stay silent
|
|
||||||
when NPI is empty so we don't double-fire."""
|
|
||||||
cfg = PayerConfig.co_medicaid()
|
|
||||||
claim = _build_claim()
|
|
||||||
claim.billing_provider.npi = ""
|
|
||||||
report = validate(claim, cfg)
|
|
||||||
assert not any(i.rule == "R021_npi_checksum" for i in report.errors + report.warnings)
|
|
||||||
|
|
||||||
|
|
||||||
def test_r030_frequency_allowed():
|
def test_r030_frequency_allowed():
|
||||||
cfg = PayerConfig.co_medicaid() # only 1, 7, 8
|
cfg = PayerConfig.co_medicaid() # only 1, 7, 8
|
||||||
claim = _build_claim()
|
claim = _build_claim()
|
||||||
|
|||||||
Generated
+31
-64
@@ -44,72 +44,21 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bcrypt"
|
name = "bcrypt"
|
||||||
version = "5.0.0"
|
version = "4.0.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/8c/ae/3af7d006aacf513975fd1948a6b4d6f8b4a307f8a244e1a3d3774b297aad/bcrypt-4.0.1.tar.gz", hash = "sha256:27d375903ac8261cfe4047f6709d16f7d18d39b1ec92aaf72af989552a650ebd", size = 25498, upload-time = "2022-10-09T15:36:49.775Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" },
|
{ url = "https://files.pythonhosted.org/packages/78/d4/3b2657bd58ef02b23a07729b0df26f21af97169dbd0b5797afa9e97ebb49/bcrypt-4.0.1-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:b1023030aec778185a6c16cf70f359cbb6e0c289fd564a7cfa29e727a1c38f8f", size = 473446, upload-time = "2022-10-09T15:36:25.481Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" },
|
{ url = "https://files.pythonhosted.org/packages/ec/0a/1582790232fef6c2aa201f345577306b8bfe465c2c665dec04c86a016879/bcrypt-4.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:08d2947c490093a11416df18043c27abe3921558d2c03e2076ccb28a116cb6d0", size = 583044, upload-time = "2022-10-09T15:37:09.447Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" },
|
{ url = "https://files.pythonhosted.org/packages/41/16/49ff5146fb815742ad58cafb5034907aa7f166b1344d0ddd7fd1c818bd17/bcrypt-4.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0eaa47d4661c326bfc9d08d16debbc4edf78778e6aaba29c1bc7ce67214d4410", size = 583189, upload-time = "2022-10-09T15:37:10.69Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" },
|
{ url = "https://files.pythonhosted.org/packages/aa/48/fd2b197a9741fa790ba0b88a9b10b5e88e62ff5cf3e1bc96d8354d7ce613/bcrypt-4.0.1-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae88eca3024bb34bb3430f964beab71226e761f51b912de5133470b649d82344", size = 593473, upload-time = "2022-10-09T15:36:27.195Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" },
|
{ url = "https://files.pythonhosted.org/packages/7d/50/e683d8418974a602ba40899c8a5c38b3decaf5a4d36c32fc65dce454d8a8/bcrypt-4.0.1-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:a522427293d77e1c29e303fc282e2d71864579527a04ddcfda6d4f8396c6c36a", size = 593249, upload-time = "2022-10-09T15:36:28.481Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" },
|
{ url = "https://files.pythonhosted.org/packages/fb/a7/ee4561fd9b78ca23c8e5591c150cc58626a5dfb169345ab18e1c2c664ee0/bcrypt-4.0.1-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:fbdaec13c5105f0c4e5c52614d04f0bca5f5af007910daa8b6b12095edaa67b3", size = 583586, upload-time = "2022-10-09T15:37:11.962Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" },
|
{ url = "https://files.pythonhosted.org/packages/64/fe/da28a5916128d541da0993328dc5cf4b43dfbf6655f2c7a2abe26ca2dc88/bcrypt-4.0.1-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ca3204d00d3cb2dfed07f2d74a25f12fc12f73e606fcaa6975d1f7ae69cacbb2", size = 593659, upload-time = "2022-10-09T15:36:30.049Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" },
|
{ url = "https://files.pythonhosted.org/packages/dd/4f/3632a69ce344c1551f7c9803196b191a8181c6a1ad2362c225581ef0d383/bcrypt-4.0.1-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:089098effa1bc35dc055366740a067a2fc76987e8ec75349eb9484061c54f535", size = 613116, upload-time = "2022-10-09T15:37:14.107Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" },
|
{ url = "https://files.pythonhosted.org/packages/87/69/edacb37481d360d06fc947dab5734aaf511acb7d1a1f9e2849454376c0f8/bcrypt-4.0.1-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:e9a51bbfe7e9802b5f3508687758b564069ba937748ad7b9e890086290d2f79e", size = 624290, upload-time = "2022-10-09T15:36:31.251Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" },
|
{ url = "https://files.pythonhosted.org/packages/aa/ca/6a534669890725cbb8c1fb4622019be31813c8edaa7b6d5b62fc9360a17e/bcrypt-4.0.1-cp36-abi3-win32.whl", hash = "sha256:2caffdae059e06ac23fce178d31b4a702f2a3264c20bfb5ff541b338194d8fab", size = 159428, upload-time = "2022-10-09T15:36:32.893Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" },
|
{ url = "https://files.pythonhosted.org/packages/46/81/d8c22cd7e5e1c6a7d48e41a1d1d46c92f17dae70a54d9814f746e6027dec/bcrypt-4.0.1-cp36-abi3-win_amd64.whl", hash = "sha256:8a68f4341daf7522fe8d73874de8906f3a339048ba406be6ddc1b3ccb16fc0d9", size = 152930, upload-time = "2022-10-09T15:36:34.635Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8a/75/4aa9f5a4d40d762892066ba1046000b329c7cd58e888a6db878019b282dc/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534", size = 271180, upload-time = "2025-09-25T19:50:38.575Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/54/79/875f9558179573d40a9cc743038ac2bf67dfb79cecb1e8b5d70e88c94c3d/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4", size = 273791, upload-time = "2025-09-25T19:50:39.913Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bc/fe/975adb8c216174bf70fc17535f75e85ac06ed5252ea077be10d9cff5ce24/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911", size = 270746, upload-time = "2025-09-25T19:50:43.306Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e4/f8/972c96f5a2b6c4b3deca57009d93e946bbdbe2241dca9806d502f29dd3ee/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4", size = 273375, upload-time = "2025-09-25T19:50:45.43Z" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -377,9 +326,11 @@ name = "cyclone"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
{ name = "bcrypt" },
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "keyring" },
|
{ name = "keyring" },
|
||||||
|
{ name = "passlib", extra = ["bcrypt"] },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "python-multipart" },
|
{ name = "python-multipart" },
|
||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
@@ -403,11 +354,13 @@ sqlcipher = [
|
|||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
|
{ name = "bcrypt", specifier = "<4.1" },
|
||||||
{ name = "click", specifier = ">=8.1,<9" },
|
{ name = "click", specifier = ">=8.1,<9" },
|
||||||
{ name = "fastapi", specifier = ">=0.110,<1" },
|
{ name = "fastapi", specifier = ">=0.110,<1" },
|
||||||
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27,<1" },
|
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27,<1" },
|
||||||
{ name = "keyring", specifier = ">=25.0,<26" },
|
{ name = "keyring", specifier = ">=25.0,<26" },
|
||||||
{ name = "paramiko", marker = "extra == 'sftp'", specifier = ">=3.4,<6" },
|
{ name = "paramiko", marker = "extra == 'sftp'", specifier = ">=3.4,<6" },
|
||||||
|
{ name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" },
|
||||||
{ name = "pydantic", specifier = ">=2.6,<3" },
|
{ name = "pydantic", specifier = ">=2.6,<3" },
|
||||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
|
||||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23,<1" },
|
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23,<1" },
|
||||||
@@ -714,6 +667,20 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/82/5b/eadf6d45de38d30ab603f49393b6cd2cbe7e233af8cf90197e32782b68a9/paramiko-5.0.0-py3-none-any.whl", hash = "sha256:b7044611c30140d9a75261653210e2002977b71a0497ff3ba0d98d7edbf62f7c", size = 208919, upload-time = "2026-05-09T18:28:50.295Z" },
|
{ url = "https://files.pythonhosted.org/packages/82/5b/eadf6d45de38d30ab603f49393b6cd2cbe7e233af8cf90197e32782b68a9/paramiko-5.0.0-py3-none-any.whl", hash = "sha256:b7044611c30140d9a75261653210e2002977b71a0497ff3ba0d98d7edbf62f7c", size = 208919, upload-time = "2026-05-09T18:28:50.295Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "passlib"
|
||||||
|
version = "1.7.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844, upload-time = "2020-10-08T19:00:52.121Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
bcrypt = [
|
||||||
|
{ name = "bcrypt" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pluggy"
|
name = "pluggy"
|
||||||
version = "1.6.0"
|
version = "1.6.0"
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Cyclone — local docker-compose stack.
|
||||||
|
#
|
||||||
|
# Two services on a user-defined network:
|
||||||
|
#
|
||||||
|
# frontend (nginx) — published on http://127.0.0.1:8080
|
||||||
|
# serves the built SPA + reverse-proxies /api/* to backend
|
||||||
|
# backend (uvicorn) — NOT published externally by default; reachable from
|
||||||
|
# the frontend over the compose network on backend:8000
|
||||||
|
# (override `ports:` below to expose it for curl/debug)
|
||||||
|
#
|
||||||
|
# Persistent state:
|
||||||
|
# - `cyclone-data` named volume mounted at /data in the backend holds
|
||||||
|
# the SQLite database file. Survives `docker compose down`; only
|
||||||
|
# `docker compose down -v` wipes it.
|
||||||
|
# - `config/payers.yaml` is baked into the backend image at build time.
|
||||||
|
# To edit payer config, change the YAML, rebuild the backend image,
|
||||||
|
# and `POST /api/admin/reload-config` (or just restart the container).
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# docker compose up -d --build # start (or rebuild + restart)
|
||||||
|
# docker compose logs -f # tail both services
|
||||||
|
# docker compose restart backend # bounce the backend (e.g. after crash)
|
||||||
|
# docker compose down # stop containers, KEEP the data volume
|
||||||
|
# docker compose down -v # stop AND wipe the data volume
|
||||||
|
|
||||||
|
name: cyclone
|
||||||
|
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: backend/Dockerfile
|
||||||
|
image: cyclone-backend:local
|
||||||
|
container_name: cyclone-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
# Bind on all interfaces inside the container (required — 127.0.0.1
|
||||||
|
# would only be reachable from inside the container itself).
|
||||||
|
CYCLONE_HOST: "0.0.0.0"
|
||||||
|
CYCLONE_PORT: "8000"
|
||||||
|
CYCLONE_RELOAD: "0"
|
||||||
|
# Absolute path inside the container; the named volume mounts at /data.
|
||||||
|
CYCLONE_DB_URL: "sqlite:////data/cyclone.db"
|
||||||
|
# Bootstrap admin (required on first boot unless at least one user
|
||||||
|
# already exists). The ${VAR:?msg} syntax makes docker-compose refuse
|
||||||
|
# to start with a clear error if the env var isn't set in the host
|
||||||
|
# environment.
|
||||||
|
CYCLONE_ADMIN_USERNAME: ${CYCLONE_ADMIN_USERNAME:?CYCLONE_ADMIN_USERNAME is required on first boot}
|
||||||
|
CYCLONE_ADMIN_PASSWORD: ${CYCLONE_ADMIN_PASSWORD:?CYCLONE_ADMIN_PASSWORD is required on first boot (min 12 chars)}
|
||||||
|
volumes:
|
||||||
|
- cyclone-data:/data
|
||||||
|
# The healthcheck in the Dockerfile hits /api/health. The frontend
|
||||||
|
# depends_on `service_healthy` so it won't accept traffic until the
|
||||||
|
# backend is responsive.
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:8000/api/health"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
# No `ports:` — the frontend reaches the backend over the compose
|
||||||
|
# network. Uncomment the next line to expose the API directly for
|
||||||
|
# `curl http://localhost:8000/api/...` from the host.
|
||||||
|
# ports:
|
||||||
|
# - "127.0.0.1:8000:8000"
|
||||||
|
networks:
|
||||||
|
- cyclone-net
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: frontend/Dockerfile
|
||||||
|
image: cyclone-frontend:local
|
||||||
|
container_name: cyclone-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
# Bind address defaults to 0.0.0.0 (reachable from the LAN). Set
|
||||||
|
# CYCLONE_BIND_ADDRESS=127.0.0.1 to tighten to loopback-only and
|
||||||
|
# match the standalone install's local-only posture.
|
||||||
|
# Port defaults to 8081 to dodge the common clash on 8080; override
|
||||||
|
# with CYCLONE_WEB_PORT=... (see README).
|
||||||
|
- "${CYCLONE_BIND_ADDRESS:-0.0.0.0}:${CYCLONE_WEB_PORT:-8081}:80"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1/healthz"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 3
|
||||||
|
start_period: 5s
|
||||||
|
networks:
|
||||||
|
- cyclone-net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
cyclone-net:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
cyclone-data:
|
||||||
|
name: cyclone-data
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,826 +0,0 @@
|
|||||||
# Cyclone Skill Catalog Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Ship 8 layer-mapped Cyclone skills under `.superpowers/skills/` (one per PR), then update the README to advertise the catalog. No code changes — pure guidance artifacts.
|
|
||||||
|
|
||||||
**Architecture:** Each skill is a single `SKILL.md` (150–250 lines, YAML frontmatter + sections per the spec template). One commit per skill. Phase order matches the spec's build order so each later skill can ship its `## Related skills` section pointing to skills that already exist.
|
|
||||||
|
|
||||||
**Tech Stack:** Markdown, YAML frontmatter. No build step, no runtime, no tests (skills are guidance, not code).
|
|
||||||
|
|
||||||
**Spec:** [`docs/superpowers/specs/2026-06-21-cyclone-skill-catalog-design.md`](../specs/2026-06-21-cyclone-skill-catalog-design.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File structure
|
|
||||||
|
|
||||||
```
|
|
||||||
.superpowers/
|
|
||||||
└── skills/
|
|
||||||
├── cyclone-spec/
|
|
||||||
│ └── SKILL.md ← Task 1
|
|
||||||
├── cyclone-tests/
|
|
||||||
│ └── SKILL.md ← Task 2
|
|
||||||
├── cyclone-edi/
|
|
||||||
│ └── SKILL.md ← Task 3
|
|
||||||
│ └── references/
|
|
||||||
│ └── parsers.md ← Task 3
|
|
||||||
├── cyclone-tail/
|
|
||||||
│ └── SKILL.md ← Task 4
|
|
||||||
│ └── references/
|
|
||||||
│ └── wire-format.md ← Task 4
|
|
||||||
├── cyclone-store/
|
|
||||||
│ └── SKILL.md ← Task 5
|
|
||||||
├── cyclone-api-router/
|
|
||||||
│ └── SKILL.md ← Task 6
|
|
||||||
├── cyclone-frontend-page/
|
|
||||||
│ └── SKILL.md ← Task 7
|
|
||||||
└── cyclone-cli/
|
|
||||||
└── SKILL.md ← Task 8
|
|
||||||
|
|
||||||
README.md ← Task 9 (catalog section)
|
|
||||||
```
|
|
||||||
|
|
||||||
Each skill is one PR. Each `SKILL.md` follows the template in spec §"Per-skill structure". Optional `references/<file>.md` only when the SKILL.md would otherwise exceed ~200 lines.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Shared SKILL.md template (lock this in Task 0, then apply to every Task)
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
---
|
|
||||||
name: <kebab-case>
|
|
||||||
description: "<one-sentence trigger, with Use when: … keywords>"
|
|
||||||
---
|
|
||||||
|
|
||||||
# <Title>
|
|
||||||
|
|
||||||
## When to use
|
|
||||||
- <bullet 1 — concrete situation>
|
|
||||||
- <bullet 2>
|
|
||||||
- <bullet 3>
|
|
||||||
|
|
||||||
## Conventions
|
|
||||||
1. <testable rule>
|
|
||||||
2. <testable rule>
|
|
||||||
3. <testable rule>
|
|
||||||
|
|
||||||
## Patterns
|
|
||||||
\`\`\`<lang>
|
|
||||||
<copy-pasteable skeleton>
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
## Anti-patterns
|
|
||||||
- <tempting-but-wrong move>
|
|
||||||
|
|
||||||
## Related skills
|
|
||||||
- `<other-skill>` — <one line>
|
|
||||||
- `<other-skill>` — <one line>
|
|
||||||
```
|
|
||||||
|
|
||||||
YAML frontmatter rules:
|
|
||||||
- `name` must be kebab-case and match the directory name.
|
|
||||||
- `description` is the only thing the auto-loader sees. One sentence, max ~200 chars. Include a `Use when:` clause listing the trigger situations.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 0: Pre-flight — verify starting state
|
|
||||||
|
|
||||||
**Goal:** Confirm `.superpowers/skills/` does not exist yet, capture a directory snapshot, and verify the catalog can be served from project scope.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Read: `.superpowers/` (existing project-scoped superpowers directory)
|
|
||||||
- Create (no commit): `/tmp/cyclone-skill-baseline.txt`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Inspect existing `.superpowers/` contents**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ls -la .superpowers/
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: shows only `plans/` and `specs/` directories (the canonical superpowers content for this repo). No `skills/` directory yet.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Confirm no stray skill files outside `.superpowers/skills/`**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
find . -path ./node_modules -prune -o -path ./.git -prune -o -path ./backend/.venv -prune -o -name "SKILL.md" -print
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: no output (or only paths under `.grok/` system directories that are not part of the repo).
|
|
||||||
|
|
||||||
- [ ] **Step 3: Verify the README's skills section is empty (or absent)**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -n -i "skill" README.md | head -10
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: a few hits for "skill" in unrelated contexts (e.g. "skill set" in marketing copy) but no existing catalog section.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Save the baseline**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ls .superpowers/ | tee /tmp/cyclone-skill-baseline.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: `plans\nspecs`.
|
|
||||||
|
|
||||||
No commit. Pre-flight only.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 1: Phase 1, Skill 1 — `cyclone-spec`
|
|
||||||
|
|
||||||
**Goal:** Codify the SP-N superpowers flow that this repo already uses (14 specs, 8 plans on disk). Pure documentation; this skill owns the convention so every later feature goes through it consistently.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `.superpowers/skills/cyclone-spec/SKILL.md`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Confirm the SP-N flow materials exist**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ls docs/superpowers/specs/ | wc -l
|
|
||||||
ls docs/superpowers/plans/ | wc -l
|
|
||||||
git log --oneline | grep -E "^(feat|docs|merge): SP[0-9]+" | head -5
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: ≥ 14 specs, ≥ 8 plans, ≥ 5 SP-N commits in history. Record the numbers for use in the skill.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Create the directory**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p .superpowers/skills/cyclone-spec
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Write the SKILL.md**
|
|
||||||
|
|
||||||
Use the shared template. Frontmatter:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
---
|
|
||||||
name: cyclone-spec
|
|
||||||
description: "Cyclone SP-N superpowers increment flow — spec → plan → implement → merge. Use when: starting a new numbered feature increment, naming a branch, opening a SP-N PR, or doing the merge dance into main."
|
|
||||||
---
|
|
||||||
```
|
|
||||||
|
|
||||||
Body sections (each must be present, in this order):
|
|
||||||
- `## When to use` — 4 bullets covering: starting a new increment, naming the spec/plan files, opening the PR, performing the merge.
|
|
||||||
- `## Conventions` — at minimum these numbered rules:
|
|
||||||
1. **Numbering.** Reserve the next SP-N number (next after the highest in `git log`). Never reuse a number.
|
|
||||||
2. **Branch.** `sp<N>-<short-kebab-topic>` (e.g. `sp22-line-reconciliation`).
|
|
||||||
3. **Spec path.** `docs/superpowers/specs/YYYY-MM-DD-cyclone-<topic>-design.md`. Status header: `Draft, pending user review`.
|
|
||||||
4. **Plan path.** `docs/superpowers/plans/YYYY-MM-DD-cyclone-<topic>.md`. Header per writing-plans skill.
|
|
||||||
5. **Commit prefix.** `feat(sp<N>): …`, `docs(spec): …`, `docs(plan): …`, `merge: SP<N> … into main`.
|
|
||||||
6. **PR title.** `SP<N> <Topic>` (matches commit history).
|
|
||||||
7. **Merge shape.** Single atomic merge commit into `main` after review; no squash, no rebase.
|
|
||||||
- `## Patterns` — copy-pasteable skeleton for the spec header (Date / Status / Branch / Scope) and the plan header (per writing-plans).
|
|
||||||
- `## Anti-patterns` — at least these:
|
|
||||||
- Don't skip the spec ("it's a small fix"). Small fixes still get a 3-line spec when they introduce a new numbered increment.
|
|
||||||
- Don't squash the merge commit — the SP-N merge commit is the audit trail.
|
|
||||||
- Don't put code in the spec — the spec is the *what*, the plan is the *how*.
|
|
||||||
- `## Related skills` — references to `cyclone-tests` (every spec mentions test impact), `cyclone-edi` / `cyclone-tail` / `cyclone-store` / `cyclone-api-router` / `cyclone-frontend-page` / `cyclone-cli` (each spec touches one or more of these), and the upstream `superpowers:brainstorming` / `superpowers:writing-plans` global skills.
|
|
||||||
|
|
||||||
Keep total under ~200 lines. Use real examples drawn from existing specs (e.g. SP9 multi-payer, SP14 5-lane Inbox) where they illustrate the convention.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Verify frontmatter and structure**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
head -5 .superpowers/skills/cyclone-spec/SKILL.md
|
|
||||||
wc -l .superpowers/skills/cyclone-spec/SKILL.md
|
|
||||||
grep -c "^## " .superpowers/skills/cyclone-spec/SKILL.md
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: first 5 lines are the YAML frontmatter (start `---`, end `---`), ~150-200 lines total, exactly 5 `## ` sections (`When to use`, `Conventions`, `Patterns`, `Anti-patterns`, `Related skills`).
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add .superpowers/skills/cyclone-spec/SKILL.md
|
|
||||||
git commit -m "feat(sp-skill-catalog): add cyclone-spec skill (SP-N flow)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 2: Phase 1, Skill 2 — `cyclone-tests`
|
|
||||||
|
|
||||||
**Goal:** Codify the pytest + vitest fixture patterns so every later skill can ship its `## Anti-patterns` cleanly. Specifically: how to drop a prodfiles fixture into `backend/tests/fixtures/`, how to write a `.test.tsx` next to a page, how to keep tests deterministic.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `.superpowers/skills/cyclone-tests/SKILL.md`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Survey the existing fixture layout**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ls backend/tests/fixtures/ | head -30
|
|
||||||
ls backend/tests/fixtures/ | wc -l
|
|
||||||
ls src/**/*.test.tsx src/**/*.test.ts 2>/dev/null | wc -l
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: dozens of fixtures under `backend/tests/fixtures/`, dozens of `.test.ts(x)` siblings across `src/`. Record counts for the skill.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Sample one backend pytest and one frontend vitest to cite**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ls backend/tests/fixtures/ | head -3
|
|
||||||
echo "---"
|
|
||||||
ls src/hooks/*.test.ts | head -3
|
|
||||||
```
|
|
||||||
|
|
||||||
Pick the smallest/most-representative example from each side to cite in the skill's `## Patterns`.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Create the directory and write the SKILL.md**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p .superpowers/skills/cyclone-tests
|
|
||||||
```
|
|
||||||
|
|
||||||
Frontmatter:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
---
|
|
||||||
name: cyclone-tests
|
|
||||||
description: "Cyclone pytest + vitest fixture patterns, prodfiles layout, backend/tests/fixtures/ conventions, .test.tsx sibling rule. Use when: adding a backend pytest case, adding a frontend vitest test, or wiring in a real-EDI prodfiles sample."
|
|
||||||
---
|
|
||||||
```
|
|
||||||
|
|
||||||
Body sections (each must be present):
|
|
||||||
- `## When to use` — 4 bullets: adding a backend test, adding a frontend test, dropping in a prodfiles fixture, debugging a flaky test.
|
|
||||||
- `## Conventions` — at minimum:
|
|
||||||
1. **Frontend sibling rule.** Every new file in `src/` that contains testable logic gets a `*.test.ts(x)` next to it. `useFoo.ts` → `useFoo.test.ts`.
|
|
||||||
2. **Backend test location.** Tests live under `backend/tests/test_*.py`. Integration tests (FastAPI) follow `test_api_*.py`; pure-unit tests (parsers, validators) follow `test_<module>_*.py`.
|
|
||||||
3. **Prodfiles drop-in.** Real EDI samples live under `docs/prodfiles/<source>/<edi>.txt`. To use one in a test: copy to `backend/tests/fixtures/<test-name>/<edi>.txt` and reference via the existing fixture helper (see `## Patterns`).
|
|
||||||
4. **Determinism.** Use `freezegun` for date-sensitive backend tests; use `vi.useFakeTimers()` for time-sensitive frontend tests. Never rely on `datetime.now()` directly.
|
|
||||||
5. **No network.** Tests must not hit the network. `httpx`/`requests` mocks live in `conftest.py` fixtures.
|
|
||||||
6. **pytest collection.** Run `cd backend && python -m pytest tests/<file>::<name> -v` for the fastest feedback loop. Full suite is the merge gate.
|
|
||||||
- `## Patterns` — three small copy-pasteable skeletons:
|
|
||||||
- One pytest using `tmp_path` + a prodfiles fixture reference.
|
|
||||||
- One vitest `*.test.ts` for a hook using `vi.useFakeTimers()`.
|
|
||||||
- One vitest `*.test.tsx` for a component using `@testing-library/react` + `happy-dom`.
|
|
||||||
- `## Anti-patterns` — at least:
|
|
||||||
- Don't put frontend tests in `src/__tests__/` (legacy location, no longer used).
|
|
||||||
- Don't reach into `docs/prodfiles/` directly from a test — copy to `backend/tests/fixtures/` so tests stay runnable when prodfiles is reorganized.
|
|
||||||
- Don't use `sleep()` for timing — use the deterministic timer tools listed in Conventions.
|
|
||||||
- `## Related skills` — references to all 7 other skills (every domain skill impacts tests) plus `superpowers:test-driven-development` upstream.
|
|
||||||
|
|
||||||
Keep under ~250 lines (this is the most cross-cutting skill, so it's the largest).
|
|
||||||
|
|
||||||
- [ ] **Step 4: Verify**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
head -5 .superpowers/skills/cyclone-tests/SKILL.md
|
|
||||||
wc -l .superpowers/skills/cyclone-tests/SKILL.md
|
|
||||||
grep -c "^## " .superpowers/skills/cyclone-tests/SKILL.md
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: frontmatter present, 150–250 lines, 5 `## ` sections.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add .superpowers/skills/cyclone-tests/SKILL.md
|
|
||||||
git commit -m "feat(sp-skill-catalog): add cyclone-tests skill (fixture patterns)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 3: Phase 2, Skill 3 — `cyclone-edi`
|
|
||||||
|
|
||||||
**Goal:** Codify Cyclone's EDI parser/validator conventions across 837P/835/999/270/271/277CA/TA1. Highest-leverage skill — 30 parsers, scattered validators, fixture sprawl.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `.superpowers/skills/cyclone-edi/SKILL.md`
|
|
||||||
- Create: `.superpowers/skills/cyclone-edi/references/parsers.md`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Enumerate the parsers and validator rules**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ls backend/src/cyclone/parsers/
|
|
||||||
grep -rn "^def parse_" backend/src/cyclone/parsers/ | head -40
|
|
||||||
grep -n "^R[0-9]" backend/src/cyclone/validator*.py 2>/dev/null | head -20
|
|
||||||
ls backend/src/cyclone/ | grep -i valid
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: ~30 parser modules, each exporting `parse_*`, plus `validator.py` and possibly `validator_835.py`. Record the R-codes (R200, R210, etc.) found.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Sample one parser to cite in the skill**
|
|
||||||
|
|
||||||
Pick the smallest one (e.g. `parse_ta1.py` or `parse_999.py`) and read the first 30 lines. The skill's `## Patterns` section will reference its structure.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Create directories and write the SKILL.md + reference**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p .superpowers/skills/cyclone-edi/references
|
|
||||||
```
|
|
||||||
|
|
||||||
Frontmatter:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
---
|
|
||||||
name: cyclone-edi
|
|
||||||
description: "Cyclone EDI parser/validator conventions (837P/835/999/270/271/277CA/TA1). Use when: adding or changing a parser, adding a validator rule (R200/R210/NPI Luhn/EIN/CAS), or mapping a new CAS adjustment reason code."
|
|
||||||
---
|
|
||||||
```
|
|
||||||
|
|
||||||
Body sections:
|
|
||||||
- `## When to use` — 4 bullets: adding a parser, adding a validator rule, wiring a new CAS code, debugging a parse failure on a prodfiles sample.
|
|
||||||
- `## Conventions` — at minimum:
|
|
||||||
1. **Parser signature.** Every parser module exports `parse(text: str) -> <TypedResult>` where `<TypedResult>` is a Pydantic model from `backend/src/cyclone/parsers/<edi>_models.py` (or co-located).
|
|
||||||
2. **Segment walk.** Parsers consume `backend/src/cyclone/segments.py` helpers (`Segment`, `Loop`, `next_segment`) — do not parse raw text inline.
|
|
||||||
3. **Validator rules.** Numbered (R200, R210, …) live in `backend/src/cyclone/validator.py` (or `validator_835.py` for 835-specific). Each rule is a function `_rule_R<n>_<name>` raising `ValidationError` with the R-code.
|
|
||||||
4. **NPI / EIN / CAS.** Identity-format rules (NPI Luhn, EIN `XX-XXXXXXX`, CAS adjustment reason codes) live in dedicated modules (`npi.py`, etc.). Don't duplicate the format logic.
|
|
||||||
5. **Prodfiles reuse.** When adding a parser for a new transaction type, ship at least one prodfiles fixture in `backend/tests/fixtures/<edi>/<sample>.txt`.
|
|
||||||
- `## Patterns` — three skeletons:
|
|
||||||
- A minimal `parse_<edi>.py` (using `segments.py`, exporting `parse(text)`).
|
|
||||||
- A validator rule `_rule_R<n>_<name>(ctx) -> None`.
|
|
||||||
- A test that uses a prodfiles fixture (`backend/tests/fixtures/<edi>/<sample>.txt`) and asserts `parse(text).foo == expected`.
|
|
||||||
- `## Anti-patterns` — at least:
|
|
||||||
- Don't re-parse raw X12 strings inside validators — always parse first, validate the typed result.
|
|
||||||
- Don't bake payer-specific logic into the generic parser — payer variations live in `backend/src/cyclone/payers.py` or `<payer>_config.py`.
|
|
||||||
- Don't add a validator rule without an R-code — the R-code is how the UI surfaces the error.
|
|
||||||
- `## Related skills` — references to `cyclone-store` (writes the parsed result), `cyclone-api-router` (exposes it), `cyclone-tests` (fixture drop-in), `cyclone-cli` (the `cyc parse <file>` smoke command).
|
|
||||||
|
|
||||||
Then write `references/parsers.md`: a flat table mapping each parser module to its transaction type, signature, primary fixtures, and any payer-specific variants. Keep under 80 lines.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Verify**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
head -5 .superpowers/skills/cyclone-edi/SKILL.md
|
|
||||||
wc -l .superpowers/skills/cyclone-edi/SKILL.md
|
|
||||||
wc -l .superpowers/skills/cyclone-edi/references/parsers.md
|
|
||||||
grep -c "^## " .superpowers/skills/cyclone-edi/SKILL.md
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: frontmatter present, SKILL.md ≤ 200 lines, references/parsers.md ≤ 80 lines, exactly 5 `## ` sections in SKILL.md.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add .superpowers/skills/cyclone-edi/
|
|
||||||
git commit -m "feat(sp-skill-catalog): add cyclone-edi skill (parser/validator conventions)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 4: Phase 2, Skill 4 — `cyclone-tail`
|
|
||||||
|
|
||||||
**Goal:** Codify the live-tail wire format and the `useTailStream` + `useMergedTail` + per-resource hook triplet. Concrete drift risk — wire format is in the README but consumed across 3 page-hook pairs.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `.superpowers/skills/cyclone-tail/SKILL.md`
|
|
||||||
- Create: `.superpowers/skills/cyclone-tail/references/wire-format.md`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Survey the live-tail surface**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -rn "/stream" backend/src/cyclone/api_routers/ src/hooks/ 2>/dev/null | head -20
|
|
||||||
ls src/hooks/use*Stream* src/hooks/use*MergedTail*
|
|
||||||
ls src/hooks/use*Tail* 2>/dev/null
|
|
||||||
grep -rn "snapshot_end\|x-ndjson" backend/src/cyclone/ src/ 2>/dev/null | head -10
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: 3 `/api/<resource>/stream` endpoints, one `useTailStream.ts`, one `useMergedTail.ts`, multiple `use<X>Stream` consumers.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Read the wire-format spec in the README**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -n -A 30 "Wire format" README.md | head -50
|
|
||||||
```
|
|
||||||
|
|
||||||
This is the canonical definition; the skill will reference it. Record the line range for the `references/wire-format.md` cross-reference.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Create directories and write the SKILL.md + reference**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p .superpowers/skills/cyclone-tail/references
|
|
||||||
```
|
|
||||||
|
|
||||||
Frontmatter:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
---
|
|
||||||
name: cyclone-tail
|
|
||||||
description: "Cyclone live-tail streaming wire format and the useTailStream / useMergedTail hook triplet. Use when: adding a new streaming list page, changing the wire format, debugging stalled/reconnecting state, or modifying the StatusPill behavior."
|
|
||||||
---
|
|
||||||
```
|
|
||||||
|
|
||||||
Body sections:
|
|
||||||
- `## When to use` — 4 bullets: adding a new streaming page, changing the wire format, debugging stalled/reconnecting, tuning heartbeat/stall timing.
|
|
||||||
- `## Conventions` — at minimum:
|
|
||||||
1. **Wire format.** Newline-delimited JSON. Line shapes: `{"type":"item","data":…}`, `{"type":"snapshot_end","data":{"count":N}}`, `{"type":"heartbeat","data":{"ts":…}}`, `{"type":"item_dropped","data":…}`, `{"type":"error","data":…}`. See `references/wire-format.md`.
|
|
||||||
2. **Hook triplet.** Streaming pages consume three hooks: `useTailStream(url)` (raw stream state), `useMergedTail(items, events)` (combine initial fetch with live events), and a per-resource hook (`useClaims`, `useRemittances`, etc.) that wraps both.
|
|
||||||
3. **Stall threshold.** 30s of total silence (heartbeat included) flips the connection to `stalled` and surfaces a `↻ Reconnect` button. Don't change this without updating the README's "Status pill" table.
|
|
||||||
4. **Snapshot first.** Every stream must emit the current snapshot before any live `item` events. The `snapshot_end` line is the marker the UI uses to flip `StatusPill` from `connecting` to `live`.
|
|
||||||
5. **Content-Type.** `application/x-ndjson`. Never `application/json` for a stream endpoint.
|
|
||||||
- `## Patterns` — three skeletons:
|
|
||||||
- A `useFoo.ts` page-hook skeleton (TanStack Query initial fetch + tail subscription).
|
|
||||||
- A new backend `/api/foo/stream` endpoint signature.
|
|
||||||
- A `StatusPill` consumer wiring.
|
|
||||||
- `## Anti-patterns` — at least:
|
|
||||||
- Don't hand-roll `fetch` + `ReadableStream` parsing in a page — go through `useTailStream` so reconnect/stall handling is consistent.
|
|
||||||
- Don't change the wire format on one endpoint without updating the other two — they share the parser in `src/lib/tail-stream.ts`.
|
|
||||||
- Don't emit `item` events before `snapshot_end` — the UI will duplicate rows.
|
|
||||||
- `## Related skills` — references to `cyclone-frontend-page` (page-level wiring), `cyclone-api-router` (the `/api/<resource>/stream` endpoint), `cyclone-store` (the event contract that drives the stream), `cyclone-tests` (`.test.ts` siblings on every hook in this skill).
|
|
||||||
|
|
||||||
Then write `references/wire-format.md`: copy the relevant README section (with attribution) and add a per-line field reference table (which fields are required, which are optional, parser tolerance). Keep under 80 lines.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Verify**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
head -5 .superpowers/skills/cyclone-tail/SKILL.md
|
|
||||||
wc -l .superpowers/skills/cyclone-tail/SKILL.md
|
|
||||||
wc -l .superpowers/skills/cyclone-tail/references/wire-format.md
|
|
||||||
grep -c "^## " .superpowers/skills/cyclone-tail/SKILL.md
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: frontmatter present, SKILL.md ≤ 200 lines, wire-format.md ≤ 80 lines, 5 `## ` sections.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add .superpowers/skills/cyclone-tail/
|
|
||||||
git commit -m "feat(sp-skill-catalog): add cyclone-tail skill (live-tail wire format)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 5: Phase 3, Skill 5 — `cyclone-store`
|
|
||||||
|
|
||||||
**Goal:** Codify the store write-paths, the pubsub event contract, and the SP21 store-split boundary map. Aligns with the SP21 store split currently in plan stage — the skill codifies the new boundaries as they land.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `.superpowers/skills/cyclone-store/SKILL.md`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Read SP21 plan to learn the new module boundaries**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cat docs/superpowers/plans/2026-06-21-cyclone-store-split.md | head -80
|
|
||||||
```
|
|
||||||
|
|
||||||
Record the module list (`batches`, `inbox`, `acks`, etc.) for use in the skill.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Survey the current pubsub surface**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -rn "_written\|_recorded" backend/src/cyclone/pubsub.py | head -20
|
|
||||||
grep -rn "publish\|subscribe" backend/src/cyclone/store.py | head -10
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: at least 3 event types — `claim_written`, `remittance_written`, `activity_recorded`. Record exact names.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Create the directory and write the SKILL.md**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p .superpowers/skills/cyclone-store
|
|
||||||
```
|
|
||||||
|
|
||||||
Frontmatter:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
---
|
|
||||||
name: cyclone-store
|
|
||||||
description: "Cyclone store write-paths, the pubsub event contract (claim_written / remittance_written / activity_recorded), and the SP21 store-split boundary map. Use when: touching store.py, adding a new entity, wiring a new write event, or splitting a store module."
|
|
||||||
---
|
|
||||||
```
|
|
||||||
|
|
||||||
Body sections:
|
|
||||||
- `## When to use` — 4 bullets: adding a new entity, wiring a new write event, debugging a write-path issue, splitting a store module.
|
|
||||||
- `## Conventions` — at minimum:
|
|
||||||
1. **All writes go through `CycloneStore`.** Route handlers and parsers must not write directly to the ORM session.
|
|
||||||
2. **Every write publishes an event.** The event name matches the entity: `claim_written`, `remittance_written`, `activity_recorded`. New entities get `<entity>_written` (or `_recorded` for non-canonical rows).
|
|
||||||
3. **Snapshot shape.** Each entity has a Pydantic serializer in `backend/src/cyclone/store/ui.py` (or its post-SP21 equivalent). The serializer is the single source of truth for what the frontend sees.
|
|
||||||
4. **SP21 boundaries.** Post-split, each domain lives in its own module (`batches`, `inbox`, `acks`, …). Cross-module writes go through `CycloneStore` facade methods, not direct module access.
|
|
||||||
5. **No business logic in route handlers.** A route handler validates input, calls `store.<method>(...)`, publishes the event, returns the serialized result. Anything more belongs in the store or a parser.
|
|
||||||
- `## Patterns` — three skeletons:
|
|
||||||
- A `CycloneStore` write method (`def add_foo(self, foo: Foo) -> FooRecord`).
|
|
||||||
- An event publication in `pubsub.py`.
|
|
||||||
- A new `<entity>_written` handler test that asserts both the row and the event.
|
|
||||||
- `## Anti-patterns` — at least:
|
|
||||||
- Don't read directly from the ORM in a route handler — go through the snapshot serializer.
|
|
||||||
- Don't introduce a new event name without updating the subscriber list (currently `api.py` and `api_routers/`).
|
|
||||||
- Don't merge a write method with its event publication into separate places — they live next to each other so reviewers see both.
|
|
||||||
- `## Related skills` — references to `cyclone-edi` (the parsed result lands here), `cyclone-api-router` (the route that calls the store), `cyclone-tail` (the event drives the stream), `cyclone-tests` (write-path tests).
|
|
||||||
|
|
||||||
Keep under ~200 lines. No `references/` needed unless the boundary map grows.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Verify**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
head -5 .superpowers/skills/cyclone-store/SKILL.md
|
|
||||||
wc -l .superpowers/skills/cyclone-store/SKILL.md
|
|
||||||
grep -c "^## " .superpowers/skills/cyclone-store/SKILL.md
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: frontmatter present, ≤ 200 lines, 5 `## ` sections.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add .superpowers/skills/cyclone-store/SKILL.md
|
|
||||||
git commit -m "feat(sp-skill-catalog): add cyclone-store skill (write paths + event contract)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 6: Phase 3, Skill 6 — `cyclone-api-router`
|
|
||||||
|
|
||||||
**Goal:** Codify FastAPI router conventions — `api_routers/`, `api_helpers.py` reuse, response shapes, error envelopes. Pairs with `cyclone-store`.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `.superpowers/skills/cyclone-api-router/SKILL.md`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Survey the existing router layout**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ls backend/src/cyclone/api_routers/
|
|
||||||
grep -l "APIRouter" backend/src/cyclone/api_routers/*.py
|
|
||||||
head -30 backend/src/cyclone/api_helpers.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Record the router filenames and which helpers exist in `api_helpers.py`.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Sample one router**
|
|
||||||
|
|
||||||
Pick the smallest existing router (likely `acks.py` or `ta1_acks.py`) and read the first 50 lines. The skill's `## Patterns` section will reference its structure.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Create the directory and write the SKILL.md**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p .superpowers/skills/cyclone-api-router
|
|
||||||
```
|
|
||||||
|
|
||||||
Frontmatter:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
---
|
|
||||||
name: cyclone-api-router
|
|
||||||
description: "Cyclone FastAPI router conventions (api_routers/, api_helpers.py, response shapes, error envelopes). Use when: adding or changing an HTTP endpoint, splitting a route out of api.py, or wiring a new helper into api_helpers.py."
|
|
||||||
---
|
|
||||||
```
|
|
||||||
|
|
||||||
Body sections:
|
|
||||||
- `## When to use` — 4 bullets: adding an endpoint, splitting a route, adding a helper, defining an error response.
|
|
||||||
- `## Conventions` — at minimum:
|
|
||||||
1. **No new top-level routes in `api.py`.** Every endpoint lives in `backend/src/cyclone/api_routers/<topic>.py` as an `APIRouter`.
|
|
||||||
2. **Reuse `api_helpers.py`.** Common response shapes, error envelopes, and parsing helpers live there. Don't duplicate them in a router.
|
|
||||||
3. **Response shape.** Every successful response is a Pydantic model. Errors use the shared `ErrorEnvelope` (from `api_helpers.py`) with `code` and `message`.
|
|
||||||
4. **Mounting.** Routers are mounted in `api.py` with their prefix; keep prefix naming consistent (`/api/<resource>`).
|
|
||||||
5. **Streaming endpoints.** Use `StreamingResponse(media_type="application/x-ndjson")` — see `cyclone-tail` for the wire format.
|
|
||||||
6. **Tests.** Every new endpoint gets a `test_api_<topic>_<verb>.py` test under `backend/tests/`.
|
|
||||||
- `## Patterns` — three skeletons:
|
|
||||||
- A new `APIRouter` skeleton (`router = APIRouter(prefix="/api/foo", tags=["foo"])`).
|
|
||||||
- A `get_one` / `get_list` / `post_one` trio with Pydantic response models.
|
|
||||||
- An error envelope usage example.
|
|
||||||
- `## Anti-patterns` — at least:
|
|
||||||
- Don't import from `cyclone.api` into a router — the dependency runs the other way.
|
|
||||||
- Don't return raw dicts — always Pydantic.
|
|
||||||
- Don't bypass `CycloneStore` to query the ORM directly — see `cyclone-store`.
|
|
||||||
- `## Related skills` — references to `cyclone-store` (most routes call store methods), `cyclone-tail` (streaming endpoints), `cyclone-edi` (parse endpoints), `cyclone-tests` (endpoint tests).
|
|
||||||
|
|
||||||
Keep under ~200 lines.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Verify**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
head -5 .superpowers/skills/cyclone-api-router/SKILL.md
|
|
||||||
wc -l .superpowers/skills/cyclone-api-router/SKILL.md
|
|
||||||
grep -c "^## " .superpowers/skills/cyclone-api-router/SKILL.md
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: frontmatter present, ≤ 200 lines, 5 `## ` sections.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add .superpowers/skills/cyclone-api-router/SKILL.md
|
|
||||||
git commit -m "feat(sp-skill-catalog): add cyclone-api-router skill (FastAPI conventions)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 7: Phase 4, Skill 7 — `cyclone-frontend-page`
|
|
||||||
|
|
||||||
**Goal:** Codify the React page-component pattern — TanStack Query, `use<X>` hook, drawer, URL state, `.test.tsx` sibling. Generalizes what `cyclone-tail` already covers.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `.superpowers/skills/cyclone-frontend-page/SKILL.md`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Survey the page layout**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ls src/pages/
|
|
||||||
echo "---"
|
|
||||||
ls src/components/ui/ | head -20
|
|
||||||
```
|
|
||||||
|
|
||||||
Record the page list and which shadcn-style UI components are in `src/components/ui/`.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Sample one page to cite**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
head -50 src/pages/Claims.tsx
|
|
||||||
```
|
|
||||||
|
|
||||||
Use this in the skill's `## Patterns` to show the canonical page structure.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Create the directory and write the SKILL.md**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p .superpowers/skills/cyclone-frontend-page
|
|
||||||
```
|
|
||||||
|
|
||||||
Frontmatter:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
---
|
|
||||||
name: cyclone-frontend-page
|
|
||||||
description: "Cyclone React page conventions (TanStack Query, use<X> hook, drawer, URL state, .test.tsx sibling, Layout / PageHeader / Sidebar). Use when: adding a new page, refactoring an existing one, or wiring a drawer into a page."
|
|
||||||
---
|
|
||||||
```
|
|
||||||
|
|
||||||
Body sections:
|
|
||||||
- `## When to use` — 4 bullets: adding a new page, refactoring an existing page, wiring a drawer, sharing state via URL.
|
|
||||||
- `## Conventions` — at minimum:
|
|
||||||
1. **Page shape.** Every page in `src/pages/<Name>.tsx` exports a default `function <Name>()`. It uses `<Layout>`, sets a `<PageHeader>`, and renders a table or list.
|
|
||||||
2. **Data hook.** Each page pairs with a `use<X>` hook in `src/hooks/use<X>.ts` that returns `{ data, isLoading, error, ...tail }`. Pages do not call `fetch` directly.
|
|
||||||
3. **Tail.** If the page shows live data, it consumes the tail-stream pattern via `cyclone-tail`. The hook, not the page, owns the streaming subscription.
|
|
||||||
4. **Drawer.** Drawers (e.g. `ClaimDrawer`, `RemitDrawer`) live in `src/components/<DrawerName>/` and are wired via `useDrawerUrlState` so the URL reflects open state.
|
|
||||||
5. **Tests.** Every page gets a `src/pages/<Name>.test.tsx` sibling. Every hook gets a `src/hooks/use<X>.test.ts` sibling.
|
|
||||||
6. **UI primitives.** Use Radix-backed components from `src/components/ui/`. Don't pull in new UI libraries without discussion.
|
|
||||||
7. **Routing.** Pages register their route in `src/App.tsx`. Lazy-load if the page is heavy.
|
|
||||||
- `## Patterns` — three skeletons:
|
|
||||||
- A minimal `Claims.tsx`-style page (Layout + PageHeader + table + drawer).
|
|
||||||
- A `use<X>` hook skeleton (TanStack Query + tail).
|
|
||||||
- A drawer component skeleton with `useDrawerUrlState`.
|
|
||||||
- `## Anti-patterns` — at least:
|
|
||||||
- Don't `fetch` from inside a page component — go through the hook.
|
|
||||||
- Don't open a drawer via local component state — use `useDrawerUrlState` so deep-links work.
|
|
||||||
- Don't put domain logic in JSX — extract to the hook or a pure helper in `src/lib/`.
|
|
||||||
- `## Related skills` — references to `cyclone-tail` (live-data pages), `cyclone-api-router` (the endpoints pages call), `cyclone-tests` (page + hook tests), `cyclone-edi` (pages that show parsed EDI content).
|
|
||||||
|
|
||||||
Keep under ~200 lines.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Verify**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
head -5 .superpowers/skills/cyclone-frontend-page/SKILL.md
|
|
||||||
wc -l .superpowers/skills/cyclone-frontend-page/SKILL.md
|
|
||||||
grep -c "^## " .superpowers/skills/cyclone-frontend-page/SKILL.md
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: frontmatter present, ≤ 200 lines, 5 `## ` sections.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add .superpowers/skills/cyclone-frontend-page/SKILL.md
|
|
||||||
git commit -m "feat(sp-skill-catalog): add cyclone-frontend-page skill (React page conventions)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 8: Phase 4, Skill 8 — `cyclone-cli`
|
|
||||||
|
|
||||||
**Goal:** Codify the CLI subcommand conventions in `cli.py` — argparse style, exit codes, smoke-test patterns. Last because nothing else depends on it.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `.superpowers/skills/cyclone-cli/SKILL.md`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Survey the existing CLI**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -n "add_parser\|sub_parsers\|set_defaults" backend/src/cyclone/cli.py | head -20
|
|
||||||
grep -n "sys.exit\|return [0-9]\|return 1\|return 2" backend/src/cyclone/cli.py | head -10
|
|
||||||
```
|
|
||||||
|
|
||||||
Record the subcommand list and exit-code conventions.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Sample one subcommand**
|
|
||||||
|
|
||||||
Pick the smallest (likely `validate`) and read its 20-30 lines. The skill's `## Patterns` will reference its structure.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Create the directory and write the SKILL.md**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p .superpowers/skills/cyclone-cli
|
|
||||||
```
|
|
||||||
|
|
||||||
Frontmatter:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
---
|
|
||||||
name: cyclone-cli
|
|
||||||
description: "Cyclone CLI subcommand conventions (cli.py, serve/parse/backup/rotate-key/validate, argparse style, exit codes, smoke tests). Use when: adding a CLI subcommand, changing exit codes, or working on operator-facing commands."
|
|
||||||
---
|
|
||||||
```
|
|
||||||
|
|
||||||
Body sections:
|
|
||||||
- `## When to use` — 4 bullets: adding a subcommand, changing exit codes, adding a smoke test, wiring a security-sensitive command (key rotation, backup).
|
|
||||||
- `## Conventions` — at minimum:
|
|
||||||
1. **Subcommand shape.** Every subcommand is a function `cmd_<name>(args) -> int` in `cli.py`. The `main()` parser dispatches via `set_defaults(func=...)`.
|
|
||||||
2. **Argparse style.** Use `argparse.ArgumentParser` sub-parsers. Long-form flags (`--rotate-key`) preferred over positional for safety.
|
|
||||||
3. **Exit codes.** `0` = success, `1` = user error (bad input, missing file), `2` = operator error (DB locked, key missing), `3` = security error (auth fail, key mismatch). Document in `cmd_<name>` docstring.
|
|
||||||
4. **Smoke test.** Every new subcommand gets a `backend/tests/test_cli_<name>.py` test using `subprocess.run` against the installed `cyc` entrypoint.
|
|
||||||
5. **Security-sensitive commands.** Key rotation (`cyc rotate-key`), backup (`cyc backup`), and any command touching `secrets.py` requires a `--confirm` flag and a dry-run path.
|
|
||||||
- `## Patterns` — three skeletons:
|
|
||||||
- A `cmd_<name>(args)` function with `argparse` setup.
|
|
||||||
- A `subprocess.run` smoke test.
|
|
||||||
- A `--confirm` / `--dry-run` pattern for security-sensitive commands.
|
|
||||||
- `## Anti-patterns` — at least:
|
|
||||||
- Don't `sys.exit()` from inside a subcommand — return the int and let `main()` exit.
|
|
||||||
- Don't print to stdout for errors — use `logging` (or `print(..., file=sys.stderr)` for top-level fatal errors).
|
|
||||||
- Don't add a subcommand without a smoke test in `backend/tests/test_cli_<name>.py`.
|
|
||||||
- `## Related skills` — references to `cyclone-store` (most subcommands touch the store), `cyclone-api-router` (`cyc serve` runs the FastAPI app), `cyclone-edi` (`cyc parse <file>`), `cyclone-tests` (CLI smoke tests).
|
|
||||||
|
|
||||||
Keep under ~200 lines.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Verify**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
head -5 .superpowers/skills/cyclone-cli/SKILL.md
|
|
||||||
wc -l .superpowers/skills/cyclone-cli/SKILL.md
|
|
||||||
grep -c "^## " .superpowers/skills/cyclone-cli/SKILL.md
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: frontmatter present, ≤ 200 lines, 5 `## ` sections.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add .superpowers/skills/cyclone-cli/SKILL.md
|
|
||||||
git commit -m "feat(sp-skill-catalog): add cyclone-cli skill (CLI conventions)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 9: Final — README catalog section
|
|
||||||
|
|
||||||
**Goal:** Advertise the catalog to human readers and to AI agents reading the README on first contact.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `README.md` (add a "Skills" section near the top)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Find a good insertion point**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -n "^## " README.md | head -20
|
|
||||||
```
|
|
||||||
|
|
||||||
Pick a sensible location — typically after the existing "Install" or "Dev" section.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add the catalog section**
|
|
||||||
|
|
||||||
Add a new `## Skills` section listing all 8 skills with a one-line description each. Example:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## Skills
|
|
||||||
|
|
||||||
Cyclone ships 8 project-scoped AI-assistant skills under
|
|
||||||
[`.superpowers/skills/`](.superpowers/skills/). Each one codifies the
|
|
||||||
conventions for a major subsystem so the next contributor (human or
|
|
||||||
AI) gets the lay of the land automatically.
|
|
||||||
|
|
||||||
| Skill | Owns |
|
|
||||||
|-------|------|
|
|
||||||
| [`cyclone-spec`](.superpowers/skills/cyclone-spec/SKILL.md) | The SP-N spec → plan → implement → merge flow. |
|
|
||||||
| [`cyclone-tests`](.superpowers/skills/cyclone-tests/SKILL.md) | pytest + vitest fixture patterns, prodfiles drop-in. |
|
|
||||||
| [`cyclone-edi`](.superpowers/skills/cyclone-edi/SKILL.md) | EDI parser/validator conventions (837P/835/999/270/271/277CA/TA1). |
|
|
||||||
| [`cyclone-tail`](.superpowers/skills/cyclone-tail/SKILL.md) | Live-tail streaming wire format and the hook triplet. |
|
|
||||||
| [`cyclone-store`](.superpowers/skills/cyclone-store/SKILL.md) | Store write-paths, pubsub event contract, SP21 split map. |
|
|
||||||
| [`cyclone-api-router`](.superpowers/skills/cyclone-api-router/SKILL.md) | FastAPI router conventions (`api_routers/`, `api_helpers.py`). |
|
|
||||||
| [`cyclone-frontend-page`](.superpowers/skills/cyclone-frontend-page/SKILL.md) | React page conventions (TanStack Query, drawer, URL state). |
|
|
||||||
| [`cyclone-cli`](.superpowers/skills/cyclone-cli/SKILL.md) | CLI subcommand conventions (`cli.py`, exit codes, smoke tests). |
|
|
||||||
|
|
||||||
Skills auto-load by description match — no slash command needed.
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Verify the section reads cleanly**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -n -A 16 "^## Skills" README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: the section renders with all 8 rows and the trailing usage note.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add README.md
|
|
||||||
git commit -m "docs(readme): add Skills section linking the 8-skill catalog"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Self-review (run after writing the plan, before executing)
|
|
||||||
|
|
||||||
- [ ] **Spec coverage.** Skim each section of `docs/superpowers/specs/2026-06-21-cyclone-skill-catalog-design.md`. Confirm:
|
|
||||||
- Spec §"The catalog" → Tasks 1-8 each create the matching skill. ✅
|
|
||||||
- Spec §"Per-skill structure" → enforced by the shared template locked in Task 0. ✅
|
|
||||||
- Spec §"Loading & cross-references" → every Task has a `## Related skills` section. ✅
|
|
||||||
- Spec §"Build order / phasing" → Phase order = Task order (1-2 foundations, 3-4 domain, 5-6 backend, 7-8 frontend+CLI). ✅
|
|
||||||
- Spec §"Verification" → covered by verification steps in each task + Task 9 README addition. ✅
|
|
||||||
- [ ] **Placeholder scan.** No "TBD" / "TODO" / "implement later" in the plan. Each task's body content is specified (frontmatter exact, sections listed, key bullets enumerated).
|
|
||||||
- [ ] **Type / name consistency.**
|
|
||||||
- Skill names: `cyclone-spec`, `cyclone-tests`, `cyclone-edi`, `cyclone-tail`, `cyclone-store`, `cyclone-api-router`, `cyclone-frontend-page`, `cyclone-cli`. Same in every task. ✅
|
|
||||||
- Commit prefix: `feat(sp-skill-catalog): …` everywhere. ✅
|
|
||||||
- Section names: `## When to use`, `## Conventions`, `## Patterns`, `## Anti-patterns`, `## Related skills`. Same in every task. ✅
|
|
||||||
|
|
||||||
Plan passes self-review.
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user