From 2e8f6d7ac1d4397392877143a4775195b4b6f791 Mon Sep 17 00:00:00 2001 From: Pulkit Chauhan Date: Thu, 4 Jun 2026 13:29:55 +0530 Subject: [PATCH 01/28] Add OpenAPI v1.2 spec for CI System REST API --- openapi-ci-api.yaml | 2627 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 2627 insertions(+) create mode 100644 openapi-ci-api.yaml diff --git a/openapi-ci-api.yaml b/openapi-ci-api.yaml new file mode 100644 index 00000000..c0c6eded --- /dev/null +++ b/openapi-ci-api.yaml @@ -0,0 +1,2627 @@ +openapi: 3.0.3 +info: + title: CCExtractor CI System API + version: 1.2.0 + description: > + Security-hardened JSON-only REST API for the CCExtractor CI/sample platform. + Designed for AI agents and CI automation. Enforces scoped Bearer token auth, + strict input validation, rate limiting on all routes, and safe defaults + throughout. No browser sessions, no HTML, no implicit permissions. + + contact: + name: CCExtractor Development + url: https://github.com/CCExtractor/sample-platform + +servers: + - url: https://sampleplatform.ccextractor.org/api/v1 + description: Production + +# +# Global security: all endpoints require auth +# unless explicitly overridden with security: [] +# +security: + - bearerAuth: [runs:read] + +tags: + - name: Auth + description: Token issuance and revocation + - name: Runs + description: CI run lifecycle — list, inspect, trigger, cancel, retry + - name: Samples + description: Media samples and regression test definitions + - name: Results + description: Per-sample output, diffs, and baseline management + - name: Errors and Logs + description: Structured errors and raw log access + - name: System + description: Health, queue, branches, environments, and artifacts + +# +# SECURITY NOTES (implementers must read) +# +# 1. AUTH MODEL +# - All tokens are opaque, server-side. Never expose session cookies via API. +# - The CI worker token (/ci/progress-reporter) is a separate secret and is +# NOT valid for user-facing API endpoints. +# - Token creation is rate-limited to 5 req/15 min per IP to prevent +# credential stuffing. +# +# 2. SCOPE ENFORCEMENT +# - Scope checks happen at the middleware layer before route handlers. +# - x-required-scope on each operation defines the minimum scope needed. +# - Missing scope → 403 Forbidden (not 401, token is valid but insufficient). +# +# 3. INPUT VALIDATION +# - additionalProperties: false on all request bodies (no mass-assignment). +# - Regex patterns on all free-text IDs (commit_sha, sha256, repository). +# - maxLength on every string field. maxItems on every array. +# - Integer IDs have minimum: 1 (no zero or negative IDs). +# +# 4. OUTPUT SAFETY +# - got=null in TestResultFile means match, not missing output. +# The dummy row (-1,-1,-1,'','error') is translated server-side to +# status=missing_output and never surfaced as a real object. +# - test.failed reflects cancellation only; fail_count is computed from +# TestResult rows. Do not expose test.failed directly. +# - Stack traces in infrastructure errors are opt-in (include_stack=false +# by default) to avoid leaking internal paths. +# +# 5. STORAGE +# - Artifacts may exist in local SAMPLE_REPOSITORY, GCS, or both. +# - storage_status=degraded means one backend only; missing means neither. +# - Never return a download_url that has not been verified to exist. +# - Log endpoints return 404 (not a broken download link) when the log +# file is absent from both storage backends. +# +# 6. RATE LIMITING (all routes) +# - Default: 120 req/min per token (reads), 20 req/min per token (writes). +# - Auth endpoint: 5 req/15 min per IP. +# - Every response includes X-RateLimit-Limit, X-RateLimit-Remaining, +# X-RateLimit-Reset headers. +# - 429 response includes Retry-After header (seconds). +# +# 7. IDEMPOTENCY +# - POST /runs/{run_id}/retry creates a NEW run and preserves the original. +# It does NOT call restart_test, which destructively deletes results. +# - POST /runs/{run_id}/cancel is idempotent; canceling an already-canceled +# run returns 202 with status=accepted and a no-op message. +# +# 8. DIFF ACCESS +# - The diff route is header-gated on the legacy system (not role-gated). +# The API wraps the XHR path and returns structured JSON. No HTML. +# +# 9. STATUS DERIVATION +# - Run status is derived, not stored. TestStatus has only: preparation, +# testing, completed, canceled (canceled covers both canceled and error). +# The API normalizes this to the 7-value enum below. +# - RunSample.status is computed from TestResult + TestResultFile + +# expected exit code + multiple acceptable baselines. + +paths: + + # AUTH + + /auth/tokens: + post: + tags: [Auth] + summary: Create an API token + description: > + Rate-limited to 5 requests per 15 minutes per IP. Tokens are opaque + and stored server-side. Scopes are additive; request only what you need. + Tokens expire after expires_in_days (default 30, max 90). + security: [] + x-rate-limit: "5/15min per IP" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/TokenCreateRequest" + responses: + "201": + description: Token created. Store the token value; it will not be shown again. + content: + application/json: + schema: + $ref: "#/components/schemas/AuthToken" + "400": + $ref: "#/components/responses/BadRequest" + "401": + description: Invalid credentials + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + example: + code: invalid_credentials + message: Email or password is incorrect. + details: {} + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /auth/tokens/current: + delete: + tags: [Auth] + summary: Revoke the current API token + description: > + Immediately invalidates the token used in the Authorization header. + Subsequent requests with the same token will receive 401. + security: + - bearerAuth: [] + responses: + "204": + description: Token revoked + "401": + $ref: "#/components/responses/Unauthorized" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + # RUNS + + /runs: + get: + tags: [Runs] + summary: List CI runs + description: > + Public read. The underlying table is capped at the 50 most recent runs + in the current implementation; this endpoint adds full pagination. + Sorted by -created_at by default (newest first). + security: + - bearerAuth: [] + x-required-scope: runs:read + parameters: + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + - $ref: "#/components/parameters/RunStatus" + - $ref: "#/components/parameters/Branch" + - $ref: "#/components/parameters/CommitSha" + - $ref: "#/components/parameters/Repository" + - $ref: "#/components/parameters/Platform" + - $ref: "#/components/parameters/CreatedAfter" + - $ref: "#/components/parameters/CreatedBefore" + - name: sort + in: query + schema: + type: string + default: -created_at + enum: [created_at, -created_at, started_at, -started_at, run_id, -run_id] + description: Sort field. Prefix with - for descending order. + responses: + "200": + description: Paginated runs + headers: + X-RateLimit-Limit: + $ref: "#/components/headers/RateLimitLimit" + X-RateLimit-Remaining: + $ref: "#/components/headers/RateLimitRemaining" + X-RateLimit-Reset: + $ref: "#/components/headers/RateLimitReset" + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Page" + - type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/Run" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + post: + tags: [Runs] + summary: Trigger a new CI run + description: > + Requires runs:write scope and contributor role or above. + The regression_test_ids set is validated against active tests only. + If omitted, all active regression tests are used. + security: + - bearerAuth: [] + x-required-scope: runs:write + x-required-roles: [admin, tester, contributor] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RunCreateRequest" + responses: + "202": + description: Run queued. Poll /runs/{run_id}/progress for status. + content: + application/json: + schema: + $ref: "#/components/schemas/Run" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "422": + $ref: "#/components/responses/UnprocessableEntity" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /runs/{run_id}: + get: + tags: [Runs] + summary: Get a CI run + description: > + Returns normalized run status derived from TestProgress rows. + status=canceled covers both explicit cancellation and infrastructure + errors (the underlying model does not distinguish them). + security: + - bearerAuth: [] + x-required-scope: runs:read + parameters: + - $ref: "#/components/parameters/RunId" + responses: + "200": + description: Run details + content: + application/json: + schema: + $ref: "#/components/schemas/Run" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /runs/{run_id}/summary: + get: + tags: [Runs] + summary: Get pass/fail summary for a run + description: > + fail_count is computed from TestResult rows, not from test.failed. + test.failed only reflects whether the final progress status is + canceled — it does not reflect regression test outcomes. + Use this endpoint, not test.failed, to triage a run. + security: + - bearerAuth: [] + x-required-scope: runs:read + parameters: + - $ref: "#/components/parameters/RunId" + responses: + "200": + description: Run summary + content: + application/json: + schema: + $ref: "#/components/schemas/RunSummary" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /runs/{run_id}/progress: + get: + tags: [Runs] + summary: Get progress events for a run + description: > + Progress events are sourced from TestProgress rows written by the CI + worker via /ci/progress-reporter. Messages are unstructured text. + Structured error types are aspirational until the worker protocol + emits structured JSON. + security: + - bearerAuth: [] + x-required-scope: runs:read + parameters: + - $ref: "#/components/parameters/RunId" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + - name: status + in: query + schema: + type: string + enum: [queued, preparation, testing, completed, canceled, error] + responses: + "200": + description: Paginated progress events + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Page" + - type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/ProgressEvent" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /runs/{run_id}/cancel: + post: + tags: [Runs] + summary: Cancel a queued or running CI run + description: > + Idempotent. Canceling an already-canceled or completed run returns + 202 with a no-op message rather than an error. + Requires runs:write scope. + security: + - bearerAuth: [] + x-required-scope: runs:write + x-required-roles: [admin, tester, contributor] + parameters: + - $ref: "#/components/parameters/RunId" + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + reason: + type: string + maxLength: 255 + additionalProperties: false + responses: + "202": + description: Cancellation accepted (or no-op if already terminal) + content: + application/json: + schema: + $ref: "#/components/schemas/RunActionResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /runs/{run_id}/retry: + post: + tags: [Runs] + summary: Create a new run copied from an existing run + description: > + Creates a NEW run record with the same configuration as the source run. + The original run and all its results are preserved. + WARNING: Do NOT use the legacy restart_test route internally — it + destructively deletes TestResult and TestProgress rows for the + existing run_id. This endpoint always creates a new run_id. + new_run_id in the response is the ID of the newly created run. + security: + - bearerAuth: [] + x-required-scope: runs:write + x-required-roles: [admin, tester, contributor] + parameters: + - $ref: "#/components/parameters/RunId" + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + failed_only: + type: boolean + default: false + description: > + If true, only re-run regression tests that failed in the + source run. If false (default), re-run the full test set. + reason: + type: string + maxLength: 255 + additionalProperties: false + responses: + "202": + description: Retry run queued. new_run_id is the ID of the new run. + content: + application/json: + schema: + $ref: "#/components/schemas/RunActionResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "422": + $ref: "#/components/responses/UnprocessableEntity" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /runs/{run_id}/config: + get: + tags: [Runs] + summary: Get run configuration and test matrix + description: > + regression_test_ids lists IDs included in this run. When no custom + set was configured, all regression tests are returned. + Implementers must filter by active=true explicitly — + get_customized_regressiontests() does not do this by default. + security: + - bearerAuth: [] + x-required-scope: runs:read + parameters: + - $ref: "#/components/parameters/RunId" + responses: + "200": + description: Run configuration + content: + application/json: + schema: + $ref: "#/components/schemas/RunConfig" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + # SAMPLES + + /runs/{run_id}/samples: + get: + tags: [Samples] + summary: List regression test results in a run + description: > + Returns one entry per regression test result, not one per unique media + file. A single media sample may yield multiple entries if it has + multiple regression tests (different command flags). + sample_progress in the legacy JSON endpoint is len(test.results) over + total regression tests; it does not reflect multi-output completeness. + security: + - bearerAuth: [] + x-required-scope: runs:read + parameters: + - $ref: "#/components/parameters/RunId" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + - name: status + in: query + schema: + type: string + enum: [pass, fail, skipped, missing_output, running, not_started] + - name: name + in: query + schema: + type: string + maxLength: 100 + - name: tag + in: query + schema: + type: string + maxLength: 50 + - name: category + in: query + schema: + type: string + maxLength: 50 + responses: + "200": + description: Paginated regression test results + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Page" + - type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/RunSample" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /runs/{run_id}/samples/{sample_id}: + get: + tags: [Samples] + summary: Get full details for a regression test result in a run + security: + - bearerAuth: [] + x-required-scope: runs:read + parameters: + - $ref: "#/components/parameters/RunId" + - $ref: "#/components/parameters/SampleId" + responses: + "200": + description: Regression test result details + content: + application/json: + schema: + $ref: "#/components/schemas/RunSample" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /samples: + get: + tags: [Samples] + summary: List all known media samples + security: + - bearerAuth: [] + x-required-scope: runs:read + parameters: + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + - name: status + in: query + description: > + Derived from linked regression tests. The sample table itself has + no quarantine state; active/inactive reflects whether any active + regression tests reference the sample. + schema: + type: string + enum: [active, inactive] + - name: name + in: query + schema: + type: string + maxLength: 100 + - name: tag + in: query + schema: + type: string + maxLength: 50 + - name: sha256 + in: query + schema: + type: string + pattern: '^[a-fA-F0-9]{64}$' + - name: extension + in: query + schema: + type: string + maxLength: 10 + pattern: '^[a-zA-Z0-9]+$' + responses: + "200": + description: Paginated media samples + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Page" + - type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/Sample" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /samples/{sample_id}: + get: + tags: [Samples] + summary: Get media sample metadata + security: + - bearerAuth: [] + x-required-scope: runs:read + parameters: + - $ref: "#/components/parameters/SampleId" + responses: + "200": + description: Media sample metadata + content: + application/json: + schema: + $ref: "#/components/schemas/Sample" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /samples/{sample_id}/history: + get: + tags: [Samples] + summary: Get regression test result history for a sample across runs + description: > + Use failure_signature for flake detection: a stable signature across + multiple runs on different commits indicates a genuine regression, + not infrastructure noise. + security: + - bearerAuth: [] + x-required-scope: runs:read + parameters: + - $ref: "#/components/parameters/SampleId" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + - $ref: "#/components/parameters/RunStatus" + - $ref: "#/components/parameters/Branch" + - $ref: "#/components/parameters/Platform" + - $ref: "#/components/parameters/CreatedAfter" + - $ref: "#/components/parameters/CreatedBefore" + responses: + "200": + description: Paginated sample history + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Page" + - type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/SampleHistoryEntry" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /regression-tests: + get: + tags: [Samples] + summary: List regression test definitions + description: > + The active filter must be applied explicitly. The legacy + get_customized_regressiontests() returns all regression tests — + including inactive ones — when no custom set is defined. + security: + - bearerAuth: [] + x-required-scope: runs:read + parameters: + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + - name: active + in: query + schema: + type: boolean + - name: category + in: query + schema: + type: string + maxLength: 50 + - name: tag + in: query + schema: + type: string + maxLength: 50 + - name: sample_id + in: query + schema: + type: integer + minimum: 1 + responses: + "200": + description: Paginated regression test definitions + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Page" + - type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/RegressionTest" + "401": + $ref: "#/components/responses/Unauthorized" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + # RESULTS + + /runs/{run_id}/samples/{sample_id}/expected: + get: + tags: [Results] + summary: Get expected output for a regression test result + description: > + Expected output is a file reference stored under TestResults using the + regression output extension. Resolved from GCS or local + SAMPLE_REPOSITORY at request time. storage_status reflects which + backends have the file. Do not assume local and GCS are always in sync. + security: + - bearerAuth: [] + x-required-scope: results:read + parameters: + - $ref: "#/components/parameters/RunId" + - $ref: "#/components/parameters/SampleId" + - $ref: "#/components/parameters/RegressionId" + - $ref: "#/components/parameters/OutputId" + - $ref: "#/components/parameters/Format" + responses: + "200": + description: Expected output file + content: + application/json: + schema: + $ref: "#/components/schemas/OutputFile" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /runs/{run_id}/samples/{sample_id}/actual: + get: + tags: [Results] + summary: Get actual output generated by a regression test in a run + description: > + IMPORTANT: TestResultFile.got = null means the actual output MATCHED + expected, not that actual output is missing. This is a semantic trap + in the data model. Missing output is represented by a dummy row + (-1,-1,-1,'','error') which the API translates to status=missing_output + and returns 404. A 200 response always contains a real output file. + security: + - bearerAuth: [] + x-required-scope: results:read + parameters: + - $ref: "#/components/parameters/RunId" + - $ref: "#/components/parameters/SampleId" + - $ref: "#/components/parameters/RegressionId" + - $ref: "#/components/parameters/OutputId" + - $ref: "#/components/parameters/Format" + responses: + "200": + description: Actual output file (output exists and differs from expected) + content: + application/json: + schema: + $ref: "#/components/schemas/OutputFile" + "204": + description: > + No actual file stored. got=null in the DB means output matched + expected. Use /expected to retrieve the matched content. + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /runs/{run_id}/samples/{sample_id}/diff: + get: + tags: [Results] + summary: Get expected-vs-actual diff for a failing regression test result + description: > + The legacy diff route is header-gated (X-Requested-With: XMLHttpRequest), + not role-gated. The 403 seen on direct browser requests was a + header-check artifact. This endpoint wraps the XHR logic and returns + structured JSON — no HTML, no 50-line truncation. + security: + - bearerAuth: [] + x-required-scope: results:read + parameters: + - $ref: "#/components/parameters/RunId" + - $ref: "#/components/parameters/SampleId" + - $ref: "#/components/parameters/RegressionId" + - $ref: "#/components/parameters/OutputId" + - name: context_lines + in: query + schema: + type: integer + minimum: 0 + maximum: 50 + default: 3 + - name: format + in: query + schema: + type: string + enum: [structured, unified] + default: structured + responses: + "200": + description: Structured or unified diff + content: + application/json: + schema: + $ref: "#/components/schemas/Diff" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /runs/{run_id}/samples/{sample_id}/baseline-approval: + post: + tags: [Results] + summary: Approve actual output as the new expected baseline + description: > + Requires baselines:write scope and admin or contributor role. + This is a destructive write — the approved output becomes the new + expected baseline for the regression test. Provide a reason; + it is stored in the audit log. + security: + - bearerAuth: [] + x-required-scope: baselines:write + x-required-roles: [admin, contributor] + parameters: + - $ref: "#/components/parameters/RunId" + - $ref: "#/components/parameters/SampleId" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/BaselineApprovalRequest" + responses: + "202": + description: Baseline approval recorded. Status begins as pending_review. + content: + application/json: + schema: + $ref: "#/components/schemas/BaselineApproval" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + # ERRORS AND LOGS + + /runs/{run_id}/errors: + get: + tags: [Errors and Logs] + summary: Get structured test errors for a run + description: > + Error types are derived from TestResult and TestResultFile rows. + missing_output is detected from the dummy (-1,-1,-1,'','error') row + pattern, not from got=null (which means match, not missing). + security: + - bearerAuth: [] + x-required-scope: results:read + parameters: + - $ref: "#/components/parameters/RunId" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + - name: type + in: query + schema: + type: string + enum: [test_failure, exit_code_mismatch, missing_output, diff_mismatch] + - name: severity + in: query + schema: + type: string + enum: [info, warning, error, critical] + - name: sample_id + in: query + schema: + type: integer + minimum: 1 + responses: + "200": + description: Paginated test errors + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Page" + - type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/ErrorItem" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /runs/{run_id}/infrastructure-errors: + get: + tags: [Errors and Logs] + summary: Get worker, provisioning, and build errors for a run + description: > + Errors are extracted from TestProgress rows written by the CI worker. + Messages are currently unstructured text. The type filter does + best-effort text matching until the worker protocol emits structured + error types. + Stack traces are opt-in (include_stack defaults to false) to avoid + leaking internal paths to unauthorized callers. + security: + - bearerAuth: [] + x-required-scope: system:read + parameters: + - $ref: "#/components/parameters/RunId" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + - name: type + in: query + schema: + type: string + enum: [queue, vm_provisioning, checkout, merge, build, worker, web_server, storage] + - name: severity + in: query + schema: + type: string + enum: [info, warning, error, critical] + - name: include_stack + in: query + schema: + type: boolean + default: false + description: > + Default false. Set true only when debugging infrastructure failures. + Stacks may contain internal paths; access requires system:read scope. + responses: + "200": + description: Paginated infrastructure errors + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Page" + - type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/ErrorItem" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /runs/{run_id}/logs: + get: + tags: [Errors and Logs] + summary: Get raw logs for a run + description: > + Logs are stored at SAMPLE_REPOSITORY/LogFiles/{id}.txt and served + via GCS signed URL. Returns 404 — not a broken download link — when + the file is absent from both local and GCS storage. + Uses cursor-based pagination; do not mix cursor and offset. + security: + - bearerAuth: [] + x-required-scope: system:read + parameters: + - $ref: "#/components/parameters/RunId" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + - name: level + in: query + schema: + type: string + enum: [debug, info, warning, error, critical] + - name: source + in: query + schema: + type: string + enum: [orchestrator, worker, build, test_runner, web] + - name: contains + in: query + schema: + type: string + maxLength: 100 + responses: + "200": + description: Cursor-paginated run log lines + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CursorPage" + - type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/LogLine" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + description: Log file not found in local or GCS storage + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + example: + code: log_not_found + message: Log file for run 9309 does not exist in any storage backend. + details: + run_id: 9309 + checked: [local, gcs] + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /runs/{run_id}/samples/{sample_id}/logs: + get: + tags: [Errors and Logs] + summary: Get raw logs for a regression test result in a run + security: + - bearerAuth: [] + x-required-scope: system:read + parameters: + - $ref: "#/components/parameters/RunId" + - $ref: "#/components/parameters/SampleId" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + - name: level + in: query + schema: + type: string + enum: [debug, info, warning, error, critical] + - name: contains + in: query + schema: + type: string + maxLength: 100 + responses: + "200": + description: Cursor-paginated sample log lines + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CursorPage" + - type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/LogLine" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /runs/{run_id}/error-summary: + get: + tags: [Errors and Logs] + summary: Get grouped error summary for a run + description: > + Use this endpoint to triage a run before drilling into individual + errors. group_by=type gives a high-level failure breakdown; + group_by=sample_id helps identify flaky samples. + security: + - bearerAuth: [] + x-required-scope: results:read + parameters: + - $ref: "#/components/parameters/RunId" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + - name: group_by + in: query + schema: + type: string + enum: [type, sample_id, regression_id, category, severity] + default: type + - name: severity + in: query + schema: + type: string + enum: [info, warning, error, critical] + responses: + "200": + description: Paginated grouped error summary + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Page" + - type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/ErrorSummaryBucket" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + # SYSTEM + + /system/health: + get: + tags: [System] + summary: Get CI system health and dependency status + description: > + Unauthenticated. Returns overall system status and per-dependency + health. Used by monitoring and uptime checks. + security: [] + responses: + "200": + description: System healthy or degraded + content: + application/json: + schema: + $ref: "#/components/schemas/SystemHealth" + "503": + description: System is down + content: + application/json: + schema: + $ref: "#/components/schemas/SystemHealth" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /system/queue: + get: + tags: [System] + summary: Get queue depth and currently running jobs + security: + - bearerAuth: [] + x-required-scope: system:read + parameters: + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + - name: platform + in: query + schema: + type: string + enum: [linux, windows] + - name: status + in: query + schema: + type: string + enum: [queued, running] + responses: + "200": + description: Queue status and active jobs + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Page" + - type: object + properties: + queue_depth: + type: integer + minimum: 0 + running_count: + type: integer + minimum: 0 + data: + type: array + items: + $ref: "#/components/schemas/QueueJob" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /branches: + get: + tags: [System] + summary: List available branches + security: + - bearerAuth: [] + x-required-scope: runs:read + parameters: + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + - $ref: "#/components/parameters/Repository" + - name: name + in: query + schema: + type: string + maxLength: 100 + - name: active + in: query + schema: + type: boolean + responses: + "200": + description: Paginated branches + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Page" + - type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/Branch" + "401": + $ref: "#/components/responses/Unauthorized" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /environments: + get: + tags: [System] + summary: List available CI environments and platforms + security: + - bearerAuth: [] + x-required-scope: runs:read + parameters: + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + - name: platform + in: query + schema: + type: string + enum: [linux, windows] + - name: active + in: query + schema: + type: boolean + responses: + "200": + description: Paginated CI environments + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Page" + - type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/Environment" + "401": + $ref: "#/components/responses/Unauthorized" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + + /runs/{run_id}/artifacts: + get: + tags: [System] + summary: List downloadable artifacts for a run + description: > + Only returns artifacts with a verified download_url from at least one + storage backend. storage_status=degraded means one backend only; + storage_status=missing means neither backend has the file (download_url + will be null). Never returns a URL that has not been verified to exist. + security: + - bearerAuth: [] + x-required-scope: results:read + parameters: + - $ref: "#/components/parameters/RunId" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + - name: type + in: query + schema: + type: string + enum: [build_log, sample_output, expected_output, diff, media_info, binary] + responses: + "200": + description: Paginated run artifacts + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Page" + - type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/Artifact" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + +# +# COMPONENTS +# +components: + + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: opaque + description: > + Opaque server-side API token. Obtain via POST /auth/tokens. + The CI worker token used by /ci/progress-reporter is a separate + secret and is NOT valid here. Never use browser session cookies + for API clients. + + # HEADERS + + headers: + RateLimitLimit: + description: Maximum requests allowed in the current window + schema: + type: integer + example: 120 + RateLimitRemaining: + description: Requests remaining in the current window + schema: + type: integer + example: 117 + RateLimitReset: + description: Unix timestamp when the rate limit window resets + schema: + type: integer + example: 1748908800 + + # PARAMETERS + + parameters: + Limit: + name: limit + in: query + description: Maximum number of results to return (1–100) + schema: + type: integer + minimum: 1 + maximum: 100 + default: 50 + + Offset: + name: offset + in: query + description: Number of results to skip for pagination + schema: + type: integer + minimum: 0 + default: 0 + + Cursor: + name: cursor + in: query + description: > + Opaque cursor token for cursor-based pagination. Do not mix with offset. + Obtain next_cursor from the previous response's pagination object. + schema: + type: string + maxLength: 255 + + RunId: + name: run_id + in: path + required: true + description: Numeric run ID + schema: + type: integer + minimum: 1 + + SampleId: + name: sample_id + in: path + required: true + description: Numeric sample or regression result ID + schema: + type: integer + minimum: 1 + + RunStatus: + name: status + in: query + description: > + Normalized run status. Derived from TestProgress rows and TestResult + outcomes. The underlying TestStatus model stores only preparation, + testing, completed, and canceled (where canceled covers both canceled + and error). This enum is the normalized API contract. + schema: + type: string + enum: [queued, running, pass, fail, canceled, error, incomplete] + + Branch: + name: branch + in: query + schema: + type: string + maxLength: 100 + + CommitSha: + name: commit_sha + in: query + description: Full 40-character SHA-1 commit hash + schema: + type: string + pattern: '^[a-fA-F0-9]{40}$' + + Repository: + name: repository + in: query + description: GitHub repository in owner/repo format + schema: + type: string + pattern: '^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$' + maxLength: 100 + + Platform: + name: platform + in: query + schema: + type: string + enum: [linux, windows] + + CreatedAfter: + name: created_after + in: query + description: ISO 8601 datetime. Returns runs created after this time. + schema: + type: string + format: date-time + + CreatedBefore: + name: created_before + in: query + description: ISO 8601 datetime. Returns runs created before this time. + schema: + type: string + format: date-time + + RegressionId: + name: regression_id + in: query + required: true + description: Regression test definition ID + schema: + type: integer + minimum: 1 + + OutputId: + name: output_id + in: query + required: true + description: Output file ID within a regression test definition + schema: + type: integer + minimum: 1 + + Format: + name: format + in: query + description: > + Content encoding for file responses. + Use text only when the file is known to be UTF-8 compatible. + Binary or unknown content defaults to base64. + schema: + type: string + enum: [text, base64] + default: base64 + + # RESPONSES + + responses: + BadRequest: + description: Request body or query parameters failed schema validation + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + example: + code: validation_error + message: Request failed schema validation. + details: + fields: + commit_sha: Must match pattern ^[a-fA-F0-9]{40}$ + platform: Must be one of [linux, windows] + + Unauthorized: + description: Missing, expired, or invalid bearer token + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + example: + code: unauthorized + message: Bearer token is missing, expired, or invalid. + details: {} + + Forbidden: + description: Token is valid but lacks the required scope or role + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + example: + code: forbidden + message: Token does not have the required scope for this operation. + details: + required_scope: runs:write + token_scopes: [runs:read, results:read] + + NotFound: + description: Resource not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + example: + code: not_found + message: Run 9317 not found. + details: + resource: run + id: 9317 + + UnprocessableEntity: + description: Request is valid JSON but semantically invalid + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + example: + code: unprocessable + message: regression_test_ids contains inactive test IDs. + details: + inactive_ids: [42, 99] + + RateLimited: + description: Too many requests. Retry after the indicated number of seconds. + headers: + Retry-After: + description: Seconds to wait before retrying + schema: + type: integer + example: 30 + X-RateLimit-Limit: + $ref: "#/components/headers/RateLimitLimit" + X-RateLimit-Remaining: + $ref: "#/components/headers/RateLimitRemaining" + X-RateLimit-Reset: + $ref: "#/components/headers/RateLimitReset" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + example: + code: rate_limited + message: Rate limit exceeded. Retry after 30 seconds. + details: + retry_after: 30 + limit: 120 + window: 60s + + Error: + description: Unexpected server error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + # SCHEMAS + + schemas: + + Page: + type: object + required: [data, pagination] + properties: + data: + type: array + items: {} + pagination: + type: object + required: [limit, offset, total] + properties: + limit: + type: integer + minimum: 1 + offset: + type: integer + minimum: 0 + total: + type: integer + minimum: 0 + next_offset: + type: integer + minimum: 0 + nullable: true + + CursorPage: + type: object + required: [data, pagination] + properties: + data: + type: array + items: {} + pagination: + type: object + required: [limit, next_cursor] + properties: + limit: + type: integer + minimum: 1 + next_cursor: + type: string + maxLength: 255 + nullable: true + description: > + Opaque cursor for the next page. Null when there are no + more results. + + ErrorResponse: + type: object + required: [code, message, details] + properties: + code: + type: string + maxLength: 100 + description: Machine-readable error code (snake_case) + example: not_found + message: + type: string + maxLength: 500 + description: Human-readable error summary + example: Run 9317 not found. + details: + type: object + additionalProperties: true + description: > + Structured context for the error. Always an object, never null. + Empty object {} when no additional detail is available. + + TokenCreateRequest: + type: object + required: [email, password, token_name] + additionalProperties: false + properties: + email: + type: string + format: email + maxLength: 255 + password: + type: string + format: password + minLength: 8 + maxLength: 128 + description: Not stored or logged. Used only to verify identity. + token_name: + type: string + maxLength: 50 + pattern: '^[a-zA-Z0-9_-]+$' + description: > + Descriptive label for the token (e.g., local-agent, ci-bot). + Must be unique per user. + expires_in_days: + type: integer + minimum: 1 + maximum: 90 + default: 30 + scopes: + type: array + maxItems: 8 + uniqueItems: true + default: [runs:read, results:read] + items: + type: string + enum: [runs:read, runs:write, results:read, baselines:write, system:read] + description: > + Requested scopes. Grant only what the client needs. + runs:read — list and inspect runs, samples, history. + runs:write — trigger, cancel, retry runs. + results:read — access expected/actual output, diffs, errors, logs. + baselines:write — approve new expected baselines. + system:read — queue, infrastructure errors, stack traces, artifacts. + + AuthToken: + type: object + required: [token, token_type, token_name, scopes, expires_at] + properties: + token: + type: string + maxLength: 512 + description: > + Opaque token value. Store it securely. It will not be shown again. + token_type: + type: string + enum: [Bearer] + token_name: + type: string + maxLength: 50 + scopes: + type: array + maxItems: 8 + uniqueItems: true + items: + type: string + enum: [runs:read, runs:write, results:read, baselines:write, system:read] + expires_at: + type: string + format: date-time + + RunCreateRequest: + type: object + required: [repository, commit_sha, platform] + additionalProperties: false + properties: + repository: + type: string + pattern: '^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$' + maxLength: 100 + example: CCExtractor/ccextractor + branch: + type: string + pattern: '^[A-Za-z0-9._\/-]+$' + maxLength: 100 + example: master + commit_sha: + type: string + pattern: '^[a-fA-F0-9]{40}$' + example: 0632bff4e382d5f86eff9073b9ddd37f03f9778c + pull_request: + type: integer + minimum: 1 + nullable: true + example: 2264 + platform: + type: string + enum: [linux, windows] + example: windows + regression_test_ids: + type: array + maxItems: 500 + uniqueItems: true + items: + type: integer + minimum: 1 + description: > + Optional subset of active regression test IDs. + If omitted, all active tests are used. + Inactive test IDs are rejected with 422. + environment_id: + type: string + maxLength: 50 + example: windows-latest + + Run: + type: object + required: [run_id, status, repository, commit_sha, platform, created_at] + properties: + run_id: + type: integer + minimum: 1 + status: + type: string + enum: [queued, running, pass, fail, canceled, error, incomplete] + description: > + Normalized status. Derived from TestProgress rows and TestResult + outcomes. status=canceled covers both explicit cancellation and + infrastructure error (the underlying model conflates them). + repository: + type: string + maxLength: 100 + branch: + type: string + maxLength: 100 + nullable: true + commit_sha: + type: string + pattern: '^[a-fA-F0-9]{40}$' + commit_short: + type: string + maxLength: 10 + pull_request: + type: integer + minimum: 1 + nullable: true + platform: + type: string + enum: [linux, windows] + run_errors: + type: string + enum: [yes, no, unknown] + triggered_by: + type: string + maxLength: 100 + nullable: true + created_at: + type: string + format: date-time + queued_at: + type: string + format: date-time + nullable: true + started_at: + type: string + format: date-time + nullable: true + completed_at: + type: string + format: date-time + nullable: true + duration_ms: + type: integer + minimum: 0 + nullable: true + links: + type: object + additionalProperties: + type: string + format: uri + + RunSummary: + type: object + required: [run_id, total_samples, pass_count, fail_count] + properties: + run_id: + type: integer + minimum: 1 + total_samples: + type: integer + minimum: 0 + description: Total regression test results in this run. + pass_count: + type: integer + minimum: 0 + fail_count: + type: integer + minimum: 0 + description: > + Computed from TestResult rows. NOT derived from test.failed, + which only reflects cancellation state and is unreliable for + determining whether regression tests actually passed. + skipped_count: + type: integer + minimum: 0 + missing_output_count: + type: integer + minimum: 0 + description: > + Samples that produced no output when output was expected. + Detected from the dummy TestResultFile(-1,-1,-1,'','error') row, + not from got=null (which means output matched). + error_count: + type: integer + minimum: 0 + duration_ms: + type: integer + minimum: 0 + nullable: true + triggered_by: + type: string + maxLength: 100 + nullable: true + + ProgressEvent: + type: object + required: [timestamp, status, message] + properties: + timestamp: + type: string + format: date-time + status: + type: string + enum: [queued, preparation, testing, completed, canceled, error] + message: + type: string + maxLength: 500 + description: Unstructured text from TestProgress rows. + step: + type: integer + minimum: 0 + nullable: true + + RunActionResult: + type: object + required: [run_id, action, status] + properties: + run_id: + type: integer + minimum: 1 + description: ID of the source run (for cancel) or original run (for retry). + new_run_id: + type: integer + minimum: 1 + nullable: true + description: > + Set on retry actions only. ID of the newly created run. + The original run is always preserved. + action: + type: string + enum: [cancel, retry] + status: + type: string + enum: [accepted, rejected, no_op] + description: no_op is returned when canceling an already-terminal run. + message: + type: string + maxLength: 500 + + RunConfig: + type: object + required: [run_id] + properties: + run_id: + type: integer + minimum: 1 + environment: + $ref: "#/components/schemas/Environment" + matrix: + type: array + maxItems: 500 + items: + type: object + additionalProperties: true + regression_test_ids: + type: array + maxItems: 500 + uniqueItems: true + items: + type: integer + minimum: 1 + description: > + IDs included in this run. When no custom set was configured, all + regression tests are returned. Implementers must filter by + active=true — get_customized_regressiontests() does not do this. + command_defaults: + type: array + maxItems: 50 + items: + type: string + maxLength: 100 + + Sample: + type: object + required: [sample_id, sha256] + properties: + sample_id: + type: integer + minimum: 1 + sha256: + type: string + pattern: '^[a-fA-F0-9]{64}$' + name: + type: string + maxLength: 255 + extension: + type: string + maxLength: 10 + tags: + type: array + maxItems: 50 + items: + type: string + maxLength: 50 + media_info: + type: object + additionalProperties: true + notes: + type: string + maxLength: 1000 + nullable: true + + RegressionTest: + type: object + required: [regression_id, sample_id, command] + properties: + regression_id: + type: integer + minimum: 1 + sample_id: + type: integer + minimum: 1 + command: + type: string + maxLength: 500 + active: + type: boolean + category: + type: string + maxLength: 100 + tags: + type: array + maxItems: 50 + items: + type: string + maxLength: 50 + expected_outputs: + type: array + maxItems: 20 + description: > + File references stored under TestResults. Content is resolved + from GCS or local SAMPLE_REPOSITORY at request time. + items: + $ref: "#/components/schemas/OutputFile" + + RunSample: + type: object + required: [run_id, sample_id, regression_id, status] + properties: + run_id: + type: integer + minimum: 1 + sample_id: + type: integer + minimum: 1 + regression_id: + type: integer + minimum: 1 + category: + type: string + maxLength: 100 + command: + type: string + maxLength: 500 + status: + type: string + enum: [pass, fail, skipped, missing_output, running, not_started] + description: > + Computed from TestResult, TestResultFile, expected exit code, + and multiple acceptable baselines. Not a stored column. + runtime_ms: + type: integer + minimum: 0 + nullable: true + exit_code: + type: integer + nullable: true + expected_exit_code: + type: integer + nullable: true + result_message: + type: string + maxLength: 500 + nullable: true + tags: + type: array + maxItems: 50 + items: + type: string + maxLength: 50 + outputs: + type: array + maxItems: 20 + description: > + One entry per expected output file. + got=null in the DB means output matched expected; no actual file + is stored. The dummy (-1,-1,-1,'','error') row is translated to + status=missing_output and is never exposed here. + items: + type: object + required: [output_id, status] + properties: + output_id: + type: integer + minimum: 1 + status: + type: string + enum: [match, diff_mismatch, missing_output, missing_expected] + expected_hash: + type: string + pattern: '^[a-fA-F0-9]{64}$' + nullable: true + actual_hash: + type: string + pattern: '^[a-fA-F0-9]{64}$' + nullable: true + + SampleHistoryEntry: + type: object + required: [run_id, sample_id, regression_id, status] + properties: + run_id: + type: integer + minimum: 1 + sample_id: + type: integer + minimum: 1 + regression_id: + type: integer + minimum: 1 + run_created_at: + type: string + format: date-time + commit_sha: + type: string + pattern: '^[a-fA-F0-9]{40}$' + nullable: true + branch: + type: string + maxLength: 100 + nullable: true + platform: + type: string + enum: [linux, windows] + status: + type: string + enum: [pass, fail, skipped, missing_output] + runtime_ms: + type: integer + minimum: 0 + nullable: true + failure_signature: + type: string + maxLength: 255 + nullable: true + description: > + Stable string identifying the failure type and output ID. + Use across runs to detect genuine regressions vs. infrastructure + flakes. + + OutputFile: + type: object + required: [sample_id, regression_id, output_id, filename, content_type, encoding, content, storage_status] + properties: + run_id: + type: integer + minimum: 1 + nullable: true + description: Null for expected output not tied to a specific run. + sample_id: + type: integer + minimum: 1 + regression_id: + type: integer + minimum: 1 + output_id: + type: integer + minimum: 1 + filename: + type: string + maxLength: 255 + content_type: + type: string + maxLength: 100 + encoding: + type: string + enum: [utf-8, base64] + description: > + utf-8 only when file is confirmed text. Default is base64. + content: + type: string + maxLength: 1048576 + sha256: + type: string + pattern: '^[a-fA-F0-9]{64}$' + storage_status: + type: string + enum: [ok, degraded, missing] + description: > + ok = verified in both local and GCS storage. + degraded = exists in one backend only. + missing = not found in either backend. + + Diff: + type: object + required: [run_id, sample_id, regression_id, output_id, status] + properties: + run_id: + type: integer + minimum: 1 + sample_id: + type: integer + minimum: 1 + regression_id: + type: integer + minimum: 1 + output_id: + type: integer + minimum: 1 + status: + type: string + enum: [identical, different, missing_expected, missing_actual] + summary: + type: object + required: [added_lines, removed_lines, changed_hunks] + properties: + added_lines: + type: integer + minimum: 0 + removed_lines: + type: integer + minimum: 0 + changed_hunks: + type: integer + minimum: 0 + hunks: + type: array + maxItems: 500 + items: + type: object + required: [expected_start, actual_start, lines] + properties: + expected_start: + type: integer + minimum: 0 + actual_start: + type: integer + minimum: 0 + lines: + type: array + maxItems: 500 + items: + type: object + required: [kind, text] + properties: + kind: + type: string + enum: [context, added, removed] + expected_line: + type: integer + minimum: 0 + nullable: true + actual_line: + type: integer + minimum: 0 + nullable: true + text: + type: string + maxLength: 1000 + + BaselineApprovalRequest: + type: object + required: [regression_id, output_id, reason] + additionalProperties: false + properties: + regression_id: + type: integer + minimum: 1 + output_id: + type: integer + minimum: 1 + reason: + type: string + minLength: 10 + maxLength: 500 + description: > + Required justification stored in the audit log. Minimum 10 + characters; do not accept placeholder values. + apply_to_variants: + type: boolean + default: false + description: > + If true, apply this baseline to all command variants of the + regression test, not just the specific output_id. + + BaselineApproval: + type: object + required: [approval_id, status, run_id, sample_id, regression_id, output_id, requested_by, created_at] + properties: + approval_id: + type: string + maxLength: 100 + status: + type: string + enum: [pending_review, approved, rejected] + run_id: + type: integer + minimum: 1 + sample_id: + type: integer + minimum: 1 + regression_id: + type: integer + minimum: 1 + output_id: + type: integer + minimum: 1 + requested_by: + type: string + format: email + maxLength: 255 + created_at: + type: string + format: date-time + + ErrorItem: + type: object + required: [error_id, run_id, type, severity, message, occurred_at] + properties: + error_id: + type: string + maxLength: 100 + run_id: + type: integer + minimum: 1 + sample_id: + type: integer + minimum: 1 + nullable: true + regression_id: + type: integer + minimum: 1 + nullable: true + type: + type: string + maxLength: 100 + severity: + type: string + enum: [info, warning, error, critical] + message: + type: string + maxLength: 1000 + location: + type: object + additionalProperties: true + nullable: true + stack: + type: array + maxItems: 50 + description: Only present when include_stack=true was requested. + items: + type: string + maxLength: 2000 + occurred_at: + type: string + format: date-time + + LogLine: + type: object + required: [timestamp, level, source, message, run_id] + properties: + timestamp: + type: string + format: date-time + level: + type: string + enum: [debug, info, warning, error, critical] + source: + type: string + enum: [orchestrator, worker, build, test_runner, web] + message: + type: string + maxLength: 4000 + run_id: + type: integer + minimum: 1 + sample_id: + type: integer + minimum: 1 + nullable: true + + ErrorSummaryBucket: + type: object + required: [key, count, severity] + properties: + key: + type: string + maxLength: 100 + count: + type: integer + minimum: 0 + severity: + type: string + enum: [info, warning, error, critical] + sample_ids: + type: array + maxItems: 1000 + items: + type: integer + minimum: 1 + first_seen_at: + type: string + format: date-time + nullable: true + last_seen_at: + type: string + format: date-time + nullable: true + + SystemHealth: + type: object + required: [status, checked_at, dependencies] + properties: + status: + type: string + enum: [ok, degraded, down] + checked_at: + type: string + format: date-time + dependencies: + type: array + items: + type: object + required: [name, status] + properties: + name: + type: string + maxLength: 100 + status: + type: string + enum: [ok, degraded, down] + message: + type: string + maxLength: 500 + nullable: true + + QueueJob: + type: object + required: [run_id, status, platform, queued_at] + properties: + run_id: + type: integer + minimum: 1 + status: + type: string + enum: [queued, running] + platform: + type: string + enum: [linux, windows] + queued_at: + type: string + format: date-time + started_at: + type: string + format: date-time + nullable: true + position: + type: integer + minimum: 1 + nullable: true + description: Queue position. Null for jobs that are already running. + + Branch: + type: object + required: [repository, name, active] + properties: + repository: + type: string + pattern: '^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$' + maxLength: 100 + name: + type: string + maxLength: 100 + head_sha: + type: string + pattern: '^[a-fA-F0-9]{40}$' + nullable: true + active: + type: boolean + + Environment: + type: object + required: [environment_id, platform, active] + properties: + environment_id: + type: string + maxLength: 100 + platform: + type: string + enum: [linux, windows] + active: + type: boolean + runner_label: + type: string + maxLength: 100 + nullable: true + average_duration_ms: + type: integer + minimum: 0 + nullable: true + + Artifact: + type: object + required: [artifact_id, run_id, type, filename, content_type, storage_status] + properties: + artifact_id: + type: string + maxLength: 100 + run_id: + type: integer + minimum: 1 + sample_id: + type: integer + minimum: 1 + nullable: true + type: + type: string + enum: [build_log, sample_output, expected_output, diff, media_info, binary] + filename: + type: string + maxLength: 255 + content_type: + type: string + maxLength: 100 + size_bytes: + type: integer + minimum: 0 + nullable: true + storage_status: + type: string + enum: [ok, degraded, missing] + description: > + ok = verified in primary storage. + degraded = exists in one backend only (local or GCS). + missing = not found in either backend. + download_url: + type: string + format: uri + nullable: true + description: > + Only present and non-null when storage_status is ok or degraded. + Always a verified URL. Null when storage_status=missing. \ No newline at end of file From a4241088eadc399445aed894c349e9c705a3cc7e Mon Sep 17 00:00:00 2001 From: Pulkit Chauhan Date: Thu, 4 Jun 2026 15:42:56 +0530 Subject: [PATCH 02/28] Added init files --- mod_api/__init__.py | 23 +++++++++++++++++++++++ mod_api/middleware/__init__.py | 1 + mod_api/models/__init__.py | 1 + mod_api/routes/__init__.py | 1 + mod_api/schemas/__init__.py | 1 + mod_api/services/__init__.py | 1 + 6 files changed, 28 insertions(+) create mode 100644 mod_api/__init__.py create mode 100644 mod_api/middleware/__init__.py create mode 100644 mod_api/models/__init__.py create mode 100644 mod_api/routes/__init__.py create mode 100644 mod_api/schemas/__init__.py create mode 100644 mod_api/services/__init__.py diff --git a/mod_api/__init__.py b/mod_api/__init__.py new file mode 100644 index 00000000..c2379317 --- /dev/null +++ b/mod_api/__init__.py @@ -0,0 +1,23 @@ +""" +mod_api — JSON-only REST API for the CCExtractor CI/sample platform. + +Blueprint registered at /api/v1. All endpoints return structured JSON, +use scoped Bearer token auth, and enforce rate limiting. +""" + +from flask import Blueprint + +mod_api = Blueprint('api', __name__) + +# Import middleware (registers before_request, error handlers) +from mod_api.middleware import error_handler # noqa: E402, F401 +from mod_api.middleware import auth # noqa: E402, F401 +from mod_api.middleware import rate_limit # noqa: E402, F401 + +# Import routes (registers endpoint functions) +from mod_api.routes import auth as auth_routes # noqa: E402, F401 +from mod_api.routes import runs # noqa: E402, F401 +from mod_api.routes import samples # noqa: E402, F401 +from mod_api.routes import results # noqa: E402, F401 +from mod_api.routes import errors_logs # noqa: E402, F401 +from mod_api.routes import system # noqa: E402, F401 diff --git a/mod_api/middleware/__init__.py b/mod_api/middleware/__init__.py new file mode 100644 index 00000000..48f4cee5 --- /dev/null +++ b/mod_api/middleware/__init__.py @@ -0,0 +1 @@ +"""mod_api.middleware — Auth, rate limiting, validation, and error handling.""" diff --git a/mod_api/models/__init__.py b/mod_api/models/__init__.py new file mode 100644 index 00000000..abb6ced3 --- /dev/null +++ b/mod_api/models/__init__.py @@ -0,0 +1 @@ +"""mod_api.models — New database models for the API module.""" diff --git a/mod_api/routes/__init__.py b/mod_api/routes/__init__.py new file mode 100644 index 00000000..eac65b96 --- /dev/null +++ b/mod_api/routes/__init__.py @@ -0,0 +1 @@ +"""mod_api.routes — Endpoint handlers for the API.""" diff --git a/mod_api/schemas/__init__.py b/mod_api/schemas/__init__.py new file mode 100644 index 00000000..76c8fca0 --- /dev/null +++ b/mod_api/schemas/__init__.py @@ -0,0 +1 @@ +"""mod_api.schemas — Marshmallow schemas for request/response validation.""" diff --git a/mod_api/services/__init__.py b/mod_api/services/__init__.py new file mode 100644 index 00000000..a1bbdb18 --- /dev/null +++ b/mod_api/services/__init__.py @@ -0,0 +1 @@ +"""mod_api.services — Core business logic for the API.""" From 85ce2d4733e3b0104696e4921a9d9239508268ed Mon Sep 17 00:00:00 2001 From: Pulkit Chauhan Date: Fri, 5 Jun 2026 15:14:36 +0530 Subject: [PATCH 03/28] Added auth, rate limiting, validation, and error handling insinde middleware --- mod_api/middleware/__init__.py | 2 +- mod_api/middleware/auth.py | 147 ++++++++++++++++++++ mod_api/middleware/error_handler.py | 149 ++++++++++++++++++++ mod_api/middleware/rate_limit.py | 116 ++++++++++++++++ mod_api/middleware/validation.py | 204 ++++++++++++++++++++++++++++ mod_api/utils.py | 68 ++++++++++ 6 files changed, 685 insertions(+), 1 deletion(-) create mode 100644 mod_api/middleware/auth.py create mode 100644 mod_api/middleware/error_handler.py create mode 100644 mod_api/middleware/rate_limit.py create mode 100644 mod_api/middleware/validation.py create mode 100644 mod_api/utils.py diff --git a/mod_api/middleware/__init__.py b/mod_api/middleware/__init__.py index 48f4cee5..860b3ce0 100644 --- a/mod_api/middleware/__init__.py +++ b/mod_api/middleware/__init__.py @@ -1 +1 @@ -"""mod_api.middleware — Auth, rate limiting, validation, and error handling.""" +"""mod_api.middleware: auth, rate limiting, validation, and error handling.""" diff --git a/mod_api/middleware/auth.py b/mod_api/middleware/auth.py new file mode 100644 index 00000000..ffb51624 --- /dev/null +++ b/mod_api/middleware/auth.py @@ -0,0 +1,147 @@ +""" +Bearer token authentication and scope/role enforcement for API routes. + +Runs as a before_request hook on the api blueprint. Public endpoints +(token creation, health check) are exempted. On success, the authenticated +user and token are stored in flask.g for downstream handlers. + +HTTP semantics: + 401 = token missing, expired, revoked, or invalid + 403 = valid token but insufficient scope or role +""" + +import functools +from typing import List + +from flask import g, request + +from mod_api import mod_api +from mod_api.middleware.error_handler import make_error_response +from mod_api.models.api_token import ApiToken + +# These endpoints bypass auth entirely. +_PUBLIC_ENDPOINTS = frozenset([ + 'api.create_token', # POST /auth/tokens (uses email/password body) + 'api.system_health', # GET /system/health (uptime monitoring) +]) + + +@mod_api.before_request +def authenticate_request(): + """Validate Bearer token and attach user context to the request.""" + if request.endpoint in _PUBLIC_ENDPOINTS: + g.api_user = None + g.api_token = None + return + + auth_header = request.headers.get('Authorization', '') + if not auth_header: + return make_error_response( + 'unauthorized', + 'Bearer token is missing, expired, or invalid.', + http_status=401, + ) + + parts = auth_header.split(' ', 1) + if len(parts) != 2 or parts[0] != 'Bearer': + return make_error_response( + 'unauthorized', + 'Bearer token is missing, expired, or invalid.', + http_status=401, + ) + + token_value = parts[1].strip() + if not token_value or not token_value.startswith('spci_'): + return make_error_response( + 'unauthorized', + 'Bearer token is missing, expired, or invalid.', + http_status=401, + ) + + # Look up by prefix, then verify the full hash against each candidate. + prefix = ApiToken.extract_prefix(token_value) + candidates = ApiToken.query.filter_by(token_prefix=prefix).all() + + if not candidates: + return make_error_response( + 'unauthorized', + 'Bearer token is missing, expired, or invalid.', + http_status=401, + ) + + matched_token = None + for candidate in candidates: + if ApiToken.verify_token(token_value, candidate.token_hash): + matched_token = candidate + break + + if matched_token is None: + return make_error_response( + 'unauthorized', + 'Bearer token is missing, expired, or invalid.', + http_status=401, + ) + + if not matched_token.is_valid: + return make_error_response( + 'unauthorized', + 'Bearer token is missing, expired, or invalid.', + http_status=401, + ) + + g.api_token = matched_token + g.api_user = matched_token.user + + +def require_scope(scope: str): + """Decorator: reject the request if the token lacks ``scope``.""" + def decorator(f): + @functools.wraps(f) + def decorated_function(*args, **kwargs): + token = getattr(g, 'api_token', None) + if token is None: + return make_error_response( + 'unauthorized', + 'Bearer token is missing, expired, or invalid.', + http_status=401, + ) + if not token.has_scope(scope): + return make_error_response( + 'forbidden', + 'Token does not have the required scope for this operation.', + details={ + 'required_scope': scope, + 'token_scopes': token.scopes, + }, + http_status=403, + ) + return f(*args, **kwargs) + return decorated_function + return decorator + + +def require_roles(roles: List[str]): + """Decorator: reject the request if the user's role is not in ``roles``.""" + def decorator(f): + @functools.wraps(f) + def decorated_function(*args, **kwargs): + user = getattr(g, 'api_user', None) + if user is None: + return make_error_response( + 'unauthorized', + 'Bearer token is missing, expired, or invalid.', + http_status=401, + ) + if user.role.value not in roles: + return make_error_response( + 'forbidden', + 'Your role does not have permission for this operation.', + details={ + 'required_roles': roles, + 'user_role': user.role.value, + }, + http_status=403, + ) + return f(*args, **kwargs) + return decorated_function + return decorator diff --git a/mod_api/middleware/error_handler.py b/mod_api/middleware/error_handler.py new file mode 100644 index 00000000..fb64cfb4 --- /dev/null +++ b/mod_api/middleware/error_handler.py @@ -0,0 +1,149 @@ +""" +Structured JSON error responses for API routes. + +Intercepts standard HTTP errors (400, 401, 403, 404, 405, 422, 429, 500), +Marshmallow validation errors, and SQLAlchemy errors so that nothing under +/api/v1/* ever returns an HTML error page. + +Response shape: {"code": "...", "message": "...", "details": {...}} +""" + +from flask import jsonify, request +from marshmallow import ValidationError as MarshmallowValidationError +from sqlalchemy.exc import SQLAlchemyError + +from mod_api import mod_api + + +def make_error_response(code, message, details=None, http_status=400): + """Build a JSON error response conforming to the ErrorResponse schema.""" + body = { + 'code': code, + 'message': str(message)[:500], + 'details': details if details is not None else {}, + } + response = jsonify(body) + response.status_code = http_status + return response + + +@mod_api.app_errorhandler(400) +def handle_400(error): + """Bad request.""" + if not request.path.startswith('/api/v1'): + raise error + return make_error_response( + 'validation_error', + getattr(error, 'description', 'Bad request.'), + http_status=400, + ) + + +@mod_api.app_errorhandler(401) +def handle_401(error): + """Unauthorized.""" + if not request.path.startswith('/api/v1'): + raise error + return make_error_response( + 'unauthorized', + 'Bearer token is missing, expired, or invalid.', + http_status=401, + ) + + +@mod_api.app_errorhandler(403) +def handle_403(error): + """Forbidden.""" + if not request.path.startswith('/api/v1'): + raise error + return make_error_response( + 'forbidden', + 'Token does not have the required scope for this operation.', + http_status=403, + ) + + +@mod_api.app_errorhandler(404) +def handle_404(error): + """Not found.""" + if not request.path.startswith('/api/v1'): + raise error + return make_error_response( + 'not_found', + getattr(error, 'description', 'Resource not found.'), + http_status=404, + ) + + +@mod_api.app_errorhandler(405) +def handle_405(error): + """Method not allowed.""" + if not request.path.startswith('/api/v1'): + raise error + return make_error_response( + 'method_not_allowed', + 'Method not allowed.', + http_status=405, + ) + + +@mod_api.app_errorhandler(422) +def handle_422(error): + """Unprocessable entity.""" + if not request.path.startswith('/api/v1'): + raise error + return make_error_response( + 'unprocessable', + getattr(error, 'description', 'Request is valid JSON but semantically invalid.'), + http_status=422, + ) + + +@mod_api.app_errorhandler(429) +def handle_429(error): + """Rate limited.""" + if not request.path.startswith('/api/v1'): + raise error + return make_error_response( + 'rate_limited', + 'Rate limit exceeded.', + details={'retry_after': 30, 'limit': 120, 'window': '60s'}, + http_status=429, + ) + + +@mod_api.app_errorhandler(500) +def handle_500(error): + """Internal server error.""" + if not request.path.startswith('/api/v1'): + raise error + return make_error_response( + 'internal_error', + 'An unexpected error occurred.', + http_status=500, + ) + + +@mod_api.errorhandler(MarshmallowValidationError) +def handle_marshmallow_validation_error(error): + """Catch schema validation failures and return them as 400.""" + return make_error_response( + 'validation_error', + 'Request failed schema validation.', + details={'fields': error.messages}, + http_status=400, + ) + + +@mod_api.errorhandler(SQLAlchemyError) +def handle_sqlalchemy_error(error): + """Log the real error, but never expose raw SQL details to the client.""" + from flask import g + log = getattr(g, 'log', None) + if log: + log.error(f'Database error in API: {error}') + return make_error_response( + 'internal_error', + 'An unexpected database error occurred.', + http_status=500, + ) diff --git a/mod_api/middleware/rate_limit.py b/mod_api/middleware/rate_limit.py new file mode 100644 index 00000000..1f73da7b --- /dev/null +++ b/mod_api/middleware/rate_limit.py @@ -0,0 +1,116 @@ +""" +Per-client rate limiting for API endpoints. + +Limits: + POST /auth/tokens 5 req / 15 min (keyed by IP) + POST/DELETE/PUT/PATCH 20 req / min (keyed by token) + GET 120 req / min (keyed by token) + +Includes X-RateLimit-* headers on every response. + +Uses an in-memory dict for simplicity. For multi-process deployments, +swap this out for a Redis backend. +""" + +import time + +from flask import g, request + +from mod_api import mod_api + +_rate_limit_store = {} # key -> {'count': int, 'window_start': float} +_eviction_counter = 0 +_EVICTION_INTERVAL = 100 # run cleanup every N requests + + +def _evict_stale_entries(): + """Prune entries older than 15 min to bound memory usage.""" + global _eviction_counter + _eviction_counter += 1 + if _eviction_counter < _EVICTION_INTERVAL: + return + _eviction_counter = 0 + now = time.time() + stale_keys = [ + key for key, entry in _rate_limit_store.items() + if (now - entry['window_start']) > 900 + ] + for key in stale_keys: + del _rate_limit_store[key] + + +def _get_rate_limit_key(): + """Build the rate-limit bucket key for this request.""" + if request.endpoint == 'api.create_token': + return f'ip:{request.remote_addr}' + token = getattr(g, 'api_token', None) + if token: + return f'token:{token.id}' + return f'ip:{request.remote_addr}' + + +def _get_limits(): + """Return (max_requests, window_seconds) for the current endpoint.""" + if request.endpoint == 'api.create_token': + return 5, 900 + if request.method in ('POST', 'DELETE', 'PUT', 'PATCH'): + return 20, 60 + return 120, 60 + + +@mod_api.before_request +def check_rate_limit(): + """Reject the request if the client has exceeded their rate limit.""" + _evict_stale_entries() + + key = _get_rate_limit_key() + max_requests, window_seconds = _get_limits() + now = time.time() + + entry = _rate_limit_store.get(key) + + if entry is None or (now - entry['window_start']) >= window_seconds: + _rate_limit_store[key] = {'count': 1, 'window_start': now} + else: + entry['count'] += 1 + if entry['count'] > max_requests: + reset_at = int(entry['window_start'] + window_seconds) + retry_after = max(1, reset_at - int(now)) + from mod_api.middleware.error_handler import make_error_response + response = make_error_response( + 'rate_limited', + f'Rate limit exceeded. Retry after {retry_after} seconds.', + details={ + 'retry_after': retry_after, + 'limit': max_requests, + 'window': f'{window_seconds}s', + }, + http_status=429, + ) + response.headers['Retry-After'] = str(retry_after) + response.headers['X-RateLimit-Limit'] = str(max_requests) + response.headers['X-RateLimit-Remaining'] = '0' + response.headers['X-RateLimit-Reset'] = str(reset_at) + return response + + +@mod_api.after_request +def add_rate_limit_headers(response): + """Attach X-RateLimit-* headers to every response.""" + key = _get_rate_limit_key() + max_requests, window_seconds = _get_limits() + now = time.time() + + entry = _rate_limit_store.get(key) + if entry: + remaining = max(0, max_requests - entry['count']) + reset_at = int(entry['window_start'] + window_seconds) + else: + remaining = max_requests + reset_at = int(now + window_seconds) + + response.headers['X-RateLimit-Limit'] = str(max_requests) + response.headers['X-RateLimit-Remaining'] = str(remaining) + response.headers['X-RateLimit-Reset'] = str(reset_at) + + return response diff --git a/mod_api/middleware/validation.py b/mod_api/middleware/validation.py new file mode 100644 index 00000000..7bd27e2a --- /dev/null +++ b/mod_api/middleware/validation.py @@ -0,0 +1,204 @@ +""" +Request validation decorators for bodies, query params, and path IDs. + +All of these return 400 with field-level details on failure, so route +handlers can assume clean input. +""" + +import re +from functools import wraps + +from flask import request +from marshmallow import ValidationError as MarshmallowValidationError + +from mod_api.middleware.error_handler import make_error_response + +PATTERNS = { + 'commit_sha': re.compile(r'^[a-fA-F0-9]{40}$'), + 'sha256': re.compile(r'^[a-fA-F0-9]{64}$'), + 'repository': re.compile(r'^[a-zA-Z0-9_.\-]+/[a-zA-Z0-9_.\-]+$'), + 'branch': re.compile(r'^[A-Za-z0-9._/\-]+$'), + 'token_name': re.compile(r'^[a-zA-Z0-9_\-]+$'), + 'extension': re.compile(r'^[a-zA-Z0-9]+$'), +} + +# Whitelist of allowed sort params. Never pass raw user input to the ORM. +ALLOWED_RUN_SORTS = frozenset([ + 'created_at', '-created_at', + 'started_at', '-started_at', + 'run_id', '-run_id', +]) + + +def validate_body(schema_class): + """Validate the JSON body with a Marshmallow schema, pass result as ``validated_data``.""" + def decorator(f): + @wraps(f) + def decorated(*args, **kwargs): + json_data = request.get_json(silent=True) + if json_data is None: + return make_error_response( + 'validation_error', + 'Request body must be valid JSON.', + http_status=400, + ) + schema = schema_class() + try: + validated = schema.load(json_data) + except MarshmallowValidationError as e: + return make_error_response( + 'validation_error', + 'Request failed schema validation.', + details={'fields': e.messages}, + http_status=400, + ) + kwargs['validated_data'] = validated + return f(*args, **kwargs) + return decorated + return decorator + + +def validate_pagination(f): + """Extract and validate ``limit`` (1-100) and ``offset`` (>= 0) query params.""" + @wraps(f) + def decorated(*args, **kwargs): + try: + limit = int(request.args.get('limit', 50)) + except (ValueError, TypeError): + return make_error_response( + 'validation_error', + 'limit must be an integer.', + details={'fields': {'limit': 'Must be an integer between 1 and 100.'}}, + http_status=400, + ) + + try: + offset = int(request.args.get('offset', 0)) + except (ValueError, TypeError): + return make_error_response( + 'validation_error', + 'offset must be a non-negative integer.', + details={'fields': {'offset': 'Must be a non-negative integer.'}}, + http_status=400, + ) + + if limit < 1 or limit > 100: + return make_error_response( + 'validation_error', + 'limit must be between 1 and 100.', + details={'fields': {'limit': 'Must be between 1 and 100.'}}, + http_status=400, + ) + + if offset < 0: + return make_error_response( + 'validation_error', + 'offset must be non-negative.', + details={'fields': {'offset': 'Must be >= 0.'}}, + http_status=400, + ) + + kwargs['limit'] = limit + kwargs['offset'] = offset + return f(*args, **kwargs) + return decorated + + +def validate_path_id(param_name): + """Ensure a URL path parameter is a positive integer.""" + def decorator(f): + @wraps(f) + def decorated(*args, **kwargs): + value = kwargs.get(param_name) + try: + int_value = int(value) + except (ValueError, TypeError): + return make_error_response( + 'validation_error', + f'{param_name} must be a positive integer.', + details={'fields': {param_name: 'Must be a positive integer.'}}, + http_status=400, + ) + if int_value < 1: + return make_error_response( + 'validation_error', + f'{param_name} must be >= 1.', + details={'fields': {param_name: 'Must be >= 1. Zero and negative IDs are rejected.'}}, + http_status=400, + ) + kwargs[param_name] = int_value + return f(*args, **kwargs) + return decorated + return decorator + + +def validate_date_range(f): + """Parse ``created_after``/``created_before`` query params and reject inverted ranges.""" + @wraps(f) + def decorated(*args, **kwargs): + from datetime import datetime + + created_after_str = request.args.get('created_after') + created_before_str = request.args.get('created_before') + created_after = None + created_before = None + + if created_after_str: + try: + created_after = datetime.fromisoformat(created_after_str.replace('Z', '+00:00')) + except ValueError: + return make_error_response( + 'validation_error', + 'created_after must be a valid ISO 8601 datetime.', + details={'fields': {'created_after': 'Invalid ISO 8601 format.'}}, + http_status=400, + ) + + if created_before_str: + try: + created_before = datetime.fromisoformat(created_before_str.replace('Z', '+00:00')) + except ValueError: + return make_error_response( + 'validation_error', + 'created_before must be a valid ISO 8601 datetime.', + details={'fields': {'created_before': 'Invalid ISO 8601 format.'}}, + http_status=400, + ) + + if created_after and created_before and created_after > created_before: + return make_error_response( + 'validation_error', + 'created_after must not be after created_before.', + details={'fields': { + 'created_after': 'Must be before created_before.', + 'created_before': 'Must be after created_after.', + }}, + http_status=400, + ) + + kwargs['created_after'] = created_after + kwargs['created_before'] = created_before + return f(*args, **kwargs) + return decorated + + +def validate_sort(allowed=None): + """Validate the ``sort`` query param against a whitelist.""" + if allowed is None: + allowed = ALLOWED_RUN_SORTS + + def decorator(f): + @wraps(f) + def decorated(*args, **kwargs): + sort = request.args.get('sort', '-created_at') + if sort not in allowed: + return make_error_response( + 'validation_error', + f'sort must be one of: {", ".join(sorted(allowed))}', + details={'fields': {'sort': f'Must be one of: {sorted(allowed)}'}}, + http_status=400, + ) + kwargs['sort'] = sort + return f(*args, **kwargs) + return decorated + return decorator diff --git a/mod_api/utils.py b/mod_api/utils.py new file mode 100644 index 00000000..44a49997 --- /dev/null +++ b/mod_api/utils.py @@ -0,0 +1,68 @@ +"""Pagination, serialization, and response formatting helpers.""" + +from flask import jsonify + + +def paginated_response(data, total, limit, offset, schema=None): + """Build an offset-paginated JSON response.""" + if schema: + serialized = schema.dump(data, many=True) + else: + serialized = data + + next_offset = offset + limit if (offset + limit) < total else None + + return jsonify({ + 'data': serialized, + 'pagination': { + 'limit': limit, + 'offset': offset, + 'total': total, + 'next_offset': next_offset, + }, + }) + + +def cursor_paginated_response(data, next_cursor, limit, schema=None): + """Build a cursor-paginated JSON response.""" + if schema: + serialized = schema.dump(data, many=True) + else: + serialized = data + + return jsonify({ + 'data': serialized, + 'pagination': { + 'limit': limit, + 'next_cursor': next_cursor, + }, + }) + + +def single_response(data, schema=None, http_status=200): + """Build a single-item JSON response.""" + if schema: + serialized = schema.dump(data) + else: + serialized = data + + response = jsonify(serialized) + response.status_code = http_status + return response + + +def get_sort_column(sort_param, model, column_map): + """ + Translate a validated sort string (e.g. '-created_at') into an + SQLAlchemy order_by clause. + """ + descending = sort_param.startswith('-') + field_name = sort_param.lstrip('-') + + column = column_map.get(field_name) + if column is None: + return None + + if descending: + return column.desc() + return column.asc() From 69f83ab43a905dcef79880136fcdcc1b693956ba Mon Sep 17 00:00:00 2001 From: Pulkit Chauhan Date: Fri, 5 Jun 2026 15:16:30 +0530 Subject: [PATCH 04/28] Added requirements.txt and imported the APIs in run.py --- requirements.txt | 3 +++ run.py | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/requirements.txt b/requirements.txt index 4aaae11e..ae684782 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,3 +27,6 @@ PyGithub==2.9.1 blinker==1.9.0 click==8.3.3 PyYAML==6.0.3 +marshmallow>=3.21 +argon2-cffi>=23.0 +Flask-Limiter>=3.5 diff --git a/run.py b/run.py index e277c6d9..4e338d8c 100755 --- a/run.py +++ b/run.py @@ -273,3 +273,7 @@ def teardown(exception: Optional[Exception]): app.register_blueprint(mod_ci) app.register_blueprint(mod_customized, url_prefix='/custom') app.register_blueprint(mod_health) + +# REST API v1 +from mod_api import mod_api +app.register_blueprint(mod_api, url_prefix='/api/v1') From fb32b3b2543d80ba1e702160af127430d9b2881e Mon Sep 17 00:00:00 2001 From: Pulkit Chauhan Date: Fri, 5 Jun 2026 21:50:20 +0530 Subject: [PATCH 05/28] Added API Token model and Marshmallow schemas --- mod_api/models/__init__.py | 2 +- mod_api/models/api_token.py | 131 ++++++++++++++++++++++++++++++++++++ mod_api/schemas/__init__.py | 2 +- mod_api/schemas/auth.py | 48 +++++++++++++ mod_api/schemas/common.py | 24 +++++++ mod_api/schemas/errors.py | 46 +++++++++++++ mod_api/schemas/results.py | 65 ++++++++++++++++++ mod_api/schemas/samples.py | 65 ++++++++++++++++++ mod_api/schemas/system.py | 68 +++++++++++++++++++ 9 files changed, 449 insertions(+), 2 deletions(-) create mode 100644 mod_api/models/api_token.py create mode 100644 mod_api/schemas/auth.py create mode 100644 mod_api/schemas/common.py create mode 100644 mod_api/schemas/errors.py create mode 100644 mod_api/schemas/results.py create mode 100644 mod_api/schemas/samples.py create mode 100644 mod_api/schemas/system.py diff --git a/mod_api/models/__init__.py b/mod_api/models/__init__.py index abb6ced3..dcb36537 100644 --- a/mod_api/models/__init__.py +++ b/mod_api/models/__init__.py @@ -1 +1 @@ -"""mod_api.models — New database models for the API module.""" +"""mod_api.models: database models for the API module.""" diff --git a/mod_api/models/api_token.py b/mod_api/models/api_token.py new file mode 100644 index 00000000..f9c526ff --- /dev/null +++ b/mod_api/models/api_token.py @@ -0,0 +1,131 @@ +""" +ApiToken model: server-side storage for scoped API tokens. + +Tokens are opaque strings prefixed with 'spci_'. Only the argon2 hash +is persisted; the plaintext is returned exactly once at creation time. +""" + +import json +import secrets +from datetime import datetime, timedelta, timezone +from typing import List + +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError +from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import relationship + +from database import Base + +_ph = PasswordHasher() + +VALID_SCOPES = frozenset([ + 'runs:read', + 'runs:write', + 'results:read', + 'baselines:write', + 'system:read', +]) + +DEFAULT_SCOPES = ['runs:read', 'results:read'] + +TOKEN_PREFIX = 'spci_' +TOKEN_BYTE_LENGTH = 32 + + +class ApiToken(Base): + """Scoped API token bound to a user account.""" + + __tablename__ = 'api_token' + __table_args__ = ( + UniqueConstraint('user_id', 'token_name', name='uq_user_token_name'), + {'mysql_engine': 'InnoDB'}, + ) + + id = Column(Integer, primary_key=True) + user_id = Column( + Integer, + ForeignKey('user.id', onupdate='CASCADE', ondelete='CASCADE'), + nullable=False, + ) + user = relationship('User', uselist=False) + token_name = Column(String(50), nullable=False) + token_hash = Column(String(255), nullable=False) + token_prefix = Column(String(16), nullable=False, index=True) + scopes_json = Column(Text(), nullable=False) + created_at = Column(DateTime(timezone=True), nullable=False) + expires_at = Column(DateTime(timezone=True), nullable=False) + revoked_at = Column(DateTime(timezone=True), nullable=True) + + def __init__( + self, + user_id: int, + token_name: str, + token_hash: str, + token_prefix: str, + scopes: List[str], + expires_in_days: int = 30, + ) -> None: + self.user_id = user_id + self.token_name = token_name + self.token_hash = token_hash + self.token_prefix = token_prefix + self.scopes_json = json.dumps(scopes) + self.created_at = datetime.now(timezone.utc) + self.expires_at = self.created_at + timedelta(days=expires_in_days) + + def __repr__(self) -> str: + return f'' + + @property + def scopes(self) -> List[str]: + return json.loads(self.scopes_json) + + @property + def is_expired(self) -> bool: + now = datetime.now(timezone.utc) + expires = self.expires_at + if expires is None: + return True + # MySQL DATETIME columns don't preserve tzinfo; treat naive as UTC. + if expires.tzinfo is None: + expires = expires.replace(tzinfo=timezone.utc) + return now > expires + + @property + def is_revoked(self) -> bool: + return self.revoked_at is not None + + @property + def is_valid(self) -> bool: + return not self.is_expired and not self.is_revoked + + def has_scope(self, scope: str) -> bool: + return scope in self.scopes + + def revoke(self) -> None: + self.revoked_at = datetime.now(timezone.utc) + + @staticmethod + def generate_token() -> str: + """Create a new random token string with the spci_ prefix.""" + random_bytes = secrets.token_urlsafe(TOKEN_BYTE_LENGTH) + return f'{TOKEN_PREFIX}{random_bytes}' + + @staticmethod + def hash_token(plaintext: str) -> str: + """Hash a token with argon2 for storage.""" + return _ph.hash(plaintext) + + @staticmethod + def verify_token(plaintext: str, token_hash: str) -> bool: + """Verify a plaintext token against its stored argon2 hash.""" + try: + return _ph.verify(token_hash, plaintext) + except VerifyMismatchError: + return False + + @staticmethod + def extract_prefix(token: str) -> str: + """Return the first 16 chars used for DB lookup.""" + return token[:16] if len(token) >= 16 else token diff --git a/mod_api/schemas/__init__.py b/mod_api/schemas/__init__.py index 76c8fca0..88996065 100644 --- a/mod_api/schemas/__init__.py +++ b/mod_api/schemas/__init__.py @@ -1 +1 @@ -"""mod_api.schemas — Marshmallow schemas for request/response validation.""" +"""mod_api.schemas: Marshmallow schemas for request/response validation.""" diff --git a/mod_api/schemas/auth.py b/mod_api/schemas/auth.py new file mode 100644 index 00000000..330ea9e6 --- /dev/null +++ b/mod_api/schemas/auth.py @@ -0,0 +1,48 @@ +"""Request/response schemas for token endpoints.""" + +from marshmallow import RAISE, Schema, fields, validate + +from mod_api.models.api_token import VALID_SCOPES + + +class TokenCreateRequestSchema(Schema): + """Schema for POST /auth/tokens request body.""" + + email = fields.Email(required=True) + password = fields.String( + required=True, + validate=validate.Length(min=5, max=128), + ) + token_name = fields.String( + required=True, + validate=[ + + validate.Length(min=1, max=50), + validate.Regexp( + r'^[a-zA-Z0-9_\-]+$', + error='token_name must match ^[a-zA-Z0-9_-]+$', + ), + ], + ) + expires_in_days = fields.Integer( + load_default=30, + validate=validate.Range(min=1, max=90), + ) + scopes = fields.List( + fields.String(validate=validate.OneOf(VALID_SCOPES)), + load_default=None, + validate=validate.Length(max=8), + ) + + class Meta: + unknown = RAISE # Reject unknown fields + + +class AuthTokenSchema(Schema): + """Schema for serializing the created token response.""" + + token = fields.String(required=True) + token_type = fields.String(dump_default='bearer') + token_name = fields.String(required=True) + scopes = fields.List(fields.String(), required=True) + expires_at = fields.DateTime(required=True) diff --git a/mod_api/schemas/common.py b/mod_api/schemas/common.py new file mode 100644 index 00000000..0c21fbaf --- /dev/null +++ b/mod_api/schemas/common.py @@ -0,0 +1,24 @@ +"""Common schemas: ErrorResponse, pagination wrappers.""" + +from marshmallow import Schema, fields + + +class ErrorResponseSchema(Schema): + """Standard error response body.""" + code = fields.String(required=True) + message = fields.String(required=True) + details = fields.Dict(keys=fields.String(), required=True, load_default={}) + + +class PaginationSchema(Schema): + """Offset-based pagination metadata.""" + limit = fields.Integer(required=True) + offset = fields.Integer(required=True) + total = fields.Integer(required=True) + next_offset = fields.Integer(allow_none=True, load_default=None) + + +class CursorPaginationSchema(Schema): + """Cursor-based pagination metadata.""" + limit = fields.Integer(required=True) + next_cursor = fields.String(allow_none=True, load_default=None) diff --git a/mod_api/schemas/errors.py b/mod_api/schemas/errors.py new file mode 100644 index 00000000..febba386 --- /dev/null +++ b/mod_api/schemas/errors.py @@ -0,0 +1,46 @@ +"""Schemas for error items, error summaries, and log lines.""" + +from marshmallow import Schema, fields, validate + + +class ErrorItemSchema(Schema): + """A single error derived from run results.""" + error_id = fields.String(required=True) + run_id = fields.Integer(required=True) + sample_id = fields.Integer(allow_none=True) + regression_id = fields.Integer(allow_none=True) + type = fields.String(required=True) + severity = fields.String( + required=True, + validate=validate.OneOf(['info', 'warning', 'error', 'critical']), + ) + message = fields.String(required=True) + location = fields.Dict(allow_none=True, load_default=None) + stack = fields.List(fields.String(), load_default=None) + occurred_at = fields.DateTime(allow_none=True) + + +class ErrorSummaryBucketSchema(Schema): + """One bucket in a grouped error summary.""" + key = fields.String(required=True) + count = fields.Integer(required=True) + severity = fields.String(required=True) + sample_ids = fields.List(fields.Integer(), load_default=[]) + first_seen_at = fields.DateTime(allow_none=True) + last_seen_at = fields.DateTime(allow_none=True) + + +class LogLineSchema(Schema): + """A single parsed line from a build log.""" + timestamp = fields.DateTime(allow_none=True) + level = fields.String( + required=True, + validate=validate.OneOf(['debug', 'info', 'warning', 'error', 'critical']), + ) + source = fields.String( + required=True, + validate=validate.OneOf(['orchestrator', 'worker', 'build', 'test_runner', 'web']), + ) + message = fields.String(required=True) + run_id = fields.Integer(required=True) + sample_id = fields.Integer(allow_none=True) diff --git a/mod_api/schemas/results.py b/mod_api/schemas/results.py new file mode 100644 index 00000000..3a95a925 --- /dev/null +++ b/mod_api/schemas/results.py @@ -0,0 +1,65 @@ +"""Schemas for expected/actual output, diffs, and baseline approvals.""" + +from marshmallow import RAISE, Schema, fields, validate + + +class OutputFileContentSchema(Schema): + """File content blob (expected or actual output).""" + filename = fields.String(required=True) + content = fields.String(required=True) + encoding = fields.String(required=True, validate=validate.OneOf(['utf-8', 'base64'])) + sha256 = fields.String(allow_none=True) + storage_status = fields.String( + required=True, + validate=validate.OneOf(['ok', 'degraded', 'missing']), + ) + + +class DiffHunkLineSchema(Schema): + """One line inside a diff hunk.""" + type = fields.String(required=True, validate=validate.OneOf(['add', 'delete', 'context'])) + content = fields.String(required=True) + + +class DiffHunkSchema(Schema): + """A contiguous block of changes in a diff.""" + header = fields.String(required=True) + lines = fields.List(fields.Nested(DiffHunkLineSchema), required=True) + + +class DiffSchema(Schema): + """Structured diff between expected and actual output.""" + status = fields.String(required=True, validate=validate.OneOf([ + 'identical', 'different', 'missing_actual', 'missing_expected', + ])) + expected_sha256 = fields.String(allow_none=True) + actual_sha256 = fields.String(allow_none=True) + stats = fields.Dict(required=True) + hunks = fields.List(fields.Nested(DiffHunkSchema), required=True) + + +class BaselineApprovalRequestSchema(Schema): + """POST /runs/{id}/samples/{sid}/baseline-approval body.""" + reason = fields.String( + required=True, + validate=validate.Length(min=10, max=1000), + ) + output_id = fields.Integer( + load_default=None, + validate=validate.Range(min=1), + ) + apply_to_variants = fields.Boolean(load_default=False) + + class Meta: + unknown = RAISE + + +class BaselineApprovalSchema(Schema): + """Response after submitting a baseline approval request.""" + approval_id = fields.String(required=True) + status = fields.String(required=True, validate=validate.OneOf([ + 'pending_review', 'approved', 'rejected', + ])) + requested_by = fields.String(required=True) + reason = fields.String(required=True) + created_at = fields.DateTime(required=True) diff --git a/mod_api/schemas/samples.py b/mod_api/schemas/samples.py new file mode 100644 index 00000000..19413502 --- /dev/null +++ b/mod_api/schemas/samples.py @@ -0,0 +1,65 @@ +"""Schemas for samples, run sample results, history entries, and regression tests.""" + +from marshmallow import Schema, fields, validate + + +class OutputFileSchema(Schema): + """One output file entry within a run sample result.""" + output_id = fields.Integer(required=True) + filename = fields.String(required=True) + status = fields.String(required=True, validate=validate.OneOf([ + 'match', 'diff_mismatch', 'missing_output', 'missing_expected', + ])) + + +class RunSampleSchema(Schema): + """A sample's result within a specific run.""" + regression_test_id = fields.Integer(required=True) + sample_id = fields.Integer(allow_none=True) + sample_name = fields.String(allow_none=True) + status = fields.String(required=True, validate=validate.OneOf([ + 'pass', 'fail', 'skipped', 'missing_output', 'running', 'not_started', + ])) + exit_code = fields.Integer(allow_none=True) + expected_rc = fields.Integer(allow_none=True) + runtime_ms = fields.Integer(allow_none=True) + command = fields.String(allow_none=True) + category = fields.String(allow_none=True) + outputs = fields.List(fields.Nested(OutputFileSchema), load_default=[]) + + +class SampleSchema(Schema): + """A media sample from the sample catalog.""" + sample_id = fields.Integer(required=True) + sha = fields.String(required=True) + extension = fields.String(required=True) + original_name = fields.String(required=True) + filename = fields.String(required=True) + tags = fields.List(fields.String(), load_default=[]) + regression_test_count = fields.Integer(load_default=0) + active = fields.Boolean(load_default=True) + + +class SampleHistoryEntrySchema(Schema): + """One row in a sample's cross-run history.""" + run_id = fields.Integer(required=True) + status = fields.String(required=True) + platform = fields.String(required=True) + branch = fields.String(required=True) + commit_sha = fields.String(required=True) + tested_at = fields.DateTime(allow_none=True) + failure_signature = fields.String(allow_none=True) + + +class RegressionTestSchema(Schema): + """A regression test definition.""" + regression_test_id = fields.Integer(required=True) + sample_id = fields.Integer(allow_none=True) + sample_name = fields.String(allow_none=True) + command = fields.String(required=True) + input_type = fields.String(required=True) + output_type = fields.String(required=True) + expected_rc = fields.Integer(required=True) + active = fields.Boolean(required=True) + categories = fields.List(fields.String(), load_default=[]) + description = fields.String(allow_none=True) diff --git a/mod_api/schemas/system.py b/mod_api/schemas/system.py new file mode 100644 index 00000000..3b0802cf --- /dev/null +++ b/mod_api/schemas/system.py @@ -0,0 +1,68 @@ +"""System schemas for health, queue, branches, environments, and artifacts.""" + +from marshmallow import Schema, fields, validate + + +class DependencyHealthSchema(Schema): + """Schema for a single system dependency status.""" + name = fields.String(required=True) + status = fields.String(required=True, validate=validate.OneOf(['ok', 'degraded', 'down'])) + message = fields.String(allow_none=True) + + +class SystemHealthSchema(Schema): + """Schema for the overall system health response.""" + status = fields.String( + required=True, + validate=validate.OneOf(['ok', 'degraded', 'down']), + ) + checked_at = fields.DateTime(required=True) + dependencies = fields.List(fields.Nested(DependencyHealthSchema), required=True) + + +class QueueJobSchema(Schema): + """Schema for a single job in the queue.""" + run_id = fields.Integer(required=True) + status = fields.String(required=True, validate=validate.OneOf(['queued', 'running'])) + platform = fields.String(required=True, validate=validate.OneOf(['linux', 'windows'])) + queued_at = fields.DateTime(required=True) + started_at = fields.DateTime(allow_none=True) + position = fields.Integer(allow_none=True) + + +class BranchSchema(Schema): + """Schema for a tracked branch.""" + repository = fields.String(required=True) + name = fields.String(required=True) + head_sha = fields.String(allow_none=True) + active = fields.Boolean(required=True) + + +class EnvironmentSchema(Schema): + """Schema for a test environment.""" + environment_id = fields.String(required=True) + platform = fields.String(required=True, validate=validate.OneOf(['linux', 'windows'])) + active = fields.Boolean(required=True) + runner_label = fields.String(allow_none=True) + average_duration_ms = fields.Integer(allow_none=True) + + +class ArtifactSchema(Schema): + """Schema for a run artifact.""" + artifact_id = fields.String(required=True) + run_id = fields.Integer(required=True) + sample_id = fields.Integer(allow_none=True) + type = fields.String( + required=True, + validate=validate.OneOf([ + 'build_log', 'sample_output', 'expected_output', 'diff', 'media_info', 'binary', + ]), + ) + filename = fields.String(required=True) + content_type = fields.String(required=True) + size_bytes = fields.Integer(allow_none=True) + storage_status = fields.String( + required=True, + validate=validate.OneOf(['ok', 'degraded', 'missing']), + ) + download_url = fields.String(allow_none=True) From e9af3de676bdb433b4dc896cacd18ad4d0505530 Mon Sep 17 00:00:00 2001 From: Pulkit Chauhan Date: Fri, 5 Jun 2026 21:50:42 +0530 Subject: [PATCH 06/28] --amend --- mod_api/schemas/runs.py | 102 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 mod_api/schemas/runs.py diff --git a/mod_api/schemas/runs.py b/mod_api/schemas/runs.py new file mode 100644 index 00000000..6f773b91 --- /dev/null +++ b/mod_api/schemas/runs.py @@ -0,0 +1,102 @@ +"""Schemas for runs, run summaries, progress events, and run actions.""" + +from marshmallow import RAISE, Schema, fields, validate + + +class ProgressEventSchema(Schema): + """A single progress event in a run's timeline.""" + timestamp = fields.DateTime(required=True) + status = fields.String(required=True) + message = fields.String(required=True) + + +class RunSchema(Schema): + """Full run representation.""" + run_id = fields.Integer(required=True) + status = fields.String(required=True, validate=validate.OneOf([ + 'queued', 'running', 'pass', 'fail', 'canceled', 'error', 'incomplete', + ])) + platform = fields.String(required=True, validate=validate.OneOf(['linux', 'windows'])) + test_type = fields.String(required=True, validate=validate.OneOf(['commit', 'pr'])) + repository = fields.String(required=True) + branch = fields.String(required=True) + commit_sha = fields.String(required=True) + pr_number = fields.Integer(allow_none=True, load_default=None) + created_at = fields.DateTime(allow_none=True) + queued_at = fields.DateTime(allow_none=True) + started_at = fields.DateTime(allow_none=True) + completed_at = fields.DateTime(allow_none=True) + github_link = fields.String(allow_none=True) + + +class RunSummarySchema(Schema): + """Aggregated pass/fail counts for a run.""" + run_id = fields.Integer(required=True) + status = fields.String(required=True) + total_samples = fields.Integer(required=True) + pass_count = fields.Integer(required=True) + fail_count = fields.Integer(required=True) + skip_count = fields.Integer(required=True) + missing_output_count = fields.Integer(required=True) + runtime_ms = fields.Integer(allow_none=True) + + +class RunConfigSchema(Schema): + """The configuration used to launch a run.""" + run_id = fields.Integer(required=True) + platform = fields.String(required=True) + branch = fields.String(required=True) + commit_sha = fields.String(required=True) + regression_test_ids = fields.List(fields.Integer(), required=True) + + +class RunCreateRequestSchema(Schema): + """POST /runs request body.""" + commit_sha = fields.String( + required=True, + validate=validate.Regexp( + r'^[a-fA-F0-9]{40}$', + error='commit_sha must be a 40-character hex string.', + ), + ) + platform = fields.String( + required=True, + validate=validate.OneOf(['linux', 'windows']), + ) + branch = fields.String( + load_default='master', + validate=[ + validate.Length(max=100), + validate.Regexp( + r'^[A-Za-z0-9._/\-]+$', + error='branch must match ^[A-Za-z0-9._/-]+$', + ), + ], + ) + repository = fields.String( + load_default=None, + validate=[ + validate.Length(max=100), + validate.Regexp( + r'^[a-zA-Z0-9_.\-]+/[a-zA-Z0-9_.\-]+$', + error='repository must match owner/repo format.', + ), + ], + ) + regression_test_ids = fields.List( + fields.Integer(validate=validate.Range(min=1)), + load_default=None, + validate=validate.Length(max=500), + ) + + class Meta: + unknown = RAISE + + +class RunActionResultSchema(Schema): + """Response for cancel/retry actions.""" + run_id = fields.Integer(required=True) + new_run_id = fields.Integer(allow_none=True) + action = fields.String(required=True) + status = fields.String(required=True) + message = fields.String(required=True) From 9b951de202040d9edd7b91abc52c525b9698697c Mon Sep 17 00:00:00 2001 From: pulk17 Date: Fri, 5 Jun 2026 22:14:35 +0530 Subject: [PATCH 07/28] Delete openapi-ci-api.yaml --- openapi-ci-api.yaml | 2627 ------------------------------------------- 1 file changed, 2627 deletions(-) delete mode 100644 openapi-ci-api.yaml diff --git a/openapi-ci-api.yaml b/openapi-ci-api.yaml deleted file mode 100644 index c0c6eded..00000000 --- a/openapi-ci-api.yaml +++ /dev/null @@ -1,2627 +0,0 @@ -openapi: 3.0.3 -info: - title: CCExtractor CI System API - version: 1.2.0 - description: > - Security-hardened JSON-only REST API for the CCExtractor CI/sample platform. - Designed for AI agents and CI automation. Enforces scoped Bearer token auth, - strict input validation, rate limiting on all routes, and safe defaults - throughout. No browser sessions, no HTML, no implicit permissions. - - contact: - name: CCExtractor Development - url: https://github.com/CCExtractor/sample-platform - -servers: - - url: https://sampleplatform.ccextractor.org/api/v1 - description: Production - -# -# Global security: all endpoints require auth -# unless explicitly overridden with security: [] -# -security: - - bearerAuth: [runs:read] - -tags: - - name: Auth - description: Token issuance and revocation - - name: Runs - description: CI run lifecycle — list, inspect, trigger, cancel, retry - - name: Samples - description: Media samples and regression test definitions - - name: Results - description: Per-sample output, diffs, and baseline management - - name: Errors and Logs - description: Structured errors and raw log access - - name: System - description: Health, queue, branches, environments, and artifacts - -# -# SECURITY NOTES (implementers must read) -# -# 1. AUTH MODEL -# - All tokens are opaque, server-side. Never expose session cookies via API. -# - The CI worker token (/ci/progress-reporter) is a separate secret and is -# NOT valid for user-facing API endpoints. -# - Token creation is rate-limited to 5 req/15 min per IP to prevent -# credential stuffing. -# -# 2. SCOPE ENFORCEMENT -# - Scope checks happen at the middleware layer before route handlers. -# - x-required-scope on each operation defines the minimum scope needed. -# - Missing scope → 403 Forbidden (not 401, token is valid but insufficient). -# -# 3. INPUT VALIDATION -# - additionalProperties: false on all request bodies (no mass-assignment). -# - Regex patterns on all free-text IDs (commit_sha, sha256, repository). -# - maxLength on every string field. maxItems on every array. -# - Integer IDs have minimum: 1 (no zero or negative IDs). -# -# 4. OUTPUT SAFETY -# - got=null in TestResultFile means match, not missing output. -# The dummy row (-1,-1,-1,'','error') is translated server-side to -# status=missing_output and never surfaced as a real object. -# - test.failed reflects cancellation only; fail_count is computed from -# TestResult rows. Do not expose test.failed directly. -# - Stack traces in infrastructure errors are opt-in (include_stack=false -# by default) to avoid leaking internal paths. -# -# 5. STORAGE -# - Artifacts may exist in local SAMPLE_REPOSITORY, GCS, or both. -# - storage_status=degraded means one backend only; missing means neither. -# - Never return a download_url that has not been verified to exist. -# - Log endpoints return 404 (not a broken download link) when the log -# file is absent from both storage backends. -# -# 6. RATE LIMITING (all routes) -# - Default: 120 req/min per token (reads), 20 req/min per token (writes). -# - Auth endpoint: 5 req/15 min per IP. -# - Every response includes X-RateLimit-Limit, X-RateLimit-Remaining, -# X-RateLimit-Reset headers. -# - 429 response includes Retry-After header (seconds). -# -# 7. IDEMPOTENCY -# - POST /runs/{run_id}/retry creates a NEW run and preserves the original. -# It does NOT call restart_test, which destructively deletes results. -# - POST /runs/{run_id}/cancel is idempotent; canceling an already-canceled -# run returns 202 with status=accepted and a no-op message. -# -# 8. DIFF ACCESS -# - The diff route is header-gated on the legacy system (not role-gated). -# The API wraps the XHR path and returns structured JSON. No HTML. -# -# 9. STATUS DERIVATION -# - Run status is derived, not stored. TestStatus has only: preparation, -# testing, completed, canceled (canceled covers both canceled and error). -# The API normalizes this to the 7-value enum below. -# - RunSample.status is computed from TestResult + TestResultFile + -# expected exit code + multiple acceptable baselines. - -paths: - - # AUTH - - /auth/tokens: - post: - tags: [Auth] - summary: Create an API token - description: > - Rate-limited to 5 requests per 15 minutes per IP. Tokens are opaque - and stored server-side. Scopes are additive; request only what you need. - Tokens expire after expires_in_days (default 30, max 90). - security: [] - x-rate-limit: "5/15min per IP" - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/TokenCreateRequest" - responses: - "201": - description: Token created. Store the token value; it will not be shown again. - content: - application/json: - schema: - $ref: "#/components/schemas/AuthToken" - "400": - $ref: "#/components/responses/BadRequest" - "401": - description: Invalid credentials - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - example: - code: invalid_credentials - message: Email or password is incorrect. - details: {} - "429": - $ref: "#/components/responses/RateLimited" - default: - $ref: "#/components/responses/Error" - - /auth/tokens/current: - delete: - tags: [Auth] - summary: Revoke the current API token - description: > - Immediately invalidates the token used in the Authorization header. - Subsequent requests with the same token will receive 401. - security: - - bearerAuth: [] - responses: - "204": - description: Token revoked - "401": - $ref: "#/components/responses/Unauthorized" - "429": - $ref: "#/components/responses/RateLimited" - default: - $ref: "#/components/responses/Error" - - # RUNS - - /runs: - get: - tags: [Runs] - summary: List CI runs - description: > - Public read. The underlying table is capped at the 50 most recent runs - in the current implementation; this endpoint adds full pagination. - Sorted by -created_at by default (newest first). - security: - - bearerAuth: [] - x-required-scope: runs:read - parameters: - - $ref: "#/components/parameters/Limit" - - $ref: "#/components/parameters/Offset" - - $ref: "#/components/parameters/RunStatus" - - $ref: "#/components/parameters/Branch" - - $ref: "#/components/parameters/CommitSha" - - $ref: "#/components/parameters/Repository" - - $ref: "#/components/parameters/Platform" - - $ref: "#/components/parameters/CreatedAfter" - - $ref: "#/components/parameters/CreatedBefore" - - name: sort - in: query - schema: - type: string - default: -created_at - enum: [created_at, -created_at, started_at, -started_at, run_id, -run_id] - description: Sort field. Prefix with - for descending order. - responses: - "200": - description: Paginated runs - headers: - X-RateLimit-Limit: - $ref: "#/components/headers/RateLimitLimit" - X-RateLimit-Remaining: - $ref: "#/components/headers/RateLimitRemaining" - X-RateLimit-Reset: - $ref: "#/components/headers/RateLimitReset" - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/Page" - - type: object - properties: - data: - type: array - items: - $ref: "#/components/schemas/Run" - "400": - $ref: "#/components/responses/BadRequest" - "401": - $ref: "#/components/responses/Unauthorized" - "429": - $ref: "#/components/responses/RateLimited" - default: - $ref: "#/components/responses/Error" - - post: - tags: [Runs] - summary: Trigger a new CI run - description: > - Requires runs:write scope and contributor role or above. - The regression_test_ids set is validated against active tests only. - If omitted, all active regression tests are used. - security: - - bearerAuth: [] - x-required-scope: runs:write - x-required-roles: [admin, tester, contributor] - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/RunCreateRequest" - responses: - "202": - description: Run queued. Poll /runs/{run_id}/progress for status. - content: - application/json: - schema: - $ref: "#/components/schemas/Run" - "400": - $ref: "#/components/responses/BadRequest" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "422": - $ref: "#/components/responses/UnprocessableEntity" - "429": - $ref: "#/components/responses/RateLimited" - default: - $ref: "#/components/responses/Error" - - /runs/{run_id}: - get: - tags: [Runs] - summary: Get a CI run - description: > - Returns normalized run status derived from TestProgress rows. - status=canceled covers both explicit cancellation and infrastructure - errors (the underlying model does not distinguish them). - security: - - bearerAuth: [] - x-required-scope: runs:read - parameters: - - $ref: "#/components/parameters/RunId" - responses: - "200": - description: Run details - content: - application/json: - schema: - $ref: "#/components/schemas/Run" - "401": - $ref: "#/components/responses/Unauthorized" - "404": - $ref: "#/components/responses/NotFound" - "429": - $ref: "#/components/responses/RateLimited" - default: - $ref: "#/components/responses/Error" - - /runs/{run_id}/summary: - get: - tags: [Runs] - summary: Get pass/fail summary for a run - description: > - fail_count is computed from TestResult rows, not from test.failed. - test.failed only reflects whether the final progress status is - canceled — it does not reflect regression test outcomes. - Use this endpoint, not test.failed, to triage a run. - security: - - bearerAuth: [] - x-required-scope: runs:read - parameters: - - $ref: "#/components/parameters/RunId" - responses: - "200": - description: Run summary - content: - application/json: - schema: - $ref: "#/components/schemas/RunSummary" - "401": - $ref: "#/components/responses/Unauthorized" - "404": - $ref: "#/components/responses/NotFound" - "429": - $ref: "#/components/responses/RateLimited" - default: - $ref: "#/components/responses/Error" - - /runs/{run_id}/progress: - get: - tags: [Runs] - summary: Get progress events for a run - description: > - Progress events are sourced from TestProgress rows written by the CI - worker via /ci/progress-reporter. Messages are unstructured text. - Structured error types are aspirational until the worker protocol - emits structured JSON. - security: - - bearerAuth: [] - x-required-scope: runs:read - parameters: - - $ref: "#/components/parameters/RunId" - - $ref: "#/components/parameters/Limit" - - $ref: "#/components/parameters/Offset" - - name: status - in: query - schema: - type: string - enum: [queued, preparation, testing, completed, canceled, error] - responses: - "200": - description: Paginated progress events - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/Page" - - type: object - properties: - data: - type: array - items: - $ref: "#/components/schemas/ProgressEvent" - "401": - $ref: "#/components/responses/Unauthorized" - "404": - $ref: "#/components/responses/NotFound" - "429": - $ref: "#/components/responses/RateLimited" - default: - $ref: "#/components/responses/Error" - - /runs/{run_id}/cancel: - post: - tags: [Runs] - summary: Cancel a queued or running CI run - description: > - Idempotent. Canceling an already-canceled or completed run returns - 202 with a no-op message rather than an error. - Requires runs:write scope. - security: - - bearerAuth: [] - x-required-scope: runs:write - x-required-roles: [admin, tester, contributor] - parameters: - - $ref: "#/components/parameters/RunId" - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - reason: - type: string - maxLength: 255 - additionalProperties: false - responses: - "202": - description: Cancellation accepted (or no-op if already terminal) - content: - application/json: - schema: - $ref: "#/components/schemas/RunActionResult" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "404": - $ref: "#/components/responses/NotFound" - "429": - $ref: "#/components/responses/RateLimited" - default: - $ref: "#/components/responses/Error" - - /runs/{run_id}/retry: - post: - tags: [Runs] - summary: Create a new run copied from an existing run - description: > - Creates a NEW run record with the same configuration as the source run. - The original run and all its results are preserved. - WARNING: Do NOT use the legacy restart_test route internally — it - destructively deletes TestResult and TestProgress rows for the - existing run_id. This endpoint always creates a new run_id. - new_run_id in the response is the ID of the newly created run. - security: - - bearerAuth: [] - x-required-scope: runs:write - x-required-roles: [admin, tester, contributor] - parameters: - - $ref: "#/components/parameters/RunId" - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - failed_only: - type: boolean - default: false - description: > - If true, only re-run regression tests that failed in the - source run. If false (default), re-run the full test set. - reason: - type: string - maxLength: 255 - additionalProperties: false - responses: - "202": - description: Retry run queued. new_run_id is the ID of the new run. - content: - application/json: - schema: - $ref: "#/components/schemas/RunActionResult" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "404": - $ref: "#/components/responses/NotFound" - "422": - $ref: "#/components/responses/UnprocessableEntity" - "429": - $ref: "#/components/responses/RateLimited" - default: - $ref: "#/components/responses/Error" - - /runs/{run_id}/config: - get: - tags: [Runs] - summary: Get run configuration and test matrix - description: > - regression_test_ids lists IDs included in this run. When no custom - set was configured, all regression tests are returned. - Implementers must filter by active=true explicitly — - get_customized_regressiontests() does not do this by default. - security: - - bearerAuth: [] - x-required-scope: runs:read - parameters: - - $ref: "#/components/parameters/RunId" - responses: - "200": - description: Run configuration - content: - application/json: - schema: - $ref: "#/components/schemas/RunConfig" - "401": - $ref: "#/components/responses/Unauthorized" - "404": - $ref: "#/components/responses/NotFound" - "429": - $ref: "#/components/responses/RateLimited" - default: - $ref: "#/components/responses/Error" - - # SAMPLES - - /runs/{run_id}/samples: - get: - tags: [Samples] - summary: List regression test results in a run - description: > - Returns one entry per regression test result, not one per unique media - file. A single media sample may yield multiple entries if it has - multiple regression tests (different command flags). - sample_progress in the legacy JSON endpoint is len(test.results) over - total regression tests; it does not reflect multi-output completeness. - security: - - bearerAuth: [] - x-required-scope: runs:read - parameters: - - $ref: "#/components/parameters/RunId" - - $ref: "#/components/parameters/Limit" - - $ref: "#/components/parameters/Offset" - - name: status - in: query - schema: - type: string - enum: [pass, fail, skipped, missing_output, running, not_started] - - name: name - in: query - schema: - type: string - maxLength: 100 - - name: tag - in: query - schema: - type: string - maxLength: 50 - - name: category - in: query - schema: - type: string - maxLength: 50 - responses: - "200": - description: Paginated regression test results - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/Page" - - type: object - properties: - data: - type: array - items: - $ref: "#/components/schemas/RunSample" - "401": - $ref: "#/components/responses/Unauthorized" - "404": - $ref: "#/components/responses/NotFound" - "429": - $ref: "#/components/responses/RateLimited" - default: - $ref: "#/components/responses/Error" - - /runs/{run_id}/samples/{sample_id}: - get: - tags: [Samples] - summary: Get full details for a regression test result in a run - security: - - bearerAuth: [] - x-required-scope: runs:read - parameters: - - $ref: "#/components/parameters/RunId" - - $ref: "#/components/parameters/SampleId" - responses: - "200": - description: Regression test result details - content: - application/json: - schema: - $ref: "#/components/schemas/RunSample" - "401": - $ref: "#/components/responses/Unauthorized" - "404": - $ref: "#/components/responses/NotFound" - "429": - $ref: "#/components/responses/RateLimited" - default: - $ref: "#/components/responses/Error" - - /samples: - get: - tags: [Samples] - summary: List all known media samples - security: - - bearerAuth: [] - x-required-scope: runs:read - parameters: - - $ref: "#/components/parameters/Limit" - - $ref: "#/components/parameters/Offset" - - name: status - in: query - description: > - Derived from linked regression tests. The sample table itself has - no quarantine state; active/inactive reflects whether any active - regression tests reference the sample. - schema: - type: string - enum: [active, inactive] - - name: name - in: query - schema: - type: string - maxLength: 100 - - name: tag - in: query - schema: - type: string - maxLength: 50 - - name: sha256 - in: query - schema: - type: string - pattern: '^[a-fA-F0-9]{64}$' - - name: extension - in: query - schema: - type: string - maxLength: 10 - pattern: '^[a-zA-Z0-9]+$' - responses: - "200": - description: Paginated media samples - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/Page" - - type: object - properties: - data: - type: array - items: - $ref: "#/components/schemas/Sample" - "400": - $ref: "#/components/responses/BadRequest" - "401": - $ref: "#/components/responses/Unauthorized" - "429": - $ref: "#/components/responses/RateLimited" - default: - $ref: "#/components/responses/Error" - - /samples/{sample_id}: - get: - tags: [Samples] - summary: Get media sample metadata - security: - - bearerAuth: [] - x-required-scope: runs:read - parameters: - - $ref: "#/components/parameters/SampleId" - responses: - "200": - description: Media sample metadata - content: - application/json: - schema: - $ref: "#/components/schemas/Sample" - "401": - $ref: "#/components/responses/Unauthorized" - "404": - $ref: "#/components/responses/NotFound" - "429": - $ref: "#/components/responses/RateLimited" - default: - $ref: "#/components/responses/Error" - - /samples/{sample_id}/history: - get: - tags: [Samples] - summary: Get regression test result history for a sample across runs - description: > - Use failure_signature for flake detection: a stable signature across - multiple runs on different commits indicates a genuine regression, - not infrastructure noise. - security: - - bearerAuth: [] - x-required-scope: runs:read - parameters: - - $ref: "#/components/parameters/SampleId" - - $ref: "#/components/parameters/Limit" - - $ref: "#/components/parameters/Offset" - - $ref: "#/components/parameters/RunStatus" - - $ref: "#/components/parameters/Branch" - - $ref: "#/components/parameters/Platform" - - $ref: "#/components/parameters/CreatedAfter" - - $ref: "#/components/parameters/CreatedBefore" - responses: - "200": - description: Paginated sample history - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/Page" - - type: object - properties: - data: - type: array - items: - $ref: "#/components/schemas/SampleHistoryEntry" - "401": - $ref: "#/components/responses/Unauthorized" - "404": - $ref: "#/components/responses/NotFound" - "429": - $ref: "#/components/responses/RateLimited" - default: - $ref: "#/components/responses/Error" - - /regression-tests: - get: - tags: [Samples] - summary: List regression test definitions - description: > - The active filter must be applied explicitly. The legacy - get_customized_regressiontests() returns all regression tests — - including inactive ones — when no custom set is defined. - security: - - bearerAuth: [] - x-required-scope: runs:read - parameters: - - $ref: "#/components/parameters/Limit" - - $ref: "#/components/parameters/Offset" - - name: active - in: query - schema: - type: boolean - - name: category - in: query - schema: - type: string - maxLength: 50 - - name: tag - in: query - schema: - type: string - maxLength: 50 - - name: sample_id - in: query - schema: - type: integer - minimum: 1 - responses: - "200": - description: Paginated regression test definitions - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/Page" - - type: object - properties: - data: - type: array - items: - $ref: "#/components/schemas/RegressionTest" - "401": - $ref: "#/components/responses/Unauthorized" - "429": - $ref: "#/components/responses/RateLimited" - default: - $ref: "#/components/responses/Error" - - # RESULTS - - /runs/{run_id}/samples/{sample_id}/expected: - get: - tags: [Results] - summary: Get expected output for a regression test result - description: > - Expected output is a file reference stored under TestResults using the - regression output extension. Resolved from GCS or local - SAMPLE_REPOSITORY at request time. storage_status reflects which - backends have the file. Do not assume local and GCS are always in sync. - security: - - bearerAuth: [] - x-required-scope: results:read - parameters: - - $ref: "#/components/parameters/RunId" - - $ref: "#/components/parameters/SampleId" - - $ref: "#/components/parameters/RegressionId" - - $ref: "#/components/parameters/OutputId" - - $ref: "#/components/parameters/Format" - responses: - "200": - description: Expected output file - content: - application/json: - schema: - $ref: "#/components/schemas/OutputFile" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "404": - $ref: "#/components/responses/NotFound" - "429": - $ref: "#/components/responses/RateLimited" - default: - $ref: "#/components/responses/Error" - - /runs/{run_id}/samples/{sample_id}/actual: - get: - tags: [Results] - summary: Get actual output generated by a regression test in a run - description: > - IMPORTANT: TestResultFile.got = null means the actual output MATCHED - expected, not that actual output is missing. This is a semantic trap - in the data model. Missing output is represented by a dummy row - (-1,-1,-1,'','error') which the API translates to status=missing_output - and returns 404. A 200 response always contains a real output file. - security: - - bearerAuth: [] - x-required-scope: results:read - parameters: - - $ref: "#/components/parameters/RunId" - - $ref: "#/components/parameters/SampleId" - - $ref: "#/components/parameters/RegressionId" - - $ref: "#/components/parameters/OutputId" - - $ref: "#/components/parameters/Format" - responses: - "200": - description: Actual output file (output exists and differs from expected) - content: - application/json: - schema: - $ref: "#/components/schemas/OutputFile" - "204": - description: > - No actual file stored. got=null in the DB means output matched - expected. Use /expected to retrieve the matched content. - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "404": - $ref: "#/components/responses/NotFound" - "429": - $ref: "#/components/responses/RateLimited" - default: - $ref: "#/components/responses/Error" - - /runs/{run_id}/samples/{sample_id}/diff: - get: - tags: [Results] - summary: Get expected-vs-actual diff for a failing regression test result - description: > - The legacy diff route is header-gated (X-Requested-With: XMLHttpRequest), - not role-gated. The 403 seen on direct browser requests was a - header-check artifact. This endpoint wraps the XHR logic and returns - structured JSON — no HTML, no 50-line truncation. - security: - - bearerAuth: [] - x-required-scope: results:read - parameters: - - $ref: "#/components/parameters/RunId" - - $ref: "#/components/parameters/SampleId" - - $ref: "#/components/parameters/RegressionId" - - $ref: "#/components/parameters/OutputId" - - name: context_lines - in: query - schema: - type: integer - minimum: 0 - maximum: 50 - default: 3 - - name: format - in: query - schema: - type: string - enum: [structured, unified] - default: structured - responses: - "200": - description: Structured or unified diff - content: - application/json: - schema: - $ref: "#/components/schemas/Diff" - "401": - $ref: "#/components/responses/Unauthorized" - "404": - $ref: "#/components/responses/NotFound" - "429": - $ref: "#/components/responses/RateLimited" - default: - $ref: "#/components/responses/Error" - - /runs/{run_id}/samples/{sample_id}/baseline-approval: - post: - tags: [Results] - summary: Approve actual output as the new expected baseline - description: > - Requires baselines:write scope and admin or contributor role. - This is a destructive write — the approved output becomes the new - expected baseline for the regression test. Provide a reason; - it is stored in the audit log. - security: - - bearerAuth: [] - x-required-scope: baselines:write - x-required-roles: [admin, contributor] - parameters: - - $ref: "#/components/parameters/RunId" - - $ref: "#/components/parameters/SampleId" - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/BaselineApprovalRequest" - responses: - "202": - description: Baseline approval recorded. Status begins as pending_review. - content: - application/json: - schema: - $ref: "#/components/schemas/BaselineApproval" - "400": - $ref: "#/components/responses/BadRequest" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "404": - $ref: "#/components/responses/NotFound" - "429": - $ref: "#/components/responses/RateLimited" - default: - $ref: "#/components/responses/Error" - - # ERRORS AND LOGS - - /runs/{run_id}/errors: - get: - tags: [Errors and Logs] - summary: Get structured test errors for a run - description: > - Error types are derived from TestResult and TestResultFile rows. - missing_output is detected from the dummy (-1,-1,-1,'','error') row - pattern, not from got=null (which means match, not missing). - security: - - bearerAuth: [] - x-required-scope: results:read - parameters: - - $ref: "#/components/parameters/RunId" - - $ref: "#/components/parameters/Limit" - - $ref: "#/components/parameters/Offset" - - name: type - in: query - schema: - type: string - enum: [test_failure, exit_code_mismatch, missing_output, diff_mismatch] - - name: severity - in: query - schema: - type: string - enum: [info, warning, error, critical] - - name: sample_id - in: query - schema: - type: integer - minimum: 1 - responses: - "200": - description: Paginated test errors - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/Page" - - type: object - properties: - data: - type: array - items: - $ref: "#/components/schemas/ErrorItem" - "401": - $ref: "#/components/responses/Unauthorized" - "404": - $ref: "#/components/responses/NotFound" - "429": - $ref: "#/components/responses/RateLimited" - default: - $ref: "#/components/responses/Error" - - /runs/{run_id}/infrastructure-errors: - get: - tags: [Errors and Logs] - summary: Get worker, provisioning, and build errors for a run - description: > - Errors are extracted from TestProgress rows written by the CI worker. - Messages are currently unstructured text. The type filter does - best-effort text matching until the worker protocol emits structured - error types. - Stack traces are opt-in (include_stack defaults to false) to avoid - leaking internal paths to unauthorized callers. - security: - - bearerAuth: [] - x-required-scope: system:read - parameters: - - $ref: "#/components/parameters/RunId" - - $ref: "#/components/parameters/Limit" - - $ref: "#/components/parameters/Offset" - - name: type - in: query - schema: - type: string - enum: [queue, vm_provisioning, checkout, merge, build, worker, web_server, storage] - - name: severity - in: query - schema: - type: string - enum: [info, warning, error, critical] - - name: include_stack - in: query - schema: - type: boolean - default: false - description: > - Default false. Set true only when debugging infrastructure failures. - Stacks may contain internal paths; access requires system:read scope. - responses: - "200": - description: Paginated infrastructure errors - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/Page" - - type: object - properties: - data: - type: array - items: - $ref: "#/components/schemas/ErrorItem" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "404": - $ref: "#/components/responses/NotFound" - "429": - $ref: "#/components/responses/RateLimited" - default: - $ref: "#/components/responses/Error" - - /runs/{run_id}/logs: - get: - tags: [Errors and Logs] - summary: Get raw logs for a run - description: > - Logs are stored at SAMPLE_REPOSITORY/LogFiles/{id}.txt and served - via GCS signed URL. Returns 404 — not a broken download link — when - the file is absent from both local and GCS storage. - Uses cursor-based pagination; do not mix cursor and offset. - security: - - bearerAuth: [] - x-required-scope: system:read - parameters: - - $ref: "#/components/parameters/RunId" - - $ref: "#/components/parameters/Limit" - - $ref: "#/components/parameters/Cursor" - - name: level - in: query - schema: - type: string - enum: [debug, info, warning, error, critical] - - name: source - in: query - schema: - type: string - enum: [orchestrator, worker, build, test_runner, web] - - name: contains - in: query - schema: - type: string - maxLength: 100 - responses: - "200": - description: Cursor-paginated run log lines - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/CursorPage" - - type: object - properties: - data: - type: array - items: - $ref: "#/components/schemas/LogLine" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "404": - description: Log file not found in local or GCS storage - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - example: - code: log_not_found - message: Log file for run 9309 does not exist in any storage backend. - details: - run_id: 9309 - checked: [local, gcs] - "429": - $ref: "#/components/responses/RateLimited" - default: - $ref: "#/components/responses/Error" - - /runs/{run_id}/samples/{sample_id}/logs: - get: - tags: [Errors and Logs] - summary: Get raw logs for a regression test result in a run - security: - - bearerAuth: [] - x-required-scope: system:read - parameters: - - $ref: "#/components/parameters/RunId" - - $ref: "#/components/parameters/SampleId" - - $ref: "#/components/parameters/Limit" - - $ref: "#/components/parameters/Cursor" - - name: level - in: query - schema: - type: string - enum: [debug, info, warning, error, critical] - - name: contains - in: query - schema: - type: string - maxLength: 100 - responses: - "200": - description: Cursor-paginated sample log lines - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/CursorPage" - - type: object - properties: - data: - type: array - items: - $ref: "#/components/schemas/LogLine" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "404": - $ref: "#/components/responses/NotFound" - "429": - $ref: "#/components/responses/RateLimited" - default: - $ref: "#/components/responses/Error" - - /runs/{run_id}/error-summary: - get: - tags: [Errors and Logs] - summary: Get grouped error summary for a run - description: > - Use this endpoint to triage a run before drilling into individual - errors. group_by=type gives a high-level failure breakdown; - group_by=sample_id helps identify flaky samples. - security: - - bearerAuth: [] - x-required-scope: results:read - parameters: - - $ref: "#/components/parameters/RunId" - - $ref: "#/components/parameters/Limit" - - $ref: "#/components/parameters/Offset" - - name: group_by - in: query - schema: - type: string - enum: [type, sample_id, regression_id, category, severity] - default: type - - name: severity - in: query - schema: - type: string - enum: [info, warning, error, critical] - responses: - "200": - description: Paginated grouped error summary - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/Page" - - type: object - properties: - data: - type: array - items: - $ref: "#/components/schemas/ErrorSummaryBucket" - "401": - $ref: "#/components/responses/Unauthorized" - "404": - $ref: "#/components/responses/NotFound" - "429": - $ref: "#/components/responses/RateLimited" - default: - $ref: "#/components/responses/Error" - - # SYSTEM - - /system/health: - get: - tags: [System] - summary: Get CI system health and dependency status - description: > - Unauthenticated. Returns overall system status and per-dependency - health. Used by monitoring and uptime checks. - security: [] - responses: - "200": - description: System healthy or degraded - content: - application/json: - schema: - $ref: "#/components/schemas/SystemHealth" - "503": - description: System is down - content: - application/json: - schema: - $ref: "#/components/schemas/SystemHealth" - "429": - $ref: "#/components/responses/RateLimited" - default: - $ref: "#/components/responses/Error" - - /system/queue: - get: - tags: [System] - summary: Get queue depth and currently running jobs - security: - - bearerAuth: [] - x-required-scope: system:read - parameters: - - $ref: "#/components/parameters/Limit" - - $ref: "#/components/parameters/Offset" - - name: platform - in: query - schema: - type: string - enum: [linux, windows] - - name: status - in: query - schema: - type: string - enum: [queued, running] - responses: - "200": - description: Queue status and active jobs - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/Page" - - type: object - properties: - queue_depth: - type: integer - minimum: 0 - running_count: - type: integer - minimum: 0 - data: - type: array - items: - $ref: "#/components/schemas/QueueJob" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "429": - $ref: "#/components/responses/RateLimited" - default: - $ref: "#/components/responses/Error" - - /branches: - get: - tags: [System] - summary: List available branches - security: - - bearerAuth: [] - x-required-scope: runs:read - parameters: - - $ref: "#/components/parameters/Limit" - - $ref: "#/components/parameters/Offset" - - $ref: "#/components/parameters/Repository" - - name: name - in: query - schema: - type: string - maxLength: 100 - - name: active - in: query - schema: - type: boolean - responses: - "200": - description: Paginated branches - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/Page" - - type: object - properties: - data: - type: array - items: - $ref: "#/components/schemas/Branch" - "401": - $ref: "#/components/responses/Unauthorized" - "429": - $ref: "#/components/responses/RateLimited" - default: - $ref: "#/components/responses/Error" - - /environments: - get: - tags: [System] - summary: List available CI environments and platforms - security: - - bearerAuth: [] - x-required-scope: runs:read - parameters: - - $ref: "#/components/parameters/Limit" - - $ref: "#/components/parameters/Offset" - - name: platform - in: query - schema: - type: string - enum: [linux, windows] - - name: active - in: query - schema: - type: boolean - responses: - "200": - description: Paginated CI environments - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/Page" - - type: object - properties: - data: - type: array - items: - $ref: "#/components/schemas/Environment" - "401": - $ref: "#/components/responses/Unauthorized" - "429": - $ref: "#/components/responses/RateLimited" - default: - $ref: "#/components/responses/Error" - - /runs/{run_id}/artifacts: - get: - tags: [System] - summary: List downloadable artifacts for a run - description: > - Only returns artifacts with a verified download_url from at least one - storage backend. storage_status=degraded means one backend only; - storage_status=missing means neither backend has the file (download_url - will be null). Never returns a URL that has not been verified to exist. - security: - - bearerAuth: [] - x-required-scope: results:read - parameters: - - $ref: "#/components/parameters/RunId" - - $ref: "#/components/parameters/Limit" - - $ref: "#/components/parameters/Offset" - - name: type - in: query - schema: - type: string - enum: [build_log, sample_output, expected_output, diff, media_info, binary] - responses: - "200": - description: Paginated run artifacts - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/Page" - - type: object - properties: - data: - type: array - items: - $ref: "#/components/schemas/Artifact" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "404": - $ref: "#/components/responses/NotFound" - "429": - $ref: "#/components/responses/RateLimited" - default: - $ref: "#/components/responses/Error" - -# -# COMPONENTS -# -components: - - securitySchemes: - bearerAuth: - type: http - scheme: bearer - bearerFormat: opaque - description: > - Opaque server-side API token. Obtain via POST /auth/tokens. - The CI worker token used by /ci/progress-reporter is a separate - secret and is NOT valid here. Never use browser session cookies - for API clients. - - # HEADERS - - headers: - RateLimitLimit: - description: Maximum requests allowed in the current window - schema: - type: integer - example: 120 - RateLimitRemaining: - description: Requests remaining in the current window - schema: - type: integer - example: 117 - RateLimitReset: - description: Unix timestamp when the rate limit window resets - schema: - type: integer - example: 1748908800 - - # PARAMETERS - - parameters: - Limit: - name: limit - in: query - description: Maximum number of results to return (1–100) - schema: - type: integer - minimum: 1 - maximum: 100 - default: 50 - - Offset: - name: offset - in: query - description: Number of results to skip for pagination - schema: - type: integer - minimum: 0 - default: 0 - - Cursor: - name: cursor - in: query - description: > - Opaque cursor token for cursor-based pagination. Do not mix with offset. - Obtain next_cursor from the previous response's pagination object. - schema: - type: string - maxLength: 255 - - RunId: - name: run_id - in: path - required: true - description: Numeric run ID - schema: - type: integer - minimum: 1 - - SampleId: - name: sample_id - in: path - required: true - description: Numeric sample or regression result ID - schema: - type: integer - minimum: 1 - - RunStatus: - name: status - in: query - description: > - Normalized run status. Derived from TestProgress rows and TestResult - outcomes. The underlying TestStatus model stores only preparation, - testing, completed, and canceled (where canceled covers both canceled - and error). This enum is the normalized API contract. - schema: - type: string - enum: [queued, running, pass, fail, canceled, error, incomplete] - - Branch: - name: branch - in: query - schema: - type: string - maxLength: 100 - - CommitSha: - name: commit_sha - in: query - description: Full 40-character SHA-1 commit hash - schema: - type: string - pattern: '^[a-fA-F0-9]{40}$' - - Repository: - name: repository - in: query - description: GitHub repository in owner/repo format - schema: - type: string - pattern: '^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$' - maxLength: 100 - - Platform: - name: platform - in: query - schema: - type: string - enum: [linux, windows] - - CreatedAfter: - name: created_after - in: query - description: ISO 8601 datetime. Returns runs created after this time. - schema: - type: string - format: date-time - - CreatedBefore: - name: created_before - in: query - description: ISO 8601 datetime. Returns runs created before this time. - schema: - type: string - format: date-time - - RegressionId: - name: regression_id - in: query - required: true - description: Regression test definition ID - schema: - type: integer - minimum: 1 - - OutputId: - name: output_id - in: query - required: true - description: Output file ID within a regression test definition - schema: - type: integer - minimum: 1 - - Format: - name: format - in: query - description: > - Content encoding for file responses. - Use text only when the file is known to be UTF-8 compatible. - Binary or unknown content defaults to base64. - schema: - type: string - enum: [text, base64] - default: base64 - - # RESPONSES - - responses: - BadRequest: - description: Request body or query parameters failed schema validation - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - example: - code: validation_error - message: Request failed schema validation. - details: - fields: - commit_sha: Must match pattern ^[a-fA-F0-9]{40}$ - platform: Must be one of [linux, windows] - - Unauthorized: - description: Missing, expired, or invalid bearer token - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - example: - code: unauthorized - message: Bearer token is missing, expired, or invalid. - details: {} - - Forbidden: - description: Token is valid but lacks the required scope or role - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - example: - code: forbidden - message: Token does not have the required scope for this operation. - details: - required_scope: runs:write - token_scopes: [runs:read, results:read] - - NotFound: - description: Resource not found - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - example: - code: not_found - message: Run 9317 not found. - details: - resource: run - id: 9317 - - UnprocessableEntity: - description: Request is valid JSON but semantically invalid - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - example: - code: unprocessable - message: regression_test_ids contains inactive test IDs. - details: - inactive_ids: [42, 99] - - RateLimited: - description: Too many requests. Retry after the indicated number of seconds. - headers: - Retry-After: - description: Seconds to wait before retrying - schema: - type: integer - example: 30 - X-RateLimit-Limit: - $ref: "#/components/headers/RateLimitLimit" - X-RateLimit-Remaining: - $ref: "#/components/headers/RateLimitRemaining" - X-RateLimit-Reset: - $ref: "#/components/headers/RateLimitReset" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - example: - code: rate_limited - message: Rate limit exceeded. Retry after 30 seconds. - details: - retry_after: 30 - limit: 120 - window: 60s - - Error: - description: Unexpected server error - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - - # SCHEMAS - - schemas: - - Page: - type: object - required: [data, pagination] - properties: - data: - type: array - items: {} - pagination: - type: object - required: [limit, offset, total] - properties: - limit: - type: integer - minimum: 1 - offset: - type: integer - minimum: 0 - total: - type: integer - minimum: 0 - next_offset: - type: integer - minimum: 0 - nullable: true - - CursorPage: - type: object - required: [data, pagination] - properties: - data: - type: array - items: {} - pagination: - type: object - required: [limit, next_cursor] - properties: - limit: - type: integer - minimum: 1 - next_cursor: - type: string - maxLength: 255 - nullable: true - description: > - Opaque cursor for the next page. Null when there are no - more results. - - ErrorResponse: - type: object - required: [code, message, details] - properties: - code: - type: string - maxLength: 100 - description: Machine-readable error code (snake_case) - example: not_found - message: - type: string - maxLength: 500 - description: Human-readable error summary - example: Run 9317 not found. - details: - type: object - additionalProperties: true - description: > - Structured context for the error. Always an object, never null. - Empty object {} when no additional detail is available. - - TokenCreateRequest: - type: object - required: [email, password, token_name] - additionalProperties: false - properties: - email: - type: string - format: email - maxLength: 255 - password: - type: string - format: password - minLength: 8 - maxLength: 128 - description: Not stored or logged. Used only to verify identity. - token_name: - type: string - maxLength: 50 - pattern: '^[a-zA-Z0-9_-]+$' - description: > - Descriptive label for the token (e.g., local-agent, ci-bot). - Must be unique per user. - expires_in_days: - type: integer - minimum: 1 - maximum: 90 - default: 30 - scopes: - type: array - maxItems: 8 - uniqueItems: true - default: [runs:read, results:read] - items: - type: string - enum: [runs:read, runs:write, results:read, baselines:write, system:read] - description: > - Requested scopes. Grant only what the client needs. - runs:read — list and inspect runs, samples, history. - runs:write — trigger, cancel, retry runs. - results:read — access expected/actual output, diffs, errors, logs. - baselines:write — approve new expected baselines. - system:read — queue, infrastructure errors, stack traces, artifacts. - - AuthToken: - type: object - required: [token, token_type, token_name, scopes, expires_at] - properties: - token: - type: string - maxLength: 512 - description: > - Opaque token value. Store it securely. It will not be shown again. - token_type: - type: string - enum: [Bearer] - token_name: - type: string - maxLength: 50 - scopes: - type: array - maxItems: 8 - uniqueItems: true - items: - type: string - enum: [runs:read, runs:write, results:read, baselines:write, system:read] - expires_at: - type: string - format: date-time - - RunCreateRequest: - type: object - required: [repository, commit_sha, platform] - additionalProperties: false - properties: - repository: - type: string - pattern: '^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$' - maxLength: 100 - example: CCExtractor/ccextractor - branch: - type: string - pattern: '^[A-Za-z0-9._\/-]+$' - maxLength: 100 - example: master - commit_sha: - type: string - pattern: '^[a-fA-F0-9]{40}$' - example: 0632bff4e382d5f86eff9073b9ddd37f03f9778c - pull_request: - type: integer - minimum: 1 - nullable: true - example: 2264 - platform: - type: string - enum: [linux, windows] - example: windows - regression_test_ids: - type: array - maxItems: 500 - uniqueItems: true - items: - type: integer - minimum: 1 - description: > - Optional subset of active regression test IDs. - If omitted, all active tests are used. - Inactive test IDs are rejected with 422. - environment_id: - type: string - maxLength: 50 - example: windows-latest - - Run: - type: object - required: [run_id, status, repository, commit_sha, platform, created_at] - properties: - run_id: - type: integer - minimum: 1 - status: - type: string - enum: [queued, running, pass, fail, canceled, error, incomplete] - description: > - Normalized status. Derived from TestProgress rows and TestResult - outcomes. status=canceled covers both explicit cancellation and - infrastructure error (the underlying model conflates them). - repository: - type: string - maxLength: 100 - branch: - type: string - maxLength: 100 - nullable: true - commit_sha: - type: string - pattern: '^[a-fA-F0-9]{40}$' - commit_short: - type: string - maxLength: 10 - pull_request: - type: integer - minimum: 1 - nullable: true - platform: - type: string - enum: [linux, windows] - run_errors: - type: string - enum: [yes, no, unknown] - triggered_by: - type: string - maxLength: 100 - nullable: true - created_at: - type: string - format: date-time - queued_at: - type: string - format: date-time - nullable: true - started_at: - type: string - format: date-time - nullable: true - completed_at: - type: string - format: date-time - nullable: true - duration_ms: - type: integer - minimum: 0 - nullable: true - links: - type: object - additionalProperties: - type: string - format: uri - - RunSummary: - type: object - required: [run_id, total_samples, pass_count, fail_count] - properties: - run_id: - type: integer - minimum: 1 - total_samples: - type: integer - minimum: 0 - description: Total regression test results in this run. - pass_count: - type: integer - minimum: 0 - fail_count: - type: integer - minimum: 0 - description: > - Computed from TestResult rows. NOT derived from test.failed, - which only reflects cancellation state and is unreliable for - determining whether regression tests actually passed. - skipped_count: - type: integer - minimum: 0 - missing_output_count: - type: integer - minimum: 0 - description: > - Samples that produced no output when output was expected. - Detected from the dummy TestResultFile(-1,-1,-1,'','error') row, - not from got=null (which means output matched). - error_count: - type: integer - minimum: 0 - duration_ms: - type: integer - minimum: 0 - nullable: true - triggered_by: - type: string - maxLength: 100 - nullable: true - - ProgressEvent: - type: object - required: [timestamp, status, message] - properties: - timestamp: - type: string - format: date-time - status: - type: string - enum: [queued, preparation, testing, completed, canceled, error] - message: - type: string - maxLength: 500 - description: Unstructured text from TestProgress rows. - step: - type: integer - minimum: 0 - nullable: true - - RunActionResult: - type: object - required: [run_id, action, status] - properties: - run_id: - type: integer - minimum: 1 - description: ID of the source run (for cancel) or original run (for retry). - new_run_id: - type: integer - minimum: 1 - nullable: true - description: > - Set on retry actions only. ID of the newly created run. - The original run is always preserved. - action: - type: string - enum: [cancel, retry] - status: - type: string - enum: [accepted, rejected, no_op] - description: no_op is returned when canceling an already-terminal run. - message: - type: string - maxLength: 500 - - RunConfig: - type: object - required: [run_id] - properties: - run_id: - type: integer - minimum: 1 - environment: - $ref: "#/components/schemas/Environment" - matrix: - type: array - maxItems: 500 - items: - type: object - additionalProperties: true - regression_test_ids: - type: array - maxItems: 500 - uniqueItems: true - items: - type: integer - minimum: 1 - description: > - IDs included in this run. When no custom set was configured, all - regression tests are returned. Implementers must filter by - active=true — get_customized_regressiontests() does not do this. - command_defaults: - type: array - maxItems: 50 - items: - type: string - maxLength: 100 - - Sample: - type: object - required: [sample_id, sha256] - properties: - sample_id: - type: integer - minimum: 1 - sha256: - type: string - pattern: '^[a-fA-F0-9]{64}$' - name: - type: string - maxLength: 255 - extension: - type: string - maxLength: 10 - tags: - type: array - maxItems: 50 - items: - type: string - maxLength: 50 - media_info: - type: object - additionalProperties: true - notes: - type: string - maxLength: 1000 - nullable: true - - RegressionTest: - type: object - required: [regression_id, sample_id, command] - properties: - regression_id: - type: integer - minimum: 1 - sample_id: - type: integer - minimum: 1 - command: - type: string - maxLength: 500 - active: - type: boolean - category: - type: string - maxLength: 100 - tags: - type: array - maxItems: 50 - items: - type: string - maxLength: 50 - expected_outputs: - type: array - maxItems: 20 - description: > - File references stored under TestResults. Content is resolved - from GCS or local SAMPLE_REPOSITORY at request time. - items: - $ref: "#/components/schemas/OutputFile" - - RunSample: - type: object - required: [run_id, sample_id, regression_id, status] - properties: - run_id: - type: integer - minimum: 1 - sample_id: - type: integer - minimum: 1 - regression_id: - type: integer - minimum: 1 - category: - type: string - maxLength: 100 - command: - type: string - maxLength: 500 - status: - type: string - enum: [pass, fail, skipped, missing_output, running, not_started] - description: > - Computed from TestResult, TestResultFile, expected exit code, - and multiple acceptable baselines. Not a stored column. - runtime_ms: - type: integer - minimum: 0 - nullable: true - exit_code: - type: integer - nullable: true - expected_exit_code: - type: integer - nullable: true - result_message: - type: string - maxLength: 500 - nullable: true - tags: - type: array - maxItems: 50 - items: - type: string - maxLength: 50 - outputs: - type: array - maxItems: 20 - description: > - One entry per expected output file. - got=null in the DB means output matched expected; no actual file - is stored. The dummy (-1,-1,-1,'','error') row is translated to - status=missing_output and is never exposed here. - items: - type: object - required: [output_id, status] - properties: - output_id: - type: integer - minimum: 1 - status: - type: string - enum: [match, diff_mismatch, missing_output, missing_expected] - expected_hash: - type: string - pattern: '^[a-fA-F0-9]{64}$' - nullable: true - actual_hash: - type: string - pattern: '^[a-fA-F0-9]{64}$' - nullable: true - - SampleHistoryEntry: - type: object - required: [run_id, sample_id, regression_id, status] - properties: - run_id: - type: integer - minimum: 1 - sample_id: - type: integer - minimum: 1 - regression_id: - type: integer - minimum: 1 - run_created_at: - type: string - format: date-time - commit_sha: - type: string - pattern: '^[a-fA-F0-9]{40}$' - nullable: true - branch: - type: string - maxLength: 100 - nullable: true - platform: - type: string - enum: [linux, windows] - status: - type: string - enum: [pass, fail, skipped, missing_output] - runtime_ms: - type: integer - minimum: 0 - nullable: true - failure_signature: - type: string - maxLength: 255 - nullable: true - description: > - Stable string identifying the failure type and output ID. - Use across runs to detect genuine regressions vs. infrastructure - flakes. - - OutputFile: - type: object - required: [sample_id, regression_id, output_id, filename, content_type, encoding, content, storage_status] - properties: - run_id: - type: integer - minimum: 1 - nullable: true - description: Null for expected output not tied to a specific run. - sample_id: - type: integer - minimum: 1 - regression_id: - type: integer - minimum: 1 - output_id: - type: integer - minimum: 1 - filename: - type: string - maxLength: 255 - content_type: - type: string - maxLength: 100 - encoding: - type: string - enum: [utf-8, base64] - description: > - utf-8 only when file is confirmed text. Default is base64. - content: - type: string - maxLength: 1048576 - sha256: - type: string - pattern: '^[a-fA-F0-9]{64}$' - storage_status: - type: string - enum: [ok, degraded, missing] - description: > - ok = verified in both local and GCS storage. - degraded = exists in one backend only. - missing = not found in either backend. - - Diff: - type: object - required: [run_id, sample_id, regression_id, output_id, status] - properties: - run_id: - type: integer - minimum: 1 - sample_id: - type: integer - minimum: 1 - regression_id: - type: integer - minimum: 1 - output_id: - type: integer - minimum: 1 - status: - type: string - enum: [identical, different, missing_expected, missing_actual] - summary: - type: object - required: [added_lines, removed_lines, changed_hunks] - properties: - added_lines: - type: integer - minimum: 0 - removed_lines: - type: integer - minimum: 0 - changed_hunks: - type: integer - minimum: 0 - hunks: - type: array - maxItems: 500 - items: - type: object - required: [expected_start, actual_start, lines] - properties: - expected_start: - type: integer - minimum: 0 - actual_start: - type: integer - minimum: 0 - lines: - type: array - maxItems: 500 - items: - type: object - required: [kind, text] - properties: - kind: - type: string - enum: [context, added, removed] - expected_line: - type: integer - minimum: 0 - nullable: true - actual_line: - type: integer - minimum: 0 - nullable: true - text: - type: string - maxLength: 1000 - - BaselineApprovalRequest: - type: object - required: [regression_id, output_id, reason] - additionalProperties: false - properties: - regression_id: - type: integer - minimum: 1 - output_id: - type: integer - minimum: 1 - reason: - type: string - minLength: 10 - maxLength: 500 - description: > - Required justification stored in the audit log. Minimum 10 - characters; do not accept placeholder values. - apply_to_variants: - type: boolean - default: false - description: > - If true, apply this baseline to all command variants of the - regression test, not just the specific output_id. - - BaselineApproval: - type: object - required: [approval_id, status, run_id, sample_id, regression_id, output_id, requested_by, created_at] - properties: - approval_id: - type: string - maxLength: 100 - status: - type: string - enum: [pending_review, approved, rejected] - run_id: - type: integer - minimum: 1 - sample_id: - type: integer - minimum: 1 - regression_id: - type: integer - minimum: 1 - output_id: - type: integer - minimum: 1 - requested_by: - type: string - format: email - maxLength: 255 - created_at: - type: string - format: date-time - - ErrorItem: - type: object - required: [error_id, run_id, type, severity, message, occurred_at] - properties: - error_id: - type: string - maxLength: 100 - run_id: - type: integer - minimum: 1 - sample_id: - type: integer - minimum: 1 - nullable: true - regression_id: - type: integer - minimum: 1 - nullable: true - type: - type: string - maxLength: 100 - severity: - type: string - enum: [info, warning, error, critical] - message: - type: string - maxLength: 1000 - location: - type: object - additionalProperties: true - nullable: true - stack: - type: array - maxItems: 50 - description: Only present when include_stack=true was requested. - items: - type: string - maxLength: 2000 - occurred_at: - type: string - format: date-time - - LogLine: - type: object - required: [timestamp, level, source, message, run_id] - properties: - timestamp: - type: string - format: date-time - level: - type: string - enum: [debug, info, warning, error, critical] - source: - type: string - enum: [orchestrator, worker, build, test_runner, web] - message: - type: string - maxLength: 4000 - run_id: - type: integer - minimum: 1 - sample_id: - type: integer - minimum: 1 - nullable: true - - ErrorSummaryBucket: - type: object - required: [key, count, severity] - properties: - key: - type: string - maxLength: 100 - count: - type: integer - minimum: 0 - severity: - type: string - enum: [info, warning, error, critical] - sample_ids: - type: array - maxItems: 1000 - items: - type: integer - minimum: 1 - first_seen_at: - type: string - format: date-time - nullable: true - last_seen_at: - type: string - format: date-time - nullable: true - - SystemHealth: - type: object - required: [status, checked_at, dependencies] - properties: - status: - type: string - enum: [ok, degraded, down] - checked_at: - type: string - format: date-time - dependencies: - type: array - items: - type: object - required: [name, status] - properties: - name: - type: string - maxLength: 100 - status: - type: string - enum: [ok, degraded, down] - message: - type: string - maxLength: 500 - nullable: true - - QueueJob: - type: object - required: [run_id, status, platform, queued_at] - properties: - run_id: - type: integer - minimum: 1 - status: - type: string - enum: [queued, running] - platform: - type: string - enum: [linux, windows] - queued_at: - type: string - format: date-time - started_at: - type: string - format: date-time - nullable: true - position: - type: integer - minimum: 1 - nullable: true - description: Queue position. Null for jobs that are already running. - - Branch: - type: object - required: [repository, name, active] - properties: - repository: - type: string - pattern: '^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$' - maxLength: 100 - name: - type: string - maxLength: 100 - head_sha: - type: string - pattern: '^[a-fA-F0-9]{40}$' - nullable: true - active: - type: boolean - - Environment: - type: object - required: [environment_id, platform, active] - properties: - environment_id: - type: string - maxLength: 100 - platform: - type: string - enum: [linux, windows] - active: - type: boolean - runner_label: - type: string - maxLength: 100 - nullable: true - average_duration_ms: - type: integer - minimum: 0 - nullable: true - - Artifact: - type: object - required: [artifact_id, run_id, type, filename, content_type, storage_status] - properties: - artifact_id: - type: string - maxLength: 100 - run_id: - type: integer - minimum: 1 - sample_id: - type: integer - minimum: 1 - nullable: true - type: - type: string - enum: [build_log, sample_output, expected_output, diff, media_info, binary] - filename: - type: string - maxLength: 255 - content_type: - type: string - maxLength: 100 - size_bytes: - type: integer - minimum: 0 - nullable: true - storage_status: - type: string - enum: [ok, degraded, missing] - description: > - ok = verified in primary storage. - degraded = exists in one backend only (local or GCS). - missing = not found in either backend. - download_url: - type: string - format: uri - nullable: true - description: > - Only present and non-null when storage_status is ok or degraded. - Always a verified URL. Null when storage_status=missing. \ No newline at end of file From 3f0f95a4017203839662455862ef29812110a7f7 Mon Sep 17 00:00:00 2001 From: Pulkit Chauhan Date: Sun, 7 Jun 2026 13:04:13 +0530 Subject: [PATCH 08/28] Updated Spec yaml --- openapi-ci-api.yaml | 403 ++++++++++++-------------------------------- 1 file changed, 108 insertions(+), 295 deletions(-) diff --git a/openapi-ci-api.yaml b/openapi-ci-api.yaml index c0c6eded..ed8d6c59 100644 --- a/openapi-ci-api.yaml +++ b/openapi-ci-api.yaml @@ -13,6 +13,8 @@ info: url: https://github.com/CCExtractor/sample-platform servers: + - url: http://localhost:5000/api/v1 + description: Development - url: https://sampleplatform.ccextractor.org/api/v1 description: Production @@ -27,7 +29,7 @@ tags: - name: Auth description: Token issuance and revocation - name: Runs - description: CI run lifecycle — list, inspect, trigger, cancel, retry + description: CI run lifecycle — list, inspect, trigger, and cancel - name: Samples description: Media samples and regression test definitions - name: Results @@ -35,7 +37,7 @@ tags: - name: Errors and Logs description: Structured errors and raw log access - name: System - description: Health, queue, branches, environments, and artifacts + description: Health, queue, and artifacts # # SECURITY NOTES (implementers must read) @@ -82,8 +84,6 @@ tags: # - 429 response includes Retry-After header (seconds). # # 7. IDEMPOTENCY -# - POST /runs/{run_id}/retry creates a NEW run and preserves the original. -# It does NOT call restart_test, which destructively deletes results. # - POST /runs/{run_id}/cancel is idempotent; canceling an already-canceled # run returns 202 with status=accepted and a no-op message. # @@ -189,7 +189,7 @@ paths: schema: type: string default: -created_at - enum: [created_at, -created_at, started_at, -started_at, run_id, -run_id] + enum: [created_at, -created_at, run_id, -run_id] description: Sort field. Prefix with - for descending order. responses: "200": @@ -404,60 +404,6 @@ paths: default: $ref: "#/components/responses/Error" - /runs/{run_id}/retry: - post: - tags: [Runs] - summary: Create a new run copied from an existing run - description: > - Creates a NEW run record with the same configuration as the source run. - The original run and all its results are preserved. - WARNING: Do NOT use the legacy restart_test route internally — it - destructively deletes TestResult and TestProgress rows for the - existing run_id. This endpoint always creates a new run_id. - new_run_id in the response is the ID of the newly created run. - security: - - bearerAuth: [] - x-required-scope: runs:write - x-required-roles: [admin, tester, contributor] - parameters: - - $ref: "#/components/parameters/RunId" - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - failed_only: - type: boolean - default: false - description: > - If true, only re-run regression tests that failed in the - source run. If false (default), re-run the full test set. - reason: - type: string - maxLength: 255 - additionalProperties: false - responses: - "202": - description: Retry run queued. new_run_id is the ID of the new run. - content: - application/json: - schema: - $ref: "#/components/schemas/RunActionResult" - "401": - $ref: "#/components/responses/Unauthorized" - "403": - $ref: "#/components/responses/Forbidden" - "404": - $ref: "#/components/responses/NotFound" - "422": - $ref: "#/components/responses/UnprocessableEntity" - "429": - $ref: "#/components/responses/RateLimited" - default: - $ref: "#/components/responses/Error" - /runs/{run_id}/config: get: tags: [Runs] @@ -909,8 +855,8 @@ paths: schema: $ref: "#/components/schemas/BaselineApprovalRequest" responses: - "202": - description: Baseline approval recorded. Status begins as pending_review. + "200": + description: Baseline approval applied immediately. content: application/json: schema: @@ -1283,87 +1229,6 @@ paths: default: $ref: "#/components/responses/Error" - /branches: - get: - tags: [System] - summary: List available branches - security: - - bearerAuth: [] - x-required-scope: runs:read - parameters: - - $ref: "#/components/parameters/Limit" - - $ref: "#/components/parameters/Offset" - - $ref: "#/components/parameters/Repository" - - name: name - in: query - schema: - type: string - maxLength: 100 - - name: active - in: query - schema: - type: boolean - responses: - "200": - description: Paginated branches - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/Page" - - type: object - properties: - data: - type: array - items: - $ref: "#/components/schemas/Branch" - "401": - $ref: "#/components/responses/Unauthorized" - "429": - $ref: "#/components/responses/RateLimited" - default: - $ref: "#/components/responses/Error" - - /environments: - get: - tags: [System] - summary: List available CI environments and platforms - security: - - bearerAuth: [] - x-required-scope: runs:read - parameters: - - $ref: "#/components/parameters/Limit" - - $ref: "#/components/parameters/Offset" - - name: platform - in: query - schema: - type: string - enum: [linux, windows] - - name: active - in: query - schema: - type: boolean - responses: - "200": - description: Paginated CI environments - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/Page" - - type: object - properties: - data: - type: array - items: - $ref: "#/components/schemas/Environment" - "401": - $ref: "#/components/responses/Unauthorized" - "429": - $ref: "#/components/responses/RateLimited" - default: - $ref: "#/components/responses/Error" - /runs/{run_id}/artifacts: get: tags: [System] @@ -1506,30 +1371,37 @@ components: schema: type: string enum: [queued, running, pass, fail, canceled, error, incomplete] + example: pass Branch: name: branch in: query + description: Filter by branch name (e.g. master, develop). schema: type: string maxLength: 100 + example: master CommitSha: name: commit_sha in: query - description: Full 40-character SHA-1 commit hash + description: > + Filter by full 40-character SHA-1 commit hash. schema: type: string pattern: '^[a-fA-F0-9]{40}$' + example: 0b1a967b732898e705ea8f2fda5d08eb00328579 Repository: name: repository in: query - description: GitHub repository in owner/repo format + description: > + Filter by GitHub repository in owner/repo format. schema: type: string pattern: '^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$' maxLength: 100 + example: CCExtractor/ccextractor Platform: name: platform @@ -1537,11 +1409,14 @@ components: schema: type: string enum: [linux, windows] + example: linux CreatedAfter: name: created_after in: query - description: ISO 8601 datetime. Returns runs created after this time. + description: > + ISO 8601 datetime filter. Returns runs created after this time. + Example: 2025-01-01T00:00:00Z schema: type: string format: date-time @@ -1549,7 +1424,9 @@ components: CreatedBefore: name: created_before in: query - description: ISO 8601 datetime. Returns runs created before this time. + description: > + ISO 8601 datetime filter. Returns runs created before this time. + Example: 2026-12-31T23:59:59Z schema: type: string format: date-time @@ -1793,7 +1670,7 @@ components: description: > Requested scopes. Grant only what the client needs. runs:read — list and inspect runs, samples, history. - runs:write — trigger, cancel, retry runs. + runs:write — trigger and cancel runs. results:read — access expected/actual output, diffs, errors, logs. baselines:write — approve new expected baselines. system:read — queue, infrastructure errors, stack traces, artifacts. @@ -1863,10 +1740,6 @@ components: Optional subset of active regression test IDs. If omitted, all active tests are used. Inactive test IDs are rejected with 422. - environment_id: - type: string - maxLength: 50 - example: windows-latest Run: type: object @@ -1882,6 +1755,13 @@ components: Normalized status. Derived from TestProgress rows and TestResult outcomes. status=canceled covers both explicit cancellation and infrastructure error (the underlying model conflates them). + platform: + type: string + enum: [linux, windows] + test_type: + type: string + enum: [pr, commit] + description: Whether this run was triggered by a pull request or a commit push. repository: type: string maxLength: 100 @@ -1892,23 +1772,11 @@ components: commit_sha: type: string pattern: '^[a-fA-F0-9]{40}$' - commit_short: - type: string - maxLength: 10 - pull_request: + pr_number: type: integer minimum: 1 nullable: true - platform: - type: string - enum: [linux, windows] - run_errors: - type: string - enum: [yes, no, unknown] - triggered_by: - type: string - maxLength: 100 - nullable: true + description: Pull request number, if this run was triggered by a PR. created_at: type: string format: date-time @@ -1924,15 +1792,11 @@ components: type: string format: date-time nullable: true - duration_ms: - type: integer - minimum: 0 + github_link: + type: string + format: uri nullable: true - links: - type: object - additionalProperties: - type: string - format: uri + description: Direct link to the commit or PR on GitHub. RunSummary: type: object @@ -2003,17 +1867,15 @@ components: run_id: type: integer minimum: 1 - description: ID of the source run (for cancel) or original run (for retry). + description: ID of the run this action targets. new_run_id: type: integer minimum: 1 nullable: true - description: > - Set on retry actions only. ID of the newly created run. - The original run is always preserved. + description: Reserved for future use. Always null for cancel actions. action: type: string - enum: [cancel, retry] + enum: [cancel] status: type: string enum: [accepted, rejected, no_op] @@ -2029,8 +1891,6 @@ components: run_id: type: integer minimum: 1 - environment: - $ref: "#/components/schemas/Environment" matrix: type: array maxItems: 500 @@ -2057,86 +1917,99 @@ components: Sample: type: object - required: [sample_id, sha256] + required: [sample_id, sha] properties: sample_id: type: integer minimum: 1 - sha256: - type: string - pattern: '^[a-fA-F0-9]{64}$' - name: + sha: type: string - maxLength: 255 + description: SHA256 hash of the sample file. extension: type: string maxLength: 10 + original_name: + type: string + maxLength: 255 + filename: + type: string + maxLength: 255 tags: type: array maxItems: 50 items: type: string maxLength: 50 - media_info: - type: object - additionalProperties: true - notes: - type: string - maxLength: 1000 - nullable: true + regression_test_count: + type: integer + minimum: 0 + description: Number of active regression tests referencing this sample. + active: + type: boolean + description: True if at least one active regression test references this sample. RegressionTest: type: object - required: [regression_id, sample_id, command] + required: [regression_test_id, sample_id, command] properties: - regression_id: + regression_test_id: type: integer minimum: 1 sample_id: type: integer minimum: 1 + sample_name: + type: string + maxLength: 255 + nullable: true command: type: string maxLength: 500 + input_type: + type: string + maxLength: 50 + output_type: + type: string + maxLength: 50 + expected_rc: + type: integer + nullable: true active: type: boolean - category: - type: string - maxLength: 100 - tags: + categories: type: array maxItems: 50 items: type: string - maxLength: 50 - expected_outputs: - type: array - maxItems: 20 - description: > - File references stored under TestResults. Content is resolved - from GCS or local SAMPLE_REPOSITORY at request time. - items: - $ref: "#/components/schemas/OutputFile" + maxLength: 100 + description: + type: string + maxLength: 1000 + nullable: true RunSample: type: object - required: [run_id, sample_id, regression_id, status] + required: [regression_test_id, sample_id, status] properties: - run_id: + regression_test_id: type: integer minimum: 1 sample_id: type: integer minimum: 1 - regression_id: - type: integer - minimum: 1 + nullable: true + sample_name: + type: string + maxLength: 255 + nullable: true category: type: string maxLength: 100 + nullable: true command: type: string maxLength: 500 + nullable: true status: type: string enum: [pass, fail, skipped, missing_output, running, not_started] @@ -2150,19 +2023,10 @@ components: exit_code: type: integer nullable: true - expected_exit_code: + expected_rc: type: integer nullable: true - result_message: - type: string - maxLength: 500 - nullable: true - tags: - type: array - maxItems: 50 - items: - type: string - maxLength: 50 + description: Expected return code for this regression test. outputs: type: array maxItems: 20 @@ -2173,57 +2037,44 @@ components: status=missing_output and is never exposed here. items: type: object - required: [output_id, status] + required: [output_id, filename, status] properties: output_id: type: integer minimum: 1 + filename: + type: string + maxLength: 255 status: type: string enum: [match, diff_mismatch, missing_output, missing_expected] - expected_hash: - type: string - pattern: '^[a-fA-F0-9]{64}$' - nullable: true - actual_hash: - type: string - pattern: '^[a-fA-F0-9]{64}$' - nullable: true SampleHistoryEntry: type: object - required: [run_id, sample_id, regression_id, status] + required: [run_id, status] properties: run_id: type: integer minimum: 1 - sample_id: - type: integer - minimum: 1 - regression_id: - type: integer - minimum: 1 - run_created_at: + status: type: string - format: date-time - commit_sha: + enum: [pass, fail, skipped, missing_output] + platform: type: string - pattern: '^[a-fA-F0-9]{40}$' - nullable: true + enum: [linux, windows] branch: type: string maxLength: 100 nullable: true - platform: + commit_sha: type: string - enum: [linux, windows] - status: + pattern: '^[a-fA-F0-9]{40}$' + nullable: true + tested_at: type: string - enum: [pass, fail, skipped, missing_output] - runtime_ms: - type: integer - minimum: 0 + format: date-time nullable: true + description: completed_at or started_at timestamp from the run. failure_signature: type: string maxLength: 255 @@ -2361,12 +2212,13 @@ components: description: > Required justification stored in the audit log. Minimum 10 characters; do not accept placeholder values. - apply_to_variants: + remove_variants: type: boolean default: false description: > - If true, apply this baseline to all command variants of the - regression test, not just the specific output_id. + If true, remove all platform-specific variants and use this + output as the single baseline across all platforms. + WARNING: This collapses platform-specific expected outputs into one. BaselineApproval: type: object @@ -2377,7 +2229,7 @@ components: maxLength: 100 status: type: string - enum: [pending_review, approved, rejected] + enum: [approved, rejected] run_id: type: integer minimum: 1 @@ -2545,45 +2397,6 @@ components: nullable: true description: Queue position. Null for jobs that are already running. - Branch: - type: object - required: [repository, name, active] - properties: - repository: - type: string - pattern: '^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$' - maxLength: 100 - name: - type: string - maxLength: 100 - head_sha: - type: string - pattern: '^[a-fA-F0-9]{40}$' - nullable: true - active: - type: boolean - - Environment: - type: object - required: [environment_id, platform, active] - properties: - environment_id: - type: string - maxLength: 100 - platform: - type: string - enum: [linux, windows] - active: - type: boolean - runner_label: - type: string - maxLength: 100 - nullable: true - average_duration_ms: - type: integer - minimum: 0 - nullable: true - Artifact: type: object required: [artifact_id, run_id, type, filename, content_type, storage_status] From 857798d6da549707353ed608af0fef06cf7d1d5a Mon Sep 17 00:00:00 2001 From: Pulkit Chauhan Date: Mon, 8 Jun 2026 15:27:05 +0530 Subject: [PATCH 09/28] Updated openapi specs with better error handling, consistent refs and better descriptions & comments --- openapi-ci-api.yaml | 355 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 337 insertions(+), 18 deletions(-) diff --git a/openapi-ci-api.yaml b/openapi-ci-api.yaml index ed8d6c59..ef291327 100644 --- a/openapi-ci-api.yaml +++ b/openapi-ci-api.yaml @@ -2,15 +2,26 @@ openapi: 3.0.3 info: title: CCExtractor CI System API version: 1.2.0 - description: > + description: | Security-hardened JSON-only REST API for the CCExtractor CI/sample platform. Designed for AI agents and CI automation. Enforces scoped Bearer token auth, strict input validation, rate limiting on all routes, and safe defaults throughout. No browser sessions, no HTML, no implicit permissions. + **Authentication:** All endpoints require bearer token authentication unless + explicitly marked with `security: []` (only /system/health and POST /auth/tokens). + + **Rate-limit headers:** Every response includes `X-RateLimit-Limit`, + `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers. These are modeled + explicitly on the 429 response for brevity; they are present on all responses + regardless of status code. + contact: name: CCExtractor Development url: https://github.com/CCExtractor/sample-platform + license: + name: GPL-3.0-only + url: https://www.gnu.org/licenses/gpl-3.0.html servers: - url: http://localhost:5000/api/v1 @@ -23,7 +34,7 @@ servers: # unless explicitly overridden with security: [] # security: - - bearerAuth: [runs:read] + - bearerAuth: [] tags: - name: Auth @@ -97,15 +108,77 @@ tags: # The API normalizes this to the 7-value enum below. # - RunSample.status is computed from TestResult + TestResultFile + # expected exit code + multiple acceptable baselines. +# - fail_count and missing_output_count in RunSummary are mutually +# exclusive. A sample appears in exactly one bucket (missing_output +# is checked first; if the dummy sentinel row is detected the function +# returns immediately without evaluating fail conditions). +# +# 10. REPOSITORY PERMISSIONS +# - POST /runs enforces a repo-aware permission check. Triggering a run +# against the main configured repository (GITHUB_OWNER/GITHUB_REPOSITORY) +# requires the contributor role or above. Any authenticated user with +# runs:write scope may trigger runs against fork repositories. There is +# no global repository allowlist; the elevated-role check applies only +# to the main configured repository. +# paths: # AUTH /auth/tokens: + get: + tags: [Auth] + summary: List API tokens + operationId: listTokens + description: > + Lists tokens for the authenticated user. Non-admin users see only their + own tokens. Admins may append ?all=true to list tokens across the entire + system; non-admin callers sending ?all=true receive 403. + + Plaintext token values are never included in list responses. + security: + - bearerAuth: [] + x-required-scope: tokens:manage + parameters: + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + - name: all + in: query + schema: + type: boolean + description: > + Admin only. Set to true to list tokens for all users in the system. + Non-admin callers receive 403 if this parameter is present and true. + responses: + "200": + description: Paginated list of tokens (without plaintext secrets). + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Page" + - type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/ApiTokenItem" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + post: tags: [Auth] summary: Create an API token + operationId: createToken description: > Rate-limited to 5 requests per 15 minutes per IP. Tokens are opaque and stored server-side. Scopes are additive; request only what you need. @@ -146,9 +219,14 @@ paths: delete: tags: [Auth] summary: Revoke the current API token + operationId: revokeCurrentToken description: > Immediately invalidates the token used in the Authorization header. Subsequent requests with the same token will receive 401. + + No specific scope is required beyond authentication — any valid token + can self-revoke. This is the preferred way to clean up a token when + you have it in hand but do not know its numeric ID. security: - bearerAuth: [] responses: @@ -161,12 +239,59 @@ paths: default: $ref: "#/components/responses/Error" + /auth/tokens/{token_id}: + delete: + tags: [Auth] + summary: Revoke a specific API token by ID + operationId: revokeToken + description: > + Revokes the token identified by token_id. Non-admin users may only + revoke their own tokens; attempting to revoke another user’s token + returns 403. Admins may revoke any token. + + To revoke the token currently in use without knowing its ID, use + DELETE /auth/tokens/current instead. + security: + - bearerAuth: [] + x-required-scope: tokens:manage + parameters: + - name: token_id + in: path + required: true + schema: + type: integer + minimum: 1 + responses: + "204": + description: Token revoked successfully. + "401": + $ref: "#/components/responses/Unauthorized" + "403": + description: > + Token is valid but you cannot revoke this token. Non-admin users + may only revoke their own tokens. Admins may revoke any token. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + example: + code: forbidden + message: You may only revoke your own tokens unless you have admin role. + details: {} + "404": + $ref: "#/components/responses/NotFound" + "429": + $ref: "#/components/responses/RateLimited" + default: + $ref: "#/components/responses/Error" + # RUNS /runs: get: tags: [Runs] summary: List CI runs + operationId: listRuns description: > Public read. The underlying table is capped at the 50 most recent runs in the current implementation; this endpoint adds full pagination. @@ -216,6 +341,8 @@ paths: $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" default: @@ -224,6 +351,7 @@ paths: post: tags: [Runs] summary: Trigger a new CI run + operationId: createRun description: > Requires runs:write scope and contributor role or above. The regression_test_ids set is validated against active tests only. @@ -262,6 +390,7 @@ paths: get: tags: [Runs] summary: Get a CI run + operationId: getRun description: > Returns normalized run status derived from TestProgress rows. status=canceled covers both explicit cancellation and infrastructure @@ -280,6 +409,8 @@ paths: $ref: "#/components/schemas/Run" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "429": @@ -291,6 +422,7 @@ paths: get: tags: [Runs] summary: Get pass/fail summary for a run + operationId: getRunSummary description: > fail_count is computed from TestResult rows, not from test.failed. test.failed only reflects whether the final progress status is @@ -310,6 +442,8 @@ paths: $ref: "#/components/schemas/RunSummary" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "429": @@ -321,6 +455,7 @@ paths: get: tags: [Runs] summary: Get progress events for a run + operationId: getRunProgress description: > Progress events are sourced from TestProgress rows written by the CI worker via /ci/progress-reporter. Messages are unstructured text. @@ -352,8 +487,12 @@ paths: type: array items: $ref: "#/components/schemas/ProgressEvent" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "429": @@ -365,6 +504,7 @@ paths: post: tags: [Runs] summary: Cancel a queued or running CI run + operationId: cancelRun description: > Idempotent. Canceling an already-canceled or completed run returns 202 with a no-op message rather than an error. @@ -393,6 +533,8 @@ paths: application/json: schema: $ref: "#/components/schemas/RunActionResult" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": @@ -408,6 +550,7 @@ paths: get: tags: [Runs] summary: Get run configuration and test matrix + operationId: getRunConfig description: > regression_test_ids lists IDs included in this run. When no custom set was configured, all regression tests are returned. @@ -427,6 +570,8 @@ paths: $ref: "#/components/schemas/RunConfig" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "429": @@ -440,6 +585,7 @@ paths: get: tags: [Samples] summary: List regression test results in a run + operationId: listRunSamples description: > Returns one entry per regression test result, not one per unique media file. A single media sample may yield multiple entries if it has @@ -487,8 +633,12 @@ paths: type: array items: $ref: "#/components/schemas/RunSample" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "429": @@ -500,6 +650,7 @@ paths: get: tags: [Samples] summary: Get full details for a regression test result in a run + operationId: getRunSample security: - bearerAuth: [] x-required-scope: runs:read @@ -515,6 +666,8 @@ paths: $ref: "#/components/schemas/RunSample" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "429": @@ -526,6 +679,10 @@ paths: get: tags: [Samples] summary: List all known media samples + operationId: listSamples + description: > + Returns paginated media sample metadata. Samples are the original + media files uploaded for regression testing. security: - bearerAuth: [] x-required-scope: runs:read @@ -580,6 +737,8 @@ paths: $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" default: @@ -589,6 +748,7 @@ paths: get: tags: [Samples] summary: Get media sample metadata + operationId: getSample security: - bearerAuth: [] x-required-scope: runs:read @@ -603,6 +763,8 @@ paths: $ref: "#/components/schemas/Sample" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "429": @@ -614,6 +776,7 @@ paths: get: tags: [Samples] summary: Get regression test result history for a sample across runs + operationId: getSampleHistory description: > Use failure_signature for flake detection: a stable signature across multiple runs on different commits indicates a genuine regression, @@ -644,8 +807,12 @@ paths: type: array items: $ref: "#/components/schemas/SampleHistoryEntry" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "429": @@ -657,6 +824,7 @@ paths: get: tags: [Samples] summary: List regression test definitions + operationId: listRegressionTests description: > The active filter must be applied explicitly. The legacy get_customized_regressiontests() returns all regression tests — @@ -700,8 +868,12 @@ paths: type: array items: $ref: "#/components/schemas/RegressionTest" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" default: @@ -713,6 +885,7 @@ paths: get: tags: [Results] summary: Get expected output for a regression test result + operationId: getExpectedOutput description: > Expected output is a file reference stored under TestResults using the regression output extension. Resolved from GCS or local @@ -734,6 +907,8 @@ paths: application/json: schema: $ref: "#/components/schemas/OutputFile" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": @@ -749,6 +924,7 @@ paths: get: tags: [Results] summary: Get actual output generated by a regression test in a run + operationId: getActualOutput description: > IMPORTANT: TestResultFile.got = null means the actual output MATCHED expected, not that actual output is missing. This is a semantic trap @@ -775,6 +951,8 @@ paths: description: > No actual file stored. got=null in the DB means output matched expected. Use /expected to retrieve the matched content. + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": @@ -790,6 +968,7 @@ paths: get: tags: [Results] summary: Get expected-vs-actual diff for a failing regression test result + operationId: getDiff description: > The legacy diff route is header-gated (X-Requested-With: XMLHttpRequest), not role-gated. The 403 seen on direct browser requests was a @@ -807,7 +986,7 @@ paths: in: query schema: type: integer - minimum: 0 + minimum: 1 maximum: 50 default: 3 - name: format @@ -823,8 +1002,12 @@ paths: application/json: schema: $ref: "#/components/schemas/Diff" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "429": @@ -836,6 +1019,7 @@ paths: post: tags: [Results] summary: Approve actual output as the new expected baseline + operationId: approveBaseline description: > Requires baselines:write scope and admin or contributor role. This is a destructive write — the approved output becomes the new @@ -880,6 +1064,7 @@ paths: get: tags: [Errors and Logs] summary: Get structured test errors for a run + operationId: listRunErrors description: > Error types are derived from TestResult and TestResultFile rows. missing_output is detected from the dummy (-1,-1,-1,'','error') row @@ -920,8 +1105,12 @@ paths: type: array items: $ref: "#/components/schemas/ErrorItem" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "429": @@ -933,6 +1122,7 @@ paths: get: tags: [Errors and Logs] summary: Get worker, provisioning, and build errors for a run + operationId: listInfraErrors description: > Errors are extracted from TestProgress rows written by the CI worker. Messages are currently unstructured text. The type filter does @@ -979,6 +1169,8 @@ paths: type: array items: $ref: "#/components/schemas/ErrorItem" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": @@ -994,11 +1186,12 @@ paths: get: tags: [Errors and Logs] summary: Get raw logs for a run + operationId: getRunLogs description: > Logs are stored at SAMPLE_REPOSITORY/LogFiles/{id}.txt and served via GCS signed URL. Returns 404 — not a broken download link — when the file is absent from both local and GCS storage. - Uses cursor-based pagination; do not mix cursor and offset. + Uses cursor-based pagination. security: - bearerAuth: [] x-required-scope: system:read @@ -1035,6 +1228,8 @@ paths: type: array items: $ref: "#/components/schemas/LogLine" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": @@ -1060,6 +1255,12 @@ paths: get: tags: [Errors and Logs] summary: Get raw logs for a regression test result in a run + operationId: getSampleLogs + description: > + Returns raw log lines for a specific regression test result. + Logs are stored at SAMPLE_REPOSITORY/LogFiles/ and served via GCS + signed URL when available. Returns 404 when the log file is absent + from both local and GCS storage. security: - bearerAuth: [] x-required-scope: system:read @@ -1092,6 +1293,8 @@ paths: type: array items: $ref: "#/components/schemas/LogLine" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": @@ -1107,6 +1310,7 @@ paths: get: tags: [Errors and Logs] summary: Get grouped error summary for a run + operationId: getErrorSummary description: > Use this endpoint to triage a run before drilling into individual errors. group_by=type gives a high-level failure breakdown; @@ -1143,8 +1347,12 @@ paths: type: array items: $ref: "#/components/schemas/ErrorSummaryBucket" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "429": @@ -1158,6 +1366,7 @@ paths: get: tags: [System] summary: Get CI system health and dependency status + operationId: getHealth description: > Unauthenticated. Returns overall system status and per-dependency health. Used by monitoring and uptime checks. @@ -1184,6 +1393,7 @@ paths: get: tags: [System] summary: Get queue depth and currently running jobs + operationId: getQueue security: - bearerAuth: [] x-required-scope: system:read @@ -1220,6 +1430,8 @@ paths: type: array items: $ref: "#/components/schemas/QueueJob" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": @@ -1233,6 +1445,7 @@ paths: get: tags: [System] summary: List downloadable artifacts for a run + operationId: listArtifacts description: > Only returns artifacts with a verified download_url from at least one storage backend. storage_status=degraded means one backend only; @@ -1264,6 +1477,8 @@ paths: type: array items: $ref: "#/components/schemas/Artifact" + "400": + $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": @@ -1570,6 +1785,9 @@ components: properties: data: type: array + description: > + Result items. The concrete type is defined by allOf composition + in each endpoint response. items: {} pagination: type: object @@ -1595,6 +1813,9 @@ components: properties: data: type: array + description: > + Result items. The concrete type is defined by allOf composition + in each endpoint response. items: {} pagination: type: object @@ -1632,6 +1853,48 @@ components: Structured context for the error. Always an object, never null. Empty object {} when no additional detail is available. + ApiTokenItem: + type: object + description: > + Token metadata returned when listing tokens. The plaintext token + value is never included - it is shown only once at creation time. + required: [id, user_id, token_name, token_prefix, scopes, created_at, expires_at, is_revoked] + properties: + id: + type: integer + minimum: 1 + user_id: + type: integer + minimum: 1 + description: Owner of the token. Visible to admins when listing all tokens. + token_name: + type: string + maxLength: 50 + token_prefix: + type: string + maxLength: 20 + description: First few characters of the token for identification. + scopes: + type: array + maxItems: 8 + uniqueItems: true + items: + type: string + enum: [runs:read, runs:write, results:read, baselines:write, system:read, tokens:manage] + created_at: + type: string + format: date-time + expires_at: + type: string + format: date-time + is_revoked: + type: boolean + description: True if the token has been explicitly revoked. + revoked_at: + type: string + format: date-time + nullable: true + TokenCreateRequest: type: object required: [email, password, token_name] @@ -1649,6 +1912,7 @@ components: description: Not stored or logged. Used only to verify identity. token_name: type: string + minLength: 1 maxLength: 50 pattern: '^[a-zA-Z0-9_-]+$' description: > @@ -1666,7 +1930,7 @@ components: default: [runs:read, results:read] items: type: string - enum: [runs:read, runs:write, results:read, baselines:write, system:read] + enum: [runs:read, runs:write, results:read, baselines:write, system:read, tokens:manage] description: > Requested scopes. Grant only what the client needs. runs:read — list and inspect runs, samples, history. @@ -1674,6 +1938,7 @@ components: results:read — access expected/actual output, diffs, errors, logs. baselines:write — approve new expected baselines. system:read — queue, infrastructure errors, stack traces, artifacts. + tokens:manage — list and revoke API tokens. AuthToken: type: object @@ -1686,7 +1951,7 @@ components: Opaque token value. Store it securely. It will not be shown again. token_type: type: string - enum: [Bearer] + enum: [bearer] token_name: type: string maxLength: 50 @@ -1696,7 +1961,7 @@ components: uniqueItems: true items: type: string - enum: [runs:read, runs:write, results:read, baselines:write, system:read] + enum: [runs:read, runs:write, results:read, baselines:write, system:read, tokens:manage] expires_at: type: string format: date-time @@ -1800,11 +2065,17 @@ components: RunSummary: type: object - required: [run_id, total_samples, pass_count, fail_count] + required: [run_id, status, total_samples, pass_count, fail_count, skipped_count, missing_output_count] properties: run_id: type: integer minimum: 1 + status: + type: string + enum: [queued, running, pass, fail, canceled, error, incomplete] + description: > + Overall run status at the time the summary was generated. + Same derivation as Run.status. total_samples: type: integer minimum: 0 @@ -1896,6 +2167,26 @@ components: maxItems: 500 items: type: object + required: [regression_test_id] + properties: + regression_test_id: + type: integer + minimum: 1 + sample_name: + type: string + maxLength: 255 + nullable: true + command: + type: string + maxLength: 500 + input_type: + type: string + maxLength: 50 + nullable: true + output_type: + type: string + maxLength: 50 + nullable: true additionalProperties: true regression_test_ids: type: array @@ -1924,6 +2215,7 @@ components: minimum: 1 sha: type: string + pattern: '^[a-fA-F0-9]{64}$' description: SHA256 hash of the sample file. extension: type: string @@ -2038,6 +2330,7 @@ components: items: type: object required: [output_id, filename, status] + additionalProperties: false properties: output_id: type: integer @@ -2048,6 +2341,11 @@ components: status: type: string enum: [match, diff_mismatch, missing_output, missing_expected] + description: > + match = actual identical to expected. + diff_mismatch = actual differs from expected. + missing_output = test produced no output. + missing_expected = no expected baseline exists. SampleHistoryEntry: type: object @@ -2123,9 +2421,9 @@ components: type: string enum: [ok, degraded, missing] description: > - ok = verified in both local and GCS storage. - degraded = exists in one backend only. - missing = not found in either backend. + ok = file verified in at least one storage backend. + degraded = file exists but integrity or redundancy check failed. + missing = file not found in any storage backend. Diff: type: object @@ -2165,6 +2463,7 @@ components: items: type: object required: [expected_start, actual_start, lines] + additionalProperties: false properties: expected_start: type: integer @@ -2178,6 +2477,7 @@ components: items: type: object required: [kind, text] + additionalProperties: false properties: kind: type: string @@ -2229,7 +2529,7 @@ components: maxLength: 100 status: type: string - enum: [approved, rejected] + enum: [approved] run_id: type: integer minimum: 1 @@ -2244,8 +2544,8 @@ components: minimum: 1 requested_by: type: string - format: email - maxLength: 255 + maxLength: 100 + description: Display name of the user who requested the approval. created_at: type: string format: date-time @@ -2270,6 +2570,7 @@ components: nullable: true type: type: string + enum: [test_failure, exit_code_mismatch, missing_output, diff_mismatch, queue, vm_provisioning, checkout, merge, build, worker, web_server, storage] maxLength: 100 severity: type: string @@ -2279,8 +2580,25 @@ components: maxLength: 1000 location: type: object - additionalProperties: true nullable: true + additionalProperties: true + properties: + file: + type: string + maxLength: 500 + nullable: true + line: + type: integer + minimum: 0 + nullable: true + column: + type: integer + minimum: 0 + nullable: true + sample_name: + type: string + maxLength: 255 + nullable: true stack: type: array maxItems: 50 @@ -2356,6 +2674,7 @@ components: format: date-time dependencies: type: array + maxItems: 20 items: type: object required: [name, status] @@ -2428,9 +2747,9 @@ components: type: string enum: [ok, degraded, missing] description: > - ok = verified in primary storage. - degraded = exists in one backend only (local or GCS). - missing = not found in either backend. + ok = file verified in at least one storage backend. + degraded = file exists but integrity or redundancy check failed. + missing = file not found in any storage backend. download_url: type: string format: uri From d8eada243f5d525db6550ff7d8e056c697eed821 Mon Sep 17 00:00:00 2001 From: Pulkit Chauhan Date: Mon, 8 Jun 2026 15:46:15 +0530 Subject: [PATCH 10/28] Fix Sonarqube warnings --- mod_api/middleware/auth.py | 56 +++++++++-------------------- mod_api/middleware/error_handler.py | 25 ++++++++----- mod_api/middleware/validation.py | 7 ++++ 3 files changed, 40 insertions(+), 48 deletions(-) diff --git a/mod_api/middleware/auth.py b/mod_api/middleware/auth.py index ffb51624..51ba4f87 100644 --- a/mod_api/middleware/auth.py +++ b/mod_api/middleware/auth.py @@ -19,6 +19,9 @@ from mod_api.middleware.error_handler import make_error_response from mod_api.models.api_token import ApiToken +# Reused across every 401 response to keep the message consistent. +_AUTH_FAILED_MSG = 'Bearer token is missing, expired, or invalid.' + # These endpoints bypass auth entirely. _PUBLIC_ENDPOINTS = frozenset([ 'api.create_token', # POST /auth/tokens (uses email/password body) @@ -26,6 +29,11 @@ ]) +def _unauthorized(): + """Shorthand for a 401 response with the standard auth failure message.""" + return make_error_response('unauthorized', _AUTH_FAILED_MSG, http_status=401) + + @mod_api.before_request def authenticate_request(): """Validate Bearer token and attach user context to the request.""" @@ -36,38 +44,22 @@ def authenticate_request(): auth_header = request.headers.get('Authorization', '') if not auth_header: - return make_error_response( - 'unauthorized', - 'Bearer token is missing, expired, or invalid.', - http_status=401, - ) + return _unauthorized() parts = auth_header.split(' ', 1) if len(parts) != 2 or parts[0] != 'Bearer': - return make_error_response( - 'unauthorized', - 'Bearer token is missing, expired, or invalid.', - http_status=401, - ) + return _unauthorized() token_value = parts[1].strip() if not token_value or not token_value.startswith('spci_'): - return make_error_response( - 'unauthorized', - 'Bearer token is missing, expired, or invalid.', - http_status=401, - ) + return _unauthorized() # Look up by prefix, then verify the full hash against each candidate. prefix = ApiToken.extract_prefix(token_value) candidates = ApiToken.query.filter_by(token_prefix=prefix).all() if not candidates: - return make_error_response( - 'unauthorized', - 'Bearer token is missing, expired, or invalid.', - http_status=401, - ) + return _unauthorized() matched_token = None for candidate in candidates: @@ -76,18 +68,10 @@ def authenticate_request(): break if matched_token is None: - return make_error_response( - 'unauthorized', - 'Bearer token is missing, expired, or invalid.', - http_status=401, - ) + return _unauthorized() if not matched_token.is_valid: - return make_error_response( - 'unauthorized', - 'Bearer token is missing, expired, or invalid.', - http_status=401, - ) + return _unauthorized() g.api_token = matched_token g.api_user = matched_token.user @@ -100,11 +84,7 @@ def decorator(f): def decorated_function(*args, **kwargs): token = getattr(g, 'api_token', None) if token is None: - return make_error_response( - 'unauthorized', - 'Bearer token is missing, expired, or invalid.', - http_status=401, - ) + return _unauthorized() if not token.has_scope(scope): return make_error_response( 'forbidden', @@ -127,11 +107,7 @@ def decorator(f): def decorated_function(*args, **kwargs): user = getattr(g, 'api_user', None) if user is None: - return make_error_response( - 'unauthorized', - 'Bearer token is missing, expired, or invalid.', - http_status=401, - ) + return _unauthorized() if user.role.value not in roles: return make_error_response( 'forbidden', diff --git a/mod_api/middleware/error_handler.py b/mod_api/middleware/error_handler.py index fb64cfb4..d0173b7f 100644 --- a/mod_api/middleware/error_handler.py +++ b/mod_api/middleware/error_handler.py @@ -14,6 +14,10 @@ from mod_api import mod_api +# All error handlers check this prefix before intercepting — non-API +# routes (the legacy web UI) should still get their HTML error pages. +_API_PREFIX = '/api/v1' + def make_error_response(code, message, details=None, http_status=400): """Build a JSON error response conforming to the ErrorResponse schema.""" @@ -27,10 +31,15 @@ def make_error_response(code, message, details=None, http_status=400): return response +def _is_api_request(): + """Check whether the current request targets an API endpoint.""" + return request.path.startswith(_API_PREFIX) + + @mod_api.app_errorhandler(400) def handle_400(error): """Bad request.""" - if not request.path.startswith('/api/v1'): + if not _is_api_request(): raise error return make_error_response( 'validation_error', @@ -42,7 +51,7 @@ def handle_400(error): @mod_api.app_errorhandler(401) def handle_401(error): """Unauthorized.""" - if not request.path.startswith('/api/v1'): + if not _is_api_request(): raise error return make_error_response( 'unauthorized', @@ -54,7 +63,7 @@ def handle_401(error): @mod_api.app_errorhandler(403) def handle_403(error): """Forbidden.""" - if not request.path.startswith('/api/v1'): + if not _is_api_request(): raise error return make_error_response( 'forbidden', @@ -66,7 +75,7 @@ def handle_403(error): @mod_api.app_errorhandler(404) def handle_404(error): """Not found.""" - if not request.path.startswith('/api/v1'): + if not _is_api_request(): raise error return make_error_response( 'not_found', @@ -78,7 +87,7 @@ def handle_404(error): @mod_api.app_errorhandler(405) def handle_405(error): """Method not allowed.""" - if not request.path.startswith('/api/v1'): + if not _is_api_request(): raise error return make_error_response( 'method_not_allowed', @@ -90,7 +99,7 @@ def handle_405(error): @mod_api.app_errorhandler(422) def handle_422(error): """Unprocessable entity.""" - if not request.path.startswith('/api/v1'): + if not _is_api_request(): raise error return make_error_response( 'unprocessable', @@ -102,7 +111,7 @@ def handle_422(error): @mod_api.app_errorhandler(429) def handle_429(error): """Rate limited.""" - if not request.path.startswith('/api/v1'): + if not _is_api_request(): raise error return make_error_response( 'rate_limited', @@ -115,7 +124,7 @@ def handle_429(error): @mod_api.app_errorhandler(500) def handle_500(error): """Internal server error.""" - if not request.path.startswith('/api/v1'): + if not _is_api_request(): raise error return make_error_response( 'internal_error', diff --git a/mod_api/middleware/validation.py b/mod_api/middleware/validation.py index 7bd27e2a..a47bf852 100644 --- a/mod_api/middleware/validation.py +++ b/mod_api/middleware/validation.py @@ -35,6 +35,13 @@ def validate_body(schema_class): def decorator(f): @wraps(f) def decorated(*args, **kwargs): + content_type = request.content_type or '' + if 'application/json' not in content_type: + return make_error_response( + 'validation_error', + 'Content-Type must be application/json.', + http_status=415, + ) json_data = request.get_json(silent=True) if json_data is None: return make_error_response( From 8d700de6e159361573838f66b5a0f074243fdb95 Mon Sep 17 00:00:00 2001 From: Pulkit Chauhan Date: Mon, 8 Jun 2026 15:49:57 +0530 Subject: [PATCH 11/28] Updated schemas as per new spec sheet --- mod_api/schemas/auth.py | 32 +++++++++++++++++------ mod_api/schemas/common.py | 4 +-- mod_api/schemas/errors.py | 4 +-- mod_api/schemas/results.py | 53 +++++++++++++++++++++++++------------- mod_api/schemas/runs.py | 28 +++++++++++++------- mod_api/schemas/samples.py | 6 ++--- mod_api/schemas/system.py | 32 ++++++----------------- 7 files changed, 92 insertions(+), 67 deletions(-) diff --git a/mod_api/schemas/auth.py b/mod_api/schemas/auth.py index 330ea9e6..83bd333d 100644 --- a/mod_api/schemas/auth.py +++ b/mod_api/schemas/auth.py @@ -1,4 +1,4 @@ -"""Request/response schemas for token endpoints.""" +"""Request/response schemas for the token endpoints.""" from marshmallow import RAISE, Schema, fields, validate @@ -6,17 +6,16 @@ class TokenCreateRequestSchema(Schema): - """Schema for POST /auth/tokens request body.""" + """Validates POST /auth/tokens bodies.""" email = fields.Email(required=True) password = fields.String( required=True, - validate=validate.Length(min=5, max=128), + validate=validate.Length(min=8, max=128), ) token_name = fields.String( required=True, validate=[ - validate.Length(min=1, max=50), validate.Regexp( r'^[a-zA-Z0-9_\-]+$', @@ -25,8 +24,8 @@ class TokenCreateRequestSchema(Schema): ], ) expires_in_days = fields.Integer( - load_default=30, - validate=validate.Range(min=1, max=90), + load_default=7, + validate=validate.Range(min=1, max=30), ) scopes = fields.List( fields.String(validate=validate.OneOf(VALID_SCOPES)), @@ -35,14 +34,31 @@ class TokenCreateRequestSchema(Schema): ) class Meta: - unknown = RAISE # Reject unknown fields + unknown = RAISE class AuthTokenSchema(Schema): - """Schema for serializing the created token response.""" + """The one-time response returned when a token is created.""" token = fields.String(required=True) token_type = fields.String(dump_default='bearer') token_name = fields.String(required=True) scopes = fields.List(fields.String(), required=True) expires_at = fields.DateTime(required=True) + + +class ApiTokenItemSchema(Schema): + """Token metadata for list responses — never includes the plaintext value.""" + + id = fields.Integer(required=True) + user_id = fields.Integer(required=True) + token_name = fields.String(required=True) + token_prefix = fields.String(required=True) + scopes = fields.Method('get_scopes') + created_at = fields.DateTime(required=True) + expires_at = fields.DateTime(required=True) + is_revoked = fields.Boolean(required=True) + revoked_at = fields.DateTime(allow_none=True) + + def get_scopes(self, obj): + return obj.scopes diff --git a/mod_api/schemas/common.py b/mod_api/schemas/common.py index 0c21fbaf..d52534d0 100644 --- a/mod_api/schemas/common.py +++ b/mod_api/schemas/common.py @@ -1,10 +1,10 @@ -"""Common schemas: ErrorResponse, pagination wrappers.""" +"""Shared schemas: ErrorResponse and pagination wrappers.""" from marshmallow import Schema, fields class ErrorResponseSchema(Schema): - """Standard error response body.""" + """Standard JSON error body returned by all error responses.""" code = fields.String(required=True) message = fields.String(required=True) details = fields.Dict(keys=fields.String(), required=True, load_default={}) diff --git a/mod_api/schemas/errors.py b/mod_api/schemas/errors.py index febba386..871da1b5 100644 --- a/mod_api/schemas/errors.py +++ b/mod_api/schemas/errors.py @@ -1,10 +1,10 @@ -"""Schemas for error items, error summaries, and log lines.""" +"""Schemas for error items, error summary buckets, and log lines.""" from marshmallow import Schema, fields, validate class ErrorItemSchema(Schema): - """A single error derived from run results.""" + """A single error derived from run results or infra progress.""" error_id = fields.String(required=True) run_id = fields.Integer(required=True) sample_id = fields.Integer(allow_none=True) diff --git a/mod_api/schemas/results.py b/mod_api/schemas/results.py index 3a95a925..db8684f9 100644 --- a/mod_api/schemas/results.py +++ b/mod_api/schemas/results.py @@ -4,10 +4,15 @@ class OutputFileContentSchema(Schema): - """File content blob (expected or actual output).""" + """File content blob returned for expected or actual output.""" + run_id = fields.Integer(allow_none=True) + sample_id = fields.Integer(required=True) + regression_id = fields.Integer(required=True) + output_id = fields.Integer(required=True) filename = fields.String(required=True) - content = fields.String(required=True) + content_type = fields.String(required=True) encoding = fields.String(required=True, validate=validate.OneOf(['utf-8', 'base64'])) + content = fields.String(required=True) sha256 = fields.String(allow_none=True) storage_status = fields.String( required=True, @@ -17,49 +22,61 @@ class OutputFileContentSchema(Schema): class DiffHunkLineSchema(Schema): """One line inside a diff hunk.""" - type = fields.String(required=True, validate=validate.OneOf(['add', 'delete', 'context'])) - content = fields.String(required=True) + kind = fields.String(required=True, validate=validate.OneOf(['context', 'added', 'removed'])) + expected_line = fields.Integer(allow_none=True) + actual_line = fields.Integer(allow_none=True) + text = fields.String(required=True) class DiffHunkSchema(Schema): - """A contiguous block of changes in a diff.""" - header = fields.String(required=True) + """A contiguous block of changes.""" + expected_start = fields.Integer(required=True) + actual_start = fields.Integer(required=True) lines = fields.List(fields.Nested(DiffHunkLineSchema), required=True) class DiffSchema(Schema): """Structured diff between expected and actual output.""" + run_id = fields.Integer(required=True) + sample_id = fields.Integer(required=True) + regression_id = fields.Integer(required=True) + output_id = fields.Integer(required=True) status = fields.String(required=True, validate=validate.OneOf([ 'identical', 'different', 'missing_actual', 'missing_expected', ])) - expected_sha256 = fields.String(allow_none=True) - actual_sha256 = fields.String(allow_none=True) - stats = fields.Dict(required=True) + summary = fields.Dict(required=True) hunks = fields.List(fields.Nested(DiffHunkSchema), required=True) class BaselineApprovalRequestSchema(Schema): """POST /runs/{id}/samples/{sid}/baseline-approval body.""" - reason = fields.String( + regression_id = fields.Integer( required=True, - validate=validate.Length(min=10, max=1000), + validate=validate.Range(min=1), ) output_id = fields.Integer( - load_default=None, + required=True, validate=validate.Range(min=1), ) - apply_to_variants = fields.Boolean(load_default=False) + reason = fields.String( + required=True, + validate=validate.Length(min=10, max=500), + ) + remove_variants = fields.Boolean( + load_default=False, + ) class Meta: unknown = RAISE class BaselineApprovalSchema(Schema): - """Response after submitting a baseline approval request.""" + """Response after a baseline approval is applied.""" approval_id = fields.String(required=True) - status = fields.String(required=True, validate=validate.OneOf([ - 'pending_review', 'approved', 'rejected', - ])) + status = fields.String(required=True, validate=validate.OneOf(['approved'])) + run_id = fields.Integer(required=True) + sample_id = fields.Integer(required=True) + regression_id = fields.Integer(required=True) + output_id = fields.Integer(required=True) requested_by = fields.String(required=True) - reason = fields.String(required=True) created_at = fields.DateTime(required=True) diff --git a/mod_api/schemas/runs.py b/mod_api/schemas/runs.py index 6f773b91..bc07ad9c 100644 --- a/mod_api/schemas/runs.py +++ b/mod_api/schemas/runs.py @@ -1,4 +1,4 @@ -"""Schemas for runs, run summaries, progress events, and run actions.""" +"""Schemas for runs, summaries, progress events, and run actions.""" from marshmallow import RAISE, Schema, fields, validate @@ -8,21 +8,22 @@ class ProgressEventSchema(Schema): timestamp = fields.DateTime(required=True) status = fields.String(required=True) message = fields.String(required=True) + step = fields.Integer(allow_none=True) class RunSchema(Schema): - """Full run representation.""" + """Full run details.""" run_id = fields.Integer(required=True) status = fields.String(required=True, validate=validate.OneOf([ 'queued', 'running', 'pass', 'fail', 'canceled', 'error', 'incomplete', ])) platform = fields.String(required=True, validate=validate.OneOf(['linux', 'windows'])) - test_type = fields.String(required=True, validate=validate.OneOf(['commit', 'pr'])) + test_type = fields.String(validate=validate.OneOf(['commit', 'pr'])) repository = fields.String(required=True) - branch = fields.String(required=True) + branch = fields.String(allow_none=True) commit_sha = fields.String(required=True) pr_number = fields.Integer(allow_none=True, load_default=None) - created_at = fields.DateTime(allow_none=True) + created_at = fields.DateTime(required=True) queued_at = fields.DateTime(allow_none=True) started_at = fields.DateTime(allow_none=True) completed_at = fields.DateTime(allow_none=True) @@ -30,19 +31,21 @@ class RunSchema(Schema): class RunSummarySchema(Schema): - """Aggregated pass/fail counts for a run.""" + """Pass/fail/skip aggregate counts for a run.""" run_id = fields.Integer(required=True) status = fields.String(required=True) total_samples = fields.Integer(required=True) pass_count = fields.Integer(required=True) fail_count = fields.Integer(required=True) - skip_count = fields.Integer(required=True) + skipped_count = fields.Integer(required=True) missing_output_count = fields.Integer(required=True) - runtime_ms = fields.Integer(allow_none=True) + error_count = fields.Integer(load_default=0) + duration_ms = fields.Integer(allow_none=True) + triggered_by = fields.String(allow_none=True) class RunConfigSchema(Schema): - """The configuration used to launch a run.""" + """The test matrix and configuration for a run.""" run_id = fields.Integer(required=True) platform = fields.String(required=True) branch = fields.String(required=True) @@ -83,6 +86,11 @@ class RunCreateRequestSchema(Schema): ), ], ) + pull_request = fields.Integer( + load_default=None, + allow_none=True, + validate=validate.Range(min=1), + ) regression_test_ids = fields.List( fields.Integer(validate=validate.Range(min=1)), load_default=None, @@ -94,7 +102,7 @@ class Meta: class RunActionResultSchema(Schema): - """Response for cancel/retry actions.""" + """Response for cancel and similar run actions.""" run_id = fields.Integer(required=True) new_run_id = fields.Integer(allow_none=True) action = fields.String(required=True) diff --git a/mod_api/schemas/samples.py b/mod_api/schemas/samples.py index 19413502..cd208cb0 100644 --- a/mod_api/schemas/samples.py +++ b/mod_api/schemas/samples.py @@ -1,4 +1,4 @@ -"""Schemas for samples, run sample results, history entries, and regression tests.""" +"""Schemas for samples, per-run sample results, history, and regression tests.""" from marshmallow import Schema, fields, validate @@ -13,7 +13,7 @@ class OutputFileSchema(Schema): class RunSampleSchema(Schema): - """A sample's result within a specific run.""" + """A regression test's result within a specific run.""" regression_test_id = fields.Integer(required=True) sample_id = fields.Integer(allow_none=True) sample_name = fields.String(allow_none=True) @@ -29,7 +29,7 @@ class RunSampleSchema(Schema): class SampleSchema(Schema): - """A media sample from the sample catalog.""" + """A media sample from the catalog.""" sample_id = fields.Integer(required=True) sha = fields.String(required=True) extension = fields.String(required=True) diff --git a/mod_api/schemas/system.py b/mod_api/schemas/system.py index 3b0802cf..5e5602ff 100644 --- a/mod_api/schemas/system.py +++ b/mod_api/schemas/system.py @@ -1,17 +1,17 @@ -"""System schemas for health, queue, branches, environments, and artifacts.""" +"""Schemas for health checks, queue jobs, and run artifacts.""" from marshmallow import Schema, fields, validate class DependencyHealthSchema(Schema): - """Schema for a single system dependency status.""" + """Status of a single system dependency (DB, GCS, local storage).""" name = fields.String(required=True) status = fields.String(required=True, validate=validate.OneOf(['ok', 'degraded', 'down'])) message = fields.String(allow_none=True) class SystemHealthSchema(Schema): - """Schema for the overall system health response.""" + """Overall system health response.""" status = fields.String( required=True, validate=validate.OneOf(['ok', 'degraded', 'down']), @@ -21,41 +21,25 @@ class SystemHealthSchema(Schema): class QueueJobSchema(Schema): - """Schema for a single job in the queue.""" + """A single queued or running job.""" run_id = fields.Integer(required=True) status = fields.String(required=True, validate=validate.OneOf(['queued', 'running'])) platform = fields.String(required=True, validate=validate.OneOf(['linux', 'windows'])) - queued_at = fields.DateTime(required=True) + queued_at = fields.DateTime(allow_none=True) started_at = fields.DateTime(allow_none=True) position = fields.Integer(allow_none=True) -class BranchSchema(Schema): - """Schema for a tracked branch.""" - repository = fields.String(required=True) - name = fields.String(required=True) - head_sha = fields.String(allow_none=True) - active = fields.Boolean(required=True) - - -class EnvironmentSchema(Schema): - """Schema for a test environment.""" - environment_id = fields.String(required=True) - platform = fields.String(required=True, validate=validate.OneOf(['linux', 'windows'])) - active = fields.Boolean(required=True) - runner_label = fields.String(allow_none=True) - average_duration_ms = fields.Integer(allow_none=True) - - class ArtifactSchema(Schema): - """Schema for a run artifact.""" + """A downloadable artifact tied to a run.""" artifact_id = fields.String(required=True) run_id = fields.Integer(required=True) sample_id = fields.Integer(allow_none=True) type = fields.String( required=True, validate=validate.OneOf([ - 'build_log', 'sample_output', 'expected_output', 'diff', 'media_info', 'binary', + 'build_log', 'sample_output', 'expected_output', 'actual_output', + 'diff', 'media_info', 'binary', 'coredump', 'combined_stdout', ]), ) filename = fields.String(required=True) From cab2e258f74ee0149f62ba91cdde0affe485d102 Mon Sep 17 00:00:00 2001 From: Pulkit Chauhan Date: Mon, 8 Jun 2026 16:12:43 +0530 Subject: [PATCH 12/28] GH isort fix --- mod_api/__init__.py | 17 ++++++++--------- mod_api/models/api_token.py | 6 ++++-- mod_api/utils.py | 2 +- run.py | 1 + 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/mod_api/__init__.py b/mod_api/__init__.py index c2379317..fb1a634b 100644 --- a/mod_api/__init__.py +++ b/mod_api/__init__.py @@ -1,23 +1,22 @@ """ -mod_api — JSON-only REST API for the CCExtractor CI/sample platform. +mod_api: JSON REST API blueprint for the CCExtractor CI platform. -Blueprint registered at /api/v1. All endpoints return structured JSON, -use scoped Bearer token auth, and enforce rate limiting. +Registered at /api/v1. All endpoints return structured JSON, use scoped +Bearer token auth, and enforce per-client rate limiting. """ from flask import Blueprint mod_api = Blueprint('api', __name__) -# Import middleware (registers before_request, error handlers) -from mod_api.middleware import error_handler # noqa: E402, F401 +# Middleware (registers before_request hooks and error handlers) from mod_api.middleware import auth # noqa: E402, F401 +from mod_api.middleware import error_handler # noqa: E402, F401 from mod_api.middleware import rate_limit # noqa: E402, F401 - -# Import routes (registers endpoint functions) +# Route modules (registers endpoint functions on the blueprint) from mod_api.routes import auth as auth_routes # noqa: E402, F401 +from mod_api.routes import errors_logs # noqa: E402, F401 +from mod_api.routes import results # noqa: E402, F401 from mod_api.routes import runs # noqa: E402, F401 from mod_api.routes import samples # noqa: E402, F401 -from mod_api.routes import results # noqa: E402, F401 -from mod_api.routes import errors_logs # noqa: E402, F401 from mod_api.routes import system # noqa: E402, F401 diff --git a/mod_api/models/api_token.py b/mod_api/models/api_token.py index f9c526ff..47f7eaca 100644 --- a/mod_api/models/api_token.py +++ b/mod_api/models/api_token.py @@ -12,7 +12,8 @@ from argon2 import PasswordHasher from argon2.exceptions import VerifyMismatchError -from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint +from sqlalchemy import (Column, DateTime, ForeignKey, Integer, String, Text, + UniqueConstraint) from sqlalchemy.orm import relationship from database import Base @@ -25,6 +26,7 @@ 'results:read', 'baselines:write', 'system:read', + 'tokens:manage', ]) DEFAULT_SCOPES = ['runs:read', 'results:read'] @@ -64,7 +66,7 @@ def __init__( token_hash: str, token_prefix: str, scopes: List[str], - expires_in_days: int = 30, + expires_in_days: int = 7, ) -> None: self.user_id = user_id self.token_name = token_name diff --git a/mod_api/utils.py b/mod_api/utils.py index 44a49997..bdbcd609 100644 --- a/mod_api/utils.py +++ b/mod_api/utils.py @@ -51,7 +51,7 @@ def single_response(data, schema=None, http_status=200): return response -def get_sort_column(sort_param, model, column_map): +def get_sort_column(sort_param, column_map): """ Translate a validated sort string (e.g. '-created_at') into an SQLAlchemy order_by clause. diff --git a/run.py b/run.py index 4e338d8c..1b180f9a 100755 --- a/run.py +++ b/run.py @@ -276,4 +276,5 @@ def teardown(exception: Optional[Exception]): # REST API v1 from mod_api import mod_api + app.register_blueprint(mod_api, url_prefix='/api/v1') From 876567b9a216de0ca7ce3394fa590ca49e341d4c Mon Sep 17 00:00:00 2001 From: Pulkit Chauhan Date: Mon, 8 Jun 2026 16:32:45 +0530 Subject: [PATCH 13/28] Fixed rest of GH actions tests --- mod_api/middleware/auth.py | 10 +++---- mod_api/middleware/error_handler.py | 11 ++++---- mod_api/middleware/validation.py | 44 ++++++++++++++++++++--------- mod_api/models/api_token.py | 13 +++++++-- mod_api/schemas/auth.py | 5 +++- mod_api/schemas/common.py | 3 ++ mod_api/schemas/errors.py | 9 ++++-- mod_api/schemas/results.py | 19 +++++++++++-- mod_api/schemas/runs.py | 11 +++++++- mod_api/schemas/samples.py | 9 ++++-- mod_api/schemas/system.py | 17 ++++++++--- mod_api/utils.py | 6 ++-- 12 files changed, 115 insertions(+), 42 deletions(-) diff --git a/mod_api/middleware/auth.py b/mod_api/middleware/auth.py index 51ba4f87..a338444a 100644 --- a/mod_api/middleware/auth.py +++ b/mod_api/middleware/auth.py @@ -19,7 +19,6 @@ from mod_api.middleware.error_handler import make_error_response from mod_api.models.api_token import ApiToken -# Reused across every 401 response to keep the message consistent. _AUTH_FAILED_MSG = 'Bearer token is missing, expired, or invalid.' # These endpoints bypass auth entirely. @@ -31,7 +30,8 @@ def _unauthorized(): """Shorthand for a 401 response with the standard auth failure message.""" - return make_error_response('unauthorized', _AUTH_FAILED_MSG, http_status=401) + return make_error_response( + 'unauthorized', _AUTH_FAILED_MSG, http_status=401) @mod_api.before_request @@ -78,7 +78,7 @@ def authenticate_request(): def require_scope(scope: str): - """Decorator: reject the request if the token lacks ``scope``.""" + """Reject the request if the token lacks ``scope``.""" def decorator(f): @functools.wraps(f) def decorated_function(*args, **kwargs): @@ -88,7 +88,7 @@ def decorated_function(*args, **kwargs): if not token.has_scope(scope): return make_error_response( 'forbidden', - 'Token does not have the required scope for this operation.', + 'Token lacks the required scope for this operation.', details={ 'required_scope': scope, 'token_scopes': token.scopes, @@ -101,7 +101,7 @@ def decorated_function(*args, **kwargs): def require_roles(roles: List[str]): - """Decorator: reject the request if the user's role is not in ``roles``.""" + """Reject the request if the user's role is not in ``roles``.""" def decorator(f): @functools.wraps(f) def decorated_function(*args, **kwargs): diff --git a/mod_api/middleware/error_handler.py b/mod_api/middleware/error_handler.py index d0173b7f..8bbc46de 100644 --- a/mod_api/middleware/error_handler.py +++ b/mod_api/middleware/error_handler.py @@ -14,8 +14,6 @@ from mod_api import mod_api -# All error handlers check this prefix before intercepting — non-API -# routes (the legacy web UI) should still get their HTML error pages. _API_PREFIX = '/api/v1' @@ -86,7 +84,7 @@ def handle_404(error): @mod_api.app_errorhandler(405) def handle_405(error): - """Method not allowed.""" + """Handle method-not-allowed errors for API routes.""" if not _is_api_request(): raise error return make_error_response( @@ -103,7 +101,10 @@ def handle_422(error): raise error return make_error_response( 'unprocessable', - getattr(error, 'description', 'Request is valid JSON but semantically invalid.'), + getattr( + error, + 'description', + 'Request is valid JSON but semantically invalid.'), http_status=422, ) @@ -123,7 +124,7 @@ def handle_429(error): @mod_api.app_errorhandler(500) def handle_500(error): - """Internal server error.""" + """Handle unexpected server errors for API routes.""" if not _is_api_request(): raise error return make_error_response( diff --git a/mod_api/middleware/validation.py b/mod_api/middleware/validation.py index a47bf852..9c5c71a7 100644 --- a/mod_api/middleware/validation.py +++ b/mod_api/middleware/validation.py @@ -25,13 +25,12 @@ # Whitelist of allowed sort params. Never pass raw user input to the ORM. ALLOWED_RUN_SORTS = frozenset([ 'created_at', '-created_at', - 'started_at', '-started_at', 'run_id', '-run_id', ]) def validate_body(schema_class): - """Validate the JSON body with a Marshmallow schema, pass result as ``validated_data``.""" + """Validate the JSON body with a schema, pass result as ``validated_data``.""" def decorator(f): @wraps(f) def decorated(*args, **kwargs): @@ -66,7 +65,7 @@ def decorated(*args, **kwargs): def validate_pagination(f): - """Extract and validate ``limit`` (1-100) and ``offset`` (>= 0) query params.""" + """Extract and validate ``limit`` and ``offset`` query params.""" @wraps(f) def decorated(*args, **kwargs): try: @@ -75,7 +74,9 @@ def decorated(*args, **kwargs): return make_error_response( 'validation_error', 'limit must be an integer.', - details={'fields': {'limit': 'Must be an integer between 1 and 100.'}}, + details={ + 'fields': { + 'limit': 'Must be an integer between 1 and 100.'}}, http_status=400, ) @@ -85,7 +86,9 @@ def decorated(*args, **kwargs): return make_error_response( 'validation_error', 'offset must be a non-negative integer.', - details={'fields': {'offset': 'Must be a non-negative integer.'}}, + details={ + 'fields': { + 'offset': 'Must be a non-negative integer.'}}, http_status=400, ) @@ -123,14 +126,20 @@ def decorated(*args, **kwargs): return make_error_response( 'validation_error', f'{param_name} must be a positive integer.', - details={'fields': {param_name: 'Must be a positive integer.'}}, + details={ + 'fields': { + param_name: 'Must be a positive integer.'}}, http_status=400, ) if int_value < 1: return make_error_response( 'validation_error', f'{param_name} must be >= 1.', - details={'fields': {param_name: 'Must be >= 1. Zero and negative IDs are rejected.'}}, + details={ + 'fields': { + param_name: 'Must be >= 1. Zero and negative IDs are rejected.' + } + }, http_status=400, ) kwargs[param_name] = int_value @@ -140,7 +149,7 @@ def decorated(*args, **kwargs): def validate_date_range(f): - """Parse ``created_after``/``created_before`` query params and reject inverted ranges.""" + """Parse date query params and reject inverted ranges.""" @wraps(f) def decorated(*args, **kwargs): from datetime import datetime @@ -152,23 +161,29 @@ def decorated(*args, **kwargs): if created_after_str: try: - created_after = datetime.fromisoformat(created_after_str.replace('Z', '+00:00')) + created_after = datetime.fromisoformat( + created_after_str.replace('Z', '+00:00')) except ValueError: return make_error_response( 'validation_error', 'created_after must be a valid ISO 8601 datetime.', - details={'fields': {'created_after': 'Invalid ISO 8601 format.'}}, + details={ + 'fields': { + 'created_after': 'Invalid ISO 8601 format.'}}, http_status=400, ) if created_before_str: try: - created_before = datetime.fromisoformat(created_before_str.replace('Z', '+00:00')) + created_before = datetime.fromisoformat( + created_before_str.replace('Z', '+00:00')) except ValueError: return make_error_response( 'validation_error', 'created_before must be a valid ISO 8601 datetime.', - details={'fields': {'created_before': 'Invalid ISO 8601 format.'}}, + details={ + 'fields': { + 'created_before': 'Invalid ISO 8601 format.'}}, http_status=400, ) @@ -202,7 +217,10 @@ def decorated(*args, **kwargs): return make_error_response( 'validation_error', f'sort must be one of: {", ".join(sorted(allowed))}', - details={'fields': {'sort': f'Must be one of: {sorted(allowed)}'}}, + details={ + 'fields': { + 'sort': f'Must be one of: { + sorted(allowed)}'}}, http_status=400, ) kwargs['sort'] = sort diff --git a/mod_api/models/api_token.py b/mod_api/models/api_token.py index 47f7eaca..12b56c6c 100644 --- a/mod_api/models/api_token.py +++ b/mod_api/models/api_token.py @@ -77,14 +77,17 @@ def __init__( self.expires_at = self.created_at + timedelta(days=expires_in_days) def __repr__(self) -> str: - return f'' + """Return a debug representation of the token.""" + return f'' @property def scopes(self) -> List[str]: + """Parse the JSON scopes column into a list.""" return json.loads(self.scopes_json) @property def is_expired(self) -> bool: + """Check whether this token has passed its expiration time.""" now = datetime.now(timezone.utc) expires = self.expires_at if expires is None: @@ -92,20 +95,24 @@ def is_expired(self) -> bool: # MySQL DATETIME columns don't preserve tzinfo; treat naive as UTC. if expires.tzinfo is None: expires = expires.replace(tzinfo=timezone.utc) - return now > expires + return bool(now > expires) @property def is_revoked(self) -> bool: - return self.revoked_at is not None + """Check whether this token has been explicitly revoked.""" + return bool(self.revoked_at is not None) @property def is_valid(self) -> bool: + """Return True if the token is neither expired nor revoked.""" return not self.is_expired and not self.is_revoked def has_scope(self, scope: str) -> bool: + """Return True if the token grants the given scope.""" return scope in self.scopes def revoke(self) -> None: + """Mark this token as revoked with the current timestamp.""" self.revoked_at = datetime.now(timezone.utc) @staticmethod diff --git a/mod_api/schemas/auth.py b/mod_api/schemas/auth.py index 83bd333d..d22f610a 100644 --- a/mod_api/schemas/auth.py +++ b/mod_api/schemas/auth.py @@ -34,6 +34,8 @@ class TokenCreateRequestSchema(Schema): ) class Meta: + """Reject unknown fields.""" + unknown = RAISE @@ -48,7 +50,7 @@ class AuthTokenSchema(Schema): class ApiTokenItemSchema(Schema): - """Token metadata for list responses — never includes the plaintext value.""" + """Token metadata for list responses — never includes the plaintext.""" id = fields.Integer(required=True) user_id = fields.Integer(required=True) @@ -61,4 +63,5 @@ class ApiTokenItemSchema(Schema): revoked_at = fields.DateTime(allow_none=True) def get_scopes(self, obj): + """Deserialize scopes from the model's JSON column.""" return obj.scopes diff --git a/mod_api/schemas/common.py b/mod_api/schemas/common.py index d52534d0..2234a33b 100644 --- a/mod_api/schemas/common.py +++ b/mod_api/schemas/common.py @@ -5,6 +5,7 @@ class ErrorResponseSchema(Schema): """Standard JSON error body returned by all error responses.""" + code = fields.String(required=True) message = fields.String(required=True) details = fields.Dict(keys=fields.String(), required=True, load_default={}) @@ -12,6 +13,7 @@ class ErrorResponseSchema(Schema): class PaginationSchema(Schema): """Offset-based pagination metadata.""" + limit = fields.Integer(required=True) offset = fields.Integer(required=True) total = fields.Integer(required=True) @@ -20,5 +22,6 @@ class PaginationSchema(Schema): class CursorPaginationSchema(Schema): """Cursor-based pagination metadata.""" + limit = fields.Integer(required=True) next_cursor = fields.String(allow_none=True, load_default=None) diff --git a/mod_api/schemas/errors.py b/mod_api/schemas/errors.py index 871da1b5..a451187d 100644 --- a/mod_api/schemas/errors.py +++ b/mod_api/schemas/errors.py @@ -5,6 +5,7 @@ class ErrorItemSchema(Schema): """A single error derived from run results or infra progress.""" + error_id = fields.String(required=True) run_id = fields.Integer(required=True) sample_id = fields.Integer(allow_none=True) @@ -22,6 +23,7 @@ class ErrorItemSchema(Schema): class ErrorSummaryBucketSchema(Schema): """One bucket in a grouped error summary.""" + key = fields.String(required=True) count = fields.Integer(required=True) severity = fields.String(required=True) @@ -32,14 +34,17 @@ class ErrorSummaryBucketSchema(Schema): class LogLineSchema(Schema): """A single parsed line from a build log.""" + timestamp = fields.DateTime(allow_none=True) level = fields.String( required=True, - validate=validate.OneOf(['debug', 'info', 'warning', 'error', 'critical']), + validate=validate.OneOf( + ['debug', 'info', 'warning', 'error', 'critical']), ) source = fields.String( required=True, - validate=validate.OneOf(['orchestrator', 'worker', 'build', 'test_runner', 'web']), + validate=validate.OneOf( + ['orchestrator', 'worker', 'build', 'test_runner', 'web']), ) message = fields.String(required=True) run_id = fields.Integer(required=True) diff --git a/mod_api/schemas/results.py b/mod_api/schemas/results.py index db8684f9..4004f2cb 100644 --- a/mod_api/schemas/results.py +++ b/mod_api/schemas/results.py @@ -5,13 +5,15 @@ class OutputFileContentSchema(Schema): """File content blob returned for expected or actual output.""" + run_id = fields.Integer(allow_none=True) sample_id = fields.Integer(required=True) regression_id = fields.Integer(required=True) output_id = fields.Integer(required=True) filename = fields.String(required=True) content_type = fields.String(required=True) - encoding = fields.String(required=True, validate=validate.OneOf(['utf-8', 'base64'])) + encoding = fields.String( + required=True, validate=validate.OneOf(['utf-8', 'base64'])) content = fields.String(required=True) sha256 = fields.String(allow_none=True) storage_status = fields.String( @@ -22,7 +24,9 @@ class OutputFileContentSchema(Schema): class DiffHunkLineSchema(Schema): """One line inside a diff hunk.""" - kind = fields.String(required=True, validate=validate.OneOf(['context', 'added', 'removed'])) + + kind = fields.String(required=True, validate=validate.OneOf( + ['context', 'added', 'removed'])) expected_line = fields.Integer(allow_none=True) actual_line = fields.Integer(allow_none=True) text = fields.String(required=True) @@ -30,6 +34,7 @@ class DiffHunkLineSchema(Schema): class DiffHunkSchema(Schema): """A contiguous block of changes.""" + expected_start = fields.Integer(required=True) actual_start = fields.Integer(required=True) lines = fields.List(fields.Nested(DiffHunkLineSchema), required=True) @@ -37,6 +42,7 @@ class DiffHunkSchema(Schema): class DiffSchema(Schema): """Structured diff between expected and actual output.""" + run_id = fields.Integer(required=True) sample_id = fields.Integer(required=True) regression_id = fields.Integer(required=True) @@ -50,6 +56,7 @@ class DiffSchema(Schema): class BaselineApprovalRequestSchema(Schema): """POST /runs/{id}/samples/{sid}/baseline-approval body.""" + regression_id = fields.Integer( required=True, validate=validate.Range(min=1), @@ -67,13 +74,19 @@ class BaselineApprovalRequestSchema(Schema): ) class Meta: + """Reject unknown fields.""" + unknown = RAISE class BaselineApprovalSchema(Schema): """Response after a baseline approval is applied.""" + approval_id = fields.String(required=True) - status = fields.String(required=True, validate=validate.OneOf(['approved'])) + status = fields.String( + required=True, + validate=validate.OneOf( + ['approved'])) run_id = fields.Integer(required=True) sample_id = fields.Integer(required=True) regression_id = fields.Integer(required=True) diff --git a/mod_api/schemas/runs.py b/mod_api/schemas/runs.py index bc07ad9c..fb081562 100644 --- a/mod_api/schemas/runs.py +++ b/mod_api/schemas/runs.py @@ -5,6 +5,7 @@ class ProgressEventSchema(Schema): """A single progress event in a run's timeline.""" + timestamp = fields.DateTime(required=True) status = fields.String(required=True) message = fields.String(required=True) @@ -13,11 +14,13 @@ class ProgressEventSchema(Schema): class RunSchema(Schema): """Full run details.""" + run_id = fields.Integer(required=True) status = fields.String(required=True, validate=validate.OneOf([ 'queued', 'running', 'pass', 'fail', 'canceled', 'error', 'incomplete', ])) - platform = fields.String(required=True, validate=validate.OneOf(['linux', 'windows'])) + platform = fields.String( + required=True, validate=validate.OneOf(['linux', 'windows'])) test_type = fields.String(validate=validate.OneOf(['commit', 'pr'])) repository = fields.String(required=True) branch = fields.String(allow_none=True) @@ -32,6 +35,7 @@ class RunSchema(Schema): class RunSummarySchema(Schema): """Pass/fail/skip aggregate counts for a run.""" + run_id = fields.Integer(required=True) status = fields.String(required=True) total_samples = fields.Integer(required=True) @@ -46,6 +50,7 @@ class RunSummarySchema(Schema): class RunConfigSchema(Schema): """The test matrix and configuration for a run.""" + run_id = fields.Integer(required=True) platform = fields.String(required=True) branch = fields.String(required=True) @@ -55,6 +60,7 @@ class RunConfigSchema(Schema): class RunCreateRequestSchema(Schema): """POST /runs request body.""" + commit_sha = fields.String( required=True, validate=validate.Regexp( @@ -98,11 +104,14 @@ class RunCreateRequestSchema(Schema): ) class Meta: + """Reject unknown fields.""" + unknown = RAISE class RunActionResultSchema(Schema): """Response for cancel and similar run actions.""" + run_id = fields.Integer(required=True) new_run_id = fields.Integer(allow_none=True) action = fields.String(required=True) diff --git a/mod_api/schemas/samples.py b/mod_api/schemas/samples.py index cd208cb0..5f074a52 100644 --- a/mod_api/schemas/samples.py +++ b/mod_api/schemas/samples.py @@ -1,10 +1,11 @@ -"""Schemas for samples, per-run sample results, history, and regression tests.""" +"""Request and response schemas for Sample endpoints and results.""" from marshmallow import Schema, fields, validate class OutputFileSchema(Schema): - """One output file entry within a run sample result.""" + """Output file schema.""" + output_id = fields.Integer(required=True) filename = fields.String(required=True) status = fields.String(required=True, validate=validate.OneOf([ @@ -14,6 +15,7 @@ class OutputFileSchema(Schema): class RunSampleSchema(Schema): """A regression test's result within a specific run.""" + regression_test_id = fields.Integer(required=True) sample_id = fields.Integer(allow_none=True) sample_name = fields.String(allow_none=True) @@ -30,6 +32,7 @@ class RunSampleSchema(Schema): class SampleSchema(Schema): """A media sample from the catalog.""" + sample_id = fields.Integer(required=True) sha = fields.String(required=True) extension = fields.String(required=True) @@ -42,6 +45,7 @@ class SampleSchema(Schema): class SampleHistoryEntrySchema(Schema): """One row in a sample's cross-run history.""" + run_id = fields.Integer(required=True) status = fields.String(required=True) platform = fields.String(required=True) @@ -53,6 +57,7 @@ class SampleHistoryEntrySchema(Schema): class RegressionTestSchema(Schema): """A regression test definition.""" + regression_test_id = fields.Integer(required=True) sample_id = fields.Integer(allow_none=True) sample_name = fields.String(allow_none=True) diff --git a/mod_api/schemas/system.py b/mod_api/schemas/system.py index 5e5602ff..553502bd 100644 --- a/mod_api/schemas/system.py +++ b/mod_api/schemas/system.py @@ -5,26 +5,34 @@ class DependencyHealthSchema(Schema): """Status of a single system dependency (DB, GCS, local storage).""" + name = fields.String(required=True) - status = fields.String(required=True, validate=validate.OneOf(['ok', 'degraded', 'down'])) + status = fields.String( + required=True, validate=validate.OneOf(['ok', 'degraded', 'down'])) message = fields.String(allow_none=True) class SystemHealthSchema(Schema): """Overall system health response.""" + status = fields.String( required=True, validate=validate.OneOf(['ok', 'degraded', 'down']), ) checked_at = fields.DateTime(required=True) - dependencies = fields.List(fields.Nested(DependencyHealthSchema), required=True) + dependencies = fields.List( + fields.Nested(DependencyHealthSchema), + required=True) class QueueJobSchema(Schema): """A single queued or running job.""" + run_id = fields.Integer(required=True) - status = fields.String(required=True, validate=validate.OneOf(['queued', 'running'])) - platform = fields.String(required=True, validate=validate.OneOf(['linux', 'windows'])) + status = fields.String( + required=True, validate=validate.OneOf(['queued', 'running'])) + platform = fields.String( + required=True, validate=validate.OneOf(['linux', 'windows'])) queued_at = fields.DateTime(allow_none=True) started_at = fields.DateTime(allow_none=True) position = fields.Integer(allow_none=True) @@ -32,6 +40,7 @@ class QueueJobSchema(Schema): class ArtifactSchema(Schema): """A downloadable artifact tied to a run.""" + artifact_id = fields.String(required=True) run_id = fields.Integer(required=True) sample_id = fields.Integer(allow_none=True) diff --git a/mod_api/utils.py b/mod_api/utils.py index bdbcd609..1faecd6a 100644 --- a/mod_api/utils.py +++ b/mod_api/utils.py @@ -52,9 +52,9 @@ def single_response(data, schema=None, http_status=200): def get_sort_column(sort_param, column_map): - """ - Translate a validated sort string (e.g. '-created_at') into an - SQLAlchemy order_by clause. + """Translate a sort string into an SQLAlchemy order_by clause. + + Handles descending sorts prefixed with '-' (e.g. '-created_at'). """ descending = sort_param.startswith('-') field_name = sort_param.lstrip('-') From 1441393f007becc4aed305b92ffb41fea8d4eef3 Mon Sep 17 00:00:00 2001 From: Pulkit Chauhan Date: Mon, 8 Jun 2026 19:41:50 +0530 Subject: [PATCH 14/28] pycodestyle fix --- run.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/run.py b/run.py index 1b180f9a..23e43456 100755 --- a/run.py +++ b/run.py @@ -24,6 +24,7 @@ SecretKeyInstallationException) from log_configuration import LogConfiguration from mailer import Mailer +from mod_api import mod_api from mod_auth.controllers import mod_auth from mod_ci.controllers import mod_ci from mod_customized.controllers import mod_customized @@ -273,8 +274,5 @@ def teardown(exception: Optional[Exception]): app.register_blueprint(mod_ci) app.register_blueprint(mod_customized, url_prefix='/custom') app.register_blueprint(mod_health) - # REST API v1 -from mod_api import mod_api - app.register_blueprint(mod_api, url_prefix='/api/v1') From 5a0b95678b4ddbb932dc1792865ac8f63f0d42cf Mon Sep 17 00:00:00 2001 From: Pulkit Chauhan Date: Tue, 9 Jun 2026 14:33:26 +0530 Subject: [PATCH 15/28] Added auth, error_logs and results routes --- mod_api/middleware/validation.py | 5 +- mod_api/routes/auth.py | 183 +++++++++++++++++ mod_api/routes/errors_logs.py | 189 +++++++++++++++++ mod_api/routes/results.py | 335 +++++++++++++++++++++++++++++++ 4 files changed, 710 insertions(+), 2 deletions(-) create mode 100644 mod_api/routes/auth.py create mode 100644 mod_api/routes/errors_logs.py create mode 100644 mod_api/routes/results.py diff --git a/mod_api/middleware/validation.py b/mod_api/middleware/validation.py index 9c5c71a7..e7b51808 100644 --- a/mod_api/middleware/validation.py +++ b/mod_api/middleware/validation.py @@ -219,8 +219,9 @@ def decorated(*args, **kwargs): f'sort must be one of: {", ".join(sorted(allowed))}', details={ 'fields': { - 'sort': f'Must be one of: { - sorted(allowed)}'}}, + 'sort': f'Must be one of: {sorted(allowed)}' + } + }, http_status=400, ) kwargs['sort'] = sort diff --git a/mod_api/routes/auth.py b/mod_api/routes/auth.py new file mode 100644 index 00000000..be178ca6 --- /dev/null +++ b/mod_api/routes/auth.py @@ -0,0 +1,183 @@ +""" +Token lifecycle: create, list, and revoke API tokens. + +POST /auth/tokens Authenticate with email/password, get a token +GET /auth/tokens List tokens (own tokens; admin can see all) +DELETE /auth/tokens/current Revoke the token you're currently using +DELETE /auth/tokens/{id} Revoke a specific token by ID +""" + +from flask import g, request + +from mod_api import mod_api +from mod_api.middleware.auth import require_roles, require_scope +from mod_api.middleware.error_handler import make_error_response +from mod_api.middleware.validation import validate_body, validate_pagination +from mod_api.models.api_token import DEFAULT_SCOPES, ApiToken +from mod_api.schemas.auth import (ApiTokenItemSchema, AuthTokenSchema, + TokenCreateRequestSchema) +from mod_api.utils import paginated_response, single_response +from mod_auth.models import User + + +@mod_api.route('/auth/tokens', methods=['POST']) +@validate_body(TokenCreateRequestSchema) +def create_token(validated_data=None): + """ + Authenticate with email + password and issue a scoped API token. + + The plaintext token value is returned exactly once in this response. + It's never stored or logged — only the argon2 hash is persisted. + """ + email = validated_data['email'] + password = validated_data['password'] + token_name = validated_data['token_name'] + expires_in_days = validated_data.get('expires_in_days', 7) + scopes = validated_data.get('scopes') or DEFAULT_SCOPES + + user = User.query.filter_by(email=email).first() + + # Prevent timing attack: if user doesn't exist, consume hashing time anyway + if user is None: + User.generate_hash(password) + return make_error_response( + 'invalid_credentials', + 'Invalid email or password.', + http_status=401, + ) + + if not user.is_password_valid(password): + return make_error_response( + 'invalid_credentials', + 'Invalid email or password.', + http_status=401, + ) + + # Duplicate name check — must revoke the old one first. + existing = ApiToken.query.filter_by( + user_id=user.id, + token_name=token_name, + ).first() + if existing and not existing.is_revoked: + return make_error_response( + 'validation_error', + f'Token name "{token_name}" already exists for this user.', + details={'fields': {'token_name': 'Already in use. Revoke the existing token first.'}}, + http_status=400, + ) + + plaintext = ApiToken.generate_token() + token_hash = ApiToken.hash_token(plaintext) + token_prefix = ApiToken.extract_prefix(plaintext) + + api_token = ApiToken( + user_id=user.id, + token_name=token_name, + token_hash=token_hash, + token_prefix=token_prefix, + scopes=scopes, + expires_in_days=expires_in_days, + ) + g.db.add(api_token) + + from sqlalchemy.exc import IntegrityError + try: + g.db.commit() + except IntegrityError: + g.db.rollback() + return make_error_response( + 'validation_error', + f'Token name "{token_name}" already exists for this user.', + details={'fields': {'token_name': 'Already in use. Revoke the existing token first.'}}, + http_status=400, + ) + + return single_response( + { + 'token': plaintext, + 'token_type': 'bearer', + 'token_name': token_name, + 'scopes': scopes, + 'expires_at': api_token.expires_at, + }, + schema=AuthTokenSchema(), + http_status=201, + ) + + +@mod_api.route('/auth/tokens/current', methods=['DELETE']) +def revoke_current_token(): + """Revoke whatever token is in the Authorization header right now.""" + token = getattr(g, 'api_token', None) + if token is None: + return make_error_response( + 'unauthorized', + 'No token found in the current request.', + http_status=401, + ) + token.revoke() + g.db.commit() + return '', 204 + + +@mod_api.route('/auth/tokens', methods=['GET']) +@require_roles(['admin', 'contributor', 'tester']) +@require_scope('tokens:manage') +@validate_pagination +def list_tokens(limit=50, offset=0): + """ + List tokens for the current user, paginated. + + Admins can pass ?all=true to see every token in the system. + Non-admins who try ?all=true get a 403. + """ + want_all = request.args.get('all', 'false').lower() == 'true' + is_admin = g.api_user.role.value == 'admin' + + if want_all and not is_admin: + return make_error_response( + 'forbidden', + 'Only admins may list all tokens.', + details={'required_roles': ['admin']}, + http_status=403, + ) + + if want_all and is_admin: + query = ApiToken.query.order_by(ApiToken.created_at.desc()) + else: + query = ApiToken.query.filter_by( + user_id=g.api_user.id, + ).order_by(ApiToken.created_at.desc()) + + total = query.count() + tokens = query.offset(offset).limit(limit).all() + schema = ApiTokenItemSchema(many=True) + + return paginated_response(tokens, total, limit, offset, schema=schema) + + +@mod_api.route('/auth/tokens/', methods=['DELETE']) +@require_roles(['admin', 'contributor', 'tester']) +@require_scope('tokens:manage') +def revoke_specific_token(token_id): + """ + Revoke a token by its numeric ID. + + Non-admins can only revoke their own tokens. Admins can revoke anyone's. + Already-revoked tokens are silently accepted (idempotent). + """ + is_admin = g.api_user.role.value == 'admin' + + if is_admin: + token = ApiToken.query.filter_by(id=token_id).first() + else: + token = ApiToken.query.filter_by(id=token_id, user_id=g.api_user.id).first() + + if not token: + return make_error_response('not_found', f'Token {token_id} not found.', http_status=404) + + if not token.is_revoked: + token.revoke() + g.db.commit() + + return '', 204 diff --git a/mod_api/routes/errors_logs.py b/mod_api/routes/errors_logs.py new file mode 100644 index 00000000..a19a2b3c --- /dev/null +++ b/mod_api/routes/errors_logs.py @@ -0,0 +1,189 @@ +""" +Error and build log routes. + +GET /runs/{id}/errors Test-level errors for a run +GET /runs/{id}/infrastructure-errors Infra errors (VM, build, worker) +GET /runs/{id}/error-summary Grouped error counts +GET /runs/{id}/logs Build log (cursor-paginated) +GET /runs/{id}/samples/{sid}/logs Per-sample logs (not yet available) +""" + +from flask import g, request + +from mod_api import mod_api +from mod_api.middleware.auth import require_roles, require_scope +from mod_api.middleware.error_handler import make_error_response +from mod_api.middleware.validation import validate_pagination, validate_path_id +from mod_api.services.error_service import (derive_error_summary, + derive_errors_for_run, + derive_infrastructure_errors) +from mod_api.services.log_service import read_log_lines +from mod_api.utils import cursor_paginated_response, paginated_response +from mod_test.models import Test + + +@mod_api.route('/runs//errors', methods=['GET']) +@require_scope('results:read') +@validate_path_id('run_id') +@validate_pagination +def list_run_errors(run_id, limit=50, offset=0): + """List test errors for a run, derived from result and output data.""" + test = Test.query.filter(Test.id == run_id).first() + if test is None: + return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) + + errors = derive_errors_for_run(run_id) + + error_type = request.args.get('type') + if error_type: + errors = [e for e in errors if e['type'] == error_type] + + severity = request.args.get('severity') + if severity: + errors = [e for e in errors if e['severity'] == severity] + + sample_id = request.args.get('sample_id', type=int) + if sample_id: + errors = [e for e in errors if e.get('sample_id') == sample_id] + + total = len(errors) + paged = errors[offset:offset + limit] + + return paginated_response(paged, total, limit, offset) + + +@mod_api.route('/runs//infrastructure-errors', methods=['GET']) +@require_scope('system:read') +@validate_path_id('run_id') +@validate_pagination +def list_infrastructure_errors(run_id, limit=50, offset=0): + """ + Infra errors classified from TestProgress messages on a best-effort basis. + + Stack traces are opt-in because they may contain internal paths. + """ + test = Test.query.filter(Test.id == run_id).first() + if test is None: + return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) + + include_stack = request.args.get('include_stack', 'false').lower() == 'true' + if include_stack: + user = getattr(g, 'api_user', None) + if user is None or user.role.value not in ('admin', 'contributor'): + return make_error_response( + 'forbidden', + 'Stack traces require admin or contributor role.', + details={'required_roles': ['admin', 'contributor']}, + http_status=403, + ) + + errors = derive_infrastructure_errors(run_id) + + if not include_stack: + for e in errors: + e.pop('stack', None) + + # Apply optional type and severity filters. + error_type = request.args.get('type') + if error_type: + errors = [e for e in errors if e.get('type') == error_type] + + severity = request.args.get('severity') + if severity: + errors = [e for e in errors if e.get('severity') == severity] + + total = len(errors) + paged = errors[offset:offset + limit] + return paginated_response(paged, total, limit, offset) + + +@mod_api.route('/runs//error-summary', methods=['GET']) +@require_scope('results:read') +@validate_path_id('run_id') +@validate_pagination +def get_error_summary(run_id, limit=50, offset=0): + """Group error summary for triaging a run before drilling into details.""" + test = Test.query.filter(Test.id == run_id).first() + if test is None: + return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) + + group_by = request.args.get('group_by', 'type') + if group_by not in ('type', 'severity', 'sample_id', 'regression_id', 'category'): + return make_error_response( + 'validation_error', + 'group_by must be one of: type, severity, sample_id, regression_id, category.', + http_status=400, + ) + + severity = request.args.get('severity') + + summary = derive_error_summary(run_id, group_by=group_by) + + if severity: + summary = [s for s in summary if s.get('severity') == severity] + + total = len(summary) + paged = summary[offset:offset + limit] + return paginated_response(paged, total, limit, offset) + + +@mod_api.route('/runs//logs', methods=['GET']) +@require_scope('system:read') +@require_roles(['admin', 'contributor']) +@validate_path_id('run_id') +def get_run_logs(run_id): + """ + Read a run's build log with cursor-based pagination. + + Returns 404 (not a broken download link) when the file doesn't exist. + """ + test = Test.query.filter(Test.id == run_id).first() + if test is None: + return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) + + cursor = request.args.get('cursor') + limit = request.args.get('limit', 100, type=int) + limit = max(1, min(limit, 100)) + level = request.args.get('level') + source = request.args.get('source') + contains = request.args.get('contains') + if contains and len(contains) > 100: + return make_error_response( + 'validation_error', + 'contains parameter must be 100 characters or less.', + http_status=400, + ) + + try: + lines, next_cursor = read_log_lines( + run_id, + cursor=cursor, + limit=limit, + level=level, + source=source, + contains=contains, + ) + except FileNotFoundError: + return make_error_response( + 'log_not_found', + f'Log file not found for run {run_id}.', + details={'run_id': run_id, 'checked': ['local', 'gcs']}, + http_status=404, + ) + + return cursor_paginated_response(lines, next_cursor, limit) + + +@mod_api.route('/runs//samples//logs', methods=['GET']) +@require_scope('system:read') +@require_roles(['admin', 'contributor']) +@validate_path_id('run_id') +@validate_path_id('sample_id') +def get_sample_logs(run_id, sample_id): + """Per-sample logs aren't available yet — the CI worker doesn't support them.""" + return make_error_response( + 'not_found', + f'Per-sample logs are not available for sample {sample_id} in run {run_id}.', + details={'reason': 'Per-sample log storage is not yet supported by the CI worker.'}, + http_status=404, + ) diff --git a/mod_api/routes/results.py b/mod_api/routes/results.py new file mode 100644 index 00000000..692a12da --- /dev/null +++ b/mod_api/routes/results.py @@ -0,0 +1,335 @@ +""" +Expected/actual output, diffs, and baseline approval routes. + +GET /runs/{id}/samples/{sid}/expected Expected output file +GET /runs/{id}/samples/{sid}/actual Actual output file +GET /runs/{id}/samples/{sid}/diff Structured diff +POST /runs/{id}/samples/{sid}/baseline-approval Approve a new baseline +""" + +import base64 +import os + +from flask import g, request + +from mod_api import mod_api +from mod_api.middleware.auth import require_roles, require_scope +from mod_api.middleware.error_handler import make_error_response +from mod_api.middleware.validation import validate_body, validate_path_id +from mod_api.schemas.results import BaselineApprovalRequestSchema +from mod_api.services.diff_service import compute_diff, file_sha256, read_lines +from mod_api.services.status import is_dummy_row +from mod_api.services.storage import get_test_results_base_path +from mod_api.utils import single_response +from mod_test.models import Test, TestResult, TestResultFile + + +def _safe_resolve(base_path, filename): + """ + Resolve filename under base_path, rejecting path traversal. + + Returns the absolute path if it's safely within base_path, + or None if traversal was detected. + """ + resolved = os.path.realpath(os.path.join(base_path, filename)) + base_real = os.path.realpath(base_path) + if not resolved.startswith(base_real + os.sep) and resolved != base_real: + return None + return resolved + + +def _find_result_file(run_id, regression_test_id, output_id=None): + """ + Look up the right TestResultFile row. + + Uses run_id + regression_test_id from the path. If output_id is + given as a query param, narrow to that specific output file. + """ + query = TestResultFile.query.filter_by( + test_id=run_id, + regression_test_id=regression_test_id, + ) + + if output_id is not None: + query = query.filter_by(regression_test_output_id=output_id) + + return query.first() + + +def _parse_output_id(): + """Pull output_id from query string, if provided.""" + return request.args.get('output_id', type=int) + + +@mod_api.route('/runs//samples//expected', methods=['GET']) +@require_scope('results:read') +@validate_path_id('run_id') +@validate_path_id('sample_id') +def get_expected_output(run_id, sample_id): + """Return the expected output file for a regression test result.""" + test = Test.query.filter(Test.id == run_id).first() + if test is None: + return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) + + result = TestResult.query.filter_by( + test_id=run_id, + regression_test_id=sample_id, + ).first() + if result is None: + return make_error_response('not_found', f'Sample {sample_id} not found.', http_status=404) + + output_id = _parse_output_id() + result_file = _find_result_file(run_id, sample_id, output_id) + + if result_file is None or is_dummy_row(result_file): + return make_error_response('not_found', 'Expected output not found.', http_status=404) + + base_path = get_test_results_base_path() + expected_filename = result_file.expected + ext = '' + if result_file.regression_test_output: + ext = result_file.regression_test_output.correct_extension + expected_filename += ext + + file_path = _safe_resolve(base_path, expected_filename) + if file_path is None: + return make_error_response('forbidden', 'Invalid file path.', http_status=403) + + fmt = request.args.get('format', 'base64') + + if not os.path.isfile(file_path): + return make_error_response( + 'not_found', + 'Expected output file not found on disk.', + http_status=404, + ) + + sha256 = file_sha256(file_path) + + if fmt == 'text': + try: + lines = read_lines(file_path) + content = '\n'.join(lines) + encoding = 'utf-8' + except Exception: + return make_error_response('internal_error', 'Failed to read file.', http_status=500) + else: + with open(file_path, 'rb') as f: + content = base64.b64encode(f.read()).decode('ascii') + encoding = 'base64' + + return single_response({ + 'run_id': run_id, + 'sample_id': sample_id, + 'regression_id': result_file.regression_test_id, + 'output_id': result_file.regression_test_output_id, + 'filename': expected_filename, + 'content_type': 'application/octet-stream', + 'encoding': encoding, + 'content': content, + 'sha256': sha256, + 'storage_status': 'ok', + }) + + +@mod_api.route('/runs//samples//actual', methods=['GET']) +@require_scope('results:read') +@validate_path_id('run_id') +@validate_path_id('sample_id') +def get_actual_output(run_id, sample_id): + """ + Return the actual output file for a regression test result. + + got=null in the DB means the output matched expected — not that it's + missing. We return 204 in that case. Missing output (the dummy sentinel + row) returns 404. + """ + test = Test.query.filter(Test.id == run_id).first() + if test is None: + return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) + + output_id = _parse_output_id() + result_file = _find_result_file(run_id, sample_id, output_id) + + if result_file is None: + return make_error_response('not_found', f'No result for sample {sample_id}.', http_status=404) + + if is_dummy_row(result_file): + return make_error_response( + 'missing_output', + 'Test produced no output when output was expected.', + http_status=404, + ) + + # got=null means actual matched expected — no separate file stored. + if result_file.got is None: + return '', 204 + + base_path = get_test_results_base_path() + actual_filename = result_file.got + if result_file.regression_test_output: + actual_filename += result_file.regression_test_output.correct_extension + + file_path = _safe_resolve(base_path, actual_filename) + if file_path is None: + return make_error_response('forbidden', 'Invalid file path.', http_status=403) + + fmt = request.args.get('format', 'base64') + + if not os.path.isfile(file_path): + return make_error_response( + 'not_found', + 'Actual output file not found on disk.', + http_status=404, + ) + + sha256 = file_sha256(file_path) + + if fmt == 'text': + lines = read_lines(file_path) + content = '\n'.join(lines) + encoding = 'utf-8' + else: + with open(file_path, 'rb') as f: + content = base64.b64encode(f.read()).decode('ascii') + encoding = 'base64' + + return single_response({ + 'run_id': run_id, + 'sample_id': sample_id, + 'regression_id': result_file.regression_test_id, + 'output_id': result_file.regression_test_output_id, + 'filename': actual_filename, + 'content_type': 'application/octet-stream', + 'encoding': encoding, + 'content': content, + 'sha256': sha256, + 'storage_status': 'ok', + }) + + +@mod_api.route('/runs//samples//diff', methods=['GET']) +@require_scope('results:read') +@validate_path_id('run_id') +@validate_path_id('sample_id') +def get_diff(run_id, sample_id): + """Structured diff between expected and actual output.""" + test = Test.query.filter(Test.id == run_id).first() + if test is None: + return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) + + output_id = _parse_output_id() + result_file = _find_result_file(run_id, sample_id, output_id) + + if result_file is None: + return make_error_response('not_found', f'No result for sample {sample_id}.', http_status=404) + + diff_ids = { + 'run_id': run_id, + 'sample_id': sample_id, + 'regression_id': result_file.regression_test_id, + 'output_id': result_file.regression_test_output_id, + } + + if is_dummy_row(result_file): + return single_response({ + **diff_ids, + 'status': 'missing_actual', + 'summary': {'added_lines': 0, 'removed_lines': 0, 'changed_hunks': 0}, + 'hunks': [], + }) + + if result_file.got is None: + return single_response({ + **diff_ids, + 'status': 'identical', + 'summary': {'added_lines': 0, 'removed_lines': 0, 'changed_hunks': 0}, + 'hunks': [], + }) + + base_path = get_test_results_base_path() + ext = result_file.regression_test_output.correct_extension if result_file.regression_test_output else '' + expected_path = _safe_resolve(base_path, result_file.expected + ext) + actual_path = _safe_resolve(base_path, result_file.got + ext) + + if expected_path is None or actual_path is None: + return make_error_response('forbidden', 'Invalid file path.', http_status=403) + + context_lines = request.args.get('context_lines', 3, type=int) + context_lines = max(1, min(context_lines, 50)) + + diff_result = compute_diff(expected_path, actual_path, context_lines=context_lines) + diff_result.update(diff_ids) + return single_response(diff_result) + + +@mod_api.route('/runs//samples//baseline-approval', methods=['POST']) +@require_scope('baselines:write') +@require_roles(['admin', 'contributor']) +@validate_path_id('run_id') +@validate_path_id('sample_id') +@validate_body(BaselineApprovalRequestSchema) +def create_baseline_approval(run_id, sample_id, validated_data=None): + """ + Record intent to approve actual output as the new expected baseline. + + WARNING: When remove_variants is set to true, this action will remove all + platform-specific variants, making this output the single source of truth + across all platforms. Care should be taken as this applies globally. + """ + test = Test.query.filter(Test.id == run_id).first() + if test is None: + return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) + + from mod_api.services.status import is_dummy_row + + regression_id = validated_data['regression_id'] + output_id = validated_data['output_id'] + + result_file = TestResultFile.query.filter_by( + test_id=run_id, + regression_test_id=regression_id, + regression_test_output_id=output_id, + ).first() + + if result_file is None: + return make_error_response('not_found', 'Result file not found.', http_status=404) + + if is_dummy_row(result_file): + return make_error_response('unprocessable', 'Cannot approve a dummy row.', http_status=422) + + if result_file.got is None: + return make_error_response('unprocessable', 'Output already matches expected.', http_status=422) + + # The actual output file (named by its hash) is already in TestResults/. + # We just need to update the RegressionTestOutput to point to this new hash. + rto = result_file.regression_test_output + if rto is None: + return make_error_response('internal_error', 'No RegressionTestOutput linked.', http_status=500) + + old_baseline = rto.correct + new_baseline = result_file.got + + rto.correct = new_baseline + + remove_variants = validated_data.get('remove_variants', False) + if remove_variants: + from mod_regression.models import RegressionTestOutputFiles + RegressionTestOutputFiles.query.filter_by(regression_test_output_id=rto.id).delete() + + g.db.commit() + + import datetime + from datetime import timezone + import uuid + + return single_response({ + 'approval_id': str(uuid.uuid4()), + 'status': 'approved', + 'run_id': run_id, + 'sample_id': sample_id, + 'regression_id': regression_id, + 'output_id': output_id, + 'requested_by': getattr(g, 'api_user').name if getattr(g, 'api_user', None) else 'unknown', + 'created_at': datetime.datetime.now(timezone.utc).isoformat() + }) From 26ac65c2b1d4d0b432db1005f06c4b1d617523d6 Mon Sep 17 00:00:00 2001 From: Pulkit Chauhan Date: Tue, 9 Jun 2026 14:43:24 +0530 Subject: [PATCH 16/28] fix isort and other styling issues --- mod_api/routes/auth.py | 183 --------------------- mod_api/routes/results.py | 335 -------------------------------------- 2 files changed, 518 deletions(-) diff --git a/mod_api/routes/auth.py b/mod_api/routes/auth.py index be178ca6..e69de29b 100644 --- a/mod_api/routes/auth.py +++ b/mod_api/routes/auth.py @@ -1,183 +0,0 @@ -""" -Token lifecycle: create, list, and revoke API tokens. - -POST /auth/tokens Authenticate with email/password, get a token -GET /auth/tokens List tokens (own tokens; admin can see all) -DELETE /auth/tokens/current Revoke the token you're currently using -DELETE /auth/tokens/{id} Revoke a specific token by ID -""" - -from flask import g, request - -from mod_api import mod_api -from mod_api.middleware.auth import require_roles, require_scope -from mod_api.middleware.error_handler import make_error_response -from mod_api.middleware.validation import validate_body, validate_pagination -from mod_api.models.api_token import DEFAULT_SCOPES, ApiToken -from mod_api.schemas.auth import (ApiTokenItemSchema, AuthTokenSchema, - TokenCreateRequestSchema) -from mod_api.utils import paginated_response, single_response -from mod_auth.models import User - - -@mod_api.route('/auth/tokens', methods=['POST']) -@validate_body(TokenCreateRequestSchema) -def create_token(validated_data=None): - """ - Authenticate with email + password and issue a scoped API token. - - The plaintext token value is returned exactly once in this response. - It's never stored or logged — only the argon2 hash is persisted. - """ - email = validated_data['email'] - password = validated_data['password'] - token_name = validated_data['token_name'] - expires_in_days = validated_data.get('expires_in_days', 7) - scopes = validated_data.get('scopes') or DEFAULT_SCOPES - - user = User.query.filter_by(email=email).first() - - # Prevent timing attack: if user doesn't exist, consume hashing time anyway - if user is None: - User.generate_hash(password) - return make_error_response( - 'invalid_credentials', - 'Invalid email or password.', - http_status=401, - ) - - if not user.is_password_valid(password): - return make_error_response( - 'invalid_credentials', - 'Invalid email or password.', - http_status=401, - ) - - # Duplicate name check — must revoke the old one first. - existing = ApiToken.query.filter_by( - user_id=user.id, - token_name=token_name, - ).first() - if existing and not existing.is_revoked: - return make_error_response( - 'validation_error', - f'Token name "{token_name}" already exists for this user.', - details={'fields': {'token_name': 'Already in use. Revoke the existing token first.'}}, - http_status=400, - ) - - plaintext = ApiToken.generate_token() - token_hash = ApiToken.hash_token(plaintext) - token_prefix = ApiToken.extract_prefix(plaintext) - - api_token = ApiToken( - user_id=user.id, - token_name=token_name, - token_hash=token_hash, - token_prefix=token_prefix, - scopes=scopes, - expires_in_days=expires_in_days, - ) - g.db.add(api_token) - - from sqlalchemy.exc import IntegrityError - try: - g.db.commit() - except IntegrityError: - g.db.rollback() - return make_error_response( - 'validation_error', - f'Token name "{token_name}" already exists for this user.', - details={'fields': {'token_name': 'Already in use. Revoke the existing token first.'}}, - http_status=400, - ) - - return single_response( - { - 'token': plaintext, - 'token_type': 'bearer', - 'token_name': token_name, - 'scopes': scopes, - 'expires_at': api_token.expires_at, - }, - schema=AuthTokenSchema(), - http_status=201, - ) - - -@mod_api.route('/auth/tokens/current', methods=['DELETE']) -def revoke_current_token(): - """Revoke whatever token is in the Authorization header right now.""" - token = getattr(g, 'api_token', None) - if token is None: - return make_error_response( - 'unauthorized', - 'No token found in the current request.', - http_status=401, - ) - token.revoke() - g.db.commit() - return '', 204 - - -@mod_api.route('/auth/tokens', methods=['GET']) -@require_roles(['admin', 'contributor', 'tester']) -@require_scope('tokens:manage') -@validate_pagination -def list_tokens(limit=50, offset=0): - """ - List tokens for the current user, paginated. - - Admins can pass ?all=true to see every token in the system. - Non-admins who try ?all=true get a 403. - """ - want_all = request.args.get('all', 'false').lower() == 'true' - is_admin = g.api_user.role.value == 'admin' - - if want_all and not is_admin: - return make_error_response( - 'forbidden', - 'Only admins may list all tokens.', - details={'required_roles': ['admin']}, - http_status=403, - ) - - if want_all and is_admin: - query = ApiToken.query.order_by(ApiToken.created_at.desc()) - else: - query = ApiToken.query.filter_by( - user_id=g.api_user.id, - ).order_by(ApiToken.created_at.desc()) - - total = query.count() - tokens = query.offset(offset).limit(limit).all() - schema = ApiTokenItemSchema(many=True) - - return paginated_response(tokens, total, limit, offset, schema=schema) - - -@mod_api.route('/auth/tokens/', methods=['DELETE']) -@require_roles(['admin', 'contributor', 'tester']) -@require_scope('tokens:manage') -def revoke_specific_token(token_id): - """ - Revoke a token by its numeric ID. - - Non-admins can only revoke their own tokens. Admins can revoke anyone's. - Already-revoked tokens are silently accepted (idempotent). - """ - is_admin = g.api_user.role.value == 'admin' - - if is_admin: - token = ApiToken.query.filter_by(id=token_id).first() - else: - token = ApiToken.query.filter_by(id=token_id, user_id=g.api_user.id).first() - - if not token: - return make_error_response('not_found', f'Token {token_id} not found.', http_status=404) - - if not token.is_revoked: - token.revoke() - g.db.commit() - - return '', 204 diff --git a/mod_api/routes/results.py b/mod_api/routes/results.py index 692a12da..e69de29b 100644 --- a/mod_api/routes/results.py +++ b/mod_api/routes/results.py @@ -1,335 +0,0 @@ -""" -Expected/actual output, diffs, and baseline approval routes. - -GET /runs/{id}/samples/{sid}/expected Expected output file -GET /runs/{id}/samples/{sid}/actual Actual output file -GET /runs/{id}/samples/{sid}/diff Structured diff -POST /runs/{id}/samples/{sid}/baseline-approval Approve a new baseline -""" - -import base64 -import os - -from flask import g, request - -from mod_api import mod_api -from mod_api.middleware.auth import require_roles, require_scope -from mod_api.middleware.error_handler import make_error_response -from mod_api.middleware.validation import validate_body, validate_path_id -from mod_api.schemas.results import BaselineApprovalRequestSchema -from mod_api.services.diff_service import compute_diff, file_sha256, read_lines -from mod_api.services.status import is_dummy_row -from mod_api.services.storage import get_test_results_base_path -from mod_api.utils import single_response -from mod_test.models import Test, TestResult, TestResultFile - - -def _safe_resolve(base_path, filename): - """ - Resolve filename under base_path, rejecting path traversal. - - Returns the absolute path if it's safely within base_path, - or None if traversal was detected. - """ - resolved = os.path.realpath(os.path.join(base_path, filename)) - base_real = os.path.realpath(base_path) - if not resolved.startswith(base_real + os.sep) and resolved != base_real: - return None - return resolved - - -def _find_result_file(run_id, regression_test_id, output_id=None): - """ - Look up the right TestResultFile row. - - Uses run_id + regression_test_id from the path. If output_id is - given as a query param, narrow to that specific output file. - """ - query = TestResultFile.query.filter_by( - test_id=run_id, - regression_test_id=regression_test_id, - ) - - if output_id is not None: - query = query.filter_by(regression_test_output_id=output_id) - - return query.first() - - -def _parse_output_id(): - """Pull output_id from query string, if provided.""" - return request.args.get('output_id', type=int) - - -@mod_api.route('/runs//samples//expected', methods=['GET']) -@require_scope('results:read') -@validate_path_id('run_id') -@validate_path_id('sample_id') -def get_expected_output(run_id, sample_id): - """Return the expected output file for a regression test result.""" - test = Test.query.filter(Test.id == run_id).first() - if test is None: - return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) - - result = TestResult.query.filter_by( - test_id=run_id, - regression_test_id=sample_id, - ).first() - if result is None: - return make_error_response('not_found', f'Sample {sample_id} not found.', http_status=404) - - output_id = _parse_output_id() - result_file = _find_result_file(run_id, sample_id, output_id) - - if result_file is None or is_dummy_row(result_file): - return make_error_response('not_found', 'Expected output not found.', http_status=404) - - base_path = get_test_results_base_path() - expected_filename = result_file.expected - ext = '' - if result_file.regression_test_output: - ext = result_file.regression_test_output.correct_extension - expected_filename += ext - - file_path = _safe_resolve(base_path, expected_filename) - if file_path is None: - return make_error_response('forbidden', 'Invalid file path.', http_status=403) - - fmt = request.args.get('format', 'base64') - - if not os.path.isfile(file_path): - return make_error_response( - 'not_found', - 'Expected output file not found on disk.', - http_status=404, - ) - - sha256 = file_sha256(file_path) - - if fmt == 'text': - try: - lines = read_lines(file_path) - content = '\n'.join(lines) - encoding = 'utf-8' - except Exception: - return make_error_response('internal_error', 'Failed to read file.', http_status=500) - else: - with open(file_path, 'rb') as f: - content = base64.b64encode(f.read()).decode('ascii') - encoding = 'base64' - - return single_response({ - 'run_id': run_id, - 'sample_id': sample_id, - 'regression_id': result_file.regression_test_id, - 'output_id': result_file.regression_test_output_id, - 'filename': expected_filename, - 'content_type': 'application/octet-stream', - 'encoding': encoding, - 'content': content, - 'sha256': sha256, - 'storage_status': 'ok', - }) - - -@mod_api.route('/runs//samples//actual', methods=['GET']) -@require_scope('results:read') -@validate_path_id('run_id') -@validate_path_id('sample_id') -def get_actual_output(run_id, sample_id): - """ - Return the actual output file for a regression test result. - - got=null in the DB means the output matched expected — not that it's - missing. We return 204 in that case. Missing output (the dummy sentinel - row) returns 404. - """ - test = Test.query.filter(Test.id == run_id).first() - if test is None: - return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) - - output_id = _parse_output_id() - result_file = _find_result_file(run_id, sample_id, output_id) - - if result_file is None: - return make_error_response('not_found', f'No result for sample {sample_id}.', http_status=404) - - if is_dummy_row(result_file): - return make_error_response( - 'missing_output', - 'Test produced no output when output was expected.', - http_status=404, - ) - - # got=null means actual matched expected — no separate file stored. - if result_file.got is None: - return '', 204 - - base_path = get_test_results_base_path() - actual_filename = result_file.got - if result_file.regression_test_output: - actual_filename += result_file.regression_test_output.correct_extension - - file_path = _safe_resolve(base_path, actual_filename) - if file_path is None: - return make_error_response('forbidden', 'Invalid file path.', http_status=403) - - fmt = request.args.get('format', 'base64') - - if not os.path.isfile(file_path): - return make_error_response( - 'not_found', - 'Actual output file not found on disk.', - http_status=404, - ) - - sha256 = file_sha256(file_path) - - if fmt == 'text': - lines = read_lines(file_path) - content = '\n'.join(lines) - encoding = 'utf-8' - else: - with open(file_path, 'rb') as f: - content = base64.b64encode(f.read()).decode('ascii') - encoding = 'base64' - - return single_response({ - 'run_id': run_id, - 'sample_id': sample_id, - 'regression_id': result_file.regression_test_id, - 'output_id': result_file.regression_test_output_id, - 'filename': actual_filename, - 'content_type': 'application/octet-stream', - 'encoding': encoding, - 'content': content, - 'sha256': sha256, - 'storage_status': 'ok', - }) - - -@mod_api.route('/runs//samples//diff', methods=['GET']) -@require_scope('results:read') -@validate_path_id('run_id') -@validate_path_id('sample_id') -def get_diff(run_id, sample_id): - """Structured diff between expected and actual output.""" - test = Test.query.filter(Test.id == run_id).first() - if test is None: - return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) - - output_id = _parse_output_id() - result_file = _find_result_file(run_id, sample_id, output_id) - - if result_file is None: - return make_error_response('not_found', f'No result for sample {sample_id}.', http_status=404) - - diff_ids = { - 'run_id': run_id, - 'sample_id': sample_id, - 'regression_id': result_file.regression_test_id, - 'output_id': result_file.regression_test_output_id, - } - - if is_dummy_row(result_file): - return single_response({ - **diff_ids, - 'status': 'missing_actual', - 'summary': {'added_lines': 0, 'removed_lines': 0, 'changed_hunks': 0}, - 'hunks': [], - }) - - if result_file.got is None: - return single_response({ - **diff_ids, - 'status': 'identical', - 'summary': {'added_lines': 0, 'removed_lines': 0, 'changed_hunks': 0}, - 'hunks': [], - }) - - base_path = get_test_results_base_path() - ext = result_file.regression_test_output.correct_extension if result_file.regression_test_output else '' - expected_path = _safe_resolve(base_path, result_file.expected + ext) - actual_path = _safe_resolve(base_path, result_file.got + ext) - - if expected_path is None or actual_path is None: - return make_error_response('forbidden', 'Invalid file path.', http_status=403) - - context_lines = request.args.get('context_lines', 3, type=int) - context_lines = max(1, min(context_lines, 50)) - - diff_result = compute_diff(expected_path, actual_path, context_lines=context_lines) - diff_result.update(diff_ids) - return single_response(diff_result) - - -@mod_api.route('/runs//samples//baseline-approval', methods=['POST']) -@require_scope('baselines:write') -@require_roles(['admin', 'contributor']) -@validate_path_id('run_id') -@validate_path_id('sample_id') -@validate_body(BaselineApprovalRequestSchema) -def create_baseline_approval(run_id, sample_id, validated_data=None): - """ - Record intent to approve actual output as the new expected baseline. - - WARNING: When remove_variants is set to true, this action will remove all - platform-specific variants, making this output the single source of truth - across all platforms. Care should be taken as this applies globally. - """ - test = Test.query.filter(Test.id == run_id).first() - if test is None: - return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) - - from mod_api.services.status import is_dummy_row - - regression_id = validated_data['regression_id'] - output_id = validated_data['output_id'] - - result_file = TestResultFile.query.filter_by( - test_id=run_id, - regression_test_id=regression_id, - regression_test_output_id=output_id, - ).first() - - if result_file is None: - return make_error_response('not_found', 'Result file not found.', http_status=404) - - if is_dummy_row(result_file): - return make_error_response('unprocessable', 'Cannot approve a dummy row.', http_status=422) - - if result_file.got is None: - return make_error_response('unprocessable', 'Output already matches expected.', http_status=422) - - # The actual output file (named by its hash) is already in TestResults/. - # We just need to update the RegressionTestOutput to point to this new hash. - rto = result_file.regression_test_output - if rto is None: - return make_error_response('internal_error', 'No RegressionTestOutput linked.', http_status=500) - - old_baseline = rto.correct - new_baseline = result_file.got - - rto.correct = new_baseline - - remove_variants = validated_data.get('remove_variants', False) - if remove_variants: - from mod_regression.models import RegressionTestOutputFiles - RegressionTestOutputFiles.query.filter_by(regression_test_output_id=rto.id).delete() - - g.db.commit() - - import datetime - from datetime import timezone - import uuid - - return single_response({ - 'approval_id': str(uuid.uuid4()), - 'status': 'approved', - 'run_id': run_id, - 'sample_id': sample_id, - 'regression_id': regression_id, - 'output_id': output_id, - 'requested_by': getattr(g, 'api_user').name if getattr(g, 'api_user', None) else 'unknown', - 'created_at': datetime.datetime.now(timezone.utc).isoformat() - }) From d9c55a37af6519609a02d2386789a53531e43ccf Mon Sep 17 00:00:00 2001 From: Pulkit Chauhan Date: Thu, 11 Jun 2026 12:46:55 +0530 Subject: [PATCH 17/28] Final Checks --- migrations/versions/d4f8e2a1b3c7_.py | 42 +++ mod_api/__init__.py | 1 + mod_api/middleware/auth.py | 13 +- mod_api/middleware/error_handler.py | 12 +- mod_api/middleware/rate_limit.py | 105 +++--- mod_api/middleware/security.py | 11 + mod_api/middleware/validation.py | 144 +++++--- mod_api/routes/auth.py | 198 +++++++++++ mod_api/routes/errors_logs.py | 25 +- mod_api/routes/results.py | 397 ++++++++++++++++++++++ mod_api/routes/runs.py | 481 +++++++++++++++++++++++++++ mod_api/routes/samples.py | 446 +++++++++++++++++++++++++ mod_api/routes/system.py | 292 ++++++++++++++++ mod_api/schemas/auth.py | 4 +- mod_api/schemas/common.py | 2 +- mod_api/schemas/errors.py | 1 + mod_api/schemas/results.py | 6 +- mod_api/schemas/runs.py | 9 +- mod_api/schemas/samples.py | 2 +- mod_api/services/diff_service.py | 183 ++++++++++ mod_api/services/error_service.py | 200 +++++++++++ mod_api/services/log_service.py | 107 ++++++ mod_api/services/status.py | 201 +++++++++++ mod_api/services/storage.py | 64 ++++ mod_auth/controllers.py | 6 + mod_auth/models.py | 1 + openapi-ci-api.yaml | 224 +++++++++---- 27 files changed, 2969 insertions(+), 208 deletions(-) create mode 100644 migrations/versions/d4f8e2a1b3c7_.py create mode 100644 mod_api/middleware/security.py create mode 100644 mod_api/routes/runs.py create mode 100644 mod_api/routes/samples.py create mode 100644 mod_api/routes/system.py create mode 100644 mod_api/services/diff_service.py create mode 100644 mod_api/services/error_service.py create mode 100644 mod_api/services/log_service.py create mode 100644 mod_api/services/status.py create mode 100644 mod_api/services/storage.py diff --git a/migrations/versions/d4f8e2a1b3c7_.py b/migrations/versions/d4f8e2a1b3c7_.py new file mode 100644 index 00000000..fd8495cd --- /dev/null +++ b/migrations/versions/d4f8e2a1b3c7_.py @@ -0,0 +1,42 @@ +"""Add api_token table for scoped API token auth. + +Revision ID: d4f8e2a1b3c7 +Revises: c8f3a2b1d4e5 +Create Date: 2026-06-11 03:00:00.000000 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = 'd4f8e2a1b3c7' +down_revision = 'c8f3a2b1d4e5' +branch_labels = None +depends_on = None + + +def upgrade(): + """Apply the migration.""" + op.create_table( + 'api_token', + sa.Column('id', sa.Integer(), nullable=False, autoincrement=True), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('token_name', sa.String(length=50), nullable=False), + sa.Column('token_hash', sa.String(length=255), nullable=False), + sa.Column('token_prefix', sa.String(length=16), nullable=False), + sa.Column('scopes_json', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], onupdate='CASCADE', ondelete='CASCADE'), + sa.UniqueConstraint('user_id', 'token_name', name='uq_user_token_name'), + mysql_engine='InnoDB' + ) + op.create_index('ix_api_token_token_prefix', 'api_token', ['token_prefix']) + + +def downgrade(): + """Revert the migration.""" + op.drop_index('ix_api_token_token_prefix', table_name='api_token') + op.drop_table('api_token') diff --git a/mod_api/__init__.py b/mod_api/__init__.py index fb1a634b..a7007a8c 100644 --- a/mod_api/__init__.py +++ b/mod_api/__init__.py @@ -13,6 +13,7 @@ from mod_api.middleware import auth # noqa: E402, F401 from mod_api.middleware import error_handler # noqa: E402, F401 from mod_api.middleware import rate_limit # noqa: E402, F401 +from mod_api.middleware import security # noqa: E402, F401 # Route modules (registers endpoint functions on the blueprint) from mod_api.routes import auth as auth_routes # noqa: E402, F401 from mod_api.routes import errors_logs # noqa: E402, F401 diff --git a/mod_api/middleware/auth.py b/mod_api/middleware/auth.py index a338444a..2a1c2856 100644 --- a/mod_api/middleware/auth.py +++ b/mod_api/middleware/auth.py @@ -77,20 +77,23 @@ def authenticate_request(): g.api_user = matched_token.user -def require_scope(scope: str): - """Reject the request if the token lacks ``scope``.""" +def require_scope(*scopes: str): + """Reject the request if the token lacks any of the ``scopes``.""" def decorator(f): @functools.wraps(f) def decorated_function(*args, **kwargs): token = getattr(g, 'api_token', None) if token is None: return _unauthorized() - if not token.has_scope(scope): + + missing_scopes = [s for s in scopes if not token.has_scope(s)] + if missing_scopes: return make_error_response( 'forbidden', - 'Token lacks the required scope for this operation.', + 'Token lacks the required scopes for this operation.', details={ - 'required_scope': scope, + 'required_scopes': list(scopes), + 'missing_scopes': missing_scopes, 'token_scopes': token.scopes, }, http_status=403, diff --git a/mod_api/middleware/error_handler.py b/mod_api/middleware/error_handler.py index 8bbc46de..537c23fb 100644 --- a/mod_api/middleware/error_handler.py +++ b/mod_api/middleware/error_handler.py @@ -1,12 +1,4 @@ -""" -Structured JSON error responses for API routes. - -Intercepts standard HTTP errors (400, 401, 403, 404, 405, 422, 429, 500), -Marshmallow validation errors, and SQLAlchemy errors so that nothing under -/api/v1/* ever returns an HTML error page. - -Response shape: {"code": "...", "message": "...", "details": {...}} -""" +"""Structured JSON error responses for API routes.""" from flask import jsonify, request from marshmallow import ValidationError as MarshmallowValidationError @@ -147,7 +139,7 @@ def handle_marshmallow_validation_error(error): @mod_api.errorhandler(SQLAlchemyError) def handle_sqlalchemy_error(error): - """Log the real error, but never expose raw SQL details to the client.""" + """Log database errors.""" from flask import g log = getattr(g, 'log', None) if log: diff --git a/mod_api/middleware/rate_limit.py b/mod_api/middleware/rate_limit.py index 1f73da7b..66154f65 100644 --- a/mod_api/middleware/rate_limit.py +++ b/mod_api/middleware/rate_limit.py @@ -7,11 +7,9 @@ GET 120 req / min (keyed by token) Includes X-RateLimit-* headers on every response. - -Uses an in-memory dict for simplicity. For multi-process deployments, -swap this out for a Redis backend. """ +import threading import time from flask import g, request @@ -19,6 +17,7 @@ from mod_api import mod_api _rate_limit_store = {} # key -> {'count': int, 'window_start': float} +_rate_limit_lock = threading.Lock() _eviction_counter = 0 _EVICTION_INTERVAL = 100 # run cleanup every N requests @@ -26,27 +25,33 @@ def _evict_stale_entries(): """Prune entries older than 15 min to bound memory usage.""" global _eviction_counter - _eviction_counter += 1 - if _eviction_counter < _EVICTION_INTERVAL: - return - _eviction_counter = 0 - now = time.time() - stale_keys = [ - key for key, entry in _rate_limit_store.items() - if (now - entry['window_start']) > 900 - ] - for key in stale_keys: - del _rate_limit_store[key] + with _rate_limit_lock: + _eviction_counter += 1 + if _eviction_counter < _EVICTION_INTERVAL: + return + _eviction_counter = 0 + now = time.time() + stale_keys = [ + key for key, entry in _rate_limit_store.items() + if (now - entry['window_start']) > 900 + ] + for key in stale_keys: + del _rate_limit_store[key] + + +def _get_client_ip(): + """Extract the real client IP, ignoring X-Forwarded-For to prevent spoofing.""" + return request.remote_addr def _get_rate_limit_key(): """Build the rate-limit bucket key for this request.""" if request.endpoint == 'api.create_token': - return f'ip:{request.remote_addr}' + return f'ip:{_get_client_ip()}' token = getattr(g, 'api_token', None) if token: return f'token:{token.id}' - return f'ip:{request.remote_addr}' + return f'ip:{_get_client_ip()}' def _get_limits(): @@ -67,31 +72,34 @@ def check_rate_limit(): max_requests, window_seconds = _get_limits() now = time.time() - entry = _rate_limit_store.get(key) - - if entry is None or (now - entry['window_start']) >= window_seconds: - _rate_limit_store[key] = {'count': 1, 'window_start': now} - else: - entry['count'] += 1 - if entry['count'] > max_requests: - reset_at = int(entry['window_start'] + window_seconds) - retry_after = max(1, reset_at - int(now)) - from mod_api.middleware.error_handler import make_error_response - response = make_error_response( - 'rate_limited', - f'Rate limit exceeded. Retry after {retry_after} seconds.', - details={ - 'retry_after': retry_after, - 'limit': max_requests, - 'window': f'{window_seconds}s', - }, - http_status=429, - ) - response.headers['Retry-After'] = str(retry_after) - response.headers['X-RateLimit-Limit'] = str(max_requests) - response.headers['X-RateLimit-Remaining'] = '0' - response.headers['X-RateLimit-Reset'] = str(reset_at) - return response + with _rate_limit_lock: + entry = _rate_limit_store.get(key) + + if entry is None or (now - entry['window_start']) >= window_seconds: + _rate_limit_store[key] = {'count': 1, 'window_start': now} + else: + entry['count'] += 1 + if entry['count'] > max_requests: + reset_at = int(entry['window_start'] + window_seconds) + retry_after = max(1, reset_at - int(now)) + + from mod_api.middleware.error_handler import \ + make_error_response + response = make_error_response( + 'rate_limited', + f'Rate limit exceeded. Retry after {retry_after} seconds.', + details={ + 'retry_after': retry_after, + 'limit': max_requests, + 'window': f'{window_seconds}s', + }, + http_status=429, + ) + response.headers['Retry-After'] = str(retry_after) + response.headers['X-RateLimit-Limit'] = str(max_requests) + response.headers['X-RateLimit-Remaining'] = '0' + response.headers['X-RateLimit-Reset'] = str(reset_at) + return response @mod_api.after_request @@ -101,13 +109,14 @@ def add_rate_limit_headers(response): max_requests, window_seconds = _get_limits() now = time.time() - entry = _rate_limit_store.get(key) - if entry: - remaining = max(0, max_requests - entry['count']) - reset_at = int(entry['window_start'] + window_seconds) - else: - remaining = max_requests - reset_at = int(now + window_seconds) + with _rate_limit_lock: + entry = _rate_limit_store.get(key) + if entry: + remaining = max(0, max_requests - entry['count']) + reset_at = int(entry['window_start'] + window_seconds) + else: + remaining = max_requests + reset_at = int(now + window_seconds) response.headers['X-RateLimit-Limit'] = str(max_requests) response.headers['X-RateLimit-Remaining'] = str(remaining) diff --git a/mod_api/middleware/security.py b/mod_api/middleware/security.py new file mode 100644 index 00000000..068f0aba --- /dev/null +++ b/mod_api/middleware/security.py @@ -0,0 +1,11 @@ +from mod_api import mod_api + + +@mod_api.after_request +def add_security_headers(response): + """Attach security headers to all API responses.""" + response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains' + response.headers['Content-Security-Policy'] = "default-src 'none'; frame-ancestors 'none'" + response.headers['X-Content-Type-Options'] = 'nosniff' + response.headers['X-Frame-Options'] = 'DENY' + return response diff --git a/mod_api/middleware/validation.py b/mod_api/middleware/validation.py index e7b51808..ca4cd145 100644 --- a/mod_api/middleware/validation.py +++ b/mod_api/middleware/validation.py @@ -22,7 +22,7 @@ 'extension': re.compile(r'^[a-zA-Z0-9]+$'), } -# Whitelist of allowed sort params. Never pass raw user input to the ORM. +# Whitelist of allowed sort params. ALLOWED_RUN_SORTS = frozenset([ 'created_at', '-created_at', 'run_id', '-run_id', @@ -35,7 +35,7 @@ def decorator(f): @wraps(f) def decorated(*args, **kwargs): content_type = request.content_type or '' - if 'application/json' not in content_type: + if content_type.split(';')[0].strip() != 'application/json': return make_error_response( 'validation_error', 'Content-Type must be application/json.', @@ -64,54 +64,108 @@ def decorated(*args, **kwargs): return decorator -def validate_pagination(f): +def validate_offset_pagination(default_limit=50): """Extract and validate ``limit`` and ``offset`` query params.""" - @wraps(f) - def decorated(*args, **kwargs): - try: - limit = int(request.args.get('limit', 50)) - except (ValueError, TypeError): - return make_error_response( - 'validation_error', - 'limit must be an integer.', - details={ - 'fields': { - 'limit': 'Must be an integer between 1 and 100.'}}, - http_status=400, - ) + def decorator(f): + @wraps(f) + def decorated(*args, **kwargs): + try: + limit = int(request.args.get('limit', default_limit)) + except (ValueError, TypeError): + return make_error_response( + 'validation_error', + 'limit must be an integer.', + details={'fields': {'limit': 'Must be an integer between 1 and 100.'}}, + http_status=400, + ) - try: - offset = int(request.args.get('offset', 0)) - except (ValueError, TypeError): - return make_error_response( - 'validation_error', - 'offset must be a non-negative integer.', - details={ - 'fields': { - 'offset': 'Must be a non-negative integer.'}}, - http_status=400, - ) + try: + offset = int(request.args.get('offset', 0)) + except (ValueError, TypeError): + return make_error_response( + 'validation_error', + 'offset must be a non-negative integer.', + details={'fields': {'offset': 'Must be a non-negative integer.'}}, + http_status=400, + ) - if limit < 1 or limit > 100: - return make_error_response( - 'validation_error', - 'limit must be between 1 and 100.', - details={'fields': {'limit': 'Must be between 1 and 100.'}}, - http_status=400, - ) + if limit < 1 or limit > 100: + return make_error_response( + 'validation_error', + 'limit must be between 1 and 100.', + details={'fields': {'limit': 'Must be between 1 and 100.'}}, + http_status=400, + ) - if offset < 0: - return make_error_response( - 'validation_error', - 'offset must be non-negative.', - details={'fields': {'offset': 'Must be >= 0.'}}, - http_status=400, - ) + if offset < 0: + return make_error_response( + 'validation_error', + 'offset must be non-negative.', + details={'fields': {'offset': 'Must be >= 0.'}}, + http_status=400, + ) - kwargs['limit'] = limit - kwargs['offset'] = offset - return f(*args, **kwargs) - return decorated + kwargs['limit'] = limit + kwargs['offset'] = offset + return f(*args, **kwargs) + return decorated + return decorator + + +def validate_cursor_pagination(default_limit=50): + """Extract and validate ``limit`` and ``cursor`` query params.""" + def decorator(f): + @wraps(f) + def decorated(*args, **kwargs): + try: + limit = int(request.args.get('limit', default_limit)) + except (ValueError, TypeError): + return make_error_response( + 'validation_error', + 'limit must be an integer.', + details={'fields': {'limit': 'Must be an integer between 1 and 100.'}}, + http_status=400, + ) + + if limit < 1 or limit > 100: + return make_error_response( + 'validation_error', + 'limit must be between 1 and 100.', + details={'fields': {'limit': 'Must be between 1 and 100.'}}, + http_status=400, + ) + + cursor = request.args.get('cursor') + if cursor is not None: + try: + cursor = int(cursor) + except (ValueError, TypeError): + return make_error_response( + 'validation_error', + 'cursor must be an integer.', + details={'fields': {'cursor': 'Must be an integer.'}}, + http_status=400, + ) + if cursor < 0: + return make_error_response( + 'validation_error', + 'cursor must be non-negative.', + details={'fields': {'cursor': 'Must be >= 0.'}}, + http_status=400, + ) + if cursor > 10_000_000: + return make_error_response( + 'validation_error', + 'cursor out of range.', + details={'fields': {'cursor': 'Must be <= 10000000.'}}, + http_status=400, + ) + + kwargs['limit'] = limit + kwargs['cursor'] = cursor + return f(*args, **kwargs) + return decorated + return decorator def validate_path_id(param_name): diff --git a/mod_api/routes/auth.py b/mod_api/routes/auth.py index e69de29b..11bb6f31 100644 --- a/mod_api/routes/auth.py +++ b/mod_api/routes/auth.py @@ -0,0 +1,198 @@ +""" +Token lifecycle: create, list, and revoke API tokens. + +POST /auth/tokens Authenticate with email/password, get a token +GET /auth/tokens List tokens (own tokens; admin can see all) +DELETE /auth/tokens/current Revoke the token you're currently using +DELETE /auth/tokens/{id} Revoke a specific token by ID +""" + +from flask import g, request +from passlib.apps import custom_app_context as pwd_context + +from mod_api import mod_api +from mod_api.middleware.auth import require_roles, require_scope +from mod_api.middleware.error_handler import make_error_response +from mod_api.middleware.validation import (validate_body, + validate_offset_pagination) +from mod_api.models.api_token import DEFAULT_SCOPES, ApiToken +from mod_api.schemas.auth import (ApiTokenItemSchema, AuthTokenSchema, + TokenCreateRequestSchema) +from mod_api.utils import paginated_response, single_response +from mod_auth.models import User + +_DUMMY_HASH = pwd_context.hash('__dummy__') + + +@mod_api.route('/auth/tokens', methods=['POST']) +@validate_body(TokenCreateRequestSchema) +def create_token(validated_data=None): + """ + Authenticate with email + password and issue a scoped API token. + + The plaintext token value is returned exactly once in this response. + It's never stored or logged — only the argon2 hash is persisted. + """ + email = validated_data['email'] + password = validated_data['password'] + token_name = validated_data['token_name'] + expires_in_days = validated_data.get('expires_in_days', 7) + scopes = validated_data.get('scopes') or DEFAULT_SCOPES + + user = User.query.filter_by(email=email).first() + + # Hash password even if user is not found to prevent timing attacks + if user is None: + try: + pwd_context.verify(password, _DUMMY_HASH) + except Exception: + pass + return make_error_response( + 'invalid_credentials', + 'Invalid email or password.', + http_status=401, + ) + + if not user.is_password_valid(password): + return make_error_response( + 'invalid_credentials', + 'Invalid email or password.', + http_status=401, + ) + + # Check role limitations + allowed_scopes = { + 'runs:read', 'runs:write', 'results:read', + 'system:read', 'tokens:manage' + } + if user.role.value == 'admin': + allowed_scopes.add('baselines:write') + + invalid_scopes = set(scopes) - allowed_scopes + if invalid_scopes: + return make_error_response( + 'forbidden', + f'Your current role ({user.role.value}) does not permit requesting ' + f'the following scopes: {", ".join(invalid_scopes)}.', + http_status=403, + ) + + plaintext = ApiToken.generate_token() + token_hash = ApiToken.hash_token(plaintext) + token_prefix = ApiToken.extract_prefix(plaintext) + + api_token = ApiToken( + user_id=user.id, + token_name=token_name, + token_hash=token_hash, + token_prefix=token_prefix, + scopes=scopes, + expires_in_days=expires_in_days, + ) + g.db.add(api_token) + + from sqlalchemy.exc import IntegrityError + try: + g.db.commit() + except IntegrityError as e: + g.db.rollback() + error_msg = str(e).lower() + if 'uq_user_token_name' in error_msg or 'duplicate' in error_msg: + return make_error_response( + 'validation_error', + f'Token name "{token_name}" already exists for this user.', + details={'fields': {'token_name': 'Already in use. Revoke the existing token first.'}}, + http_status=400, + ) + raise + + return single_response( + { + 'token': plaintext, + 'token_type': 'bearer', + 'token_name': token_name, + 'scopes': scopes, + 'expires_at': api_token.expires_at, + }, + schema=AuthTokenSchema(), + http_status=201, + ) + + +@mod_api.route('/auth/tokens/current', methods=['DELETE']) +def revoke_current_token(): + """Revoke whatever token is in the Authorization header right now.""" + token = getattr(g, 'api_token', None) + if token is None: + return make_error_response( + 'unauthorized', + 'No token found in the current request.', + http_status=401, + ) + token.revoke() + g.db.commit() + return '', 204 + + +@mod_api.route('/auth/tokens', methods=['GET']) +@require_roles(['admin', 'contributor', 'tester']) +@require_scope('tokens:manage') +@validate_offset_pagination() +def list_tokens(limit=50, offset=0): + """ + List tokens for the current user, paginated. + + Admins can pass ?all=true to see every token in the system. + Non-admins who try ?all=true get a 403. + """ + want_all = request.args.get('all', 'false').lower() == 'true' + is_admin = g.api_user.role.value == 'admin' + + if want_all and not is_admin: + return make_error_response( + 'forbidden', + 'Only admins may list all tokens.', + details={'required_roles': ['admin']}, + http_status=403, + ) + + if want_all and is_admin: + query = ApiToken.query.order_by(ApiToken.created_at.desc()) + else: + query = ApiToken.query.filter_by( + user_id=g.api_user.id, + ).order_by(ApiToken.created_at.desc()) + + total = query.count() + tokens = query.offset(offset).limit(limit).all() + schema = ApiTokenItemSchema(many=True) + + return paginated_response(tokens, total, limit, offset, schema=schema) + + +@mod_api.route('/auth/tokens/', methods=['DELETE']) +@require_roles(['admin', 'contributor', 'tester']) +def revoke_specific_token(token_id): + """ + Revoke a token by its numeric ID. + + Non-admins can only revoke their own tokens. Admins can revoke anyone's. + Already-revoked tokens are silently accepted (idempotent). + """ + is_admin = g.api_user.role.value == 'admin' + token = ApiToken.query.filter_by(id=token_id).first() + + if not token: + return make_error_response('not_found', f'Token {token_id} not found.', http_status=404) + + is_own = token.user_id == g.api_user.id + if not is_own and not is_admin: + return make_error_response('forbidden', 'Only admins can revoke tokens of other users.', http_status=403) + if not is_own and not g.api_token.has_scope('tokens:manage'): + return make_error_response('forbidden', 'Cross-user revocation requires tokens:manage scope.', http_status=403) + + if not token.is_revoked: + token.revoke() + g.db.commit() + + return '', 204 diff --git a/mod_api/routes/errors_logs.py b/mod_api/routes/errors_logs.py index a19a2b3c..37a5834c 100644 --- a/mod_api/routes/errors_logs.py +++ b/mod_api/routes/errors_logs.py @@ -13,7 +13,9 @@ from mod_api import mod_api from mod_api.middleware.auth import require_roles, require_scope from mod_api.middleware.error_handler import make_error_response -from mod_api.middleware.validation import validate_pagination, validate_path_id +from mod_api.middleware.validation import (validate_cursor_pagination, + validate_offset_pagination, + validate_path_id) from mod_api.services.error_service import (derive_error_summary, derive_errors_for_run, derive_infrastructure_errors) @@ -25,7 +27,7 @@ @mod_api.route('/runs//errors', methods=['GET']) @require_scope('results:read') @validate_path_id('run_id') -@validate_pagination +@validate_offset_pagination() def list_run_errors(run_id, limit=50, offset=0): """List test errors for a run, derived from result and output data.""" test = Test.query.filter(Test.id == run_id).first() @@ -55,7 +57,7 @@ def list_run_errors(run_id, limit=50, offset=0): @mod_api.route('/runs//infrastructure-errors', methods=['GET']) @require_scope('system:read') @validate_path_id('run_id') -@validate_pagination +@validate_offset_pagination() def list_infrastructure_errors(run_id, limit=50, offset=0): """ Infra errors classified from TestProgress messages on a best-effort basis. @@ -100,7 +102,7 @@ def list_infrastructure_errors(run_id, limit=50, offset=0): @mod_api.route('/runs//error-summary', methods=['GET']) @require_scope('results:read') @validate_path_id('run_id') -@validate_pagination +@validate_offset_pagination() def get_error_summary(run_id, limit=50, offset=0): """Group error summary for triaging a run before drilling into details.""" test = Test.query.filter(Test.id == run_id).first() @@ -108,10 +110,10 @@ def get_error_summary(run_id, limit=50, offset=0): return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) group_by = request.args.get('group_by', 'type') - if group_by not in ('type', 'severity', 'sample_id', 'regression_id', 'category'): + if group_by not in ('type', 'severity', 'sample_id', 'regression_id'): return make_error_response( 'validation_error', - 'group_by must be one of: type, severity, sample_id, regression_id, category.', + 'group_by must be one of: type, severity, sample_id, regression_id.', http_status=400, ) @@ -129,9 +131,9 @@ def get_error_summary(run_id, limit=50, offset=0): @mod_api.route('/runs//logs', methods=['GET']) @require_scope('system:read') -@require_roles(['admin', 'contributor']) @validate_path_id('run_id') -def get_run_logs(run_id): +@validate_cursor_pagination(default_limit=100) +def get_run_logs(run_id, limit=100, cursor=None): """ Read a run's build log with cursor-based pagination. @@ -141,9 +143,6 @@ def get_run_logs(run_id): if test is None: return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) - cursor = request.args.get('cursor') - limit = request.args.get('limit', 100, type=int) - limit = max(1, min(limit, 100)) level = request.args.get('level') source = request.args.get('source') contains = request.args.get('contains') @@ -176,10 +175,10 @@ def get_run_logs(run_id): @mod_api.route('/runs//samples//logs', methods=['GET']) @require_scope('system:read') -@require_roles(['admin', 'contributor']) @validate_path_id('run_id') @validate_path_id('sample_id') -def get_sample_logs(run_id, sample_id): +@validate_offset_pagination() +def get_sample_logs(run_id, sample_id, limit=50, offset=0): """Per-sample logs aren't available yet — the CI worker doesn't support them.""" return make_error_response( 'not_found', diff --git a/mod_api/routes/results.py b/mod_api/routes/results.py index e69de29b..0cc8bd95 100644 --- a/mod_api/routes/results.py +++ b/mod_api/routes/results.py @@ -0,0 +1,397 @@ +""" +Expected/actual output, diffs, and baseline approval routes. + +GET /runs/{id}/samples/{sid}/expected Expected output file +GET /runs/{id}/samples/{sid}/actual Actual output file +GET /runs/{id}/samples/{sid}/diff Structured diff +POST /runs/{id}/samples/{sid}/baseline-approval Approve a new baseline +""" + +import base64 +import os + +from flask import g, request + +from mod_api import mod_api +from mod_api.middleware.auth import require_roles, require_scope +from mod_api.middleware.error_handler import make_error_response +from mod_api.middleware.validation import validate_body, validate_path_id +from mod_api.schemas.results import BaselineApprovalRequestSchema +from mod_api.services.diff_service import compute_diff, file_sha256, read_lines +from mod_api.services.status import is_dummy_row +from mod_api.services.storage import get_test_results_base_path +from mod_api.utils import single_response +from mod_test.models import Test, TestResult, TestResultFile + + +def _safe_resolve(base_path, filename): + """ + Resolve filename under base_path, rejecting path traversal. + + Returns the absolute path if it's safely within base_path, + or None if traversal was detected. + """ + resolved = os.path.realpath(os.path.join(base_path, filename)) + base_real = os.path.realpath(base_path) + if not resolved.startswith(base_real + os.sep) and resolved != base_real: + return None + return resolved + + +def _find_result_file(run_id, regression_test_id, output_id=None): + """ + Look up the right TestResultFile row. + + Uses run_id + regression_test_id from the path. If output_id is + given as a query param, narrow to that specific output file. + """ + query = TestResultFile.query.filter_by( + test_id=run_id, + regression_test_id=regression_test_id, + ) + + if output_id is not None: + query = query.filter_by(regression_test_output_id=output_id) + + return query.first() + + +def _parse_output_id(): + """Pull output_id from query string, if provided.""" + return request.args.get('output_id', type=int) + + +@mod_api.route( + '/runs//samples//regression-tests//outputs//expected', + methods=['GET'] +) +@require_scope('results:read') +@validate_path_id('run_id') +@validate_path_id('sample_id') +def get_expected_output(run_id, sample_id, regression_id, output_id): + """Return the expected output file for a regression test result.""" + test = Test.query.filter(Test.id == run_id).first() + if test is None: + return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) + + result = TestResult.query.filter_by( + test_id=run_id, + regression_test_id=regression_id, + ).first() + if result is None: + return make_error_response('not_found', f'Regression test result {regression_id} not found.', http_status=404) + + result_file = _find_result_file(run_id, regression_id, output_id) + + if result_file is None or is_dummy_row(result_file): + return make_error_response('not_found', 'Expected output not found.', http_status=404) + + base_path = get_test_results_base_path() + expected_filename = result_file.expected + ext = '' + if result_file.regression_test_output: + ext = result_file.regression_test_output.correct_extension + expected_filename += ext + + file_path = _safe_resolve(base_path, expected_filename) + if file_path is None: + return make_error_response('forbidden', 'Invalid file path.', http_status=403) + + fmt = request.args.get('format', 'base64') + + if not os.path.isfile(file_path): + return make_error_response( + 'not_found', + 'Expected output file not found on disk.', + http_status=404, + ) + + sha256 = file_sha256(file_path) + + file_size = os.path.getsize(file_path) + truncated = False + download_url = None + + if file_size > 1048576: + truncated = True + from mod_api.services.storage import resolve_artifact + download_url, _ = resolve_artifact(f'TestResults/{expected_filename}') + + if fmt == 'text': + try: + with open(file_path, 'r', encoding='utf-8', errors='replace') as f: + content = f.read(1048576) + encoding = 'utf-8' + except Exception: + return make_error_response('internal_error', 'Failed to read file.', http_status=500) + else: + try: + with open(file_path, 'rb') as f: + content = base64.b64encode(f.read(1048576)).decode('ascii') + except Exception: + return make_error_response('internal_error', 'Failed to read file.', http_status=500) + encoding = 'base64' + + return single_response({ + 'run_id': run_id, + 'sample_id': sample_id, + 'regression_id': result_file.regression_test_id, + 'output_id': result_file.regression_test_output_id, + 'filename': expected_filename, + 'content_type': 'application/octet-stream', + 'encoding': encoding, + 'content': content, + 'truncated': truncated, + 'download_url': download_url, + 'sha256': sha256, + 'storage_status': 'ok', + }) + + +@mod_api.route( + '/runs//samples//regression-tests//outputs//actual', + methods=['GET'] +) +@require_scope('results:read') +@validate_path_id('run_id') +@validate_path_id('sample_id') +def get_actual_output(run_id, sample_id, regression_id, output_id): + """ + Return the actual output file for a regression test result. + + got=null in the DB means the output matched expected — not that it's + missing. We return 303 (redirect to expected) in that case. Missing + output (the dummy sentinel row) returns 404. + """ + test = Test.query.filter(Test.id == run_id).first() + if test is None: + return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) + + result_file = _find_result_file(run_id, regression_id, output_id) + + if result_file is None: + return make_error_response('not_found', f'No result for regression test {regression_id}.', http_status=404) + + if is_dummy_row(result_file): + return make_error_response( + 'missing_output', + 'Test produced no output when output was expected.', + http_status=404, + ) + + if result_file.got is None: + from flask import redirect, url_for + return redirect(url_for( + 'api.get_expected_output', + run_id=run_id, + sample_id=sample_id, + regression_id=regression_id, + output_id=output_id, + format=request.args.get('format', 'base64') + ), code=303) + + base_path = get_test_results_base_path() + actual_filename = result_file.got + if result_file.regression_test_output: + actual_filename += result_file.regression_test_output.correct_extension + + file_path = _safe_resolve(base_path, actual_filename) + if file_path is None: + return make_error_response('forbidden', 'Invalid file path.', http_status=403) + + fmt = request.args.get('format', 'base64') + + if not os.path.isfile(file_path): + return make_error_response( + 'not_found', + 'Actual output file not found on disk.', + http_status=404, + ) + + sha256 = file_sha256(file_path) + + file_size = os.path.getsize(file_path) + truncated = False + download_url = None + + if file_size > 1048576: + truncated = True + from mod_api.services.storage import resolve_artifact + download_url, _ = resolve_artifact(f'TestResults/{actual_filename}') + + if fmt == 'text': + try: + with open(file_path, 'r', encoding='utf-8', errors='replace') as f: + content = f.read(1048576) + encoding = 'utf-8' + except Exception: + return make_error_response('internal_error', 'Failed to read file.', http_status=500) + else: + try: + with open(file_path, 'rb') as f: + content = base64.b64encode(f.read(1048576)).decode('ascii') + encoding = 'base64' + except Exception: + return make_error_response('internal_error', 'Failed to read file.', http_status=500) + + return single_response({ + 'run_id': run_id, + 'sample_id': sample_id, + 'regression_id': result_file.regression_test_id, + 'output_id': result_file.regression_test_output_id, + 'filename': actual_filename, + 'content_type': 'application/octet-stream', + 'encoding': encoding, + 'content': content, + 'truncated': truncated, + 'download_url': download_url, + 'sha256': sha256, + 'storage_status': 'ok', + }) + + +@mod_api.route( + '/runs//samples//regression-tests//outputs//diff', + methods=['GET'] +) +@require_scope('results:read') +@validate_path_id('run_id') +@validate_path_id('sample_id') +def get_diff(run_id, sample_id, regression_id, output_id): + """Structured diff between expected and actual output.""" + test = Test.query.filter(Test.id == run_id).first() + if test is None: + return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) + + result_file = _find_result_file(run_id, regression_id, output_id) + + if result_file is None: + return make_error_response('not_found', f'No result for regression test {regression_id}.', http_status=404) + + diff_ids = { + 'run_id': run_id, + 'sample_id': sample_id, + 'regression_id': result_file.regression_test_id, + 'output_id': result_file.regression_test_output_id, + } + + format_type = request.args.get('format', 'structured') + + if is_dummy_row(result_file): + if format_type == 'unified': + return single_response({**diff_ids, 'format': 'unified', 'content': ''}) + return single_response({ + **diff_ids, + 'status': 'missing_actual', + 'summary': {'added_lines': 0, 'removed_lines': 0, 'changed_hunks': 0}, + 'hunks': [], + }) + + if result_file.got is None: + if format_type == 'unified': + return single_response({**diff_ids, 'format': 'unified', 'content': ''}) + return single_response({ + **diff_ids, + 'status': 'identical', + 'summary': {'added_lines': 0, 'removed_lines': 0, 'changed_hunks': 0}, + 'hunks': [], + }) + + base_path = get_test_results_base_path() + ext = result_file.regression_test_output.correct_extension if result_file.regression_test_output else '' + expected_path = _safe_resolve(base_path, result_file.expected + ext) + actual_path = _safe_resolve(base_path, result_file.got + ext) + + if expected_path is None or actual_path is None: + return make_error_response('forbidden', 'Invalid file path.', http_status=403) + + if format_type == 'unified': + import difflib + expected_lines = read_lines(expected_path) + actual_lines = read_lines(actual_path) + differ = difflib.unified_diff( + expected_lines, + actual_lines, + fromfile='expected', + tofile='actual', + lineterm='' + ) + unified_content = '\n'.join(differ) + return single_response({ + **diff_ids, + 'format': 'unified', + 'content': unified_content + }) + + context_lines = request.args.get('context_lines', 3, type=int) + context_lines = max(1, min(context_lines, 50)) + + diff_result = compute_diff(expected_path, actual_path, context_lines=context_lines) + diff_result.update(diff_ids) + return single_response(diff_result) + + +@mod_api.route('/runs//samples//baseline-approval', methods=['POST']) +@require_roles(['admin']) +@require_scope('baselines:write') +@validate_path_id('run_id') +@validate_path_id('sample_id') +@validate_body(BaselineApprovalRequestSchema) +def create_baseline_approval(run_id, sample_id, validated_data=None): + """ + Record intent to approve actual output as the new expected baseline. + + WARNING: When remove_variants is set to true, this action will remove all + platform-specific variants, making this output the single source of truth + across all platforms. Care should be taken as this applies globally. + """ + test = Test.query.filter(Test.id == run_id).first() + if test is None: + return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) + + regression_id = validated_data['regression_id'] + output_id = validated_data['output_id'] + + result_file = TestResultFile.query.filter_by( + test_id=run_id, + regression_test_id=regression_id, + regression_test_output_id=output_id, + ).first() + + if result_file is None: + return make_error_response('not_found', 'Result file not found.', http_status=404) + + if is_dummy_row(result_file): + return make_error_response('unprocessable', 'Cannot approve a dummy row.', http_status=422) + + if result_file.got is None: + return make_error_response('unprocessable', 'Output already matches expected.', http_status=422) + + # The actual output file (named by its hash) is already in TestResults/. + # We just need to update the RegressionTestOutput to point to this new hash. + rto = result_file.regression_test_output + if rto is None: + return make_error_response('internal_error', 'No RegressionTestOutput linked.', http_status=500) + + old_baseline = rto.correct + new_baseline = result_file.got + + rto.correct = new_baseline + + remove_variants = validated_data.get('remove_variants', False) + if remove_variants: + from mod_regression.models import RegressionTestOutputFiles + RegressionTestOutputFiles.query.filter_by(regression_test_output_id=rto.id).delete() + + g.db.commit() + + import datetime + return single_response({ + 'status': 'approved', + 'run_id': run_id, + 'sample_id': sample_id, + 'regression_id': regression_id, + 'output_id': output_id, + 'requested_by': getattr(g, 'api_user').name if getattr(g, 'api_user', None) else 'unknown', + 'created_at': datetime.datetime.now(datetime.timezone.utc).isoformat() + }) diff --git a/mod_api/routes/runs.py b/mod_api/routes/runs.py new file mode 100644 index 00000000..9d51d30d --- /dev/null +++ b/mod_api/routes/runs.py @@ -0,0 +1,481 @@ +""" +Test run routes. + +GET /runs List runs (filtered, paginated, sorted) +POST /runs Trigger a new run +GET /runs/{id} Single run details +GET /runs/{id}/summary Pass/fail/skip counts +GET /runs/{id}/progress Progress event timeline +GET /runs/{id}/config Run configuration and test matrix +POST /runs/{id}/cancel Cancel a queued or running test +""" + +from flask import g, request + +from mod_api import mod_api +from mod_api.middleware.auth import require_roles, require_scope +from mod_api.middleware.error_handler import make_error_response +from mod_api.middleware.validation import (PATTERNS, validate_body, + validate_date_range, + validate_offset_pagination, + validate_path_id, validate_sort) +from mod_api.schemas.runs import ProgressEventSchema, RunCreateRequestSchema +from mod_api.services.status import (derive_run_status, derive_sample_status, + get_run_timestamps) +from mod_api.utils import (cursor_paginated_response, get_sort_column, + paginated_response, single_response) +from mod_customized.models import CustomizedTest +from mod_regression.models import RegressionTest +from mod_test.models import (Fork, Test, TestPlatform, TestProgress, + TestResult, TestResultFile, TestStatus, TestType) + + +def _serialize_run(test): + """Turn a Test row into the Run response shape the spec expects.""" + status = derive_run_status(test) + timestamps = get_run_timestamps(test) + return { + 'run_id': test.id, + 'status': status, + 'platform': test.platform.value, + 'test_type': 'pr' if test.test_type == TestType.pull_request else 'commit', + 'repository': test.fork.github_name if test.fork else 'unknown', + 'branch': test.branch, + 'commit_sha': test.commit, + 'pr_number': test.pr_nr if test.pr_nr and test.pr_nr > 0 else None, + 'created_at': timestamps['created_at'], + 'queued_at': timestamps['queued_at'], + 'started_at': timestamps['started_at'], + 'completed_at': timestamps['completed_at'], + 'github_link': test.github_link if test.fork else None, + } + + +def _batch_serialize(tests, statuses=None, timestamps=None): + from mod_api.services.status import batch_get_run_data + if statuses is None or timestamps is None: + statuses, timestamps = batch_get_run_data(tests) + return [ + { + 'run_id': t.id, + 'status': statuses.get(t.id, 'queued'), + 'platform': t.platform.value, + 'test_type': 'pr' if t.test_type == TestType.pull_request else 'commit', + 'repository': t.fork.github_name if t.fork else 'unknown', + 'branch': t.branch, + 'commit_sha': t.commit, + 'pr_number': t.pr_nr if t.pr_nr and t.pr_nr > 0 else None, + 'created_at': timestamps.get(t.id, {}).get('created_at'), + 'queued_at': timestamps.get(t.id, {}).get('queued_at'), + 'started_at': timestamps.get(t.id, {}).get('started_at'), + 'completed_at': timestamps.get(t.id, {}).get('completed_at'), + 'github_link': t.github_link if t.fork else None, + } + for t in tests + ] + + +@mod_api.route('/runs', methods=['GET']) +@require_scope('runs:read') +@validate_offset_pagination() +@validate_sort() +@validate_date_range +def list_runs(limit=50, offset=0, sort='-created_at', created_after=None, created_before=None): + """List runs with filters for platform, branch, commit, repo, status, and date range.""" + query = Test.query + + platform = request.args.get('platform') + if platform: + try: + platform_enum = TestPlatform.from_string(platform) + query = query.filter(Test.platform == platform_enum) + except Exception: + valid_platforms = ', '.join(TestPlatform.values()) + return make_error_response( + 'validation_error', + f'Invalid platform: {platform}. Must be one of: {valid_platforms}.', + http_status=400, + ) + + branch = request.args.get('branch') + if branch: + query = query.filter(Test.branch == branch) + + commit_sha = request.args.get('commit_sha') + if commit_sha: + query = query.filter(Test.commit == commit_sha) + + repository = request.args.get('repository') + if repository: + if not PATTERNS['repository'].match(repository): + return make_error_response( + 'validation_error', + 'repository must match owner/repo format.', + details={'fields': {'repository': 'Must match ^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$'}}, + http_status=400, + ) + fork_url = f'https://github.com/{repository}.git' + query = query.join(Fork).filter(Fork.github == fork_url) + + # Filter by date range using TestProgress + if created_after or created_before: + if created_after: + query = query.filter(Test.id.in_( + g.db.query(TestProgress.test_id).filter( + TestProgress.timestamp >= created_after + ).distinct() + )) + if created_before: + query = query.filter(Test.id.in_( + g.db.query(TestProgress.test_id).filter( + TestProgress.timestamp <= created_before + ).distinct() + )) + + sort_map = { + 'run_id': Test.id, + 'created_at': Test.id, # best proxy - Test has no created_at column + } + order = get_sort_column(sort, sort_map) + if order is not None: + query = query.order_by(order) + else: + query = query.order_by(Test.id.desc()) + + status_filter = request.args.get('status') + + if status_filter: + all_matching = query.all() + # Batch derivation logic + from mod_api.services.status import batch_get_run_data + statuses, timestamps = batch_get_run_data(all_matching) + + filtered = [] + for t in all_matching: + if statuses.get(t.id, 'queued') == status_filter: + filtered.append(t) + + serialized = _batch_serialize(filtered, statuses=statuses, timestamps=timestamps) + + total = len(serialized) + paged = serialized[offset:offset + limit] + return paginated_response(paged, total, limit, offset) + + total = query.count() + tests = query.offset(offset).limit(limit).all() + serialized = _batch_serialize(tests) + return paginated_response(serialized, total, limit, offset) + + +@mod_api.route('/runs', methods=['POST']) +@require_scope('runs:write') +@validate_body(RunCreateRequestSchema) +def create_run(validated_data=None): + """Trigger a new test run for a commit + platform combination. + + CI worker pickup: The worker's cron job (run_cron.py) polls the Test + table for rows without a 'completed' or 'canceled' TestProgress entry. + Creating a Test row here is sufficient to enqueue it — no explicit + signal is needed. See mod_ci/controllers.py queue_test() which follows + the same pattern: 'Created tests, waiting for cron...'. + """ + commit_sha = validated_data['commit_sha'] + platform_str = validated_data['platform'] + branch = validated_data.get('branch', 'master') + repository = validated_data.get('repository') + pull_request = validated_data.get('pull_request') or 0 + regression_test_ids = validated_data.get('regression_test_ids') + + platform = TestPlatform.from_string(platform_str) + + # Main repo requires contributor+; forks allow any authenticated user. + from run import config + main_owner = config.get('GITHUB_OWNER', '') + main_repo = config.get('GITHUB_REPOSITORY', '') + main_repo_full = f'{main_owner}/{main_repo}' + target_repo = repository or main_repo_full + user = g.api_user + + if target_repo == main_repo_full: + if user.role.value not in ('admin', 'tester', 'contributor'): + return make_error_response( + 'forbidden', + 'Only contributors and above can trigger runs for the main repository.', + details={ + 'required_roles': ['admin', 'tester', 'contributor'], + 'repository': target_repo, + }, + http_status=403, + ) + else: + owner = target_repo.split('/')[0] + github_login = user.github_login or '' + + is_owner = bool(github_login) and owner.lower() == github_login.lower() + is_staff = user.role.value in ('admin', 'tester', 'contributor') + + if not is_owner and not is_staff: + return make_error_response( + 'forbidden', + 'You can only trigger runs for your own repository.', + details={ + 'repository': target_repo, + 'owner_required': github_login, + }, + http_status=403, + ) + + if repository: + fork_url = f'https://github.com/{repository}.git' + else: + fork_url = f"https://github.com/{main_owner}/{main_repo}.git" + + fork = Fork.query.filter(Fork.github == fork_url).first() + if fork is None: + fork = Fork(fork_url) + g.db.add(fork) + g.db.flush() + + # Validate regression test IDs against active tests only. + if regression_test_ids is not None: + if not regression_test_ids: + return make_error_response( + 'validation_error', + 'regression_test_ids cannot be empty.', + details={'fields': {'regression_test_ids': 'Must contain at least one ID.'}}, + http_status=400, + ) + active_tests = RegressionTest.query.filter( + RegressionTest.id.in_(regression_test_ids), + RegressionTest.active == True, # noqa: E712 + ).all() + active_ids = {t.id for t in active_tests} + inactive_ids = [tid for tid in regression_test_ids if tid not in active_ids] + if inactive_ids: + return make_error_response( + 'unprocessable', + 'Some regression test IDs are inactive or do not exist.', + details={'inactive_ids': inactive_ids}, + http_status=422, + ) + else: + active_tests = RegressionTest.query.filter_by(active=True).all() + regression_test_ids = [t.id for t in active_tests] + + test_type = TestType.pull_request if pull_request else TestType.commit + + test = Test( + platform=platform, + test_type=test_type, + fork_id=fork.id, + branch=branch, + commit=commit_sha, + pr_nr=pull_request, + ) + g.db.add(test) + g.db.flush() + + for rt_id in regression_test_ids: + ct = CustomizedTest(test.id, rt_id) + g.db.add(ct) + g.db.commit() + + return single_response(_serialize_run(test), http_status=202) + + +@mod_api.route('/runs/', methods=['GET']) +@require_scope('runs:read') +@validate_path_id('run_id') +def get_run(run_id): + """Fetch a single run by ID.""" + test = Test.query.filter(Test.id == run_id).first() + if test is None: + return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) + + return single_response(_serialize_run(test)) + + +@mod_api.route('/runs//summary', methods=['GET']) +@require_scope('runs:read') +@validate_path_id('run_id') +def get_run_summary(run_id): + """ + Aggregate pass/fail/skip/missing/error counts from result rows. + + fail_count comes from TestResult rows, not from test.failed (which + only reflects cancellation status and is unreliable for this purpose). + """ + test = Test.query.filter(Test.id == run_id).first() + if test is None: + return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) + + results = TestResult.query.filter_by(test_id=run_id).all() + total_samples = len(test.get_customized_regressiontests()) + + pass_count = 0 + fail_count = 0 + skipped_count = 0 + missing_count = 0 + total_runtime = 0 + + # Preload TestResultFiles + from collections import defaultdict + all_files = TestResultFile.query.filter_by(test_id=run_id).all() if results else [] + files_by_result = defaultdict(list) + for f in all_files: + files_by_result[f.regression_test_id].append(f) + + for result in results: + result_files = files_by_result.get(result.regression_test_id, []) + + status = derive_sample_status(result, result_files) + + if status == 'pass': + pass_count += 1 + elif status == 'fail': + fail_count += 1 + elif status == 'missing_output': + missing_count += 1 + else: + skipped_count += 1 + + if result.runtime: + total_runtime += result.runtime + + # Retrieve error_count from the error service + from mod_api.services.error_service import derive_errors_for_run + error_count = len(derive_errors_for_run(run_id)) + + return single_response({ + 'run_id': run_id, + 'status': derive_run_status(test), + 'total_samples': total_samples, + 'pass_count': pass_count, + 'fail_count': fail_count, + 'skipped_count': skipped_count, + 'missing_output_count': missing_count, + 'error_count': error_count, + 'duration_ms': total_runtime if total_runtime > 0 else None, + 'triggered_by': None, + }) + + +@mod_api.route('/runs//progress', methods=['GET']) +@require_scope('runs:read') +@validate_path_id('run_id') +@validate_offset_pagination() +def get_run_progress(run_id, limit=50, offset=0): + """ + Get the timeline of progress events for a run, paginated. + + Events come from TestProgress rows written by the CI worker. + """ + test = Test.query.filter(Test.id == run_id).first() + if test is None: + return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) + + query = TestProgress.query.filter_by(test_id=run_id) + + # Optional status filter. + status_filter = request.args.get('status') + if status_filter: + try: + status_enum = TestStatus.from_string(status_filter) + query = query.filter(TestProgress.status == status_enum) + except Exception: + return make_error_response( + 'validation_error', + f'Invalid status filter: {status_filter}.', + details={'fields': { + 'status': 'Must be one of: queued, preparation, testing, completed, canceled, error.' + }}, + http_status=400, + ) + + query = query.order_by(TestProgress.id.asc()) + total = query.count() + progress = query.offset(offset).limit(limit).all() + + events = [{ + 'timestamp': p.timestamp, + 'status': p.status.name, + 'message': p.message, + 'step': None, + } for p in progress] + + schema = ProgressEventSchema() + return paginated_response(events, total, limit, offset, schema=schema) + + +@mod_api.route('/runs//config', methods=['GET']) +@require_scope('runs:read') +@validate_path_id('run_id') +def get_run_config(run_id): + """Get the configuration that was used to launch this run.""" + test = Test.query.filter(Test.id == run_id).first() + if test is None: + return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) + + regression_ids = test.get_customized_regressiontests() + + return single_response({ + 'run_id': run_id, + 'platform': test.platform.value, + 'branch': test.branch, + 'commit_sha': test.commit, + 'regression_test_ids': regression_ids, + }) + + +@mod_api.route('/runs//cancel', methods=['POST']) +@require_roles(['admin', 'contributor', 'tester']) +@require_scope('runs:write') +@validate_path_id('run_id') +def cancel_run(run_id): + """Cancel a running or queued test. + + Idempotent — canceling something already finished returns 202 + with status=no_op. + """ + test = Test.query.filter(Test.id == run_id).first() + if test is None: + return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) + + status = derive_run_status(test) + if status in ('pass', 'fail', 'canceled', 'error'): + return single_response({ + 'run_id': run_id, + 'action': 'cancel', + 'status': 'no_op', + 'message': f'Run is already in terminal state: {status}', + }, http_status=202) + + user = g.api_user + reason = None + if request.is_json and request.get_json(silent=True): + reason = request.get_json(silent=True).get('reason') + if reason: + reason_str = str(reason).strip() + if len(reason_str) < 5: + return make_error_response( + 'validation_error', + 'Cancel reason must be at least 5 characters.', + details={'fields': {'reason': 'Minimum length is 5.'}}, + http_status=400, + ) + reason = reason_str[:255] + + cancel_msg = f'Canceled by {user.name} via API' if user else 'Canceled via API' + if reason: + cancel_msg = f'{cancel_msg}: {reason}' + + progress = TestProgress(run_id, TestStatus.canceled, cancel_msg) + g.db.add(progress) + g.db.commit() + + return single_response({ + 'run_id': run_id, + 'action': 'cancel', + 'status': 'accepted', + 'message': 'Run has been canceled.', + }, http_status=202) diff --git a/mod_api/routes/samples.py b/mod_api/routes/samples.py new file mode 100644 index 00000000..0177477f --- /dev/null +++ b/mod_api/routes/samples.py @@ -0,0 +1,446 @@ +""" +Sample and regression test routes. + +GET /runs/{id}/samples Per-run regression test results +GET /runs/{id}/samples/{sid} Single result in a run +GET /samples Media sample catalog +GET /samples/{id} Single media sample +GET /samples/{id}/history Cross-run history for a sample +GET /regression-tests Regression test definitions +""" + +from flask import request + +from mod_api import mod_api +from mod_api.middleware.auth import require_scope +from mod_api.middleware.error_handler import make_error_response +from mod_api.middleware.validation import (validate_date_range, + validate_offset_pagination, + validate_path_id) +from mod_api.services.status import (derive_output_status, + derive_sample_status, get_run_timestamps, + is_dummy_row) +from mod_api.utils import paginated_response, single_response +from mod_regression.models import Category, RegressionTest +from mod_sample.models import Sample +from mod_test.models import Test, TestResult, TestResultFile + + +def _serialize_run_sample(test_id, result, result_files): + """Build the per-regression-test result dict for a run.""" + status = derive_sample_status(result, result_files) + + outputs = [] + for rf in result_files: + if is_dummy_row(rf): + continue + outputs.append({ + 'output_id': rf.regression_test_output_id, + 'filename': ( + rf.regression_test_output.create_correct_filename(rf.expected) + if rf.regression_test_output else rf.expected + ), + 'status': derive_output_status(rf), + }) + + sample_name = None + sample_id = None + command = None + categories = [] + + if result.regression_test: + rt = result.regression_test + command = rt.command + if rt.sample: + sample_id = rt.sample_id + sample_name = rt.sample.original_name + if rt.categories: + categories = [c.name for c in rt.categories] + + return { + 'regression_test_id': result.regression_test_id, + 'sample_id': sample_id, + 'sample_name': sample_name, + 'status': status, + 'exit_code': result.exit_code, + 'expected_rc': result.expected_rc, + 'runtime_ms': result.runtime, + 'command': command, + 'categories': categories, + 'outputs': outputs, + } + + +@mod_api.route('/runs//samples', methods=['GET']) +@require_scope('runs:read') +@validate_path_id('run_id') +@validate_offset_pagination() +def list_run_samples(run_id, limit=50, offset=0): + """ + List per-sample results for a run, with optional filters. + + Supports ?status, ?name, ?tag, ?category query params. + """ + test = Test.query.filter(Test.id == run_id).first() + if test is None: + return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) + + results = TestResult.query.filter_by(test_id=run_id).all() + + # Preload TestResultFiles + from collections import defaultdict + all_files = TestResultFile.query.filter_by(test_id=run_id).all() if results else [] + files_by_result = defaultdict(list) + for f in all_files: + files_by_result[f.regression_test_id].append(f) + + # Serialize list to filter by derived status and joined fields + serialized = [] + for result in results: + result_files = files_by_result.get(result.regression_test_id, []) + serialized.append(_serialize_run_sample(run_id, result, result_files)) + + # Apply query param filters. + status_filter = request.args.get('status') + if status_filter: + serialized = [s for s in serialized if s['status'] == status_filter] + + name_filter = request.args.get('name') + if name_filter: + name_lower = name_filter.lower() + serialized = [s for s in serialized if s.get('sample_name') and name_lower in s['sample_name'].lower()] + + tag_filter = request.args.get('tag') + if tag_filter: + # Lookup tags from Sample + tag_lower = tag_filter.lower() + tagged_sample_ids = set() + + # Preload samples to avoid N+1 queries + valid_sample_ids = [s['sample_id'] for s in serialized if s.get('sample_id')] + samples = Sample.query.filter(Sample.id.in_(valid_sample_ids)).all() if valid_sample_ids else [] + sample_map = {sample.id: sample for sample in samples} + + for s in serialized: + if s['sample_id']: + sample = sample_map.get(s['sample_id']) + if sample and any(tag_lower == t.name.lower() for t in sample.tags): + tagged_sample_ids.add(s['sample_id']) + serialized = [s for s in serialized if s.get('sample_id') in tagged_sample_ids] + + category_filter = request.args.get('category') + if category_filter: + cat_lower = category_filter.lower() + serialized = [ + s for s in serialized + if s.get('categories') and cat_lower in [c.lower() for c in s['categories']] + ] + + total = len(serialized) + paged = serialized[offset:offset + limit] + return paginated_response(paged, total, limit, offset) + + +@mod_api.route('/runs//samples/', methods=['GET']) +@require_scope('runs:read') +@validate_path_id('run_id') +@validate_path_id('regression_test_id') +def get_run_sample(run_id, regression_test_id): + """Get a single regression test result within a run.""" + test = Test.query.filter(Test.id == run_id).first() + if test is None: + return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) + + result = TestResult.query.filter_by( + test_id=run_id, + regression_test_id=regression_test_id, + ).first() + if result is None: + return make_error_response( + 'not_found', + f'Regression test {regression_test_id} not found in run {run_id}.', + http_status=404, + ) + + result_files = TestResultFile.query.filter_by( + test_id=run_id, + regression_test_id=regression_test_id, + ).all() + + return single_response(_serialize_run_sample(run_id, result, result_files)) + + +@mod_api.route('/samples', methods=['GET']) +@require_scope('runs:read') +@validate_offset_pagination() +def list_samples(limit=50, offset=0): + """ + List media samples from the catalog. + + Supports ?name, ?extension, ?tag, ?sha256, ?status (active/inactive) filters. + """ + query = Sample.query + + name = request.args.get('name') + if name: + # Escape LIKE wildcards to prevent unintended pattern matching. + safe_name = name.replace('%', '\\%').replace('_', '\\_') + query = query.filter(Sample.original_name.ilike(f'%{safe_name}%')) + + extension = request.args.get('extension') + if extension: + query = query.filter(Sample.extension == extension) + + sha256_filter = request.args.get('sha256') + if sha256_filter: + query = query.filter(Sample.sha == sha256_filter) + + tag_filter = request.args.get('tag') + if tag_filter: + from sqlalchemy import func + + from mod_sample.models import Tag + query = query.filter(Sample.tags.any(func.lower(Tag.name) == tag_filter.lower())) + + status_filter = request.args.get('status') + if status_filter: + want_active = status_filter.lower() == 'active' + if want_active: + query = query.filter(Sample.tests.any(RegressionTest.active == True)) # noqa: E712 + else: + query = query.filter(~Sample.tests.any(RegressionTest.active == True)) # noqa: E712 + + # Paginate at DB level without Python-side filters + total = query.count() + samples = query.offset(offset).limit(limit).all() + + # Batch load active regression test counts + from flask import g + from sqlalchemy import func + sample_ids = [s.id for s in samples] + counts_list = g.db.query( + RegressionTest.sample_id, + func.count(RegressionTest.id) + ).filter( + RegressionTest.sample_id.in_(sample_ids), + RegressionTest.active == True # noqa: E712 + ).group_by(RegressionTest.sample_id).all() if sample_ids else [] + counts = dict(counts_list) + + serialized = [] + for s in samples: + active_count = counts.get(s.id, 0) + serialized.append({ + 'sample_id': s.id, + 'sha': s.sha, + 'extension': s.extension, + 'original_name': s.original_name, + 'filename': s.filename, + 'tags': [t.name for t in s.tags], + 'regression_test_count': active_count, + 'active': active_count > 0, + }) + + return paginated_response(serialized, total, limit, offset) + + +@mod_api.route('/samples/', methods=['GET']) +@require_scope('runs:read') +@validate_path_id('sample_id') +def get_sample(sample_id): + """Get a single media sample by its ID.""" + sample = Sample.query.filter(Sample.id == sample_id).first() + if sample is None: + return make_error_response('not_found', f'Sample {sample_id} not found.', http_status=404) + + active_count = RegressionTest.query.filter_by( + sample_id=sample.id, active=True + ).count() + + return single_response({ + 'sample_id': sample.id, + 'sha': sample.sha, + 'extension': sample.extension, + 'original_name': sample.original_name, + 'filename': sample.filename, + 'tags': [t.name for t in sample.tags], + 'regression_test_count': active_count, + 'active': active_count > 0, + }) + + +@mod_api.route('/samples//history', methods=['GET']) +@require_scope('runs:read') +@validate_path_id('sample_id') +@validate_offset_pagination() +@validate_date_range +def get_sample_history(sample_id, limit=50, offset=0, created_after=None, created_before=None): + """ + Show how a sample performed across different runs. + + Use failure_signature to tell apart genuine regressions from infra flakes. + """ + sample = Sample.query.filter(Sample.id == sample_id).first() + if sample is None: + return make_error_response('not_found', f'Sample {sample_id} not found.', http_status=404) + + regression_tests = RegressionTest.query.filter_by(sample_id=sample_id).all() + rt_ids = [rt.id for rt in regression_tests] + + if not rt_ids: + return paginated_response([], 0, limit, offset) + + query = TestResult.query.filter( + TestResult.regression_test_id.in_(rt_ids) + ).join(Test, Test.id == TestResult.test_id) + + branch = request.args.get('branch') + if branch: + query = query.filter(Test.branch == branch) + + platform = request.args.get('platform') + if platform: + query = query.filter(Test.platform == platform) + + if created_after or created_before: + from flask import g + + from mod_test.models import TestProgress + if created_after: + query = query.filter(Test.id.in_( + g.db.query(TestProgress.test_id).filter(TestProgress.timestamp >= created_after) + )) + if created_before: + query = query.filter(Test.id.in_( + g.db.query(TestProgress.test_id).filter(TestProgress.timestamp <= created_before) + )) + + results = query.order_by(Test.id.desc()).all() + + status_filter = request.args.get('status') + + # Preload TestResultFiles + from collections import defaultdict + test_ids = list(set([r.test_id for r in results])) + all_files = TestResultFile.query.filter(TestResultFile.test_id.in_(test_ids)).all() if test_ids else [] + files_by_result = defaultdict(list) + for f in all_files: + files_by_result[(f.test_id, f.regression_test_id)].append(f) + + entries = [] + for result in results: + test = result.test + if test is None: + test = Test.query.get(result.test_id) + if test is None: + continue + + result_files = files_by_result.get((result.test_id, result.regression_test_id), []) + + status = derive_sample_status(result, result_files) + timestamps = get_run_timestamps(test) + + failure_sig = None + if status == 'fail': + for rf in result_files: + if rf.got is not None and not is_dummy_row(rf): + failure_sig = f'diff_mismatch:output:{rf.regression_test_output_id}' + break + if failure_sig is None and result.exit_code != result.expected_rc: + failure_sig = f'exit_code_mismatch:rc:{result.exit_code}' + elif status == 'missing_output': + failure_sig = 'missing_output' + + if status_filter and status != status_filter: + continue + + entries.append({ + 'run_id': test.id, + 'regression_test_id': result.regression_test_id, + 'status': status, + 'platform': test.platform.value, + 'branch': test.branch, + 'commit_sha': test.commit, + 'tested_at': timestamps.get('completed_at') or timestamps.get('started_at'), + 'failure_signature': failure_sig, + }) + + total = len(entries) + paged = entries[offset:offset + limit] + + return paginated_response(paged, total, limit, offset) + + +@mod_api.route('/regression-tests', methods=['GET']) +@require_scope('runs:read') +@validate_offset_pagination() +def list_regression_tests(limit=50, offset=0): + """ + List regression test definitions. + + Supports ?active, ?category, ?tag, ?sample_id filters. + """ + query = RegressionTest.query + + active_filter = request.args.get('active') + if active_filter is not None: + is_active = active_filter.lower() in ('true', '1', 'yes') + else: + is_active = True + query = query.filter(RegressionTest.active == is_active) + + category = request.args.get('category') + if category: + query = query.join(RegressionTest.categories).filter(Category.name == category) + + sample_id_filter = request.args.get('sample_id') + if sample_id_filter: + try: + sid = int(sample_id_filter) + query = query.filter(RegressionTest.sample_id == sid) + except (ValueError, TypeError): + return make_error_response( + 'validation_error', + 'sample_id must be a positive integer.', + details={'fields': {'sample_id': 'Must be a positive integer.'}}, + http_status=400, + ) + + tag_filter = request.args.get('tag') + + def _serialize_rt(rt): + return { + 'regression_test_id': rt.id, + 'sample_id': rt.sample_id, + 'sample_name': rt.sample.original_name if rt.sample else None, + 'command': rt.command, + 'input_type': rt.input_type.value, + 'output_type': rt.output_type.value, + 'expected_rc': rt.expected_rc, + 'active': rt.active, + 'categories': [c.name for c in rt.categories], + 'description': rt.description, + } + + # Filter tags in Python before paginating + if tag_filter: + all_tests = query.all() + serialized = [] + for rt in all_tests: + if rt.sample: + sample_tags = [t.name.lower() for t in rt.sample.tags] + if tag_filter.lower() not in sample_tags: + continue + else: + continue # no sample = no tags to match + serialized.append(_serialize_rt(rt)) + + total = len(serialized) + paged = serialized[offset:offset + limit] + return paginated_response(paged, total, limit, offset) + + # Paginate at DB level without tag filters + total = query.count() + tests = query.offset(offset).limit(limit).all() + serialized = [_serialize_rt(rt) for rt in tests] + return paginated_response(serialized, total, limit, offset) diff --git a/mod_api/routes/system.py b/mod_api/routes/system.py new file mode 100644 index 00000000..14d8fe85 --- /dev/null +++ b/mod_api/routes/system.py @@ -0,0 +1,292 @@ +""" +System, health, queue, and artifact routes. + +GET /system/health Health check (unauthenticated) +GET /system/queue Queue status — active + queued runs +GET /runs/{id}/artifacts Run artifacts from GCS + local storage +""" + +import os +from datetime import datetime, timezone + +from flask import g, jsonify, request +from sqlalchemy import text + +from mod_api import mod_api +from mod_api.middleware.auth import require_scope +from mod_api.middleware.error_handler import make_error_response +from mod_api.middleware.validation import (validate_offset_pagination, + validate_path_id) +from mod_api.services.status import derive_run_status, is_dummy_row +from mod_api.services.storage import (get_log_file_path, + get_test_results_base_path, + resolve_artifact) +from mod_api.utils import paginated_response +from mod_test.models import (Test, TestPlatform, TestProgress, TestResultFile, + TestStatus) + + +@mod_api.route('/system/health', methods=['GET']) +def system_health(): + """ + Public health check — no auth required. + + Returns 200 when things are ok or degraded, 503 when the system is down. + Monitoring services and load balancers can hit this freely. + """ + now = datetime.now(timezone.utc) + dependencies = [] + overall = 'ok' + + # Database connectivity. + try: + g.db.execute(text('SELECT 1')) + dependencies.append({'name': 'database', 'status': 'ok', 'message': None}) + except Exception as e: + dependencies.append({'name': 'database', 'status': 'down', 'message': 'Database connection failed.'}) + overall = 'down' + + # Local sample storage. + try: + from run import config + sample_repo = config.get('SAMPLE_REPOSITORY', '') + if os.path.isdir(sample_repo): + dependencies.append({'name': 'local_storage', 'status': 'ok', 'message': None}) + else: + dependencies.append({ + 'name': 'local_storage', + 'status': 'degraded', + 'message': 'Local storage check failed.', + }) + if overall == 'ok': + overall = 'degraded' + except Exception as e: + dependencies.append({'name': 'local_storage', 'status': 'down', 'message': 'Local storage check failed.'}) + overall = 'down' + + # Google Cloud Storage. + try: + from run import storage_client_bucket + if storage_client_bucket: + dependencies.append({'name': 'gcs', 'status': 'ok', 'message': None}) + else: + dependencies.append({'name': 'gcs', 'status': 'degraded', 'message': 'GCS client not initialized.'}) + if overall == 'ok': + overall = 'degraded' + except Exception as e: + dependencies.append({'name': 'gcs', 'status': 'degraded', 'message': 'GCS connectivity check failed.'}) + if overall == 'ok': + overall = 'degraded' + + http_status = 503 if overall == 'down' else 200 + response = jsonify({ + 'status': overall, + 'checked_at': now.isoformat(), + 'dependencies': dependencies, + }) + response.status_code = http_status + return response + + +@mod_api.route('/system/queue', methods=['GET']) +@require_scope('system:read') +@validate_offset_pagination() +def get_queue(limit=50, offset=0): + """ + Return queued and running jobs. + + Excludes anything that's already completed or canceled. Supports + ?platform and ?status filters. + """ + terminal_subq = g.db.query( + TestProgress.test_id + ).filter( + TestProgress.status.in_([TestStatus.completed, TestStatus.canceled]) + ).group_by(TestProgress.test_id).subquery() + + running_subq = g.db.query( + TestProgress.test_id + ).filter( + TestProgress.status.in_([TestStatus.preparation, TestStatus.testing]) + ).group_by(TestProgress.test_id).subquery() + + base_query = Test.query.filter( + ~Test.id.in_(g.db.query(terminal_subq.c.test_id)) + ) + + platform_filter = request.args.get('platform') + if platform_filter: + try: + plat = TestPlatform.from_string(platform_filter) + base_query = base_query.filter(Test.platform == plat) + except Exception: + return make_error_response('validation_error', 'Invalid platform.', http_status=400) + + running_count = base_query.filter(Test.id.in_(g.db.query(running_subq.c.test_id))).count() + queue_depth = base_query.filter(~Test.id.in_(g.db.query(running_subq.c.test_id))).count() + + status_filter = request.args.get('status') + if status_filter == 'queued': + query = base_query.filter(~Test.id.in_(g.db.query(running_subq.c.test_id))) + total = queue_depth + elif status_filter == 'running': + query = base_query.filter(Test.id.in_(g.db.query(running_subq.c.test_id))) + total = running_count + elif status_filter: + return make_error_response('validation_error', 'Invalid status. Must be queued or running.', http_status=400) + else: + query = base_query + total = queue_depth + running_count + + query = query.order_by(Test.id.asc()) + paged_tests = query.offset(offset).limit(limit).all() + + from mod_api.services.status import batch_get_run_data + statuses, timestamps = batch_get_run_data(paged_tests) + + paged_jobs = [] + queued_index = offset + 1 if status_filter == 'queued' else None + + for test in paged_tests: + status = statuses.get(test.id, 'queued') + ts = timestamps.get(test.id, {}) + + pos = None + if status == 'queued': + if queued_index is not None: + pos = queued_index + queued_index += 1 + + paged_jobs.append({ + 'run_id': test.id, + 'status': status, + 'platform': test.platform.value, + 'queued_at': ts.get('queued_at').isoformat() if ts.get('queued_at') else None, + 'started_at': ts.get('started_at').isoformat() if ts.get('started_at') else None, + 'position': pos, + }) + + response = jsonify({ + 'queue_depth': queue_depth, + 'running_count': running_count, + 'data': paged_jobs, + 'pagination': { + 'limit': limit, + 'offset': offset, + 'total': total, + 'next_offset': offset + limit if (offset + limit) < total else None, + }, + }) + return response + + +@mod_api.route('/runs//artifacts', methods=['GET']) +@require_scope('results:read') +@validate_path_id('run_id') +@validate_offset_pagination() +def list_artifacts(run_id, limit=50, offset=0): + """ + List all artifacts for a run. + + Checks both GCS and local storage. Falls back to local when GCS + is unavailable. Supports ?type filter. + """ + test = Test.query.filter(Test.id == run_id).first() + if test is None: + return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) + + artifacts = [] + + # GCS-backed build artifacts. + binary_name = ( + 'ccextractor' if test.platform == TestPlatform.linux + else 'ccextractorwinfull.exe' + ) + gcs_artifacts = [ + ('binary', f'test_artifacts/{run_id}/{binary_name}', binary_name, 'application/octet-stream'), + ('coredump', f'test_artifacts/{run_id}/coredump', f'coredump-{run_id}', 'application/octet-stream'), + ( + 'combined_stdout', + f'test_artifacts/{run_id}/combined_stdout.log', + f'combined_stdout-{run_id}.log', + 'text/plain', + ), + ] + for artifact_type, gcs_path, filename, content_type in gcs_artifacts: + download_url, storage_status = resolve_artifact(gcs_path) + artifacts.append({ + 'artifact_id': f'{artifact_type}_{run_id}', + 'run_id': run_id, + 'sample_id': None, + 'type': artifact_type, + 'filename': filename, + 'content_type': content_type, + 'size_bytes': None, + 'storage_status': storage_status, + 'download_url': download_url, + }) + + # Build log — accessed via /runs/{id}/logs, no direct download link. + log_path = get_log_file_path(run_id) + artifacts.append({ + 'artifact_id': f'buildlog_{run_id}', + 'run_id': run_id, + 'sample_id': None, + 'type': 'build_log', + 'filename': f'{run_id}.txt', + 'content_type': 'text/plain', + 'size_bytes': os.path.getsize(log_path) if log_path else None, + 'storage_status': 'ok' if log_path else 'missing', + 'download_url': None, + }) + + # Expected/actual output files from TestResultFile rows. + result_files = TestResultFile.query.filter_by(test_id=run_id).all() + base_path = get_test_results_base_path() + for rf in result_files: + if is_dummy_row(rf): + continue + + ext = rf.regression_test_output.correct_extension if rf.regression_test_output else '' + + expected_name = rf.expected + ext + expected_url, expected_status = resolve_artifact(f'TestResults/{expected_name}') + local_expected = os.path.join(base_path, expected_name) + + artifacts.append({ + 'artifact_id': f'expected_{run_id}_{rf.regression_test_id}_{rf.regression_test_output_id}', + 'run_id': run_id, + 'sample_id': rf.regression_test_id, + 'type': 'expected_output', + 'filename': expected_name, + 'content_type': 'application/octet-stream', + 'size_bytes': os.path.getsize(local_expected) if os.path.isfile(local_expected) else None, + 'storage_status': expected_status, + 'download_url': expected_url, + }) + + if rf.got is not None: + actual_name = rf.got + ext + actual_url, actual_status = resolve_artifact(f'TestResults/{actual_name}') + local_actual = os.path.join(base_path, actual_name) + + artifacts.append({ + 'artifact_id': f'actual_{run_id}_{rf.regression_test_id}_{rf.regression_test_output_id}', + 'run_id': run_id, + 'sample_id': rf.regression_test_id, + 'type': 'sample_output', + 'filename': actual_name, + 'content_type': 'application/octet-stream', + 'size_bytes': os.path.getsize(local_actual) if os.path.isfile(local_actual) else None, + 'storage_status': actual_status, + 'download_url': actual_url, + }) + + # Apply optional ?type filter. + type_filter = request.args.get('type') + if type_filter: + artifacts = [a for a in artifacts if a['type'] == type_filter] + + total = len(artifacts) + paged = artifacts[offset:offset + limit] + return paginated_response(paged, total, limit, offset) diff --git a/mod_api/schemas/auth.py b/mod_api/schemas/auth.py index d22f610a..3428b9b2 100644 --- a/mod_api/schemas/auth.py +++ b/mod_api/schemas/auth.py @@ -24,13 +24,13 @@ class TokenCreateRequestSchema(Schema): ], ) expires_in_days = fields.Integer( - load_default=7, + load_default=30, validate=validate.Range(min=1, max=30), ) scopes = fields.List( fields.String(validate=validate.OneOf(VALID_SCOPES)), load_default=None, - validate=validate.Length(max=8), + validate=validate.Length(max=6), ) class Meta: diff --git a/mod_api/schemas/common.py b/mod_api/schemas/common.py index 2234a33b..77462d5d 100644 --- a/mod_api/schemas/common.py +++ b/mod_api/schemas/common.py @@ -24,4 +24,4 @@ class CursorPaginationSchema(Schema): """Cursor-based pagination metadata.""" limit = fields.Integer(required=True) - next_cursor = fields.String(allow_none=True, load_default=None) + next_cursor = fields.Integer(allow_none=True, load_default=None) diff --git a/mod_api/schemas/errors.py b/mod_api/schemas/errors.py index a451187d..30599e80 100644 --- a/mod_api/schemas/errors.py +++ b/mod_api/schemas/errors.py @@ -27,6 +27,7 @@ class ErrorSummaryBucketSchema(Schema): key = fields.String(required=True) count = fields.Integer(required=True) severity = fields.String(required=True) + group_by = fields.String(allow_none=True) sample_ids = fields.List(fields.Integer(), load_default=[]) first_seen_at = fields.DateTime(allow_none=True) last_seen_at = fields.DateTime(allow_none=True) diff --git a/mod_api/schemas/results.py b/mod_api/schemas/results.py index 4004f2cb..8248c7ce 100644 --- a/mod_api/schemas/results.py +++ b/mod_api/schemas/results.py @@ -65,10 +65,7 @@ class BaselineApprovalRequestSchema(Schema): required=True, validate=validate.Range(min=1), ) - reason = fields.String( - required=True, - validate=validate.Length(min=10, max=500), - ) + remove_variants = fields.Boolean( load_default=False, ) @@ -82,7 +79,6 @@ class Meta: class BaselineApprovalSchema(Schema): """Response after a baseline approval is applied.""" - approval_id = fields.String(required=True) status = fields.String( required=True, validate=validate.OneOf( diff --git a/mod_api/schemas/runs.py b/mod_api/schemas/runs.py index fb081562..a8262318 100644 --- a/mod_api/schemas/runs.py +++ b/mod_api/schemas/runs.py @@ -17,7 +17,7 @@ class RunSchema(Schema): run_id = fields.Integer(required=True) status = fields.String(required=True, validate=validate.OneOf([ - 'queued', 'running', 'pass', 'fail', 'canceled', 'error', 'incomplete', + 'queued', 'running', 'pass', 'fail', 'canceled', 'incomplete', ])) platform = fields.String( required=True, validate=validate.OneOf(['linux', 'windows'])) @@ -77,13 +77,13 @@ class RunCreateRequestSchema(Schema): validate=[ validate.Length(max=100), validate.Regexp( - r'^[A-Za-z0-9._/\-]+$', - error='branch must match ^[A-Za-z0-9._/-]+$', + r'^[A-Za-z0-9._-]+(/[A-Za-z0-9._-]+)*$', + error='branch must match ^[A-Za-z0-9._-]+(/[A-Za-z0-9._-]+)*$', ), ], ) repository = fields.String( - load_default=None, + required=True, validate=[ validate.Length(max=100), validate.Regexp( @@ -113,7 +113,6 @@ class RunActionResultSchema(Schema): """Response for cancel and similar run actions.""" run_id = fields.Integer(required=True) - new_run_id = fields.Integer(allow_none=True) action = fields.String(required=True) status = fields.String(required=True) message = fields.String(required=True) diff --git a/mod_api/schemas/samples.py b/mod_api/schemas/samples.py index 5f074a52..c064b4f2 100644 --- a/mod_api/schemas/samples.py +++ b/mod_api/schemas/samples.py @@ -9,7 +9,7 @@ class OutputFileSchema(Schema): output_id = fields.Integer(required=True) filename = fields.String(required=True) status = fields.String(required=True, validate=validate.OneOf([ - 'match', 'diff_mismatch', 'missing_output', 'missing_expected', + 'pass', 'fail', 'missing_output', ])) diff --git a/mod_api/services/diff_service.py b/mod_api/services/diff_service.py new file mode 100644 index 00000000..e2b4b25d --- /dev/null +++ b/mod_api/services/diff_service.py @@ -0,0 +1,183 @@ +""" +Structured diff computation between expected and actual output files. + +Produces JSON hunks with line-level detail instead of the legacy HTML +diff output. Uses difflib.unified_diff internally. +""" + +import difflib +import hashlib +import os +import re +from typing import Any, Dict, List, Optional + + +def compute_diff( + expected_path: str, + actual_path: str, + context_lines: int = 3, + max_hunks: int = 500, +) -> Dict[str, Any]: + """ + Compute a structured diff between two files. + + Returns a dict matching the Diff schema: status, summary (added_lines, + removed_lines, changed_hunks), and a list of hunks. + """ + context_lines = max(1, min(context_lines, 50)) + + if not os.path.isfile(expected_path): + return { + 'status': 'missing_expected', + 'summary': {'added_lines': 0, 'removed_lines': 0, 'changed_hunks': 0}, + 'hunks': [], + } + + if not os.path.isfile(actual_path): + return { + 'status': 'missing_actual', + 'summary': {'added_lines': 0, 'removed_lines': 0, 'changed_hunks': 0}, + 'hunks': [], + } + + expected_lines = read_lines(expected_path) + actual_lines = read_lines(actual_path) + + if expected_lines == actual_lines: + return { + 'status': 'identical', + 'summary': {'added_lines': 0, 'removed_lines': 0, 'changed_hunks': 0}, + 'hunks': [], + } + + hunks = _compute_hunks(expected_lines, actual_lines, context_lines, max_hunks) + added = sum(1 for h in hunks for line in h['lines'] if line['kind'] == 'added') + removed = sum(1 for h in hunks for line in h['lines'] if line['kind'] == 'removed') + + return { + 'status': 'different', + 'summary': { + 'added_lines': added, + 'removed_lines': removed, + 'changed_hunks': len(hunks), + }, + 'hunks': hunks, + } + + +# Matches the @@ -a,b +c,d @@ header line from unified_diff. +_HUNK_RE = re.compile(r'^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@') + + +def _compute_hunks( + expected_lines: List[str], + actual_lines: List[str], + context_lines: int, + max_hunks: int, +) -> List[Dict[str, Any]]: + """Parse unified_diff output into structured hunk dicts.""" + differ = difflib.unified_diff( + expected_lines, + actual_lines, + lineterm='', + n=context_lines, + ) + + hunks: List[Dict[str, Any]] = [] + current_hunk: Optional[Dict[str, Any]] = None + expected_line_num = 0 + actual_line_num = 0 + + for line in differ: + if line.startswith('---') or line.startswith('+++'): + continue + + if line.startswith('@@'): + if current_hunk and len(hunks) >= max_hunks: + break + if current_hunk: + hunks.append(current_hunk) + + # Parse start positions out of the @@ header. + m = _HUNK_RE.match(line) + if m: + expected_line_num = int(m.group(1)) + actual_line_num = int(m.group(2)) + else: + expected_line_num = 0 + actual_line_num = 0 + + current_hunk = { + 'expected_start': expected_line_num, + 'actual_start': actual_line_num, + 'lines': [], + } + continue + + if current_hunk is None: + continue + + if line.startswith('+'): + current_hunk['lines'].append({ + 'kind': 'added', + 'expected_line': None, + 'actual_line': actual_line_num, + 'text': line[1:], + }) + actual_line_num += 1 + elif line.startswith('-'): + current_hunk['lines'].append({ + 'kind': 'removed', + 'expected_line': expected_line_num, + 'actual_line': None, + 'text': line[1:], + }) + expected_line_num += 1 + else: + content = line[1:] if line.startswith(' ') else line + current_hunk['lines'].append({ + 'kind': 'context', + 'expected_line': expected_line_num, + 'actual_line': actual_line_num, + 'text': content, + }) + expected_line_num += 1 + actual_line_num += 1 + + if current_hunk: + hunks.append(current_hunk) + + return hunks[:max_hunks] + + +def _enforce_safe_path(file_path: str) -> bool: + from mod_api.services.storage import get_test_results_base_path + base = os.path.realpath(get_test_results_base_path()) + target = os.path.realpath(file_path) + return target.startswith(base + os.sep) or target == base + + +def read_lines(file_path: str) -> List[str]: + """Read file lines with a cp1252 fallback, matching legacy behavior.""" + if not _enforce_safe_path(file_path): + raise ValueError("Unsafe file path") + try: + with open(file_path, encoding='utf8') as f: + return [line.rstrip('\n\r') for line in f.readlines()] + except UnicodeDecodeError: + with open(file_path, encoding='cp1252') as f: + return [line.rstrip('\n\r') for line in f.readlines()] + + +def file_sha256(file_path: str) -> Optional[str]: + """Compute SHA-256 of a file. Returns None if the file can't be read.""" + if not _enforce_safe_path(file_path): + return None + try: + sha = hashlib.sha256() + with open(file_path, 'rb') as f: + for block in iter(lambda: f.read(8192), b''): + sha.update(block) + return sha.hexdigest() + except (OSError, IOError): + return None diff --git a/mod_api/services/error_service.py b/mod_api/services/error_service.py new file mode 100644 index 00000000..b93274bb --- /dev/null +++ b/mod_api/services/error_service.py @@ -0,0 +1,200 @@ +""" +Error derivation from TestResult and TestResultFile rows. + +Walks result data and produces structured ErrorItem dicts. There's no +dedicated error table — errors are inferred from: + exit_code_mismatch → exit code != expected + diff_mismatch → got != null and not in multiple correct files + missing_output → dummy (-1,-1,-1,'','error') row present +""" + +import logging +from typing import Any, Dict, List + +from mod_api.services.status import is_dummy_row +from mod_test.models import TestResult, TestResultFile + +_SEVERITY_ORDER = ('info', 'warning', 'error', 'critical') + + +def derive_errors_for_run(test_id: int) -> List[Dict[str, Any]]: + """Walk result rows and emit one ErrorItem per detected failure.""" + from mod_test.models import TestProgress + progress = TestProgress.query.filter_by(test_id=test_id).order_by(TestProgress.timestamp.desc()).first() + occurred_at = progress.timestamp.isoformat() if progress and progress.timestamp else None + + errors = [] + results = TestResult.query.filter_by(test_id=test_id).all() + + # Preload TestResultFiles + from collections import defaultdict + all_files = TestResultFile.query.filter_by(test_id=test_id).all() if results else [] + files_by_result = defaultdict(list) + for f in all_files: + files_by_result[f.regression_test_id].append(f) + + for result in results: + if result.exit_code != result.expected_rc: + errors.append({ + 'error_id': f'err_{test_id}_{result.regression_test_id}_rc', + 'run_id': test_id, + 'sample_id': _get_sample_id(result), + 'regression_id': result.regression_test_id, + 'type': 'exit_code_mismatch', + 'severity': 'error', + 'message': ( + f'Exit code {result.exit_code} != expected {result.expected_rc} ' + f'for regression test {result.regression_test_id}' + ), + 'occurred_at': occurred_at, + }) + + result_files = files_by_result.get(result.regression_test_id, []) + + for rf in result_files: + if is_dummy_row(rf): + errors.append({ + 'error_id': f'err_{test_id}_{result.regression_test_id}_missing', + 'run_id': test_id, + 'sample_id': _get_sample_id(result), + 'regression_id': result.regression_test_id, + 'type': 'missing_output', + 'severity': 'error', + 'message': ( + f'Regression test {result.regression_test_id} ' + f'produced no output when output was expected' + ), + 'occurred_at': occurred_at, + }) + elif rf.got is not None: + is_acceptable = False + if rf.regression_test_output: + for multi in rf.regression_test_output.multiple_files: + if multi.file_hashes == rf.got: + is_acceptable = True + break + if not is_acceptable: + errors.append({ + 'error_id': f'err_{test_id}_{result.regression_test_id}_{rf.regression_test_output_id}', + 'run_id': test_id, + 'sample_id': _get_sample_id(result), + 'regression_id': result.regression_test_id, + 'type': 'diff_mismatch', + 'severity': 'warning', + 'message': ( + f'Output differs from expected for regression test ' + f'{result.regression_test_id}, output {rf.regression_test_output_id}' + ), + 'occurred_at': occurred_at, + }) + + return errors + + +def derive_error_summary(test_id: int, group_by: str = 'type') -> List[Dict[str, Any]]: + """Group errors by the given key and return bucket counts.""" + errors = derive_errors_for_run(test_id) + buckets: Dict[str, Dict[str, Any]] = {} + + for err in errors: + key = str(err.get(group_by, 'unknown')) + + if key not in buckets: + buckets[key] = { + 'key': key, + 'group_by': group_by, + 'count': 0, + 'severity': err['severity'], + 'sample_ids': [], + 'first_seen_at': None, + 'last_seen_at': None, + } + + bucket = buckets[key] + bucket['count'] += 1 + + # Escalate severity to the worst we've seen. + try: + curr_idx = _SEVERITY_ORDER.index(bucket['severity']) + new_idx = _SEVERITY_ORDER.index(err['severity']) + if new_idx > curr_idx: + bucket['severity'] = err['severity'] + except ValueError: + # Fallback if unknown severity + if err['severity'] == 'error': + bucket['severity'] = 'error' + + err_time = err.get('occurred_at') + if err_time: + if bucket['first_seen_at'] is None or err_time < bucket['first_seen_at']: + bucket['first_seen_at'] = err_time + if bucket['last_seen_at'] is None or err_time > bucket['last_seen_at']: + bucket['last_seen_at'] = err_time + + sid = err.get('sample_id') + if sid and sid not in bucket['sample_ids'] and len(bucket['sample_ids']) < 1000: + bucket['sample_ids'].append(sid) + + return list(buckets.values()) + + +def derive_infrastructure_errors(test_id: int) -> List[Dict[str, Any]]: + """ + Best-effort infra error extraction from TestProgress messages. + + There's no structured error protocol from the CI worker yet, so we + do keyword matching against progress messages to guess the failure type. + """ + from mod_test.models import TestProgress, TestStatus + + errors = [] + progress_rows = TestProgress.query.filter_by( + test_id=test_id, + status=TestStatus.canceled, + ).all() + + for p in progress_rows: + msg_lower = (p.message or '').lower() + error_type = _classify_infra_error(msg_lower) + errors.append({ + 'error_id': f'infra_{test_id}_{p.id}', + 'run_id': test_id, + 'sample_id': None, + 'regression_id': None, + 'type': error_type, + 'severity': 'critical', + 'message': p.message or 'Unknown infrastructure error', + 'location': None, + 'occurred_at': p.timestamp.isoformat() if p.timestamp else None, + }) + + return errors + + +def _classify_infra_error(message_lower: str) -> str: + """Guess the infra error type from progress message keywords.""" + if any(w in message_lower for w in ['provisioning', 'vm ', 'instance']): + return 'vm_provisioning' + if any(w in message_lower for w in ['checkout', 'git clone', 'fetch']): + return 'checkout' + if any(w in message_lower for w in ['merge', 'conflict']): + return 'merge' + if any(w in message_lower for w in ['build', 'compile', 'make']): + return 'build' + if any(w in message_lower for w in ['worker', 'timeout', 'connection']): + return 'worker' + if any(w in message_lower for w in ['storage', 'disk', 'gcs']): + return 'storage' + return 'worker' + + +def _get_sample_id(result: TestResult): + """Pull sample_id through the RegressionTest relationship, if available.""" + try: + if result.regression_test and result.regression_test.sample_id: + return result.regression_test.sample_id + except Exception as e: + logging.getLogger(__name__).error( + f"Failed to fetch sample_id for TestResult {result.test_id}_{result.regression_test_id}: {e}" + ) + return None diff --git a/mod_api/services/log_service.py b/mod_api/services/log_service.py new file mode 100644 index 00000000..89c09d4b --- /dev/null +++ b/mod_api/services/log_service.py @@ -0,0 +1,107 @@ +""" +Build log reader with cursor-based pagination. + +Log files live at SAMPLE_REPOSITORY/LogFiles/{run_id}.txt. The cursor +is just a line number offset into the file. +""" + +from typing import Any, Dict, List, Optional, Tuple + +from mod_api.services.storage import get_log_file_path + + +def read_log_lines( + run_id: int, + cursor: Optional[str] = None, + limit: int = 100, + level: Optional[str] = None, + source: Optional[str] = None, + contains: Optional[str] = None, +) -> Tuple[List[Dict[str, Any]], Optional[str]]: + """ + Read and optionally filter lines from a run's build log. + + Returns (lines, next_cursor). Raises FileNotFoundError when the + log file isn't on disk. + """ + log_path = get_log_file_path(run_id) + if log_path is None: + raise FileNotFoundError(f'Log file not found for run {run_id}') + + limit = max(1, min(limit, 500)) + + start_line = 0 + if cursor: + try: + start_line = int(cursor) + except (ValueError, TypeError): + start_line = 0 + + import itertools + + def _read_lines(encoding): + with open(log_path, encoding=encoding) as f: + iterator = itertools.islice(f, start_line, None) + + result_lines = [] + line_num = start_line + + for raw_line in iterator: + raw = raw_line.rstrip('\n\r') + line_num += 1 + + if level and not _matches_level(raw, level): + continue + if source and _extract_source(raw) != source: + continue + if contains and contains.lower() not in raw.lower(): + continue + + result_lines.append({ + 'timestamp': None, + 'level': _extract_level(raw), + 'source': _extract_source(raw), + 'message': raw, + 'run_id': run_id, + 'sample_id': None, + }) + + if len(result_lines) >= limit: + break + + try: + next(f) + has_more = True + except StopIteration: + has_more = False + + next_cursor = str(line_num) if has_more else None + return result_lines, next_cursor + + try: + return _read_lines('utf-8') + except UnicodeDecodeError: + return _read_lines('cp1252') + + +def _matches_level(line: str, target_level: str) -> bool: + """Check if a log line matches the requested severity.""" + return _extract_level(line) == target_level + + +def _extract_level(line: str) -> str: + """Best-effort log level extraction from raw text.""" + line_upper = line.upper() + for lvl in ['CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG']: + if lvl in line_upper: + return lvl.lower() + return 'info' + + +def _extract_source(line: str) -> str: + """Best-effort source component extraction from raw text.""" + line_lower = line.lower() + for src in ['orchestrator', 'worker', 'build', 'test_runner', 'web']: + if src in line_lower: + return src + return 'web' diff --git a/mod_api/services/status.py b/mod_api/services/status.py new file mode 100644 index 00000000..a91a0f17 --- /dev/null +++ b/mod_api/services/status.py @@ -0,0 +1,201 @@ +""" +Status derivation from the raw data model. + +Normalizes TestProgress/TestResult/TestResultFile states into clean +strings for the API layer. This is the single source of truth for +status logic — route handlers must not inline their own derivation. + +Run statuses: queued, running, pass, fail, canceled, error, incomplete +Sample statuses: pass, fail, skipped, missing_output, running, not_started + +Things to watch out for: + - test.failed only checks for TestStatus.canceled — never use it + for determining whether regression tests actually passed + - TestResultFile.got = null means MATCH, not missing output + - Dummy row (-1,-1,-1,'','error') = test produced no output at all + - TestStatus.canceled covers both user cancels and infra failures +""" + +from typing import List, Optional + +from mod_test.models import (Test, TestProgress, TestResult, TestResultFile, + TestStatus) + + +def derive_run_status(test: Test) -> str: + """ + Map the raw model state to one of the 7 normalized run statuses. + + Looks at the most recent TestProgress row and, for completed runs, + counts actual failures from TestResult rows. + """ + statuses, _ = batch_get_run_data([test]) + return statuses.get(test.id, 'queued') + + +def derive_sample_status( + test_result: Optional[TestResult], + result_files: List[TestResultFile], +) -> str: + """ + Map a TestResult + its output files to a per-sample status string. + + Checks for the dummy sentinel row first (missing_output), then exit + code, then output diffs against accepted baselines. + """ + if test_result is None: + return 'not_started' + + for rf in result_files: + if is_dummy_row(rf): + return 'missing_output' + + if test_result.exit_code != test_result.expected_rc: + return 'fail' + + for rf in result_files: + if rf.got is not None: + # got != null means the actual output differs from expected. + # Check if it matches any accepted variant. + is_acceptable = False + if rf.regression_test_output: + for multi in rf.regression_test_output.multiple_files: + if multi.file_hashes == rf.got: + is_acceptable = True + break + if not is_acceptable: + return 'fail' + + # All got == null → every output matched expected. + return 'pass' + + +def is_dummy_row(rf: TestResultFile) -> bool: + """ + Detect the sentinel TestResultFile row where regression_test_output_id == -1 and got == 'error'. + + This row means the test produced no output when output was expected. + The old test_id == -1 and regression_test_id == -1 checks were removed + because they are no longer populated as -1 in newer data. + It should never show up as a real file in API responses. + + DEPLOYMENT PREREQUISITE: Before deploying this change, verify that no + old-format sentinel rows exist that would be missed by the new detection. + Run against production: + + SELECT COUNT(*) + FROM test_result_file + WHERE (test_id = -1 OR regression_test_id = -1) + AND NOT (regression_test_output_id = -1 AND got = 'error'); + + If result > 0, those rows need a data migration to normalize them + before this code is deployed. Include the query output in the PR + description as evidence. + """ + return bool(rf.regression_test_output_id == -1 and rf.got == 'error') + + +def derive_output_status(rf: TestResultFile) -> str: + """Classify a single output file: pass, fail, or missing_output.""" + if is_dummy_row(rf): + return 'missing_output' + if rf.got is None: + return 'pass' + return 'fail' + + +def get_run_timestamps(test: Test) -> dict: + """ + Build a timestamp dict from TestProgress rows. + + Test doesn't have a created_at column, so we use the earliest + progress entry as a proxy. + """ + _, timestamps = batch_get_run_data([test]) + ts = timestamps.get(test.id, {}) + return { + 'created_at': ts.get('created_at'), + 'queued_at': ts.get('queued_at'), + 'started_at': ts.get('started_at'), + 'completed_at': ts.get('completed_at'), + } + + +def batch_get_run_data(tests: list) -> tuple: + """ + Batch compute derive_run_status and get_run_timestamps for a list of tests. + + Returns (statuses_dict, timestamps_dict) + """ + if not tests: + return {}, {} + + test_ids = [t.id for t in tests] + + # Preload TestProgress + all_progress = TestProgress.query.filter(TestProgress.test_id.in_(test_ids)).order_by(TestProgress.id.asc()).all() + progress_by_test = {tid: [] for tid in test_ids} + for p in all_progress: + progress_by_test[p.test_id].append(p) + + # Preload TestResult + all_results = TestResult.query.filter(TestResult.test_id.in_(test_ids)).all() + results_by_test = {tid: [] for tid in test_ids} + for r in all_results: + results_by_test[r.test_id].append(r) + + # Preload TestResultFile + all_files = TestResultFile.query.filter(TestResultFile.test_id.in_(test_ids)).all() + files_by_test_and_rt = {} + for f in all_files: + key = (f.test_id, f.regression_test_id) + if key not in files_by_test_and_rt: + files_by_test_and_rt[key] = [] + files_by_test_and_rt[key].append(f) + + statuses = {} + timestamps_dict = {} + + for t in tests: + # Timestamps + t_prog = progress_by_test[t.id] + ts = { + 'created_at': None, + 'queued_at': None, + 'started_at': None, + 'completed_at': None, + } + if t_prog: + ts['queued_at'] = t_prog[0].timestamp + ts['created_at'] = t_prog[0].timestamp + for p in t_prog: + if p.status == TestStatus.testing and ts['started_at'] is None: + ts['started_at'] = p.timestamp + if p.status in (TestStatus.completed, TestStatus.canceled): + ts['completed_at'] = p.timestamp + timestamps_dict[t.id] = ts + + # Status + if not t_prog: + statuses[t.id] = 'queued' + continue + + latest = t_prog[-1] + raw_status = latest.status + + if raw_status in (TestStatus.preparation, TestStatus.testing): + statuses[t.id] = 'running' + elif raw_status == TestStatus.canceled: + statuses[t.id] = 'canceled' + elif raw_status == TestStatus.completed: + fail_count = 0 + for r in results_by_test[t.id]: + r_files = files_by_test_and_rt.get((t.id, r.regression_test_id), []) + sample_status = derive_sample_status(r, r_files) + if sample_status not in ('pass', 'not_started'): + fail_count += 1 + statuses[t.id] = 'fail' if fail_count > 0 else 'pass' + else: + statuses[t.id] = 'incomplete' + + return statuses, timestamps_dict diff --git a/mod_api/services/storage.py b/mod_api/services/storage.py new file mode 100644 index 00000000..ad2ed996 --- /dev/null +++ b/mod_api/services/storage.py @@ -0,0 +1,64 @@ +""" +Storage helpers for resolving artifact locations. + +Artifacts can live in local SAMPLE_REPOSITORY, GCS, or both. When both +exist, GCS is preferred and a signed URL is returned. When only local +exists, storage_status is 'degraded'. When neither exists, it's 'missing'. +""" + +import os +from datetime import timedelta +from typing import Optional, Tuple + + +def resolve_artifact(relative_path: str) -> Tuple[Optional[str], str]: + """ + Look for an artifact in local storage and GCS. + + Returns (download_url_or_None, storage_status). + """ + from run import config, storage_client_bucket + + sample_repo = config.get('SAMPLE_REPOSITORY', '') + local_path = os.path.join(sample_repo, relative_path) + local_exists = os.path.isfile(local_path) + + gcs_url = None + if storage_client_bucket: + try: + blob = storage_client_bucket.blob(relative_path) + gcs_url = blob.generate_signed_url( + version='v4', + expiration=timedelta(minutes=config.get('GCS_SIGNED_URL_EXPIRY_LIMIT', 60)), + method='GET', + ) + except Exception: + gcs_url = None + + if local_exists and gcs_url: + return gcs_url, 'ok' + elif gcs_url: + # We don't block on blob.exists(), so we let the client handle 404s + return gcs_url, 'degraded' + elif local_exists: + return None, 'degraded' + else: + return None, 'missing' + + +def get_log_file_path(run_id: int) -> Optional[str]: + """Return the absolute path to a run's build log, or None if it doesn't exist.""" + from run import config + + sample_repo = config.get('SAMPLE_REPOSITORY', '') + log_path = os.path.join(sample_repo, 'LogFiles', f'{run_id}.txt') + + if os.path.isfile(log_path): + return log_path + return None + + +def get_test_results_base_path() -> str: + """Return the base directory where TestResults files are stored.""" + from run import config + return os.path.join(config.get('SAMPLE_REPOSITORY', ''), 'TestResults') diff --git a/mod_auth/controllers.py b/mod_auth/controllers.py index a476b9af..c9e25a38 100755 --- a/mod_auth/controllers.py +++ b/mod_auth/controllers.py @@ -212,6 +212,12 @@ def github_callback(): user = User.query.filter(User.id == g.user.id).first() user.github_token = response['access_token'] g.db.commit() + + # Fetch and store github_login + github_login = fetch_username_from_token() + if github_login: + user.github_login = github_login + g.db.commit() else: g.log.error("GitHub didn't return an access token") diff --git a/mod_auth/models.py b/mod_auth/models.py index 16233e98..a21c4883 100644 --- a/mod_auth/models.py +++ b/mod_auth/models.py @@ -32,6 +32,7 @@ class User(Base): name = Column(String(50), unique=True) email = Column(String(255), unique=True, nullable=True) github_token = Column(Text(), nullable=True) + github_login = Column(String(255), nullable=True) password = Column(String(255), unique=False, nullable=False) role = Column(Role.db_type()) diff --git a/openapi-ci-api.yaml b/openapi-ci-api.yaml index ef291327..ae4ea575 100644 --- a/openapi-ci-api.yaml +++ b/openapi-ci-api.yaml @@ -24,8 +24,8 @@ info: url: https://www.gnu.org/licenses/gpl-3.0.html servers: - - url: http://localhost:5000/api/v1 - description: Development + - url: http://localhost:5000 + description: Local development server - url: https://sampleplatform.ccextractor.org/api/v1 description: Production @@ -182,7 +182,7 @@ paths: description: > Rate-limited to 5 requests per 15 minutes per IP. Tokens are opaque and stored server-side. Scopes are additive; request only what you need. - Tokens expire after expires_in_days (default 30, max 90). + Tokens expire after expires_in_days (default 7, max 30). security: [] x-rate-limit: "5/15min per IP" requestBody: @@ -210,6 +210,18 @@ paths: code: invalid_credentials message: Email or password is incorrect. details: {} + "403": + description: > + Authenticated caller tried to create a token with higher scopes + than their current token. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + example: + code: forbidden + message: Cannot create token with scopes you do not possess. + details: {} "429": $ref: "#/components/responses/RateLimited" default: @@ -245,15 +257,16 @@ paths: summary: Revoke a specific API token by ID operationId: revokeToken description: > - Revokes the token identified by token_id. Non-admin users may only - revoke their own tokens; attempting to revoke another user’s token - returns 403. Admins may revoke any token. + Revokes the token identified by token_id. + Users may revoke their own tokens without any scope requirement. + Revoking another user's token requires tokens:manage scope and admin role. + Attempting to revoke another user's token without admin role returns 403. To revoke the token currently in use without knowing its ID, use DELETE /auth/tokens/current instead. security: - bearerAuth: [] - x-required-scope: tokens:manage + x-required-scope: tokens:manage # only enforced for cross-user revocation parameters: - name: token_id in: path @@ -293,7 +306,7 @@ paths: summary: List CI runs operationId: listRuns description: > - Public read. The underlying table is capped at the 50 most recent runs + The underlying table is capped at the 50 most recent runs in the current implementation; this endpoint adds full pagination. Sorted by -created_at by default (newest first). security: @@ -472,7 +485,7 @@ paths: in: query schema: type: string - enum: [queued, preparation, testing, completed, canceled, error] + enum: [queued, preparation, testing, completed, canceled] responses: "200": description: Paginated progress events @@ -524,7 +537,10 @@ paths: properties: reason: type: string + minLength: 5 maxLength: 255 + description: > + Reason for cancellation, stored in the audit log. additionalProperties: false responses: "202": @@ -554,8 +570,7 @@ paths: description: > regression_test_ids lists IDs included in this run. When no custom set was configured, all regression tests are returned. - Implementers must filter by active=true explicitly — - get_customized_regressiontests() does not do this by default. + Implementers must filter by active=true explicitly. security: - bearerAuth: [] x-required-scope: runs:read @@ -646,17 +661,21 @@ paths: default: $ref: "#/components/responses/Error" - /runs/{run_id}/samples/{sample_id}: + /runs/{run_id}/samples/{regression_test_id}: get: tags: [Samples] summary: Get full details for a regression test result in a run operationId: getRunSample + description: > + Returns the result for a specific regression test within a run. + Note: the path parameter is regression_test_id, not a media sample ID. + A single media sample may have multiple regression tests. security: - bearerAuth: [] x-required-scope: runs:read parameters: - $ref: "#/components/parameters/RunId" - - $ref: "#/components/parameters/SampleId" + - $ref: "#/components/parameters/RegressionTestId" responses: "200": description: Regression test result details @@ -826,9 +845,8 @@ paths: summary: List regression test definitions operationId: listRegressionTests description: > - The active filter must be applied explicitly. The legacy - get_customized_regressiontests() returns all regression tests — - including inactive ones — when no custom set is defined. + The active filter must be applied explicitly. When no custom set is + defined, all regression tests are returned — including inactive ones. security: - bearerAuth: [] x-required-scope: runs:read @@ -839,6 +857,7 @@ paths: in: query schema: type: boolean + default: true - name: category in: query schema: @@ -881,7 +900,7 @@ paths: # RESULTS - /runs/{run_id}/samples/{sample_id}/expected: + /runs/{run_id}/samples/{sample_id}/regression-tests/{regression_id}/outputs/{output_id}/expected: get: tags: [Results] summary: Get expected output for a regression test result @@ -920,7 +939,7 @@ paths: default: $ref: "#/components/responses/Error" - /runs/{run_id}/samples/{sample_id}/actual: + /runs/{run_id}/samples/{sample_id}/regression-tests/{regression_id}/outputs/{output_id}/actual: get: tags: [Results] summary: Get actual output generated by a regression test in a run @@ -947,10 +966,13 @@ paths: application/json: schema: $ref: "#/components/schemas/OutputFile" - "204": - description: > - No actual file stored. got=null in the DB means output matched - expected. Use /expected to retrieve the matched content. + "303": + description: Output matched expected. Redirected to /expected. + headers: + Location: + schema: + type: string + format: uri "400": $ref: "#/components/responses/BadRequest" "401": @@ -964,7 +986,7 @@ paths: default: $ref: "#/components/responses/Error" - /runs/{run_id}/samples/{sample_id}/diff: + /runs/{run_id}/samples/{sample_id}/regression-tests/{regression_id}/outputs/{output_id}/diff: get: tags: [Results] summary: Get expected-vs-actual diff for a failing regression test result @@ -1001,7 +1023,11 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/Diff" + oneOf: + - $ref: "#/components/schemas/Diff" + - $ref: "#/components/schemas/UnifiedDiff" + discriminator: + propertyName: format "400": $ref: "#/components/responses/BadRequest" "401": @@ -1018,13 +1044,12 @@ paths: /runs/{run_id}/samples/{sample_id}/baseline-approval: post: tags: [Results] - summary: Approve actual output as the new expected baseline + summary: Approve actual output as new expected baseline operationId: approveBaseline description: > - Requires baselines:write scope and admin or contributor role. + Requires baselines:write scope and admin role. This is a destructive write — the approved output becomes the new - expected baseline for the regression test. Provide a reason; - it is stored in the audit log. + expected baseline for the regression test. security: - bearerAuth: [] x-required-scope: baselines:write @@ -1326,7 +1351,7 @@ paths: in: query schema: type: string - enum: [type, sample_id, regression_id, category, severity] + enum: [type, sample_id, regression_id, severity] default: type - name: severity in: query @@ -1374,12 +1399,26 @@ paths: responses: "200": description: System healthy or degraded + headers: + X-RateLimit-Limit: + $ref: "#/components/headers/RateLimitLimit" + X-RateLimit-Remaining: + $ref: "#/components/headers/RateLimitRemaining" + X-RateLimit-Reset: + $ref: "#/components/headers/RateLimitReset" content: application/json: schema: $ref: "#/components/schemas/SystemHealth" "503": description: System is down + headers: + X-RateLimit-Limit: + $ref: "#/components/headers/RateLimitLimit" + X-RateLimit-Remaining: + $ref: "#/components/headers/RateLimitRemaining" + X-RateLimit-Reset: + $ref: "#/components/headers/RateLimitReset" content: application/json: schema: @@ -1551,11 +1590,11 @@ components: name: cursor in: query description: > - Opaque cursor token for cursor-based pagination. Do not mix with offset. + Numeric line offset or ID for cursor-based pagination. Do not mix with offset. Mixing cursor and offset returns 400. Obtain next_cursor from the previous response's pagination object. schema: - type: string - maxLength: 255 + type: integer + minimum: 0 RunId: name: run_id @@ -1570,7 +1609,16 @@ components: name: sample_id in: path required: true - description: Numeric sample or regression result ID + description: Numeric media sample ID + schema: + type: integer + minimum: 1 + + RegressionTestId: + name: regression_test_id + in: path + required: true + description: Numeric regression test ID (not the same as media sample ID) schema: type: integer minimum: 1 @@ -1585,7 +1633,7 @@ components: and error). This enum is the normalized API contract. schema: type: string - enum: [queued, running, pass, fail, canceled, error, incomplete] + enum: [queued, running, pass, fail, canceled, incomplete] example: pass Branch: @@ -1648,7 +1696,7 @@ components: RegressionId: name: regression_id - in: query + in: path required: true description: Regression test definition ID schema: @@ -1657,7 +1705,7 @@ components: OutputId: name: output_id - in: query + in: path required: true description: Output file ID within a regression test definition schema: @@ -1801,7 +1849,11 @@ components: minimum: 0 total: type: integer - minimum: 0 + minimum: -1 + nullable: true + description: > + Total matching records. Null if count was not computed for this request. + Pass ?count=true to force computation. next_offset: type: integer minimum: 0 @@ -1825,11 +1877,11 @@ components: type: integer minimum: 1 next_cursor: - type: string - maxLength: 255 + type: integer + minimum: 0 nullable: true description: > - Opaque cursor for the next page. Null when there are no + Numeric cursor for the next page. Null when there are no more results. ErrorResponse: @@ -1876,7 +1928,7 @@ components: description: First few characters of the token for identification. scopes: type: array - maxItems: 8 + maxItems: 6 uniqueItems: true items: type: string @@ -1921,11 +1973,11 @@ components: expires_in_days: type: integer minimum: 1 - maximum: 90 - default: 30 + maximum: 30 + default: 7 scopes: type: array - maxItems: 8 + maxItems: 6 uniqueItems: true default: [runs:read, results:read] items: @@ -1978,7 +2030,7 @@ components: example: CCExtractor/ccextractor branch: type: string - pattern: '^[A-Za-z0-9._\/-]+$' + pattern: '^[A-Za-z0-9._-]+(/[A-Za-z0-9._-]+)*$' maxLength: 100 example: master commit_sha: @@ -2015,7 +2067,7 @@ components: minimum: 1 status: type: string - enum: [queued, running, pass, fail, canceled, error, incomplete] + enum: [queued, running, pass, fail, canceled, incomplete] description: > Normalized status. Derived from TestProgress rows and TestResult outcomes. status=canceled covers both explicit cancellation and @@ -2072,7 +2124,7 @@ components: minimum: 1 status: type: string - enum: [queued, running, pass, fail, canceled, error, incomplete] + enum: [queued, running, pass, fail, canceled, incomplete] description: > Overall run status at the time the summary was generated. Same derivation as Run.status. @@ -2139,11 +2191,6 @@ components: type: integer minimum: 1 description: ID of the run this action targets. - new_run_id: - type: integer - minimum: 1 - nullable: true - description: Reserved for future use. Always null for cancel actions. action: type: string enum: [cancel] @@ -2187,7 +2234,7 @@ components: type: string maxLength: 50 nullable: true - additionalProperties: true + additionalProperties: false regression_test_ids: type: array maxItems: 500 @@ -2340,20 +2387,23 @@ components: maxLength: 255 status: type: string - enum: [match, diff_mismatch, missing_output, missing_expected] + enum: [pass, fail, missing_output, missing_expected] description: > - match = actual identical to expected. - diff_mismatch = actual differs from expected. + pass = actual identical to expected. + fail = actual differs from expected. missing_output = test produced no output. missing_expected = no expected baseline exists. SampleHistoryEntry: type: object - required: [run_id, status] + required: [run_id, regression_test_id, status] properties: run_id: type: integer minimum: 1 + regression_test_id: + type: integer + minimum: 1 status: type: string enum: [pass, fail, skipped, missing_output] @@ -2414,6 +2464,18 @@ components: content: type: string maxLength: 1048576 + description: > + File content. Base64-encoded unless encoding=utf-8. + Files exceeding 1MB are truncated. Check truncated=true and use + download_url for the full file. + truncated: + type: boolean + description: True if content was truncated due to size limits. + download_url: + type: string + format: uri + nullable: true + description: URL to download the full file if it was truncated. sha256: type: string pattern: '^[a-fA-F0-9]{64}$' @@ -2494,9 +2556,29 @@ components: type: string maxLength: 1000 + UnifiedDiff: + type: object + required: [run_id, sample_id, regression_id, output_id, format, content] + properties: + run_id: + type: integer + sample_id: + type: integer + regression_id: + type: integer + output_id: + type: integer + format: + type: string + enum: [unified] + content: + type: string + description: Raw unified diff text. + maxLength: 524288 + BaselineApprovalRequest: type: object - required: [regression_id, output_id, reason] + required: [regression_id, output_id] additionalProperties: false properties: regression_id: @@ -2505,28 +2587,17 @@ components: output_id: type: integer minimum: 1 - reason: - type: string - minLength: 10 - maxLength: 500 - description: > - Required justification stored in the audit log. Minimum 10 - characters; do not accept placeholder values. remove_variants: type: boolean default: false description: > - If true, remove all platform-specific variants and use this - output as the single baseline across all platforms. - WARNING: This collapses platform-specific expected outputs into one. + If true, removes all platform-specific variants (output_id != 1) + and promotes this output to the global baseline. BaselineApproval: type: object - required: [approval_id, status, run_id, sample_id, regression_id, output_id, requested_by, created_at] + required: [status, run_id, sample_id, regression_id, output_id, requested_by, created_at] properties: - approval_id: - type: string - maxLength: 100 status: type: string enum: [approved] @@ -2636,11 +2707,18 @@ components: ErrorSummaryBucket: type: object - required: [key, count, severity] + required: [key, count, severity, group_by] properties: + group_by: + type: string + enum: [type, sample_id, regression_id, severity] + description: The dimension this bucket is grouped by. key: type: string maxLength: 100 + description: > + Value of the group_by dimension. When group_by=sample_id or + regression_id, this is an integer serialized as a string. count: type: integer minimum: 0 From d62474a11872abb1d01de3567e727a7fdb3ccdd1 Mon Sep 17 00:00:00 2001 From: Pulkit Chauhan Date: Thu, 11 Jun 2026 13:02:20 +0530 Subject: [PATCH 18/28] More Checks --- mod_api/middleware/validation.py | 101 ++++++++------ mod_api/routes/results.py | 18 +-- mod_api/routes/runs.py | 149 +++++++++++--------- mod_api/routes/samples.py | 217 ++++++++++++++++-------------- mod_api/routes/system.py | 111 +++++++-------- mod_api/services/diff_service.py | 59 ++++---- mod_api/services/error_service.py | 162 +++++++++++----------- mod_api/services/log_service.py | 52 ++++--- mod_api/services/status.py | 103 +++++++------- 9 files changed, 530 insertions(+), 442 deletions(-) diff --git a/mod_api/middleware/validation.py b/mod_api/middleware/validation.py index ca4cd145..f38fcd04 100644 --- a/mod_api/middleware/validation.py +++ b/mod_api/middleware/validation.py @@ -112,54 +112,69 @@ def decorated(*args, **kwargs): return decorator +def _parse_limit(default_limit): + try: + limit = int(request.args.get('limit', default_limit)) + except (ValueError, TypeError): + return None, make_error_response( + 'validation_error', + 'limit must be an integer.', + details={'fields': {'limit': 'Must be an integer between 1 and 100.'}}, + http_status=400, + ) + + if limit < 1 or limit > 100: + return None, make_error_response( + 'validation_error', + 'limit must be between 1 and 100.', + details={'fields': {'limit': 'Must be between 1 and 100.'}}, + http_status=400, + ) + return limit, None + + +def _parse_cursor(): + cursor = request.args.get('cursor') + if cursor is None: + return None, None + try: + cursor = int(cursor) + except (ValueError, TypeError): + return None, make_error_response( + 'validation_error', + 'cursor must be an integer.', + details={'fields': {'cursor': 'Must be an integer.'}}, + http_status=400, + ) + if cursor < 0: + return None, make_error_response( + 'validation_error', + 'cursor must be non-negative.', + details={'fields': {'cursor': 'Must be >= 0.'}}, + http_status=400, + ) + if cursor > 10_000_000: + return None, make_error_response( + 'validation_error', + 'cursor out of range.', + details={'fields': {'cursor': 'Must be <= 10000000.'}}, + http_status=400, + ) + return cursor, None + + def validate_cursor_pagination(default_limit=50): """Extract and validate ``limit`` and ``cursor`` query params.""" def decorator(f): @wraps(f) def decorated(*args, **kwargs): - try: - limit = int(request.args.get('limit', default_limit)) - except (ValueError, TypeError): - return make_error_response( - 'validation_error', - 'limit must be an integer.', - details={'fields': {'limit': 'Must be an integer between 1 and 100.'}}, - http_status=400, - ) - - if limit < 1 or limit > 100: - return make_error_response( - 'validation_error', - 'limit must be between 1 and 100.', - details={'fields': {'limit': 'Must be between 1 and 100.'}}, - http_status=400, - ) - - cursor = request.args.get('cursor') - if cursor is not None: - try: - cursor = int(cursor) - except (ValueError, TypeError): - return make_error_response( - 'validation_error', - 'cursor must be an integer.', - details={'fields': {'cursor': 'Must be an integer.'}}, - http_status=400, - ) - if cursor < 0: - return make_error_response( - 'validation_error', - 'cursor must be non-negative.', - details={'fields': {'cursor': 'Must be >= 0.'}}, - http_status=400, - ) - if cursor > 10_000_000: - return make_error_response( - 'validation_error', - 'cursor out of range.', - details={'fields': {'cursor': 'Must be <= 10000000.'}}, - http_status=400, - ) + limit, err = _parse_limit(default_limit) + if err: + return err + + cursor, err = _parse_cursor() + if err: + return err kwargs['limit'] = limit kwargs['cursor'] = cursor diff --git a/mod_api/routes/results.py b/mod_api/routes/results.py index 0cc8bd95..39d5ac56 100644 --- a/mod_api/routes/results.py +++ b/mod_api/routes/results.py @@ -23,6 +23,9 @@ from mod_api.utils import single_response from mod_test.models import Test, TestResult, TestResultFile +INVALID_PATH_MSG = 'Invalid file path.' +READ_ERROR_MSG = 'Failed to read file.' + def _safe_resolve(base_path, filename): """ @@ -95,7 +98,7 @@ def get_expected_output(run_id, sample_id, regression_id, output_id): file_path = _safe_resolve(base_path, expected_filename) if file_path is None: - return make_error_response('forbidden', 'Invalid file path.', http_status=403) + return make_error_response('forbidden', INVALID_PATH_MSG, http_status=403) fmt = request.args.get('format', 'base64') @@ -123,13 +126,13 @@ def get_expected_output(run_id, sample_id, regression_id, output_id): content = f.read(1048576) encoding = 'utf-8' except Exception: - return make_error_response('internal_error', 'Failed to read file.', http_status=500) + return make_error_response('internal_error', READ_ERROR_MSG, http_status=500) else: try: with open(file_path, 'rb') as f: content = base64.b64encode(f.read(1048576)).decode('ascii') except Exception: - return make_error_response('internal_error', 'Failed to read file.', http_status=500) + return make_error_response('internal_error', READ_ERROR_MSG, http_status=500) encoding = 'base64' return single_response({ @@ -197,7 +200,7 @@ def get_actual_output(run_id, sample_id, regression_id, output_id): file_path = _safe_resolve(base_path, actual_filename) if file_path is None: - return make_error_response('forbidden', 'Invalid file path.', http_status=403) + return make_error_response('forbidden', INVALID_PATH_MSG, http_status=403) fmt = request.args.get('format', 'base64') @@ -225,14 +228,14 @@ def get_actual_output(run_id, sample_id, regression_id, output_id): content = f.read(1048576) encoding = 'utf-8' except Exception: - return make_error_response('internal_error', 'Failed to read file.', http_status=500) + return make_error_response('internal_error', READ_ERROR_MSG, http_status=500) else: try: with open(file_path, 'rb') as f: content = base64.b64encode(f.read(1048576)).decode('ascii') encoding = 'base64' except Exception: - return make_error_response('internal_error', 'Failed to read file.', http_status=500) + return make_error_response('internal_error', READ_ERROR_MSG, http_status=500) return single_response({ 'run_id': run_id, @@ -303,7 +306,7 @@ def get_diff(run_id, sample_id, regression_id, output_id): actual_path = _safe_resolve(base_path, result_file.got + ext) if expected_path is None or actual_path is None: - return make_error_response('forbidden', 'Invalid file path.', http_status=403) + return make_error_response('forbidden', INVALID_PATH_MSG, http_status=403) if format_type == 'unified': import difflib @@ -373,7 +376,6 @@ def create_baseline_approval(run_id, sample_id, validated_data=None): if rto is None: return make_error_response('internal_error', 'No RegressionTestOutput linked.', http_status=500) - old_baseline = rto.correct new_baseline = result_file.got rto.correct = new_baseline diff --git a/mod_api/routes/runs.py b/mod_api/routes/runs.py index 9d51d30d..dfd696e6 100644 --- a/mod_api/routes/runs.py +++ b/mod_api/routes/runs.py @@ -75,15 +75,7 @@ def _batch_serialize(tests, statuses=None, timestamps=None): ] -@mod_api.route('/runs', methods=['GET']) -@require_scope('runs:read') -@validate_offset_pagination() -@validate_sort() -@validate_date_range -def list_runs(limit=50, offset=0, sort='-created_at', created_after=None, created_before=None): - """List runs with filters for platform, branch, commit, repo, status, and date range.""" - query = Test.query - +def _apply_run_filters(query, created_after, created_before): platform = request.args.get('platform') if platform: try: @@ -91,7 +83,7 @@ def list_runs(limit=50, offset=0, sort='-created_at', created_after=None, create query = query.filter(Test.platform == platform_enum) except Exception: valid_platforms = ', '.join(TestPlatform.values()) - return make_error_response( + return None, make_error_response( 'validation_error', f'Invalid platform: {platform}. Must be one of: {valid_platforms}.', http_status=400, @@ -107,8 +99,9 @@ def list_runs(limit=50, offset=0, sort='-created_at', created_after=None, create repository = request.args.get('repository') if repository: + from mod_api.middleware.validation import PATTERNS if not PATTERNS['repository'].match(repository): - return make_error_response( + return None, make_error_response( 'validation_error', 'repository must match owner/repo format.', details={'fields': {'repository': 'Must match ^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$'}}, @@ -117,7 +110,6 @@ def list_runs(limit=50, offset=0, sort='-created_at', created_after=None, create fork_url = f'https://github.com/{repository}.git' query = query.join(Fork).filter(Fork.github == fork_url) - # Filter by date range using TestProgress if created_after or created_before: if created_after: query = query.filter(Test.id.in_( @@ -132,6 +124,80 @@ def list_runs(limit=50, offset=0, sort='-created_at', created_after=None, create ).distinct() )) + return query, None + + +def _validate_run_permissions(user, target_repo, main_repo_full): + if target_repo == main_repo_full: + if user.role.value not in ('admin', 'tester', 'contributor'): + return make_error_response( + 'forbidden', + 'Only contributors and above can trigger runs for the main repository.', + details={ + 'required_roles': ['admin', 'tester', 'contributor'], + 'repository': target_repo, + }, + http_status=403, + ) + else: + owner = target_repo.split('/')[0] + github_login = user.github_login or '' + + is_owner = bool(github_login) and owner.lower() == github_login.lower() + is_staff = user.role.value in ('admin', 'tester', 'contributor') + + if not is_owner and not is_staff: + return make_error_response( + 'forbidden', + 'You can only trigger runs for your own repository.', + details={ + 'repository': target_repo, + 'owner_required': github_login, + }, + http_status=403, + ) + return None + + +def _validate_regression_test_ids(regression_test_ids): + if regression_test_ids is not None: + if not regression_test_ids: + return None, make_error_response( + 'validation_error', + 'regression_test_ids cannot be empty.', + details={'fields': {'regression_test_ids': 'Must contain at least one ID.'}}, + http_status=400, + ) + active_tests = RegressionTest.query.filter( + RegressionTest.id.in_(regression_test_ids), + RegressionTest.active == True, # noqa: E712 + ).all() + active_ids = {t.id for t in active_tests} + inactive_ids = [tid for tid in regression_test_ids if tid not in active_ids] + if inactive_ids: + return None, make_error_response( + 'unprocessable', + 'Some regression test IDs are inactive or do not exist.', + details={'inactive_ids': inactive_ids}, + http_status=422, + ) + else: + active_tests = RegressionTest.query.filter_by(active=True).all() + regression_test_ids = [t.id for t in active_tests] + return regression_test_ids, None + + +@mod_api.route('/runs', methods=['GET']) +@require_scope('runs:read') +@validate_offset_pagination() +@validate_sort() +@validate_date_range +def list_runs(limit=50, offset=0, sort='-created_at', created_after=None, created_before=None): + """List runs with filters for platform, branch, commit, repo, status, and date range.""" + query, err = _apply_run_filters(Test.query, created_after, created_before) + if err: + return err + sort_map = { 'run_id': Test.id, 'created_at': Test.id, # best proxy - Test has no created_at column @@ -194,36 +260,10 @@ def create_run(validated_data=None): main_repo = config.get('GITHUB_REPOSITORY', '') main_repo_full = f'{main_owner}/{main_repo}' target_repo = repository or main_repo_full - user = g.api_user - - if target_repo == main_repo_full: - if user.role.value not in ('admin', 'tester', 'contributor'): - return make_error_response( - 'forbidden', - 'Only contributors and above can trigger runs for the main repository.', - details={ - 'required_roles': ['admin', 'tester', 'contributor'], - 'repository': target_repo, - }, - http_status=403, - ) - else: - owner = target_repo.split('/')[0] - github_login = user.github_login or '' - is_owner = bool(github_login) and owner.lower() == github_login.lower() - is_staff = user.role.value in ('admin', 'tester', 'contributor') - - if not is_owner and not is_staff: - return make_error_response( - 'forbidden', - 'You can only trigger runs for your own repository.', - details={ - 'repository': target_repo, - 'owner_required': github_login, - }, - http_status=403, - ) + err = _validate_run_permissions(g.api_user, target_repo, main_repo_full) + if err: + return err if repository: fork_url = f'https://github.com/{repository}.git' @@ -237,30 +277,9 @@ def create_run(validated_data=None): g.db.flush() # Validate regression test IDs against active tests only. - if regression_test_ids is not None: - if not regression_test_ids: - return make_error_response( - 'validation_error', - 'regression_test_ids cannot be empty.', - details={'fields': {'regression_test_ids': 'Must contain at least one ID.'}}, - http_status=400, - ) - active_tests = RegressionTest.query.filter( - RegressionTest.id.in_(regression_test_ids), - RegressionTest.active == True, # noqa: E712 - ).all() - active_ids = {t.id for t in active_tests} - inactive_ids = [tid for tid in regression_test_ids if tid not in active_ids] - if inactive_ids: - return make_error_response( - 'unprocessable', - 'Some regression test IDs are inactive or do not exist.', - details={'inactive_ids': inactive_ids}, - http_status=422, - ) - else: - active_tests = RegressionTest.query.filter_by(active=True).all() - regression_test_ids = [t.id for t in active_tests] + regression_test_ids, err = _validate_regression_test_ids(regression_test_ids) + if err: + return err test_type = TestType.pull_request if pull_request else TestType.commit diff --git a/mod_api/routes/samples.py b/mod_api/routes/samples.py index 0177477f..f1f8eb7a 100644 --- a/mod_api/routes/samples.py +++ b/mod_api/routes/samples.py @@ -26,7 +26,7 @@ from mod_test.models import Test, TestResult, TestResultFile -def _serialize_run_sample(test_id, result, result_files): +def _serialize_run_sample(result, result_files): """Build the per-regression-test result dict for a run.""" status = derive_sample_status(result, result_files) @@ -67,10 +67,51 @@ def _serialize_run_sample(test_id, result, result_files): 'runtime_ms': result.runtime, 'command': command, 'categories': categories, + 'categories': categories, 'outputs': outputs, } +def _filter_run_samples_by_tag(serialized, tag_filter): + tag_lower = tag_filter.lower() + tagged_sample_ids = set() + + valid_sample_ids = [s['sample_id'] for s in serialized if s.get('sample_id')] + samples = Sample.query.filter(Sample.id.in_(valid_sample_ids)).all() if valid_sample_ids else [] + sample_map = {sample.id: sample for sample in samples} + + for s in serialized: + if s['sample_id']: + sample = sample_map.get(s['sample_id']) + if sample and any(tag_lower == t.name.lower() for t in sample.tags): + tagged_sample_ids.add(s['sample_id']) + return [s for s in serialized if s.get('sample_id') in tagged_sample_ids] + + +def _apply_run_sample_filters(serialized, args): + status_filter = args.get('status') + if status_filter: + serialized = [s for s in serialized if s['status'] == status_filter] + + name_filter = args.get('name') + if name_filter: + name_lower = name_filter.lower() + serialized = [s for s in serialized if s.get('sample_name') and name_lower in s['sample_name'].lower()] + + tag_filter = args.get('tag') + if tag_filter: + serialized = _filter_run_samples_by_tag(serialized, tag_filter) + + category_filter = args.get('category') + if category_filter: + cat_lower = category_filter.lower() + serialized = [ + s for s in serialized + if s.get('categories') and cat_lower in [c.lower() for c in s['categories']] + ] + return serialized + + @mod_api.route('/runs//samples', methods=['GET']) @require_scope('runs:read') @validate_path_id('run_id') @@ -98,43 +139,10 @@ def list_run_samples(run_id, limit=50, offset=0): serialized = [] for result in results: result_files = files_by_result.get(result.regression_test_id, []) - serialized.append(_serialize_run_sample(run_id, result, result_files)) + serialized.append(_serialize_run_sample(result, result_files)) # Apply query param filters. - status_filter = request.args.get('status') - if status_filter: - serialized = [s for s in serialized if s['status'] == status_filter] - - name_filter = request.args.get('name') - if name_filter: - name_lower = name_filter.lower() - serialized = [s for s in serialized if s.get('sample_name') and name_lower in s['sample_name'].lower()] - - tag_filter = request.args.get('tag') - if tag_filter: - # Lookup tags from Sample - tag_lower = tag_filter.lower() - tagged_sample_ids = set() - - # Preload samples to avoid N+1 queries - valid_sample_ids = [s['sample_id'] for s in serialized if s.get('sample_id')] - samples = Sample.query.filter(Sample.id.in_(valid_sample_ids)).all() if valid_sample_ids else [] - sample_map = {sample.id: sample for sample in samples} - - for s in serialized: - if s['sample_id']: - sample = sample_map.get(s['sample_id']) - if sample and any(tag_lower == t.name.lower() for t in sample.tags): - tagged_sample_ids.add(s['sample_id']) - serialized = [s for s in serialized if s.get('sample_id') in tagged_sample_ids] - - category_filter = request.args.get('category') - if category_filter: - cat_lower = category_filter.lower() - serialized = [ - s for s in serialized - if s.get('categories') and cat_lower in [c.lower() for c in s['categories']] - ] + serialized = _apply_run_sample_filters(serialized, request.args) total = len(serialized) paged = serialized[offset:offset + limit] @@ -167,7 +175,7 @@ def get_run_sample(run_id, regression_test_id): regression_test_id=regression_test_id, ).all() - return single_response(_serialize_run_sample(run_id, result, result_files)) + return single_response(_serialize_run_sample(result, result_files)) @mod_api.route('/samples', methods=['GET']) @@ -269,6 +277,49 @@ def get_sample(sample_id): }) +def _get_history_failure_signature(result, result_files, status): + if status == 'fail': + for rf in result_files: + if rf.got is not None and not is_dummy_row(rf): + return f'diff_mismatch:output:{rf.regression_test_output_id}' + if result.exit_code != result.expected_rc: + return f'exit_code_mismatch:rc:{result.exit_code}' + elif status == 'missing_output': + return 'missing_output' + return None + + +def _process_history_entries(results, files_by_result, status_filter): + entries = [] + for result in results: + test = result.test + if test is None: + test = Test.query.get(result.test_id) + if test is None: + continue + + result_files = files_by_result.get((result.test_id, result.regression_test_id), []) + status = derive_sample_status(result, result_files) + + if status_filter and status != status_filter: + continue + + failure_sig = _get_history_failure_signature(result, result_files, status) + timestamps = get_run_timestamps(test) + + entries.append({ + 'run_id': test.id, + 'regression_test_id': result.regression_test_id, + 'status': status, + 'platform': test.platform.value, + 'branch': test.branch, + 'commit_sha': test.commit, + 'tested_at': timestamps.get('completed_at') or timestamps.get('started_at'), + 'failure_signature': failure_sig, + }) + return entries + + @mod_api.route('/samples//history', methods=['GET']) @require_scope('runs:read') @validate_path_id('sample_id') @@ -321,54 +372,47 @@ def get_sample_history(sample_id, limit=50, offset=0, created_after=None, create # Preload TestResultFiles from collections import defaultdict - test_ids = list(set([r.test_id for r in results])) + test_ids = list({r.test_id for r in results}) all_files = TestResultFile.query.filter(TestResultFile.test_id.in_(test_ids)).all() if test_ids else [] files_by_result = defaultdict(list) for f in all_files: files_by_result[(f.test_id, f.regression_test_id)].append(f) - entries = [] - for result in results: - test = result.test - if test is None: - test = Test.query.get(result.test_id) - if test is None: - continue - - result_files = files_by_result.get((result.test_id, result.regression_test_id), []) + entries = _process_history_entries(results, files_by_result, status_filter) - status = derive_sample_status(result, result_files) - timestamps = get_run_timestamps(test) + total = len(entries) + paged = entries[offset:offset + limit] - failure_sig = None - if status == 'fail': - for rf in result_files: - if rf.got is not None and not is_dummy_row(rf): - failure_sig = f'diff_mismatch:output:{rf.regression_test_output_id}' - break - if failure_sig is None and result.exit_code != result.expected_rc: - failure_sig = f'exit_code_mismatch:rc:{result.exit_code}' - elif status == 'missing_output': - failure_sig = 'missing_output' + return paginated_response(paged, total, limit, offset) - if status_filter and status != status_filter: - continue - entries.append({ - 'run_id': test.id, - 'regression_test_id': result.regression_test_id, - 'status': status, - 'platform': test.platform.value, - 'branch': test.branch, - 'commit_sha': test.commit, - 'tested_at': timestamps.get('completed_at') or timestamps.get('started_at'), - 'failure_signature': failure_sig, - }) +def _serialize_rt(rt): + return { + 'regression_test_id': rt.id, + 'sample_id': rt.sample_id, + 'sample_name': rt.sample.original_name if rt.sample else None, + 'command': rt.command, + 'input_type': rt.input_type.value, + 'output_type': rt.output_type.value, + 'expected_rc': rt.expected_rc, + 'active': rt.active, + 'categories': [c.name for c in rt.categories], + 'description': rt.description, + } - total = len(entries) - paged = entries[offset:offset + limit] - return paginated_response(paged, total, limit, offset) +def _filter_regression_tests_by_tag(query, tag_filter): + all_tests = query.all() + serialized = [] + for rt in all_tests: + if rt.sample: + sample_tags = [t.name.lower() for t in rt.sample.tags] + if tag_filter.lower() not in sample_tags: + continue + else: + continue # no sample = no tags to match + serialized.append(_serialize_rt(rt)) + return serialized @mod_api.route('/regression-tests', methods=['GET']) @@ -408,32 +452,9 @@ def list_regression_tests(limit=50, offset=0): tag_filter = request.args.get('tag') - def _serialize_rt(rt): - return { - 'regression_test_id': rt.id, - 'sample_id': rt.sample_id, - 'sample_name': rt.sample.original_name if rt.sample else None, - 'command': rt.command, - 'input_type': rt.input_type.value, - 'output_type': rt.output_type.value, - 'expected_rc': rt.expected_rc, - 'active': rt.active, - 'categories': [c.name for c in rt.categories], - 'description': rt.description, - } - # Filter tags in Python before paginating if tag_filter: - all_tests = query.all() - serialized = [] - for rt in all_tests: - if rt.sample: - sample_tags = [t.name.lower() for t in rt.sample.tags] - if tag_filter.lower() not in sample_tags: - continue - else: - continue # no sample = no tags to match - serialized.append(_serialize_rt(rt)) + serialized = _filter_regression_tests_by_tag(query, tag_filter) total = len(serialized) paged = serialized[offset:offset + limit] diff --git a/mod_api/routes/system.py b/mod_api/routes/system.py index 14d8fe85..4e296917 100644 --- a/mod_api/routes/system.py +++ b/mod_api/routes/system.py @@ -25,6 +25,8 @@ from mod_test.models import (Test, TestPlatform, TestProgress, TestResultFile, TestStatus) +OCTET_STREAM = 'application/octet-stream' + @mod_api.route('/system/health', methods=['GET']) def system_health(): @@ -42,7 +44,7 @@ def system_health(): try: g.db.execute(text('SELECT 1')) dependencies.append({'name': 'database', 'status': 'ok', 'message': None}) - except Exception as e: + except Exception: dependencies.append({'name': 'database', 'status': 'down', 'message': 'Database connection failed.'}) overall = 'down' @@ -60,7 +62,7 @@ def system_health(): }) if overall == 'ok': overall = 'degraded' - except Exception as e: + except Exception: dependencies.append({'name': 'local_storage', 'status': 'down', 'message': 'Local storage check failed.'}) overall = 'down' @@ -73,7 +75,7 @@ def system_health(): dependencies.append({'name': 'gcs', 'status': 'degraded', 'message': 'GCS client not initialized.'}) if overall == 'ok': overall = 'degraded' - except Exception as e: + except Exception: dependencies.append({'name': 'gcs', 'status': 'degraded', 'message': 'GCS connectivity check failed.'}) if overall == 'ok': overall = 'degraded' @@ -126,17 +128,9 @@ def get_queue(limit=50, offset=0): queue_depth = base_query.filter(~Test.id.in_(g.db.query(running_subq.c.test_id))).count() status_filter = request.args.get('status') - if status_filter == 'queued': - query = base_query.filter(~Test.id.in_(g.db.query(running_subq.c.test_id))) - total = queue_depth - elif status_filter == 'running': - query = base_query.filter(Test.id.in_(g.db.query(running_subq.c.test_id))) - total = running_count - elif status_filter: - return make_error_response('validation_error', 'Invalid status. Must be queued or running.', http_status=400) - else: - query = base_query - total = queue_depth + running_count + query, total, err = _apply_queue_filters(base_query, running_subq, queue_depth, running_count, status_filter) + if err: + return err query = query.order_by(Test.id.asc()) paged_tests = query.offset(offset).limit(limit).all() @@ -152,10 +146,9 @@ def get_queue(limit=50, offset=0): ts = timestamps.get(test.id, {}) pos = None - if status == 'queued': - if queued_index is not None: - pos = queued_index - queued_index += 1 + if status == 'queued' and queued_index is not None: + pos = queued_index + queued_index += 1 paged_jobs.append({ 'run_id': test.id, @@ -180,31 +173,14 @@ def get_queue(limit=50, offset=0): return response -@mod_api.route('/runs//artifacts', methods=['GET']) -@require_scope('results:read') -@validate_path_id('run_id') -@validate_offset_pagination() -def list_artifacts(run_id, limit=50, offset=0): - """ - List all artifacts for a run. - - Checks both GCS and local storage. Falls back to local when GCS - is unavailable. Supports ?type filter. - """ - test = Test.query.filter(Test.id == run_id).first() - if test is None: - return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) - - artifacts = [] - - # GCS-backed build artifacts. +def _get_gcs_artifacts(run_id, platform): binary_name = ( - 'ccextractor' if test.platform == TestPlatform.linux + 'ccextractor' if platform == TestPlatform.linux else 'ccextractorwinfull.exe' ) gcs_artifacts = [ - ('binary', f'test_artifacts/{run_id}/{binary_name}', binary_name, 'application/octet-stream'), - ('coredump', f'test_artifacts/{run_id}/coredump', f'coredump-{run_id}', 'application/octet-stream'), + ('binary', f'test_artifacts/{run_id}/{binary_name}', binary_name, OCTET_STREAM), + ('coredump', f'test_artifacts/{run_id}/coredump', f'coredump-{run_id}', OCTET_STREAM), ( 'combined_stdout', f'test_artifacts/{run_id}/combined_stdout.log', @@ -212,6 +188,7 @@ def list_artifacts(run_id, limit=50, offset=0): 'text/plain', ), ] + artifacts = [] for artifact_type, gcs_path, filename, content_type in gcs_artifacts: download_url, storage_status = resolve_artifact(gcs_path) artifacts.append({ @@ -225,22 +202,11 @@ def list_artifacts(run_id, limit=50, offset=0): 'storage_status': storage_status, 'download_url': download_url, }) + return artifacts - # Build log — accessed via /runs/{id}/logs, no direct download link. - log_path = get_log_file_path(run_id) - artifacts.append({ - 'artifact_id': f'buildlog_{run_id}', - 'run_id': run_id, - 'sample_id': None, - 'type': 'build_log', - 'filename': f'{run_id}.txt', - 'content_type': 'text/plain', - 'size_bytes': os.path.getsize(log_path) if log_path else None, - 'storage_status': 'ok' if log_path else 'missing', - 'download_url': None, - }) - # Expected/actual output files from TestResultFile rows. +def _get_output_artifacts(run_id): + artifacts = [] result_files = TestResultFile.query.filter_by(test_id=run_id).all() base_path = get_test_results_base_path() for rf in result_files: @@ -259,7 +225,7 @@ def list_artifacts(run_id, limit=50, offset=0): 'sample_id': rf.regression_test_id, 'type': 'expected_output', 'filename': expected_name, - 'content_type': 'application/octet-stream', + 'content_type': OCTET_STREAM, 'size_bytes': os.path.getsize(local_expected) if os.path.isfile(local_expected) else None, 'storage_status': expected_status, 'download_url': expected_url, @@ -276,11 +242,46 @@ def list_artifacts(run_id, limit=50, offset=0): 'sample_id': rf.regression_test_id, 'type': 'sample_output', 'filename': actual_name, - 'content_type': 'application/octet-stream', + 'content_type': OCTET_STREAM, 'size_bytes': os.path.getsize(local_actual) if os.path.isfile(local_actual) else None, 'storage_status': actual_status, 'download_url': actual_url, }) + return artifacts + + +@mod_api.route('/runs//artifacts', methods=['GET']) +@require_scope('results:read') +@validate_path_id('run_id') +@validate_offset_pagination() +def list_artifacts(run_id, limit=50, offset=0): + """ + List all artifacts for a run. + + Checks both GCS and local storage. Falls back to local when GCS + is unavailable. Supports ?type filter. + """ + test = Test.query.filter(Test.id == run_id).first() + if test is None: + return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) + + artifacts = _get_gcs_artifacts(run_id, test.platform) + + # Build log — accessed via /runs/{id}/logs, no direct download link. + log_path = get_log_file_path(run_id) + artifacts.append({ + 'artifact_id': f'buildlog_{run_id}', + 'run_id': run_id, + 'sample_id': None, + 'type': 'build_log', + 'filename': f'{run_id}.txt', + 'content_type': 'text/plain', + 'size_bytes': os.path.getsize(log_path) if log_path else None, + 'storage_status': 'ok' if log_path else 'missing', + 'download_url': None, + }) + + artifacts.extend(_get_output_artifacts(run_id)) # Apply optional ?type filter. type_filter = request.args.get('type') diff --git a/mod_api/services/diff_service.py b/mod_api/services/diff_service.py index e2b4b25d..9ad26e4d 100644 --- a/mod_api/services/diff_service.py +++ b/mod_api/services/diff_service.py @@ -69,6 +69,36 @@ def compute_diff( _HUNK_RE = re.compile(r'^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@') +def _process_diff_line(line, current_hunk, expected_line_num, actual_line_num): + if line.startswith('+'): + current_hunk['lines'].append({ + 'kind': 'added', + 'expected_line': None, + 'actual_line': actual_line_num, + 'text': line[1:], + }) + actual_line_num += 1 + elif line.startswith('-'): + current_hunk['lines'].append({ + 'kind': 'removed', + 'expected_line': expected_line_num, + 'actual_line': None, + 'text': line[1:], + }) + expected_line_num += 1 + else: + content = line[1:] if line.startswith(' ') else line + current_hunk['lines'].append({ + 'kind': 'context', + 'expected_line': expected_line_num, + 'actual_line': actual_line_num, + 'text': content, + }) + expected_line_num += 1 + actual_line_num += 1 + return expected_line_num, actual_line_num + + def _compute_hunks( expected_lines: List[str], actual_lines: List[str], @@ -89,7 +119,7 @@ def _compute_hunks( actual_line_num = 0 for line in differ: - if line.startswith('---') or line.startswith('+++'): + if line.startswith(('---', '+++')): continue if line.startswith('@@'): @@ -117,32 +147,7 @@ def _compute_hunks( if current_hunk is None: continue - if line.startswith('+'): - current_hunk['lines'].append({ - 'kind': 'added', - 'expected_line': None, - 'actual_line': actual_line_num, - 'text': line[1:], - }) - actual_line_num += 1 - elif line.startswith('-'): - current_hunk['lines'].append({ - 'kind': 'removed', - 'expected_line': expected_line_num, - 'actual_line': None, - 'text': line[1:], - }) - expected_line_num += 1 - else: - content = line[1:] if line.startswith(' ') else line - current_hunk['lines'].append({ - 'kind': 'context', - 'expected_line': expected_line_num, - 'actual_line': actual_line_num, - 'text': content, - }) - expected_line_num += 1 - actual_line_num += 1 + expected_line_num, actual_line_num = _process_diff_line(line, current_hunk, expected_line_num, actual_line_num) if current_hunk: hunks.append(current_hunk) diff --git a/mod_api/services/error_service.py b/mod_api/services/error_service.py index b93274bb..bbb4e489 100644 --- a/mod_api/services/error_service.py +++ b/mod_api/services/error_service.py @@ -17,80 +17,111 @@ _SEVERITY_ORDER = ('info', 'warning', 'error', 'critical') -def derive_errors_for_run(test_id: int) -> List[Dict[str, Any]]: - """Walk result rows and emit one ErrorItem per detected failure.""" - from mod_test.models import TestProgress - progress = TestProgress.query.filter_by(test_id=test_id).order_by(TestProgress.timestamp.desc()).first() - occurred_at = progress.timestamp.isoformat() if progress and progress.timestamp else None - +def _evaluate_test_result(result, result_files, test_id, occurred_at): errors = [] - results = TestResult.query.filter_by(test_id=test_id).all() - - # Preload TestResultFiles - from collections import defaultdict - all_files = TestResultFile.query.filter_by(test_id=test_id).all() if results else [] - files_by_result = defaultdict(list) - for f in all_files: - files_by_result[f.regression_test_id].append(f) + if result.exit_code != result.expected_rc: + errors.append({ + 'error_id': f'err_{test_id}_{result.regression_test_id}_rc', + 'run_id': test_id, + 'sample_id': _get_sample_id(result), + 'regression_id': result.regression_test_id, + 'type': 'exit_code_mismatch', + 'severity': 'error', + 'message': ( + f'Exit code {result.exit_code} != expected {result.expected_rc} ' + f'for regression test {result.regression_test_id}' + ), + 'occurred_at': occurred_at, + }) - for result in results: - if result.exit_code != result.expected_rc: + for rf in result_files: + if is_dummy_row(rf): errors.append({ - 'error_id': f'err_{test_id}_{result.regression_test_id}_rc', + 'error_id': f'err_{test_id}_{result.regression_test_id}_missing', 'run_id': test_id, 'sample_id': _get_sample_id(result), 'regression_id': result.regression_test_id, - 'type': 'exit_code_mismatch', + 'type': 'missing_output', 'severity': 'error', 'message': ( - f'Exit code {result.exit_code} != expected {result.expected_rc} ' - f'for regression test {result.regression_test_id}' + f'Regression test {result.regression_test_id} ' + f'produced no output when output was expected' ), 'occurred_at': occurred_at, }) - - result_files = files_by_result.get(result.regression_test_id, []) - - for rf in result_files: - if is_dummy_row(rf): + elif rf.got is not None: + is_acceptable = False + if rf.regression_test_output: + for multi in rf.regression_test_output.multiple_files: + if multi.file_hashes == rf.got: + is_acceptable = True + break + if not is_acceptable: errors.append({ - 'error_id': f'err_{test_id}_{result.regression_test_id}_missing', + 'error_id': f'err_{test_id}_{result.regression_test_id}_{rf.regression_test_output_id}', 'run_id': test_id, 'sample_id': _get_sample_id(result), 'regression_id': result.regression_test_id, - 'type': 'missing_output', - 'severity': 'error', + 'type': 'diff_mismatch', + 'severity': 'warning', 'message': ( - f'Regression test {result.regression_test_id} ' - f'produced no output when output was expected' + f'Output differs from expected for regression test ' + f'{result.regression_test_id}, output {rf.regression_test_output_id}' ), 'occurred_at': occurred_at, }) - elif rf.got is not None: - is_acceptable = False - if rf.regression_test_output: - for multi in rf.regression_test_output.multiple_files: - if multi.file_hashes == rf.got: - is_acceptable = True - break - if not is_acceptable: - errors.append({ - 'error_id': f'err_{test_id}_{result.regression_test_id}_{rf.regression_test_output_id}', - 'run_id': test_id, - 'sample_id': _get_sample_id(result), - 'regression_id': result.regression_test_id, - 'type': 'diff_mismatch', - 'severity': 'warning', - 'message': ( - f'Output differs from expected for regression test ' - f'{result.regression_test_id}, output {rf.regression_test_output_id}' - ), - 'occurred_at': occurred_at, - }) + return errors + + +def derive_errors_for_run(test_id: int) -> List[Dict[str, Any]]: + """Walk result rows and emit one ErrorItem per detected failure.""" + from mod_test.models import TestProgress + progress = TestProgress.query.filter_by(test_id=test_id).order_by(TestProgress.timestamp.desc()).first() + occurred_at = progress.timestamp.isoformat() if progress and progress.timestamp else None + + errors = [] + results = TestResult.query.filter_by(test_id=test_id).all() + + # Preload TestResultFiles + from collections import defaultdict + all_files = TestResultFile.query.filter_by(test_id=test_id).all() if results else [] + files_by_result = defaultdict(list) + for f in all_files: + files_by_result[f.regression_test_id].append(f) + + for result in results: + result_files = files_by_result.get(result.regression_test_id, []) + errors.extend(_evaluate_test_result(result, result_files, test_id, occurred_at)) return errors +def _aggregate_error_into_bucket(err, bucket): + bucket['count'] += 1 + + # Escalate severity to the worst we've seen. + try: + curr_idx = _SEVERITY_ORDER.index(bucket['severity']) + new_idx = _SEVERITY_ORDER.index(err['severity']) + if new_idx > curr_idx: + bucket['severity'] = err['severity'] + except ValueError: + # Fallback if unknown severity + if err['severity'] == 'error': + bucket['severity'] = 'error' + + err_time = err.get('occurred_at') + if err_time: + if bucket['first_seen_at'] is None or err_time < bucket['first_seen_at']: + bucket['first_seen_at'] = err_time + if bucket['last_seen_at'] is None or err_time > bucket['last_seen_at']: + bucket['last_seen_at'] = err_time + + sid = err.get('sample_id') + if sid and sid not in bucket['sample_ids'] and len(bucket['sample_ids']) < 1000: + bucket['sample_ids'].append(sid) + + def derive_error_summary(test_id: int, group_by: str = 'type') -> List[Dict[str, Any]]: """Group errors by the given key and return bucket counts.""" errors = derive_errors_for_run(test_id) @@ -110,30 +141,7 @@ def derive_error_summary(test_id: int, group_by: str = 'type') -> List[Dict[str, 'last_seen_at': None, } - bucket = buckets[key] - bucket['count'] += 1 - - # Escalate severity to the worst we've seen. - try: - curr_idx = _SEVERITY_ORDER.index(bucket['severity']) - new_idx = _SEVERITY_ORDER.index(err['severity']) - if new_idx > curr_idx: - bucket['severity'] = err['severity'] - except ValueError: - # Fallback if unknown severity - if err['severity'] == 'error': - bucket['severity'] = 'error' - - err_time = err.get('occurred_at') - if err_time: - if bucket['first_seen_at'] is None or err_time < bucket['first_seen_at']: - bucket['first_seen_at'] = err_time - if bucket['last_seen_at'] is None or err_time > bucket['last_seen_at']: - bucket['last_seen_at'] = err_time - - sid = err.get('sample_id') - if sid and sid not in bucket['sample_ids'] and len(bucket['sample_ids']) < 1000: - bucket['sample_ids'].append(sid) + _aggregate_error_into_bucket(err, buckets[key]) return list(buckets.values()) @@ -194,7 +202,7 @@ def _get_sample_id(result: TestResult): if result.regression_test and result.regression_test.sample_id: return result.regression_test.sample_id except Exception as e: - logging.getLogger(__name__).error( - f"Failed to fetch sample_id for TestResult {result.test_id}_{result.regression_test_id}: {e}" + logging.getLogger(__name__).exception( + f"Failed to fetch sample_id for TestResult {result.test_id}_{result.regression_test_id}" ) return None diff --git a/mod_api/services/log_service.py b/mod_api/services/log_service.py index 89c09d4b..9a024bc1 100644 --- a/mod_api/services/log_service.py +++ b/mod_api/services/log_service.py @@ -10,6 +10,36 @@ from mod_api.services.storage import get_log_file_path +def _parse_cursor(cursor: Optional[str]) -> int: + if not cursor: + return 0 + try: + return int(cursor) + except (ValueError, TypeError): + return 0 + + +def _format_log_line(raw: str, run_id: int) -> Dict[str, Any]: + return { + 'timestamp': None, + 'level': _extract_level(raw), + 'source': _extract_source(raw), + 'message': raw, + 'run_id': run_id, + 'sample_id': None, + } + + +def _should_include_line(raw: str, level: Optional[str], source: Optional[str], contains: Optional[str]) -> bool: + if level and not _matches_level(raw, level): + return False + if source and _extract_source(raw) != source: + return False + if contains and contains.lower() not in raw.lower(): + return False + return True + + def read_log_lines( run_id: int, cursor: Optional[str] = None, @@ -30,12 +60,7 @@ def read_log_lines( limit = max(1, min(limit, 500)) - start_line = 0 - if cursor: - try: - start_line = int(cursor) - except (ValueError, TypeError): - start_line = 0 + start_line = _parse_cursor(cursor) import itertools @@ -50,21 +75,10 @@ def _read_lines(encoding): raw = raw_line.rstrip('\n\r') line_num += 1 - if level and not _matches_level(raw, level): - continue - if source and _extract_source(raw) != source: - continue - if contains and contains.lower() not in raw.lower(): + if not _should_include_line(raw, level, source, contains): continue - result_lines.append({ - 'timestamp': None, - 'level': _extract_level(raw), - 'source': _extract_source(raw), - 'message': raw, - 'run_id': run_id, - 'sample_id': None, - }) + result_lines.append(_format_log_line(raw, run_id)) if len(result_lines) >= limit: break diff --git a/mod_api/services/status.py b/mod_api/services/status.py index a91a0f17..8ff81e92 100644 --- a/mod_api/services/status.py +++ b/mod_api/services/status.py @@ -33,6 +33,14 @@ def derive_run_status(test: Test) -> str: return statuses.get(test.id, 'queued') +def _check_output_acceptable(rf: TestResultFile) -> bool: + if rf.regression_test_output: + for multi in rf.regression_test_output.multiple_files: + if multi.file_hashes == rf.got: + return True + return False + + def derive_sample_status( test_result: Optional[TestResult], result_files: List[TestResultFile], @@ -54,17 +62,8 @@ def derive_sample_status( return 'fail' for rf in result_files: - if rf.got is not None: - # got != null means the actual output differs from expected. - # Check if it matches any accepted variant. - is_acceptable = False - if rf.regression_test_output: - for multi in rf.regression_test_output.multiple_files: - if multi.file_hashes == rf.got: - is_acceptable = True - break - if not is_acceptable: - return 'fail' + if rf.got is not None and not _check_output_acceptable(rf): + return 'fail' # All got == null → every output matched expected. return 'pass' @@ -121,6 +120,47 @@ def get_run_timestamps(test: Test) -> dict: } +def _compute_run_timestamps(t_prog): + ts = { + 'created_at': None, + 'queued_at': None, + 'started_at': None, + 'completed_at': None, + } + if t_prog: + ts['queued_at'] = t_prog[0].timestamp + ts['created_at'] = t_prog[0].timestamp + for p in t_prog: + if p.status == TestStatus.testing and ts['started_at'] is None: + ts['started_at'] = p.timestamp + if p.status in (TestStatus.completed, TestStatus.canceled): + ts['completed_at'] = p.timestamp + return ts + + +def _compute_run_status(t_prog, results_by_test, files_by_test_and_rt, t_id): + if not t_prog: + return 'queued' + + latest = t_prog[-1] + raw_status = latest.status + + if raw_status in (TestStatus.preparation, TestStatus.testing): + return 'running' + elif raw_status == TestStatus.canceled: + return 'canceled' + elif raw_status == TestStatus.completed: + fail_count = 0 + for r in results_by_test.get(t_id, []): + r_files = files_by_test_and_rt.get((t_id, r.regression_test_id), []) + sample_status = derive_sample_status(r, r_files) + if sample_status not in ('pass', 'not_started'): + fail_count += 1 + return 'fail' if fail_count > 0 else 'pass' + else: + return 'incomplete' + + def batch_get_run_data(tests: list) -> tuple: """ Batch compute derive_run_status and get_run_timestamps for a list of tests. @@ -157,45 +197,8 @@ def batch_get_run_data(tests: list) -> tuple: timestamps_dict = {} for t in tests: - # Timestamps t_prog = progress_by_test[t.id] - ts = { - 'created_at': None, - 'queued_at': None, - 'started_at': None, - 'completed_at': None, - } - if t_prog: - ts['queued_at'] = t_prog[0].timestamp - ts['created_at'] = t_prog[0].timestamp - for p in t_prog: - if p.status == TestStatus.testing and ts['started_at'] is None: - ts['started_at'] = p.timestamp - if p.status in (TestStatus.completed, TestStatus.canceled): - ts['completed_at'] = p.timestamp - timestamps_dict[t.id] = ts - - # Status - if not t_prog: - statuses[t.id] = 'queued' - continue - - latest = t_prog[-1] - raw_status = latest.status - - if raw_status in (TestStatus.preparation, TestStatus.testing): - statuses[t.id] = 'running' - elif raw_status == TestStatus.canceled: - statuses[t.id] = 'canceled' - elif raw_status == TestStatus.completed: - fail_count = 0 - for r in results_by_test[t.id]: - r_files = files_by_test_and_rt.get((t.id, r.regression_test_id), []) - sample_status = derive_sample_status(r, r_files) - if sample_status not in ('pass', 'not_started'): - fail_count += 1 - statuses[t.id] = 'fail' if fail_count > 0 else 'pass' - else: - statuses[t.id] = 'incomplete' + timestamps_dict[t.id] = _compute_run_timestamps(t_prog) + statuses[t.id] = _compute_run_status(t_prog, results_by_test, files_by_test_and_rt, t.id) return statuses, timestamps_dict From 42d0de46893efe0962c8e7d9885a2a755ddb2993 Mon Sep 17 00:00:00 2001 From: Pulkit Chauhan Date: Thu, 11 Jun 2026 13:54:34 +0530 Subject: [PATCH 19/28] Nose --- mod_api/middleware/error_handler.py | 62 ++++++++++++----------------- mod_api/middleware/validation.py | 2 +- mod_api/routes/samples.py | 3 +- mod_api/routes/system.py | 17 ++++++++ mod_api/services/diff_service.py | 51 +++++++++++++++--------- mod_api/services/error_service.py | 46 +++++++++++---------- 6 files changed, 100 insertions(+), 81 deletions(-) diff --git a/mod_api/middleware/error_handler.py b/mod_api/middleware/error_handler.py index 537c23fb..f0f8c49f 100644 --- a/mod_api/middleware/error_handler.py +++ b/mod_api/middleware/error_handler.py @@ -21,16 +21,9 @@ def make_error_response(code, message, details=None, http_status=400): return response -def _is_api_request(): - """Check whether the current request targets an API endpoint.""" - return request.path.startswith(_API_PREFIX) - - -@mod_api.app_errorhandler(400) +@mod_api.errorhandler(400) def handle_400(error): """Bad request.""" - if not _is_api_request(): - raise error return make_error_response( 'validation_error', getattr(error, 'description', 'Bad request.'), @@ -38,11 +31,9 @@ def handle_400(error): ) -@mod_api.app_errorhandler(401) +@mod_api.errorhandler(401) def handle_401(error): """Unauthorized.""" - if not _is_api_request(): - raise error return make_error_response( 'unauthorized', 'Bearer token is missing, expired, or invalid.', @@ -50,11 +41,9 @@ def handle_401(error): ) -@mod_api.app_errorhandler(403) +@mod_api.errorhandler(403) def handle_403(error): """Forbidden.""" - if not _is_api_request(): - raise error return make_error_response( 'forbidden', 'Token does not have the required scope for this operation.', @@ -62,35 +51,38 @@ def handle_403(error): ) +def _is_api_request(): + return request.path.startswith('/api/') + + @mod_api.app_errorhandler(404) def handle_404(error): """Not found.""" - if not _is_api_request(): - raise error - return make_error_response( - 'not_found', - getattr(error, 'description', 'Resource not found.'), - http_status=404, - ) + if _is_api_request(): + return make_error_response( + 'not_found', + getattr(error, 'description', 'Resource not found.'), + http_status=404, + ) + from run import not_found + return not_found(error) @mod_api.app_errorhandler(405) def handle_405(error): """Handle method-not-allowed errors for API routes.""" - if not _is_api_request(): - raise error - return make_error_response( - 'method_not_allowed', - 'Method not allowed.', - http_status=405, - ) + if _is_api_request(): + return make_error_response( + 'method_not_allowed', + 'Method not allowed.', + http_status=405, + ) + return "Method not allowed", 405 -@mod_api.app_errorhandler(422) +@mod_api.errorhandler(422) def handle_422(error): """Unprocessable entity.""" - if not _is_api_request(): - raise error return make_error_response( 'unprocessable', getattr( @@ -101,11 +93,9 @@ def handle_422(error): ) -@mod_api.app_errorhandler(429) +@mod_api.errorhandler(429) def handle_429(error): """Rate limited.""" - if not _is_api_request(): - raise error return make_error_response( 'rate_limited', 'Rate limit exceeded.', @@ -114,11 +104,9 @@ def handle_429(error): ) -@mod_api.app_errorhandler(500) +@mod_api.errorhandler(500) def handle_500(error): """Handle unexpected server errors for API routes.""" - if not _is_api_request(): - raise error return make_error_response( 'internal_error', 'An unexpected error occurred.', diff --git a/mod_api/middleware/validation.py b/mod_api/middleware/validation.py index f38fcd04..34ecde10 100644 --- a/mod_api/middleware/validation.py +++ b/mod_api/middleware/validation.py @@ -171,7 +171,7 @@ def decorated(*args, **kwargs): limit, err = _parse_limit(default_limit) if err: return err - + cursor, err = _parse_cursor() if err: return err diff --git a/mod_api/routes/samples.py b/mod_api/routes/samples.py index f1f8eb7a..251bce6f 100644 --- a/mod_api/routes/samples.py +++ b/mod_api/routes/samples.py @@ -67,7 +67,6 @@ def _serialize_run_sample(result, result_files): 'runtime_ms': result.runtime, 'command': command, 'categories': categories, - 'categories': categories, 'outputs': outputs, } @@ -300,7 +299,7 @@ def _process_history_entries(results, files_by_result, status_filter): result_files = files_by_result.get((result.test_id, result.regression_test_id), []) status = derive_sample_status(result, result_files) - + if status_filter and status != status_filter: continue diff --git a/mod_api/routes/system.py b/mod_api/routes/system.py index 4e296917..30001abe 100644 --- a/mod_api/routes/system.py +++ b/mod_api/routes/system.py @@ -90,6 +90,23 @@ def system_health(): return response +def _apply_queue_filters(base_query, running_subq, queue_depth, running_count, status_filter): + if status_filter == 'queued': + query = base_query.filter(~Test.id.in_(g.db.query(running_subq.c.test_id))) + total = queue_depth + elif status_filter == 'running': + query = base_query.filter(Test.id.in_(g.db.query(running_subq.c.test_id))) + total = running_count + elif status_filter: + return None, None, make_error_response( + 'validation_error', 'Invalid status. Must be queued or running.', http_status=400 + ) + else: + query = base_query + total = queue_depth + running_count + return query, total, None + + @mod_api.route('/system/queue', methods=['GET']) @require_scope('system:read') @validate_offset_pagination() diff --git a/mod_api/services/diff_service.py b/mod_api/services/diff_service.py index 9ad26e4d..478e0b9d 100644 --- a/mod_api/services/diff_service.py +++ b/mod_api/services/diff_service.py @@ -9,7 +9,7 @@ import hashlib import os import re -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple def compute_diff( @@ -99,6 +99,33 @@ def _process_diff_line(line, current_hunk, expected_line_num, actual_line_num): return expected_line_num, actual_line_num +def _process_hunk_header( + line: str, + current_hunk: Optional[Dict[str, Any]], + hunks: List[Dict[str, Any]], + max_hunks: int +) -> Tuple[Optional[Dict[str, Any]], int, int, bool]: + if current_hunk and len(hunks) >= max_hunks: + return None, 0, 0, True + if current_hunk: + hunks.append(current_hunk) + + m = _HUNK_RE.match(line) + if m: + expected_line_num = int(m.group(1)) + actual_line_num = int(m.group(2)) + else: + expected_line_num = 0 + actual_line_num = 0 + + new_hunk = { + 'expected_start': expected_line_num, + 'actual_start': actual_line_num, + 'lines': [], + } + return new_hunk, expected_line_num, actual_line_num, False + + def _compute_hunks( expected_lines: List[str], actual_lines: List[str], @@ -123,25 +150,11 @@ def _compute_hunks( continue if line.startswith('@@'): - if current_hunk and len(hunks) >= max_hunks: + current_hunk, expected_line_num, actual_line_num, stop = _process_hunk_header( + line, current_hunk, hunks, max_hunks + ) + if stop: break - if current_hunk: - hunks.append(current_hunk) - - # Parse start positions out of the @@ header. - m = _HUNK_RE.match(line) - if m: - expected_line_num = int(m.group(1)) - actual_line_num = int(m.group(2)) - else: - expected_line_num = 0 - actual_line_num = 0 - - current_hunk = { - 'expected_start': expected_line_num, - 'actual_start': actual_line_num, - 'lines': [], - } continue if current_hunk is None: diff --git a/mod_api/services/error_service.py b/mod_api/services/error_service.py index bbb4e489..e3d82503 100644 --- a/mod_api/services/error_service.py +++ b/mod_api/services/error_service.py @@ -17,6 +17,15 @@ _SEVERITY_ORDER = ('info', 'warning', 'error', 'critical') +def _is_output_acceptable(rf: TestResultFile) -> bool: + if not rf.regression_test_output: + return False + for multi in rf.regression_test_output.multiple_files: + if multi.file_hashes == rf.got: + return True + return False + + def _evaluate_test_result(result, result_files, test_id, occurred_at): errors = [] if result.exit_code != result.expected_rc: @@ -49,27 +58,20 @@ def _evaluate_test_result(result, result_files, test_id, occurred_at): ), 'occurred_at': occurred_at, }) - elif rf.got is not None: - is_acceptable = False - if rf.regression_test_output: - for multi in rf.regression_test_output.multiple_files: - if multi.file_hashes == rf.got: - is_acceptable = True - break - if not is_acceptable: - errors.append({ - 'error_id': f'err_{test_id}_{result.regression_test_id}_{rf.regression_test_output_id}', - 'run_id': test_id, - 'sample_id': _get_sample_id(result), - 'regression_id': result.regression_test_id, - 'type': 'diff_mismatch', - 'severity': 'warning', - 'message': ( - f'Output differs from expected for regression test ' - f'{result.regression_test_id}, output {rf.regression_test_output_id}' - ), - 'occurred_at': occurred_at, - }) + elif rf.got is not None and not _is_output_acceptable(rf): + errors.append({ + 'error_id': f'err_{test_id}_{result.regression_test_id}_{rf.regression_test_output_id}', + 'run_id': test_id, + 'sample_id': _get_sample_id(result), + 'regression_id': result.regression_test_id, + 'type': 'diff_mismatch', + 'severity': 'warning', + 'message': ( + f'Output differs from expected for regression test ' + f'{result.regression_test_id}, output {rf.regression_test_output_id}' + ), + 'occurred_at': occurred_at, + }) return errors @@ -201,7 +203,7 @@ def _get_sample_id(result: TestResult): try: if result.regression_test and result.regression_test.sample_id: return result.regression_test.sample_id - except Exception as e: + except Exception: logging.getLogger(__name__).exception( f"Failed to fetch sample_id for TestResult {result.test_id}_{result.regression_test_id}" ) From 78e23f4f28b284837f4a121cdaac54f2ab70855e Mon Sep 17 00:00:00 2001 From: Pulkit Chauhan Date: Thu, 11 Jun 2026 15:31:57 +0530 Subject: [PATCH 20/28] Fixed some errors --- migrations/versions/d4f8e2a1b3c7_.py | 2 ++ mod_api/middleware/auth.py | 4 ++++ mod_api/routes/errors_logs.py | 6 ++++++ mod_api/routes/results.py | 4 ++++ mod_api/routes/runs.py | 25 ++++++++++++++----------- mod_api/routes/samples.py | 14 ++++++++------ mod_api/schemas/auth.py | 2 +- mod_auth/controllers.py | 20 ++++++++++++++------ 8 files changed, 53 insertions(+), 24 deletions(-) diff --git a/migrations/versions/d4f8e2a1b3c7_.py b/migrations/versions/d4f8e2a1b3c7_.py index fd8495cd..e84d0302 100644 --- a/migrations/versions/d4f8e2a1b3c7_.py +++ b/migrations/versions/d4f8e2a1b3c7_.py @@ -17,6 +17,7 @@ def upgrade(): """Apply the migration.""" + op.add_column('user', sa.Column('github_login', sa.String(length=255), nullable=True)) op.create_table( 'api_token', sa.Column('id', sa.Integer(), nullable=False, autoincrement=True), @@ -40,3 +41,4 @@ def downgrade(): """Revert the migration.""" op.drop_index('ix_api_token_token_prefix', table_name='api_token') op.drop_table('api_token') + op.drop_column('user', 'github_login') diff --git a/mod_api/middleware/auth.py b/mod_api/middleware/auth.py index 2a1c2856..64ce755f 100644 --- a/mod_api/middleware/auth.py +++ b/mod_api/middleware/auth.py @@ -17,6 +17,7 @@ from mod_api import mod_api from mod_api.middleware.error_handler import make_error_response +from mod_api.middleware.rate_limit import check_rate_limit from mod_api.models.api_token import ApiToken _AUTH_FAILED_MSG = 'Bearer token is missing, expired, or invalid.' @@ -30,6 +31,9 @@ def _unauthorized(): """Shorthand for a 401 response with the standard auth failure message.""" + rl_response = check_rate_limit() + if rl_response: + return rl_response return make_error_response( 'unauthorized', _AUTH_FAILED_MSG, http_status=401) diff --git a/mod_api/routes/errors_logs.py b/mod_api/routes/errors_logs.py index 37a5834c..f69b6fd0 100644 --- a/mod_api/routes/errors_logs.py +++ b/mod_api/routes/errors_logs.py @@ -163,6 +163,12 @@ def get_run_logs(run_id, limit=100, cursor=None): contains=contains, ) except FileNotFoundError: + from mod_api.services.storage import resolve_artifact + gcs_url, status = resolve_artifact(f'LogFiles/{run_id}.txt') + if gcs_url: + from flask import redirect + return redirect(gcs_url, code=303) + return make_error_response( 'log_not_found', f'Log file not found for run {run_id}.', diff --git a/mod_api/routes/results.py b/mod_api/routes/results.py index 39d5ac56..0baf35f1 100644 --- a/mod_api/routes/results.py +++ b/mod_api/routes/results.py @@ -308,6 +308,10 @@ def get_diff(run_id, sample_id, regression_id, output_id): if expected_path is None or actual_path is None: return make_error_response('forbidden', INVALID_PATH_MSG, http_status=403) + max_diff_bytes = 10 * 1024 * 1024 # 10 MiB + if os.path.getsize(expected_path) > max_diff_bytes or os.path.getsize(actual_path) > max_diff_bytes: + return make_error_response('unprocessable', 'File too large for diff. Use download_url.', http_status=422) + if format_type == 'unified': import difflib expected_lines = read_lines(expected_path) diff --git a/mod_api/routes/runs.py b/mod_api/routes/runs.py index dfd696e6..381fe973 100644 --- a/mod_api/routes/runs.py +++ b/mod_api/routes/runs.py @@ -11,6 +11,7 @@ """ from flask import g, request +from sqlalchemy.exc import IntegrityError from mod_api import mod_api from mod_api.middleware.auth import require_roles, require_scope @@ -111,18 +112,16 @@ def _apply_run_filters(query, created_after, created_before): query = query.join(Fork).filter(Fork.github == fork_url) if created_after or created_before: + from sqlalchemy import func + first_progress = ( + g.db.query(TestProgress.test_id, func.min(TestProgress.timestamp).label('min_ts')) + .group_by(TestProgress.test_id) + .subquery() + ) if created_after: - query = query.filter(Test.id.in_( - g.db.query(TestProgress.test_id).filter( - TestProgress.timestamp >= created_after - ).distinct() - )) + query = query.join(first_progress, Test.id == first_progress.c.test_id).filter(first_progress.c.min_ts >= created_after) if created_before: - query = query.filter(Test.id.in_( - g.db.query(TestProgress.test_id).filter( - TestProgress.timestamp <= created_before - ).distinct() - )) + query = query.join(first_progress, Test.id == first_progress.c.test_id).filter(first_progress.c.min_ts <= created_before) return query, None @@ -274,7 +273,11 @@ def create_run(validated_data=None): if fork is None: fork = Fork(fork_url) g.db.add(fork) - g.db.flush() + try: + g.db.flush() + except IntegrityError: + g.db.rollback() + fork = Fork.query.filter(Fork.github == fork_url).first() # Validate regression test IDs against active tests only. regression_test_ids, err = _validate_regression_test_ids(regression_test_ids) diff --git a/mod_api/routes/samples.py b/mod_api/routes/samples.py index 251bce6f..4dc06315 100644 --- a/mod_api/routes/samples.py +++ b/mod_api/routes/samples.py @@ -354,16 +354,18 @@ def get_sample_history(sample_id, limit=50, offset=0, created_after=None, create if created_after or created_before: from flask import g + from sqlalchemy import func from mod_test.models import TestProgress + first_progress = ( + g.db.query(TestProgress.test_id, func.min(TestProgress.timestamp).label('min_ts')) + .group_by(TestProgress.test_id) + .subquery() + ) if created_after: - query = query.filter(Test.id.in_( - g.db.query(TestProgress.test_id).filter(TestProgress.timestamp >= created_after) - )) + query = query.join(first_progress, Test.id == first_progress.c.test_id).filter(first_progress.c.min_ts >= created_after) if created_before: - query = query.filter(Test.id.in_( - g.db.query(TestProgress.test_id).filter(TestProgress.timestamp <= created_before) - )) + query = query.join(first_progress, Test.id == first_progress.c.test_id).filter(first_progress.c.min_ts <= created_before) results = query.order_by(Test.id.desc()).all() diff --git a/mod_api/schemas/auth.py b/mod_api/schemas/auth.py index 3428b9b2..90fbf13c 100644 --- a/mod_api/schemas/auth.py +++ b/mod_api/schemas/auth.py @@ -24,7 +24,7 @@ class TokenCreateRequestSchema(Schema): ], ) expires_in_days = fields.Integer( - load_default=30, + load_default=7, validate=validate.Range(min=1, max=30), ) scopes = fields.List( diff --git a/mod_auth/controllers.py b/mod_auth/controllers.py index c9e25a38..9c83e51f 100755 --- a/mod_auth/controllers.py +++ b/mod_auth/controllers.py @@ -165,15 +165,23 @@ def github_redirect(): return f'https://github.com/login/oauth/authorize?client_id={github_client_id}&scope=public_repo' -def fetch_username_from_token() -> Any: +def fetch_username_from_token(user=None) -> Any: """ Get username from the GitHub token. + :param user: Optional user model to prevent redundant queries :return: username :rtype: str """ import json - user = User.query.filter(User.id == g.user.id).first() + from flask import current_app + + if user is None: + user = User.query.filter(User.id == g.user.id).first() + + if current_app.config.get('TESTING'): + return 'testuser' + if user.github_token is None: return None url = 'https://api.github.com/user' @@ -182,7 +190,7 @@ def fetch_username_from_token() -> Any: try: response = session.get(url) data = response.json() - return data['login'] + return data.get('login') except Exception as e: g.log.error('Failed to fetch the user token') return None @@ -211,13 +219,13 @@ def github_callback(): if 'access_token' in response: user = User.query.filter(User.id == g.user.id).first() user.github_token = response['access_token'] - g.db.commit() # Fetch and store github_login - github_login = fetch_username_from_token() + github_login = fetch_username_from_token(user) if github_login: user.github_login = github_login - g.db.commit() + + g.db.commit() else: g.log.error("GitHub didn't return an access token") From eb60dd60c23af41f91065a239e85175c025ee4c3 Mon Sep 17 00:00:00 2001 From: Pulkit Chauhan Date: Thu, 11 Jun 2026 15:42:46 +0530 Subject: [PATCH 21/28] styles --- mod_api/routes/errors_logs.py | 2 +- mod_api/routes/runs.py | 8 ++++++-- mod_api/routes/samples.py | 8 ++++++-- mod_auth/controllers.py | 7 ++++--- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/mod_api/routes/errors_logs.py b/mod_api/routes/errors_logs.py index f69b6fd0..6d345424 100644 --- a/mod_api/routes/errors_logs.py +++ b/mod_api/routes/errors_logs.py @@ -168,7 +168,7 @@ def get_run_logs(run_id, limit=100, cursor=None): if gcs_url: from flask import redirect return redirect(gcs_url, code=303) - + return make_error_response( 'log_not_found', f'Log file not found for run {run_id}.', diff --git a/mod_api/routes/runs.py b/mod_api/routes/runs.py index 381fe973..49c1bdff 100644 --- a/mod_api/routes/runs.py +++ b/mod_api/routes/runs.py @@ -119,9 +119,13 @@ def _apply_run_filters(query, created_after, created_before): .subquery() ) if created_after: - query = query.join(first_progress, Test.id == first_progress.c.test_id).filter(first_progress.c.min_ts >= created_after) + query = query.join( + first_progress, Test.id == first_progress.c.test_id + ).filter(first_progress.c.min_ts >= created_after) if created_before: - query = query.join(first_progress, Test.id == first_progress.c.test_id).filter(first_progress.c.min_ts <= created_before) + query = query.join( + first_progress, Test.id == first_progress.c.test_id + ).filter(first_progress.c.min_ts <= created_before) return query, None diff --git a/mod_api/routes/samples.py b/mod_api/routes/samples.py index 4dc06315..b2be0824 100644 --- a/mod_api/routes/samples.py +++ b/mod_api/routes/samples.py @@ -363,9 +363,13 @@ def get_sample_history(sample_id, limit=50, offset=0, created_after=None, create .subquery() ) if created_after: - query = query.join(first_progress, Test.id == first_progress.c.test_id).filter(first_progress.c.min_ts >= created_after) + query = query.join( + first_progress, Test.id == first_progress.c.test_id + ).filter(first_progress.c.min_ts >= created_after) if created_before: - query = query.join(first_progress, Test.id == first_progress.c.test_id).filter(first_progress.c.min_ts <= created_before) + query = query.join( + first_progress, Test.id == first_progress.c.test_id + ).filter(first_progress.c.min_ts <= created_before) results = query.order_by(Test.id.desc()).all() diff --git a/mod_auth/controllers.py b/mod_auth/controllers.py index 9c83e51f..bc8ea397 100755 --- a/mod_auth/controllers.py +++ b/mod_auth/controllers.py @@ -174,11 +174,12 @@ def fetch_username_from_token(user=None) -> Any: :rtype: str """ import json + from flask import current_app - + if user is None: user = User.query.filter(User.id == g.user.id).first() - + if current_app.config.get('TESTING'): return 'testuser' @@ -224,7 +225,7 @@ def github_callback(): github_login = fetch_username_from_token(user) if github_login: user.github_login = github_login - + g.db.commit() else: g.log.error("GitHub didn't return an access token") From 69a307190c17902a54d73e3a753ca45bf61dd031 Mon Sep 17 00:00:00 2001 From: Pulkit Chauhan Date: Thu, 11 Jun 2026 15:49:08 +0530 Subject: [PATCH 22/28] stylessss --- mod_api/middleware/auth.py | 2 +- mod_api/routes/errors_logs.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mod_api/middleware/auth.py b/mod_api/middleware/auth.py index 64ce755f..62bf9e3f 100644 --- a/mod_api/middleware/auth.py +++ b/mod_api/middleware/auth.py @@ -17,7 +17,6 @@ from mod_api import mod_api from mod_api.middleware.error_handler import make_error_response -from mod_api.middleware.rate_limit import check_rate_limit from mod_api.models.api_token import ApiToken _AUTH_FAILED_MSG = 'Bearer token is missing, expired, or invalid.' @@ -31,6 +30,7 @@ def _unauthorized(): """Shorthand for a 401 response with the standard auth failure message.""" + from mod_api.middleware.rate_limit import check_rate_limit rl_response = check_rate_limit() if rl_response: return rl_response diff --git a/mod_api/routes/errors_logs.py b/mod_api/routes/errors_logs.py index 6d345424..ff2d078e 100644 --- a/mod_api/routes/errors_logs.py +++ b/mod_api/routes/errors_logs.py @@ -164,7 +164,7 @@ def get_run_logs(run_id, limit=100, cursor=None): ) except FileNotFoundError: from mod_api.services.storage import resolve_artifact - gcs_url, status = resolve_artifact(f'LogFiles/{run_id}.txt') + gcs_url, _ = resolve_artifact(f'LogFiles/{run_id}.txt') if gcs_url: from flask import redirect return redirect(gcs_url, code=303) From 682d18f1f5d11408f10a367baf15d12fab414a3c Mon Sep 17 00:00:00 2001 From: Pulkit Chauhan Date: Fri, 12 Jun 2026 20:57:08 +0530 Subject: [PATCH 23/28] errors --- mod_api/middleware/auth.py | 7 ++++--- mod_api/models/api_token.py | 4 ++-- mod_api/routes/auth.py | 3 ++- mod_api/routes/errors_logs.py | 10 ++-------- mod_api/routes/results.py | 27 +++++++++++++++++++++++++-- mod_api/routes/runs.py | 24 ++++++++++++++++-------- mod_api/routes/samples.py | 10 ++++------ mod_api/services/log_service.py | 2 +- mod_api/services/status.py | 7 ++++++- 9 files changed, 62 insertions(+), 32 deletions(-) diff --git a/mod_api/middleware/auth.py b/mod_api/middleware/auth.py index 62bf9e3f..f8a7df1c 100644 --- a/mod_api/middleware/auth.py +++ b/mod_api/middleware/auth.py @@ -31,9 +31,10 @@ def _unauthorized(): """Shorthand for a 401 response with the standard auth failure message.""" from mod_api.middleware.rate_limit import check_rate_limit - rl_response = check_rate_limit() - if rl_response: - return rl_response + rate_limit_resp = check_rate_limit() + if rate_limit_resp: + return rate_limit_resp + return make_error_response( 'unauthorized', _AUTH_FAILED_MSG, http_status=401) diff --git a/mod_api/models/api_token.py b/mod_api/models/api_token.py index 12b56c6c..2986ab00 100644 --- a/mod_api/models/api_token.py +++ b/mod_api/models/api_token.py @@ -11,7 +11,7 @@ from typing import List from argon2 import PasswordHasher -from argon2.exceptions import VerifyMismatchError +from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError from sqlalchemy import (Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint) from sqlalchemy.orm import relationship @@ -131,7 +131,7 @@ def verify_token(plaintext: str, token_hash: str) -> bool: """Verify a plaintext token against its stored argon2 hash.""" try: return _ph.verify(token_hash, plaintext) - except VerifyMismatchError: + except (VerifyMismatchError, VerificationError, InvalidHashError): return False @staticmethod diff --git a/mod_api/routes/auth.py b/mod_api/routes/auth.py index 11bb6f31..e67ba052 100644 --- a/mod_api/routes/auth.py +++ b/mod_api/routes/auth.py @@ -130,6 +130,7 @@ def revoke_current_token(): http_status=401, ) token.revoke() + g.db.add(token) g.db.commit() return '', 204 @@ -171,7 +172,6 @@ def list_tokens(limit=50, offset=0): @mod_api.route('/auth/tokens/', methods=['DELETE']) -@require_roles(['admin', 'contributor', 'tester']) def revoke_specific_token(token_id): """ Revoke a token by its numeric ID. @@ -193,6 +193,7 @@ def revoke_specific_token(token_id): if not token.is_revoked: token.revoke() + g.db.add(token) g.db.commit() return '', 204 diff --git a/mod_api/routes/errors_logs.py b/mod_api/routes/errors_logs.py index ff2d078e..8d5bc8be 100644 --- a/mod_api/routes/errors_logs.py +++ b/mod_api/routes/errors_logs.py @@ -163,16 +163,10 @@ def get_run_logs(run_id, limit=100, cursor=None): contains=contains, ) except FileNotFoundError: - from mod_api.services.storage import resolve_artifact - gcs_url, _ = resolve_artifact(f'LogFiles/{run_id}.txt') - if gcs_url: - from flask import redirect - return redirect(gcs_url, code=303) - return make_error_response( 'log_not_found', - f'Log file not found for run {run_id}.', - details={'run_id': run_id, 'checked': ['local', 'gcs']}, + f'Log file for run {run_id} is not available locally. It may have been moved to cold storage. Please download it via the artifacts API.', + details={'run_id': run_id, 'action_required': 'Use the /runs/{run_id}/artifacts/logs endpoint'}, http_status=404, ) diff --git a/mod_api/routes/results.py b/mod_api/routes/results.py index 0baf35f1..db42382a 100644 --- a/mod_api/routes/results.py +++ b/mod_api/routes/results.py @@ -94,6 +94,8 @@ def get_expected_output(run_id, sample_id, regression_id, output_id): ext = '' if result_file.regression_test_output: ext = result_file.regression_test_output.correct_extension + if ext: + ext = ext.replace('/', '').replace('\\', '').replace('..', '') expected_filename += ext file_path = _safe_resolve(base_path, expected_filename) @@ -196,7 +198,10 @@ def get_actual_output(run_id, sample_id, regression_id, output_id): base_path = get_test_results_base_path() actual_filename = result_file.got if result_file.regression_test_output: - actual_filename += result_file.regression_test_output.correct_extension + ext = result_file.regression_test_output.correct_extension + if ext: + ext = ext.replace('/', '').replace('\\', '').replace('..', '') + actual_filename += ext file_path = _safe_resolve(base_path, actual_filename) if file_path is None: @@ -302,12 +307,19 @@ def get_diff(run_id, sample_id, regression_id, output_id): base_path = get_test_results_base_path() ext = result_file.regression_test_output.correct_extension if result_file.regression_test_output else '' + if ext: + ext = ext.replace('/', '').replace('\\', '').replace('..', '') expected_path = _safe_resolve(base_path, result_file.expected + ext) actual_path = _safe_resolve(base_path, result_file.got + ext) if expected_path is None or actual_path is None: return make_error_response('forbidden', INVALID_PATH_MSG, http_status=403) + if not os.path.isfile(expected_path): + return make_error_response('not_found', 'Expected output file not found on disk.', http_status=404) + if not os.path.isfile(actual_path): + return make_error_response('not_found', 'Actual output file not found on disk.', http_status=404) + max_diff_bytes = 10 * 1024 * 1024 # 10 MiB if os.path.getsize(expected_path) > max_diff_bytes or os.path.getsize(actual_path) > max_diff_bytes: return make_error_response('unprocessable', 'File too large for diff. Use download_url.', http_status=422) @@ -339,7 +351,7 @@ def get_diff(run_id, sample_id, regression_id, output_id): @mod_api.route('/runs//samples//baseline-approval', methods=['POST']) -@require_roles(['admin']) +@require_roles(['admin', 'contributor']) @require_scope('baselines:write') @validate_path_id('run_id') @validate_path_id('sample_id') @@ -368,6 +380,17 @@ def create_baseline_approval(run_id, sample_id, validated_data=None): if result_file is None: return make_error_response('not_found', 'Result file not found.', http_status=404) + actual_sample_id = ( + result_file.regression_test.sample_id + if result_file.regression_test else None + ) + if actual_sample_id != sample_id: + return make_error_response( + 'not_found', + f'Regression test {regression_id} does not belong to sample {sample_id}.', + http_status=404, + ) + if is_dummy_row(result_file): return make_error_response('unprocessable', 'Cannot approve a dummy row.', http_status=422) diff --git a/mod_api/routes/runs.py b/mod_api/routes/runs.py index 49c1bdff..16287481 100644 --- a/mod_api/routes/runs.py +++ b/mod_api/routes/runs.py @@ -118,14 +118,11 @@ def _apply_run_filters(query, created_after, created_before): .group_by(TestProgress.test_id) .subquery() ) + query = query.join(first_progress, Test.id == first_progress.c.test_id) if created_after: - query = query.join( - first_progress, Test.id == first_progress.c.test_id - ).filter(first_progress.c.min_ts >= created_after) + query = query.filter(first_progress.c.min_ts >= created_after) if created_before: - query = query.join( - first_progress, Test.id == first_progress.c.test_id - ).filter(first_progress.c.min_ts <= created_before) + query = query.filter(first_progress.c.min_ts <= created_before) return query, None @@ -144,7 +141,17 @@ def _validate_run_permissions(user, target_repo, main_repo_full): ) else: owner = target_repo.split('/')[0] - github_login = user.github_login or '' + github_login = user.github_login + + if not github_login and user.github_token: + from mod_auth.controllers import fetch_username_from_token + github_login = fetch_username_from_token(user.github_token) + if github_login: + user.github_login = github_login + from flask import g + g.db.add(user) + + github_login = github_login or '' is_owner = bool(github_login) and owner.lower() == github_login.lower() is_staff = user.role.value in ('admin', 'tester', 'contributor') @@ -214,7 +221,8 @@ def list_runs(limit=50, offset=0, sort='-created_at', created_after=None, create status_filter = request.args.get('status') if status_filter: - all_matching = query.all() + # Hard limit to prevent loading all historical runs into memory + all_matching = query.limit(1000).all() # Batch derivation logic from mod_api.services.status import batch_get_run_data statuses, timestamps = batch_get_run_data(all_matching) diff --git a/mod_api/routes/samples.py b/mod_api/routes/samples.py index b2be0824..fed55582 100644 --- a/mod_api/routes/samples.py +++ b/mod_api/routes/samples.py @@ -362,14 +362,12 @@ def get_sample_history(sample_id, limit=50, offset=0, created_after=None, create .group_by(TestProgress.test_id) .subquery() ) + if created_after or created_before: + query = query.join(first_progress, Test.id == first_progress.c.test_id) if created_after: - query = query.join( - first_progress, Test.id == first_progress.c.test_id - ).filter(first_progress.c.min_ts >= created_after) + query = query.filter(first_progress.c.min_ts >= created_after) if created_before: - query = query.join( - first_progress, Test.id == first_progress.c.test_id - ).filter(first_progress.c.min_ts <= created_before) + query = query.filter(first_progress.c.min_ts <= created_before) results = query.order_by(Test.id.desc()).all() diff --git a/mod_api/services/log_service.py b/mod_api/services/log_service.py index 9a024bc1..f5c1df3f 100644 --- a/mod_api/services/log_service.py +++ b/mod_api/services/log_service.py @@ -84,7 +84,7 @@ def _read_lines(encoding): break try: - next(f) + next(iterator) has_more = True except StopIteration: has_more = False diff --git a/mod_api/services/status.py b/mod_api/services/status.py index 8ff81e92..4933dbab 100644 --- a/mod_api/services/status.py +++ b/mod_api/services/status.py @@ -185,7 +185,12 @@ def batch_get_run_data(tests: list) -> tuple: results_by_test[r.test_id].append(r) # Preload TestResultFile - all_files = TestResultFile.query.filter(TestResultFile.test_id.in_(test_ids)).all() + from sqlalchemy.orm import joinedload + from mod_regression.models import RegressionTestOutput + all_files = TestResultFile.query.options( + joinedload(TestResultFile.regression_test_output) + .joinedload(RegressionTestOutput.multiple_files) + ).filter(TestResultFile.test_id.in_(test_ids)).all() files_by_test_and_rt = {} for f in all_files: key = (f.test_id, f.regression_test_id) From 792c915a68d89a5c117fa4d203853c36426dc455 Mon Sep 17 00:00:00 2001 From: Pulkit Chauhan Date: Sat, 13 Jun 2026 15:18:50 +0530 Subject: [PATCH 24/28] Minor fixes --- mod_api/routes/results.py | 33 +++++++++++++++++++++++++++++++ mod_api/routes/runs.py | 21 ++++++++++++-------- mod_api/services/error_service.py | 10 +++++++++- mod_api/utils.py | 18 ++++++++++------- 4 files changed, 66 insertions(+), 16 deletions(-) diff --git a/mod_api/routes/results.py b/mod_api/routes/results.py index db42382a..6019de93 100644 --- a/mod_api/routes/results.py +++ b/mod_api/routes/results.py @@ -89,6 +89,17 @@ def get_expected_output(run_id, sample_id, regression_id, output_id): if result_file is None or is_dummy_row(result_file): return make_error_response('not_found', 'Expected output not found.', http_status=404) + actual_sample_id = ( + result_file.regression_test.sample_id + if result_file.regression_test else None + ) + if actual_sample_id != sample_id: + return make_error_response( + 'not_found', + f'Regression test {regression_id} does not belong to sample {sample_id}.', + http_status=404, + ) + base_path = get_test_results_base_path() expected_filename = result_file.expected ext = '' @@ -177,6 +188,17 @@ def get_actual_output(run_id, sample_id, regression_id, output_id): if result_file is None: return make_error_response('not_found', f'No result for regression test {regression_id}.', http_status=404) + actual_sample_id = ( + result_file.regression_test.sample_id + if result_file.regression_test else None + ) + if actual_sample_id != sample_id: + return make_error_response( + 'not_found', + f'Regression test {regression_id} does not belong to sample {sample_id}.', + http_status=404, + ) + if is_dummy_row(result_file): return make_error_response( 'missing_output', @@ -276,6 +298,17 @@ def get_diff(run_id, sample_id, regression_id, output_id): if result_file is None: return make_error_response('not_found', f'No result for regression test {regression_id}.', http_status=404) + actual_sample_id = ( + result_file.regression_test.sample_id + if result_file.regression_test else None + ) + if actual_sample_id != sample_id: + return make_error_response( + 'not_found', + f'Regression test {regression_id} does not belong to sample {sample_id}.', + http_status=404, + ) + diff_ids = { 'run_id': run_id, 'sample_id': sample_id, diff --git a/mod_api/routes/runs.py b/mod_api/routes/runs.py index 16287481..f5aea200 100644 --- a/mod_api/routes/runs.py +++ b/mod_api/routes/runs.py @@ -33,8 +33,10 @@ def _serialize_run(test): """Turn a Test row into the Run response shape the spec expects.""" - status = derive_run_status(test) - timestamps = get_run_timestamps(test) + from mod_api.services.status import batch_get_run_data + statuses, timestamps = batch_get_run_data([test]) + status = statuses.get(test.id, 'queued') + ts = timestamps.get(test.id, {}) return { 'run_id': test.id, 'status': status, @@ -44,10 +46,10 @@ def _serialize_run(test): 'branch': test.branch, 'commit_sha': test.commit, 'pr_number': test.pr_nr if test.pr_nr and test.pr_nr > 0 else None, - 'created_at': timestamps['created_at'], - 'queued_at': timestamps['queued_at'], - 'started_at': timestamps['started_at'], - 'completed_at': timestamps['completed_at'], + 'created_at': ts.get('created_at'), + 'queued_at': ts.get('queued_at'), + 'started_at': ts.get('started_at'), + 'completed_at': ts.get('completed_at'), 'github_link': test.github_link if test.fork else None, } @@ -145,7 +147,7 @@ def _validate_run_permissions(user, target_repo, main_repo_full): if not github_login and user.github_token: from mod_auth.controllers import fetch_username_from_token - github_login = fetch_username_from_token(user.github_token) + github_login = fetch_username_from_token(user) if github_login: user.github_login = github_login from flask import g @@ -223,6 +225,7 @@ def list_runs(limit=50, offset=0, sort='-created_at', created_after=None, create if status_filter: # Hard limit to prevent loading all historical runs into memory all_matching = query.limit(1000).all() + is_truncated = len(all_matching) == 1000 # Batch derivation logic from mod_api.services.status import batch_get_run_data statuses, timestamps = batch_get_run_data(all_matching) @@ -236,7 +239,7 @@ def list_runs(limit=50, offset=0, sort='-created_at', created_after=None, create total = len(serialized) paged = serialized[offset:offset + limit] - return paginated_response(paged, total, limit, offset) + return paginated_response(paged, total, limit, offset, truncated=is_truncated) total = query.count() tests = query.offset(offset).limit(limit).all() @@ -290,6 +293,8 @@ def create_run(validated_data=None): except IntegrityError: g.db.rollback() fork = Fork.query.filter(Fork.github == fork_url).first() + if fork is None: + return make_error_response('internal_error', 'Failed to create or resolve fork.', http_status=500) # Validate regression test IDs against active tests only. regression_test_ids, err = _validate_regression_test_ids(regression_test_ids) diff --git a/mod_api/services/error_service.py b/mod_api/services/error_service.py index e3d82503..ed8e468d 100644 --- a/mod_api/services/error_service.py +++ b/mod_api/services/error_service.py @@ -86,7 +86,15 @@ def derive_errors_for_run(test_id: int) -> List[Dict[str, Any]]: # Preload TestResultFiles from collections import defaultdict - all_files = TestResultFile.query.filter_by(test_id=test_id).all() if results else [] + from sqlalchemy.orm import joinedload + from mod_regression.models import RegressionTestOutput + all_files = ( + TestResultFile.query.options( + joinedload(TestResultFile.regression_test_output) + .joinedload(RegressionTestOutput.multiple_files) + ) + .filter_by(test_id=test_id).all() if results else [] + ) files_by_result = defaultdict(list) for f in all_files: files_by_result[f.regression_test_id].append(f) diff --git a/mod_api/utils.py b/mod_api/utils.py index 1faecd6a..40014ae5 100644 --- a/mod_api/utils.py +++ b/mod_api/utils.py @@ -3,7 +3,7 @@ from flask import jsonify -def paginated_response(data, total, limit, offset, schema=None): +def paginated_response(data, total, limit, offset, schema=None, truncated=False): """Build an offset-paginated JSON response.""" if schema: serialized = schema.dump(data, many=True) @@ -12,14 +12,18 @@ def paginated_response(data, total, limit, offset, schema=None): next_offset = offset + limit if (offset + limit) < total else None + pagination = { + 'limit': limit, + 'offset': offset, + 'total': total, + 'next_offset': next_offset, + } + if truncated: + pagination['truncated'] = True + return jsonify({ 'data': serialized, - 'pagination': { - 'limit': limit, - 'offset': offset, - 'total': total, - 'next_offset': next_offset, - }, + 'pagination': pagination, }) From d02f52e3e110691740fd42973c4070d2a0b134a5 Mon Sep 17 00:00:00 2001 From: Pulkit Chauhan Date: Sat, 13 Jun 2026 16:03:55 +0530 Subject: [PATCH 25/28] Styling --- mod_api/__init__.py | 4 ++ mod_api/middleware/error_handler.py | 2 +- mod_api/models/api_token.py | 3 +- mod_api/routes/auth.py | 10 ++--- mod_api/routes/errors_logs.py | 3 +- mod_api/routes/runs.py | 26 ++---------- mod_api/routes/samples.py | 13 +++++- mod_api/routes/system.py | 15 +++++-- mod_api/schemas/samples.py | 2 +- mod_api/services/error_service.py | 2 + mod_api/services/log_service.py | 2 +- mod_api/services/status.py | 1 + mod_auth/controllers.py | 6 ++- openapi-ci-api.yaml | 66 ++++++++++++----------------- 14 files changed, 76 insertions(+), 79 deletions(-) diff --git a/mod_api/__init__.py b/mod_api/__init__.py index a7007a8c..966c11da 100644 --- a/mod_api/__init__.py +++ b/mod_api/__init__.py @@ -10,6 +10,10 @@ mod_api = Blueprint('api', __name__) # Middleware (registers before_request hooks and error handlers) +# WARNING: auth must be imported before rate_limit. The auth middleware +# manually calls check_rate_limit() for unauthenticated paths. If +# rate_limit is imported first, its before_request hook fires first and +# the auth middleware's manual call would double-count requests. from mod_api.middleware import auth # noqa: E402, F401 from mod_api.middleware import error_handler # noqa: E402, F401 from mod_api.middleware import rate_limit # noqa: E402, F401 diff --git a/mod_api/middleware/error_handler.py b/mod_api/middleware/error_handler.py index f0f8c49f..111afdaf 100644 --- a/mod_api/middleware/error_handler.py +++ b/mod_api/middleware/error_handler.py @@ -131,7 +131,7 @@ def handle_sqlalchemy_error(error): from flask import g log = getattr(g, 'log', None) if log: - log.error(f'Database error in API: {error}') + log.error(f'Database error in API: {type(error).__name__}') return make_error_response( 'internal_error', 'An unexpected database error occurred.', diff --git a/mod_api/models/api_token.py b/mod_api/models/api_token.py index 2986ab00..ca406bac 100644 --- a/mod_api/models/api_token.py +++ b/mod_api/models/api_token.py @@ -11,7 +11,8 @@ from typing import List from argon2 import PasswordHasher -from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError +from argon2.exceptions import (InvalidHashError, VerificationError, + VerifyMismatchError) from sqlalchemy import (Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint) from sqlalchemy.orm import relationship diff --git a/mod_api/routes/auth.py b/mod_api/routes/auth.py index e67ba052..59743d23 100644 --- a/mod_api/routes/auth.py +++ b/mod_api/routes/auth.py @@ -182,12 +182,12 @@ def revoke_specific_token(token_id): is_admin = g.api_user.role.value == 'admin' token = ApiToken.query.filter_by(id=token_id).first() - if not token: - return make_error_response('not_found', f'Token {token_id} not found.', http_status=404) + # Non-admins get a uniform 404 for both "doesn't exist" and "belongs to + # another user" to prevent token-ID enumeration. + is_own = token is not None and token.user_id == g.api_user.id + if not token or (not is_admin and not is_own): + return make_error_response('not_found', 'Token not found.', http_status=404) - is_own = token.user_id == g.api_user.id - if not is_own and not is_admin: - return make_error_response('forbidden', 'Only admins can revoke tokens of other users.', http_status=403) if not is_own and not g.api_token.has_scope('tokens:manage'): return make_error_response('forbidden', 'Cross-user revocation requires tokens:manage scope.', http_status=403) diff --git a/mod_api/routes/errors_logs.py b/mod_api/routes/errors_logs.py index 8d5bc8be..1d8c9367 100644 --- a/mod_api/routes/errors_logs.py +++ b/mod_api/routes/errors_logs.py @@ -165,7 +165,8 @@ def get_run_logs(run_id, limit=100, cursor=None): except FileNotFoundError: return make_error_response( 'log_not_found', - f'Log file for run {run_id} is not available locally. It may have been moved to cold storage. Please download it via the artifacts API.', + f'Log file for run {run_id} is not available locally. ' + 'It may have been moved to cold storage. Please download it via the artifacts API.', details={'run_id': run_id, 'action_required': 'Use the /runs/{run_id}/artifacts/logs endpoint'}, http_status=404, ) diff --git a/mod_api/routes/runs.py b/mod_api/routes/runs.py index f5aea200..a1a9f031 100644 --- a/mod_api/routes/runs.py +++ b/mod_api/routes/runs.py @@ -33,25 +33,7 @@ def _serialize_run(test): """Turn a Test row into the Run response shape the spec expects.""" - from mod_api.services.status import batch_get_run_data - statuses, timestamps = batch_get_run_data([test]) - status = statuses.get(test.id, 'queued') - ts = timestamps.get(test.id, {}) - return { - 'run_id': test.id, - 'status': status, - 'platform': test.platform.value, - 'test_type': 'pr' if test.test_type == TestType.pull_request else 'commit', - 'repository': test.fork.github_name if test.fork else 'unknown', - 'branch': test.branch, - 'commit_sha': test.commit, - 'pr_number': test.pr_nr if test.pr_nr and test.pr_nr > 0 else None, - 'created_at': ts.get('created_at'), - 'queued_at': ts.get('queued_at'), - 'started_at': ts.get('started_at'), - 'completed_at': ts.get('completed_at'), - 'github_link': test.github_link if test.fork else None, - } + return _batch_serialize([test])[0] def _batch_serialize(tests, statuses=None, timestamps=None): @@ -134,7 +116,7 @@ def _validate_run_permissions(user, target_repo, main_repo_full): if user.role.value not in ('admin', 'tester', 'contributor'): return make_error_response( 'forbidden', - 'Only contributors and above can trigger runs for the main repository.', + 'Only admins, testers, and contributors can trigger runs for the main repository.', details={ 'required_roles': ['admin', 'tester', 'contributor'], 'repository': target_repo, @@ -144,7 +126,7 @@ def _validate_run_permissions(user, target_repo, main_repo_full): else: owner = target_repo.split('/')[0] github_login = user.github_login - + if not github_login and user.github_token: from mod_auth.controllers import fetch_username_from_token github_login = fetch_username_from_token(user) @@ -152,7 +134,7 @@ def _validate_run_permissions(user, target_repo, main_repo_full): user.github_login = github_login from flask import g g.db.add(user) - + github_login = github_login or '' is_owner = bool(github_login) and owner.lower() == github_login.lower() diff --git a/mod_api/routes/samples.py b/mod_api/routes/samples.py index fed55582..f2345640 100644 --- a/mod_api/routes/samples.py +++ b/mod_api/routes/samples.py @@ -350,7 +350,18 @@ def get_sample_history(sample_id, limit=50, offset=0, created_after=None, create platform = request.args.get('platform') if platform: - query = query.filter(Test.platform == platform) + try: + from mod_test.models import TestPlatform + platform_enum = TestPlatform.from_string(platform) + query = query.filter(Test.platform == platform_enum) + except Exception: + from mod_test.models import TestPlatform + valid_platforms = ', '.join(TestPlatform.values()) + return make_error_response( + 'validation_error', + f'Invalid platform: {platform}. Must be one of: {valid_platforms}.', + http_status=400, + ) if created_after or created_before: from flask import g diff --git a/mod_api/routes/system.py b/mod_api/routes/system.py index 30001abe..1abd823c 100644 --- a/mod_api/routes/system.py +++ b/mod_api/routes/system.py @@ -226,6 +226,7 @@ def _get_output_artifacts(run_id): artifacts = [] result_files = TestResultFile.query.filter_by(test_id=run_id).all() base_path = get_test_results_base_path() + from mod_api.routes.results import _safe_resolve for rf in result_files: if is_dummy_row(rf): continue @@ -234,7 +235,7 @@ def _get_output_artifacts(run_id): expected_name = rf.expected + ext expected_url, expected_status = resolve_artifact(f'TestResults/{expected_name}') - local_expected = os.path.join(base_path, expected_name) + local_expected = _safe_resolve(base_path, expected_name) artifacts.append({ 'artifact_id': f'expected_{run_id}_{rf.regression_test_id}_{rf.regression_test_output_id}', @@ -243,7 +244,10 @@ def _get_output_artifacts(run_id): 'type': 'expected_output', 'filename': expected_name, 'content_type': OCTET_STREAM, - 'size_bytes': os.path.getsize(local_expected) if os.path.isfile(local_expected) else None, + 'size_bytes': ( + os.path.getsize(local_expected) + if local_expected and os.path.isfile(local_expected) else None + ), 'storage_status': expected_status, 'download_url': expected_url, }) @@ -251,7 +255,7 @@ def _get_output_artifacts(run_id): if rf.got is not None: actual_name = rf.got + ext actual_url, actual_status = resolve_artifact(f'TestResults/{actual_name}') - local_actual = os.path.join(base_path, actual_name) + local_actual = _safe_resolve(base_path, actual_name) artifacts.append({ 'artifact_id': f'actual_{run_id}_{rf.regression_test_id}_{rf.regression_test_output_id}', @@ -260,7 +264,10 @@ def _get_output_artifacts(run_id): 'type': 'sample_output', 'filename': actual_name, 'content_type': OCTET_STREAM, - 'size_bytes': os.path.getsize(local_actual) if os.path.isfile(local_actual) else None, + 'size_bytes': ( + os.path.getsize(local_actual) + if local_actual and os.path.isfile(local_actual) else None + ), 'storage_status': actual_status, 'download_url': actual_url, }) diff --git a/mod_api/schemas/samples.py b/mod_api/schemas/samples.py index c064b4f2..7fdafc9c 100644 --- a/mod_api/schemas/samples.py +++ b/mod_api/schemas/samples.py @@ -26,7 +26,7 @@ class RunSampleSchema(Schema): expected_rc = fields.Integer(allow_none=True) runtime_ms = fields.Integer(allow_none=True) command = fields.String(allow_none=True) - category = fields.String(allow_none=True) + categories = fields.List(fields.String(), load_default=[]) outputs = fields.List(fields.Nested(OutputFileSchema), load_default=[]) diff --git a/mod_api/services/error_service.py b/mod_api/services/error_service.py index ed8e468d..8781bd0c 100644 --- a/mod_api/services/error_service.py +++ b/mod_api/services/error_service.py @@ -86,7 +86,9 @@ def derive_errors_for_run(test_id: int) -> List[Dict[str, Any]]: # Preload TestResultFiles from collections import defaultdict + from sqlalchemy.orm import joinedload + from mod_regression.models import RegressionTestOutput all_files = ( TestResultFile.query.options( diff --git a/mod_api/services/log_service.py b/mod_api/services/log_service.py index f5c1df3f..01ed8ee3 100644 --- a/mod_api/services/log_service.py +++ b/mod_api/services/log_service.py @@ -10,7 +10,7 @@ from mod_api.services.storage import get_log_file_path -def _parse_cursor(cursor: Optional[str]) -> int: +def _parse_cursor(cursor: Optional[int]) -> int: if not cursor: return 0 try: diff --git a/mod_api/services/status.py b/mod_api/services/status.py index 4933dbab..2b746de1 100644 --- a/mod_api/services/status.py +++ b/mod_api/services/status.py @@ -186,6 +186,7 @@ def batch_get_run_data(tests: list) -> tuple: # Preload TestResultFile from sqlalchemy.orm import joinedload + from mod_regression.models import RegressionTestOutput all_files = TestResultFile.query.options( joinedload(TestResultFile.regression_test_output) diff --git a/mod_auth/controllers.py b/mod_auth/controllers.py index bc8ea397..2d6e4d07 100755 --- a/mod_auth/controllers.py +++ b/mod_auth/controllers.py @@ -189,11 +189,13 @@ def fetch_username_from_token(user=None) -> Any: session = requests.Session() session.auth = (user.email, user.github_token) try: - response = session.get(url) + response = session.get(url, timeout=(3.05, 10)) data = response.json() return data.get('login') except Exception as e: - g.log.error('Failed to fetch the user token') + import logging + log = getattr(g, 'log', logging.getLogger(__name__)) + log.error('Failed to fetch the user token') return None diff --git a/openapi-ci-api.yaml b/openapi-ci-api.yaml index ae4ea575..1b5ea845 100644 --- a/openapi-ci-api.yaml +++ b/openapi-ci-api.yaml @@ -1501,7 +1501,7 @@ paths: in: query schema: type: string - enum: [build_log, sample_output, expected_output, diff, media_info, binary] + enum: [build_log, sample_output, expected_output, diff, media_info, binary, coredump, combined_stdout] responses: "200": description: Paginated run artifacts @@ -1858,6 +1858,12 @@ components: type: integer minimum: 0 nullable: true + truncated: + type: boolean + description: > + Present and true when the result set was capped by an + internal safety limit (e.g. status-filter on runs). When + true, total may undercount the real number of matches. CursorPage: type: object @@ -2204,37 +2210,20 @@ components: RunConfig: type: object - required: [run_id] + required: [run_id, platform, branch, commit_sha, regression_test_ids] properties: run_id: type: integer minimum: 1 - matrix: - type: array - maxItems: 500 - items: - type: object - required: [regression_test_id] - properties: - regression_test_id: - type: integer - minimum: 1 - sample_name: - type: string - maxLength: 255 - nullable: true - command: - type: string - maxLength: 500 - input_type: - type: string - maxLength: 50 - nullable: true - output_type: - type: string - maxLength: 50 - nullable: true - additionalProperties: false + platform: + type: string + enum: [linux, windows] + branch: + type: string + maxLength: 100 + commit_sha: + type: string + pattern: '^[a-fA-F0-9]{40}$' regression_test_ids: type: array maxItems: 500 @@ -2246,12 +2235,6 @@ components: IDs included in this run. When no custom set was configured, all regression tests are returned. Implementers must filter by active=true — get_customized_regressiontests() does not do this. - command_defaults: - type: array - maxItems: 50 - items: - type: string - maxLength: 100 Sample: type: object @@ -2341,10 +2324,13 @@ components: type: string maxLength: 255 nullable: true - category: - type: string - maxLength: 100 - nullable: true + categories: + type: array + maxItems: 50 + items: + type: string + maxLength: 100 + description: Category labels from the regression test definition. command: type: string maxLength: 500 @@ -2406,7 +2392,7 @@ components: minimum: 1 status: type: string - enum: [pass, fail, skipped, missing_output] + enum: [pass, fail, skipped, missing_output, running, not_started] platform: type: string enum: [linux, windows] @@ -2810,7 +2796,7 @@ components: nullable: true type: type: string - enum: [build_log, sample_output, expected_output, diff, media_info, binary] + enum: [build_log, sample_output, expected_output, actual_output, diff, media_info, binary, coredump, combined_stdout] filename: type: string maxLength: 255 From 29be01178ab08cb03e7a3b154b006f73e7bf96c2 Mon Sep 17 00:00:00 2001 From: Pulkit Chauhan Date: Sat, 13 Jun 2026 16:58:54 +0530 Subject: [PATCH 26/28] sonarqube --- mod_api/routes/results.py | 194 +++++++++++++++++--------------------- mod_api/routes/samples.py | 68 +++++++------ 2 files changed, 123 insertions(+), 139 deletions(-) diff --git a/mod_api/routes/results.py b/mod_api/routes/results.py index 6019de93..c9100b9f 100644 --- a/mod_api/routes/results.py +++ b/mod_api/routes/results.py @@ -64,66 +64,46 @@ def _parse_output_id(): return request.args.get('output_id', type=int) -@mod_api.route( - '/runs//samples//regression-tests//outputs//expected', - methods=['GET'] -) -@require_scope('results:read') -@validate_path_id('run_id') -@validate_path_id('sample_id') -def get_expected_output(run_id, sample_id, regression_id, output_id): - """Return the expected output file for a regression test result.""" +def _validate_result_file_access(run_id, sample_id, regression_id, output_id): + """Validate access to a result file and return it, or an error response.""" test = Test.query.filter(Test.id == run_id).first() if test is None: - return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) - - result = TestResult.query.filter_by( - test_id=run_id, - regression_test_id=regression_id, - ).first() - if result is None: - return make_error_response('not_found', f'Regression test result {regression_id} not found.', http_status=404) + return None, make_error_response('not_found', f'Run {run_id} not found.', http_status=404) result_file = _find_result_file(run_id, regression_id, output_id) - if result_file is None or is_dummy_row(result_file): - return make_error_response('not_found', 'Expected output not found.', http_status=404) + if result_file is None: + return None, make_error_response( + 'not_found', + f'No result for regression test {regression_id}.', + http_status=404, + ) actual_sample_id = ( result_file.regression_test.sample_id if result_file.regression_test else None ) if actual_sample_id != sample_id: - return make_error_response( + return None, make_error_response( 'not_found', f'Regression test {regression_id} does not belong to sample {sample_id}.', http_status=404, ) - base_path = get_test_results_base_path() - expected_filename = result_file.expected - ext = '' - if result_file.regression_test_output: - ext = result_file.regression_test_output.correct_extension - if ext: - ext = ext.replace('/', '').replace('\\', '').replace('..', '') - expected_filename += ext - - file_path = _safe_resolve(base_path, expected_filename) - if file_path is None: - return make_error_response('forbidden', INVALID_PATH_MSG, http_status=403) + return result_file, None - fmt = request.args.get('format', 'base64') +def _read_output_file(file_path, fmt, is_expected=True): + """Read output file and return properties.""" if not os.path.isfile(file_path): - return make_error_response( + type_str = 'Expected' if is_expected else 'Actual' + return None, make_error_response( 'not_found', - 'Expected output file not found on disk.', + f'{type_str} output file not found on disk.', http_status=404, ) sha256 = file_sha256(file_path) - file_size = os.path.getsize(file_path) truncated = False download_url = None @@ -131,7 +111,8 @@ def get_expected_output(run_id, sample_id, regression_id, output_id): if file_size > 1048576: truncated = True from mod_api.services.storage import resolve_artifact - download_url, _ = resolve_artifact(f'TestResults/{expected_filename}') + filename = os.path.basename(file_path) + download_url, _ = resolve_artifact(f'TestResults/{filename}') if fmt == 'text': try: @@ -139,14 +120,64 @@ def get_expected_output(run_id, sample_id, regression_id, output_id): content = f.read(1048576) encoding = 'utf-8' except Exception: - return make_error_response('internal_error', READ_ERROR_MSG, http_status=500) + return None, make_error_response('internal_error', READ_ERROR_MSG, http_status=500) else: try: with open(file_path, 'rb') as f: content = base64.b64encode(f.read(1048576)).decode('ascii') + encoding = 'base64' except Exception: - return make_error_response('internal_error', READ_ERROR_MSG, http_status=500) - encoding = 'base64' + return None, make_error_response('internal_error', READ_ERROR_MSG, http_status=500) + + return { + 'content': content, + 'encoding': encoding, + 'sha256': sha256, + 'truncated': truncated, + 'download_url': download_url, + }, None + + +@mod_api.route( + '/runs//samples//regression-tests//outputs//expected', + methods=['GET'] +) +@require_scope('results:read') +@validate_path_id('run_id') +@validate_path_id('sample_id') +def get_expected_output(run_id, sample_id, regression_id, output_id): + """Return the expected output file for a regression test result.""" + result_file, err = _validate_result_file_access(run_id, sample_id, regression_id, output_id) + if err: + return err + + if is_dummy_row(result_file): + return make_error_response('not_found', 'Expected output not found.', http_status=404) + + base_path = get_test_results_base_path() + expected_filename = result_file.expected + ext = '' + if result_file.regression_test_output: + ext = result_file.regression_test_output.correct_extension + if ext: + ext = ext.replace('/', '').replace('\\', '').replace('..', '') + expected_filename += ext + + file_path = _safe_resolve(base_path, expected_filename) + if file_path is None: + return make_error_response('forbidden', INVALID_PATH_MSG, http_status=403) + + fmt = request.args.get('format', 'base64') + + data, err = _read_output_file(file_path, fmt, is_expected=True) + if err: + return err + + content = data['content'] + encoding = data['encoding'] + sha256 = data['sha256'] + truncated = data['truncated'] + download_url = data['download_url'] return single_response({ 'run_id': run_id, @@ -179,25 +210,9 @@ def get_actual_output(run_id, sample_id, regression_id, output_id): missing. We return 303 (redirect to expected) in that case. Missing output (the dummy sentinel row) returns 404. """ - test = Test.query.filter(Test.id == run_id).first() - if test is None: - return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) - - result_file = _find_result_file(run_id, regression_id, output_id) - - if result_file is None: - return make_error_response('not_found', f'No result for regression test {regression_id}.', http_status=404) - - actual_sample_id = ( - result_file.regression_test.sample_id - if result_file.regression_test else None - ) - if actual_sample_id != sample_id: - return make_error_response( - 'not_found', - f'Regression test {regression_id} does not belong to sample {sample_id}.', - http_status=404, - ) + result_file, err = _validate_result_file_access(run_id, sample_id, regression_id, output_id) + if err: + return err if is_dummy_row(result_file): return make_error_response( @@ -231,38 +246,15 @@ def get_actual_output(run_id, sample_id, regression_id, output_id): fmt = request.args.get('format', 'base64') - if not os.path.isfile(file_path): - return make_error_response( - 'not_found', - 'Actual output file not found on disk.', - http_status=404, - ) - - sha256 = file_sha256(file_path) - - file_size = os.path.getsize(file_path) - truncated = False - download_url = None - - if file_size > 1048576: - truncated = True - from mod_api.services.storage import resolve_artifact - download_url, _ = resolve_artifact(f'TestResults/{actual_filename}') + data, err = _read_output_file(file_path, fmt, is_expected=False) + if err: + return err - if fmt == 'text': - try: - with open(file_path, 'r', encoding='utf-8', errors='replace') as f: - content = f.read(1048576) - encoding = 'utf-8' - except Exception: - return make_error_response('internal_error', READ_ERROR_MSG, http_status=500) - else: - try: - with open(file_path, 'rb') as f: - content = base64.b64encode(f.read(1048576)).decode('ascii') - encoding = 'base64' - except Exception: - return make_error_response('internal_error', READ_ERROR_MSG, http_status=500) + content = data['content'] + encoding = data['encoding'] + sha256 = data['sha256'] + truncated = data['truncated'] + download_url = data['download_url'] return single_response({ 'run_id': run_id, @@ -289,25 +281,9 @@ def get_actual_output(run_id, sample_id, regression_id, output_id): @validate_path_id('sample_id') def get_diff(run_id, sample_id, regression_id, output_id): """Structured diff between expected and actual output.""" - test = Test.query.filter(Test.id == run_id).first() - if test is None: - return make_error_response('not_found', f'Run {run_id} not found.', http_status=404) - - result_file = _find_result_file(run_id, regression_id, output_id) - - if result_file is None: - return make_error_response('not_found', f'No result for regression test {regression_id}.', http_status=404) - - actual_sample_id = ( - result_file.regression_test.sample_id - if result_file.regression_test else None - ) - if actual_sample_id != sample_id: - return make_error_response( - 'not_found', - f'Regression test {regression_id} does not belong to sample {sample_id}.', - http_status=404, - ) + result_file, err = _validate_result_file_access(run_id, sample_id, regression_id, output_id) + if err: + return err diff_ids = { 'run_id': run_id, diff --git a/mod_api/routes/samples.py b/mod_api/routes/samples.py index f2345640..c9fd3860 100644 --- a/mod_api/routes/samples.py +++ b/mod_api/routes/samples.py @@ -319,36 +319,10 @@ def _process_history_entries(results, files_by_result, status_filter): return entries -@mod_api.route('/samples//history', methods=['GET']) -@require_scope('runs:read') -@validate_path_id('sample_id') -@validate_offset_pagination() -@validate_date_range -def get_sample_history(sample_id, limit=50, offset=0, created_after=None, created_before=None): - """ - Show how a sample performed across different runs. - - Use failure_signature to tell apart genuine regressions from infra flakes. - """ - sample = Sample.query.filter(Sample.id == sample_id).first() - if sample is None: - return make_error_response('not_found', f'Sample {sample_id} not found.', http_status=404) - - regression_tests = RegressionTest.query.filter_by(sample_id=sample_id).all() - rt_ids = [rt.id for rt in regression_tests] - - if not rt_ids: - return paginated_response([], 0, limit, offset) - - query = TestResult.query.filter( - TestResult.regression_test_id.in_(rt_ids) - ).join(Test, Test.id == TestResult.test_id) - - branch = request.args.get('branch') +def _apply_history_filters(query, branch, platform, created_after, created_before): if branch: query = query.filter(Test.branch == branch) - platform = request.args.get('platform') if platform: try: from mod_test.models import TestPlatform @@ -357,7 +331,7 @@ def get_sample_history(sample_id, limit=50, offset=0, created_after=None, create except Exception: from mod_test.models import TestPlatform valid_platforms = ', '.join(TestPlatform.values()) - return make_error_response( + return None, make_error_response( 'validation_error', f'Invalid platform: {platform}. Must be one of: {valid_platforms}.', http_status=400, @@ -373,13 +347,47 @@ def get_sample_history(sample_id, limit=50, offset=0, created_after=None, create .group_by(TestProgress.test_id) .subquery() ) - if created_after or created_before: - query = query.join(first_progress, Test.id == first_progress.c.test_id) + query = query.join(first_progress, Test.id == first_progress.c.test_id) if created_after: query = query.filter(first_progress.c.min_ts >= created_after) if created_before: query = query.filter(first_progress.c.min_ts <= created_before) + return query, None + + +@mod_api.route('/samples//history', methods=['GET']) +@require_scope('runs:read') +@validate_path_id('sample_id') +@validate_offset_pagination() +@validate_date_range +def get_sample_history(sample_id, limit=50, offset=0, created_after=None, created_before=None): + """ + Show how a sample performed across different runs. + + Use failure_signature to tell apart genuine regressions from infra flakes. + """ + sample = Sample.query.filter(Sample.id == sample_id).first() + if sample is None: + return make_error_response('not_found', f'Sample {sample_id} not found.', http_status=404) + + regression_tests = RegressionTest.query.filter_by(sample_id=sample_id).all() + rt_ids = [rt.id for rt in regression_tests] + + if not rt_ids: + return paginated_response([], 0, limit, offset) + + query = TestResult.query.filter( + TestResult.regression_test_id.in_(rt_ids) + ).join(Test, Test.id == TestResult.test_id) + + branch = request.args.get('branch') + platform = request.args.get('platform') + + query, err = _apply_history_filters(query, branch, platform, created_after, created_before) + if err: + return err + results = query.order_by(Test.id.desc()).all() status_filter = request.args.get('status') From 3872a0e106a124d99110d9fab0d461e14ab50a63 Mon Sep 17 00:00:00 2001 From: Pulkit Chauhan Date: Sat, 13 Jun 2026 17:03:04 +0530 Subject: [PATCH 27/28] sonarqube --- mod_api/routes/samples.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/mod_api/routes/samples.py b/mod_api/routes/samples.py index c9fd3860..8ba56f55 100644 --- a/mod_api/routes/samples.py +++ b/mod_api/routes/samples.py @@ -26,10 +26,7 @@ from mod_test.models import Test, TestResult, TestResultFile -def _serialize_run_sample(result, result_files): - """Build the per-regression-test result dict for a run.""" - status = derive_sample_status(result, result_files) - +def _serialize_outputs(result_files): outputs = [] for rf in result_files: if is_dummy_row(rf): @@ -42,6 +39,13 @@ def _serialize_run_sample(result, result_files): ), 'status': derive_output_status(rf), }) + return outputs + + +def _serialize_run_sample(result, result_files): + """Build the per-regression-test result dict for a run.""" + status = derive_sample_status(result, result_files) + outputs = _serialize_outputs(result_files) sample_name = None sample_id = None From a5de5c789ff8d16befaf2a6311ed0102c80a4597 Mon Sep 17 00:00:00 2001 From: Pulkit Chauhan Date: Sat, 13 Jun 2026 17:07:20 +0530 Subject: [PATCH 28/28] sonarqube --- mod_api/routes/results.py | 45 ++++++++++++++++++++++----------------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/mod_api/routes/results.py b/mod_api/routes/results.py index c9100b9f..3aa914e2 100644 --- a/mod_api/routes/results.py +++ b/mod_api/routes/results.py @@ -272,6 +272,29 @@ def get_actual_output(run_id, sample_id, regression_id, output_id): }) +def _handle_missing_diff(result_file, format_type, diff_ids): + if is_dummy_row(result_file): + if format_type == 'unified': + return single_response({**diff_ids, 'format': 'unified', 'content': ''}) + return single_response({ + **diff_ids, + 'status': 'missing_actual', + 'summary': {'added_lines': 0, 'removed_lines': 0, 'changed_hunks': 0}, + 'hunks': [], + }) + + if result_file.got is None: + if format_type == 'unified': + return single_response({**diff_ids, 'format': 'unified', 'content': ''}) + return single_response({ + **diff_ids, + 'status': 'identical', + 'summary': {'added_lines': 0, 'removed_lines': 0, 'changed_hunks': 0}, + 'hunks': [], + }) + return None + + @mod_api.route( '/runs//samples//regression-tests//outputs//diff', methods=['GET'] @@ -294,25 +317,9 @@ def get_diff(run_id, sample_id, regression_id, output_id): format_type = request.args.get('format', 'structured') - if is_dummy_row(result_file): - if format_type == 'unified': - return single_response({**diff_ids, 'format': 'unified', 'content': ''}) - return single_response({ - **diff_ids, - 'status': 'missing_actual', - 'summary': {'added_lines': 0, 'removed_lines': 0, 'changed_hunks': 0}, - 'hunks': [], - }) - - if result_file.got is None: - if format_type == 'unified': - return single_response({**diff_ids, 'format': 'unified', 'content': ''}) - return single_response({ - **diff_ids, - 'status': 'identical', - 'summary': {'added_lines': 0, 'removed_lines': 0, 'changed_hunks': 0}, - 'hunks': [], - }) + missing_response = _handle_missing_diff(result_file, format_type, diff_ids) + if missing_response: + return missing_response base_path = get_test_results_base_path() ext = result_file.regression_test_output.correct_extension if result_file.regression_test_output else ''