From 86c00337baf8923436df820e5e3487fb5f1b876b Mon Sep 17 00:00:00 2001 From: t0kubetsu Date: Mon, 10 Aug 2026 15:11:55 +0200 Subject: [PATCH 1/3] docs(reference): document ScanResult.trend and drop the statements-only 500 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - api-design §2.4 and data-model §8.1 now describe the computed `trend` (`TrendDirection`), previously present only in the contract and core/enums.py (review A4 residual, Taiga #199). - `GET /statements` no longer declares a 500: the global fallback handler answers problem+json everywhere, and no artifact explained why this one operation advertised a special failure mode (review A19, Taiga #199). --- api/openapi.json | 10 ---------- docs/reference/api-design-v4_0_1.md | 1 + docs/reference/data-model-v4_0_1.md | 1 + src/nc3_testing_platform/domains/statements/router.py | 1 - 4 files changed, 2 insertions(+), 11 deletions(-) diff --git a/api/openapi.json b/api/openapi.json index d0badfb..2ef0bc1 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -4374,16 +4374,6 @@ } } } - }, - "500": { - "description": "Internal Server Error", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetail" - } - } - } } } } diff --git a/docs/reference/api-design-v4_0_1.md b/docs/reference/api-design-v4_0_1.md index a03007d..b7f9f92 100644 --- a/docs/reference/api-design-v4_0_1.md +++ b/docs/reference/api-design-v4_0_1.md @@ -88,6 +88,7 @@ GET /api/v1/scans/{scan_id}/events - Job statuses: `queued`, `running`, `completed`, `partial`, `failed`, `canceled`. - Task statuses: `queued`, `running`, `completed`, `failed`, `skipped`, `blocked`, `canceled`. - A ScanResult belongs to one ScanTask, not directly to the ScanJob. +- A ScanResult carries an optional `trend`: movement against the previous result for the same test, as a `direction` (`improving`, `unchanged`, `declining`), a signed `delta`, and the id of the compared result. It tracks `grade` where the test is graded and total findings where it is not. Computed at read time; null on the first result for a test, or once the predecessor has been purged. - Specific causes use stable machine-readable `status_reason` values. Labels, descriptions, localization, and operator guidance stay code-owned. - A task timeout produces `failed` with a timeout `status_reason`. A job timeout terminates unfinished work; the job becomes `partial` when usable results exist, otherwise `failed`. - `POST /scans/{scan_id}/cancel` records cancellation intent and preserves scan history. `DELETE` is never used to stop execution. diff --git a/docs/reference/data-model-v4_0_1.md b/docs/reference/data-model-v4_0_1.md index 52360b5..7549e7e 100644 --- a/docs/reference/data-model-v4_0_1.md +++ b/docs/reference/data-model-v4_0_1.md @@ -531,6 +531,7 @@ Constraints: - `grade` is used only for Email, Web headers, and Web TLS tasks. - Non-graded tasks use severity counts or per-step verdicts in `summary`. - No cross-module composite score is stored. +- The API-level `ScanResult.trend` (`TrendDirection`: `improving`, `unchanged`, `declining`) is computed at read time from the previous comparable result and is not stored. No PostgreSQL enum carries it, like the computed verification status (§4.2). ### 8.2 `finding` diff --git a/src/nc3_testing_platform/domains/statements/router.py b/src/nc3_testing_platform/domains/statements/router.py index 890333a..32d9377 100644 --- a/src/nc3_testing_platform/domains/statements/router.py +++ b/src/nc3_testing_platform/domains/statements/router.py @@ -24,7 +24,6 @@ @router.get( "/statements", summary="List active statements", - responses=problem_responses(500), ) async def list_statements() -> list[Statement]: """Statements currently in force. From b2a83b49ed2178ac6234ee21c5767871c0e3b0c0 Mon Sep 17 00:00:00 2001 From: t0kubetsu Date: Mon, 10 Aug 2026 15:11:56 +0200 Subject: [PATCH 2/3] fix(mocks): make sample chronologies coherent (Taiga #199) - Revoked-feed sample: `revoked_at` now falls after `last_used_at` instead of preceding it by two months. - Cancel sample: `POST /scans/{id}/cancel` returns a `canceled` job with a cancellation `status_reason`; `canceled` appeared in no other sample. - Verification challenge sample: requested recently enough that its answer window is still open at the recorded recheck, so a `pending` verification no longer carries an unanswerable challenge. --- .../domains/assets/examples.py | 16 ++++++++++++++-- .../domains/assets/router.py | 4 +--- .../domains/scans/examples.py | 11 +++++++++++ src/nc3_testing_platform/domains/scans/router.py | 2 +- 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/nc3_testing_platform/domains/assets/examples.py b/src/nc3_testing_platform/domains/assets/examples.py index ef63657..a66d1e4 100644 --- a/src/nc3_testing_platform/domains/assets/examples.py +++ b/src/nc3_testing_platform/domains/assets/examples.py @@ -32,6 +32,11 @@ _T0 = datetime(2026, 6, 1, 8, 30, tzinfo=UTC) _T1 = datetime(2026, 7, 31, 9, 1, 12, tzinfo=UTC) +# The challenge is younger than the proof: its answer window must still be open at +# the _T1 recheck, or a `pending` sample would carry an unanswerable challenge. +_CHALLENGE_REQUESTED_AT = datetime(2026, 7, 28, 8, 30, tzinfo=UTC) +_FEED_REVOKED_AT = datetime(2026, 7, 31, 10, 0, tzinfo=UTC) + _SUBDOMAIN_ASSET_ID = UUID("019ee1a3-0011-7a22-8b33-4c44d5e66f77") _CHALLENGE_ID = UUID("019ee1a3-1122-7b33-9c44-5d55e6f77a88") _FEED_ID = UUID("019ee1a3-2233-7c44-ad55-6e66f7a88b99") @@ -82,9 +87,9 @@ def sample_challenge(checked: bool = False) -> VerificationChallenge: requested_scope=VerificationScope.ZONE, record_name=verification_record_name("example.lu"), verification_token="verify-4f7a2c9e1b8d3056", - token_expires_at=_T0 + VERIFICATION_TOKEN_TTL, + token_expires_at=_CHALLENGE_REQUESTED_AT + VERIFICATION_TOKEN_TTL, requested_by_user_id=USER_ID, - requested_at=_T0, + requested_at=_CHALLENGE_REQUESTED_AT, last_recheck_at=_T1 if checked else None, failure_code="dns.txt_record_not_found" if checked else None, ) @@ -128,6 +133,13 @@ def sample_feed() -> AssetFeed: ) +def revoked_feed() -> AssetFeed: + """The feed after revocation, revoked after its last delivery.""" + feed = sample_feed() + feed.revoked_at = _FEED_REVOKED_AT + return feed + + def sample_feed_created() -> AssetFeedCreated: """The one response that carries the plaintext token.""" return AssetFeedCreated( diff --git a/src/nc3_testing_platform/domains/assets/router.py b/src/nc3_testing_platform/domains/assets/router.py index f944ba1..b8b0b93 100644 --- a/src/nc3_testing_platform/domains/assets/router.py +++ b/src/nc3_testing_platform/domains/assets/router.py @@ -237,9 +237,7 @@ async def revoke_asset_feed(asset_id: ResourceId, feed_id: ResourceId) -> AssetF A `POST` rather than a `DELETE`, because revocation is a recorded event and the lifecycle survives it. """ - feed = examples.sample_feed() - feed.revoked_at = feed.created_at - return feed + return examples.revoked_feed() @public_feed_router.get( diff --git a/src/nc3_testing_platform/domains/scans/examples.py b/src/nc3_testing_platform/domains/scans/examples.py index 3f2f014..ac3c417 100644 --- a/src/nc3_testing_platform/domains/scans/examples.py +++ b/src/nc3_testing_platform/domains/scans/examples.py @@ -199,6 +199,17 @@ def sample_job( ) +def canceled_job() -> ScanJob: + """The job once cancellation intent has taken effect. + + `canceled` appears in no other sample, and the cancel response is where a + client learns the terminal shape it must render. + """ + job = sample_job(status=ScanJobStatus.CANCELED) + job.status_reason = "scan.canceled_by_user" + return job + + def sample_job_detail() -> ScanJobDetail: """The job/task snapshot a live-progress client fetches before subscribing.""" return ScanJobDetail(**sample_job().model_dump(), tasks=sample_tasks()) diff --git a/src/nc3_testing_platform/domains/scans/router.py b/src/nc3_testing_platform/domains/scans/router.py index 940ffe3..021fb9c 100644 --- a/src/nc3_testing_platform/domains/scans/router.py +++ b/src/nc3_testing_platform/domains/scans/router.py @@ -275,7 +275,7 @@ async def cancel_scan(scan_id: ResourceId) -> ScanJob: safe interruption points; a canceled task cannot later produce an accepted successful result. """ - return examples.sample_job() + return examples.canceled_job() @router.post( From 292d697c3322ea63322108f7914502d0726f3193 Mon Sep 17 00:00:00 2001 From: t0kubetsu Date: Mon, 10 Aug 2026 15:28:42 +0200 Subject: [PATCH 3/3] fix(review): assert the challenge window is open at the recorded recheck VERIFICATION_TOKEN_TTL is env-configurable; a short TTL would silently recreate the pending-with-unanswerable-challenge incoherence this branch fixes. The invariant now fails at import instead (CodeRabbit round 1). --- src/nc3_testing_platform/domains/assets/examples.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/nc3_testing_platform/domains/assets/examples.py b/src/nc3_testing_platform/domains/assets/examples.py index a66d1e4..d6c2c16 100644 --- a/src/nc3_testing_platform/domains/assets/examples.py +++ b/src/nc3_testing_platform/domains/assets/examples.py @@ -34,7 +34,11 @@ # The challenge is younger than the proof: its answer window must still be open at # the _T1 recheck, or a `pending` sample would carry an unanswerable challenge. +# VERIFICATION_TOKEN_TTL is env-configurable, so the invariant is asserted rather +# than assumed — a TTL short enough to close the window fails at import. _CHALLENGE_REQUESTED_AT = datetime(2026, 7, 28, 8, 30, tzinfo=UTC) +assert _CHALLENGE_REQUESTED_AT + VERIFICATION_TOKEN_TTL > _T1 + _FEED_REVOKED_AT = datetime(2026, 7, 31, 10, 0, tzinfo=UTC) _SUBDOMAIN_ASSET_ID = UUID("019ee1a3-0011-7a22-8b33-4c44d5e66f77")