Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 86 additions & 2 deletions .github/workflows/quality-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -164,17 +164,22 @@ jobs:
# the build on any skip.
# The API reads PGDATABASE (api/config.py). The schema lives in
# te_mgmt_dev, not the job-level default "postgres" (issue #45).
# Accessibility tests need Playwright browsers and a running frontend,
# neither of which exists in this job — they run in the `accessibility`
# job below. Deselected, not skipped: test_report.py accounts for them
# under NOT RUN.
- name: Full test suite — final result with skip accounting
env:
PGDATABASE: te_mgmt_dev
run: python3 scripts/test_report.py --strict
run: python3 scripts/test_report.py --markers "not accessibility" --strict

# #194: measure api/services coverage with per-loader floors.
# Accessibility tests need a browser and a running frontend (own job below).
- name: api/services coverage gate
env:
PGDATABASE: te_mgmt_dev
run: |
python -m pytest tests/ \
python -m pytest tests/ -m "not accessibility" \
--cov=api/services \
--cov-report=json:coverage-services.json \
--cov-report=term-missing \
Expand All @@ -189,3 +194,82 @@ jobs:
path: evals/reports/
if-no-files-found: ignore
retention-days: 30

# WCAG 2.1 AA scans (#168). Needs a browser and a running frontend, so it
# cannot ride either job above.
#
# The scan step is continue-on-error ON PURPOSE: the frontend has known,
# unfixed accessibility defects (sonner toast contrast, selects without
# accessible names, download-menu aria-labels, upload progress bar), so the
# scans fail today and that failure is the backlog, not a regression. The
# report is uploaded as an artifact either way. Remove continue-on-error —
# and only then add this job to branch protection — once the frontend fixes
# land.
accessibility:
name: accessibility (WCAG 2.1 AA)
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v7

- name: Setup Python
uses: actions/setup-python@v7
with:
python-version: '3.11'
cache: 'pip'
cache-dependency-path: |
requirements-dev.txt
api/requirements.txt

- name: Install dev dependencies
run: pip install -r requirements-dev.txt -r api/requirements.txt

- name: Install Playwright Chromium
run: python -m playwright install --with-deps chromium

- name: Setup Node
uses: actions/setup-node@v5
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: csv-table-hub-main/package-lock.json

# npm, not bun: bun.lock is out of step with package.json (zod ^3 vs ^4),
# so `bun install --frozen-lockfile` fails; package-lock.json is current.
- name: Install frontend dependencies
working-directory: csv-table-hub-main
run: npm ci --no-audit --no-fund

- name: Start the Vite dev server
working-directory: csv-table-hub-main
run: |
npx vite dev --host 127.0.0.1 --port 5173 --strictPort > "$RUNNER_TEMP/vite.log" 2>&1 &
for i in $(seq 1 60); do
if curl -fsS -o /dev/null http://127.0.0.1:5173/auth; then
echo "dev server up after ${i}s"; exit 0
fi
sleep 1
done
echo "::error::dev server did not answer on 127.0.0.1:5173 within 60s"
cat "$RUNNER_TEMP/vite.log"
exit 1

- name: WCAG 2.1 AA scans
continue-on-error: true
env:
A11Y_BASE_URL: http://127.0.0.1:5173
run: |
python -m pytest tests/accessibility -v \
--junitxml="$RUNNER_TEMP/accessibility.xml"

- name: Upload accessibility results
if: always()
uses: actions/upload-artifact@v7
with:
name: accessibility-results
path: |
${{ runner.temp }}/accessibility.xml
${{ runner.temp }}/vite.log
if-no-files-found: ignore
retention-days: 30
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,9 @@ _norton_/
extend to Oracle dbs/

# Runtime test artifacts (regenerate with scripts/test_report.py)
# tests/snapshots/ holds committed fixtures AND runtime-generated output.
# tests/unit/snapshots/ holds committed fixtures AND runtime-generated output.
# Ignore only the generated file; fixtures must be committed.
tests/snapshots/all_valid_expected_valid.csv
tests/unit/snapshots/all_valid_expected_valid.csv
tfplan
*.tfplan
terraform-provider-*.log
Expand Down
30 changes: 30 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
.PHONY: test-free test-gate test-evals test-e2e test-all \
test-unit test-system test-sit test-performance test-accessibility test-uat \
test-catalogue \
lint lint-diff health \
eval-list eval-compare eval-summary select-tests \
csv-load csv-list csv-demo
Expand Down Expand Up @@ -32,6 +34,34 @@ test-all:
pytest tests/ --cov=build/csv --cov=evals --cov-report=term-missing
python3 evals/runner.py --tiers p,i,s,e --verbose

# ── Test categories (#167) ────────────────────────────────────────────────────
# One target per folder under tests/. tests/conftest.py gives every test the
# marker of its folder, so `make test-sit` and `pytest -m sit` select the same
# set. unit needs nothing; the rest need PostgreSQL, and accessibility also
# needs Playwright browsers plus a running frontend (see tests/README.md).

test-unit:
pytest tests/unit -v

test-system:
pytest tests/system -v

test-sit:
pytest tests/system_integration -v

test-performance:
pytest tests/performance -v

test-accessibility:
pytest tests/accessibility -v

test-uat:
pytest tests/uat -v

# Regenerate docs/TEST_CASES.md from the test tree (#169).
test-catalogue:
python3 scripts/test_catalogue.py

# ── Code quality ──────────────────────────────────────────────────────────────

# Full flake8 + bandit scan over all Python source.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ organisations ──< personnel
| Connection Limit | 10 | 15 | 25 | 50 |
| Seed Data | ✅ | ✅ | ❌ | ❌ |

Staging and Prod are **never seeded**. The `tests/environments/` contract tests
Staging and Prod are **never seeded**. The `tests/system_integration/environments/` contract tests
verify this automatically.

---
Expand Down
2 changes: 1 addition & 1 deletion api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ async def tenant_scope(authorization: str = Header(default=""),


# M2c (#94): which roles may perform each action. docs/ROLES.md shows the same
# table, and tests/test_roles.py checks that the two agree.
# table, and tests/system_integration/test_roles.py checks that the two agree.
PERMISSIONS: dict[str, frozenset[str]] = {
"preview": frozenset({"uploader", "tenant_admin"}),
"upload": frozenset({"uploader", "tenant_admin"}),
Expand Down
2 changes: 1 addition & 1 deletion api/routers/audit_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
O-04 update: the table is no longer delete-only. Successful uploads also write a
row, through the shared writer in api/services/audit.py, so this feed now covers
creations as well as destructions. Integration coverage lives in
tests/test_api_integration.py::AuditLogTrail."""
tests/system_integration/test_api_integration.py::AuditLogTrail."""

from __future__ import annotations

Expand Down
36 changes: 26 additions & 10 deletions api/services/dynamic_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,19 +125,16 @@ def upload_dynamic(

try:
return _do_upload(file_name, types, overwrite, logs, schema, rows, file_hash, session=session)
except psycopg2.errors.UniqueViolation:
except (psycopg2.errors.UniqueViolation, psycopg2.errors.DuplicateTable,
psycopg2.errors.DuplicateObject):
# #81: an identical concurrent upload won a race that _do_upload could
# not settle itself (e.g. both creating csv_<hash> at once). Nothing
# from this request was committed.
# from this request was committed. Which error the loser sees depends on
# where the race lands: a unique index (UniqueViolation), the table
# (DuplicateTable) or its row type (DuplicateObject) — #191 caught the
# last two surfacing as a generic "database error".
_log(logs, "duplicate_check", "Lost race to a concurrent identical upload", "warn")
return {
"status": "duplicate_file",
"reason": "content",
"existingFileName": file_name,
"tableName": f"csv_{file_hash[:16]}",
"existingRowCount": 0,
"logs": logs,
}
return _lost_race(logs, schema, file_name, file_hash)
except psycopg2.Error as exc:
# Mirror te_loader's structured-error contract: an unexpected DB error
# (e.g. a column type edge case not caught by cast_value) should not
Expand All @@ -151,6 +148,25 @@ def upload_dynamic(
}


def _lost_race(logs: list[dict], schema: str, file_name: str, file_hash: str) -> dict:
"""Name the upload that won. The winner commits its table and its registry
row together, so once the loser has seen the clash the row is visible."""
with Conn() as conn, conn.cursor() as cur:
cur.execute(
sql.SQL("SELECT file_name, table_name, row_count, file_hash FROM {}.csv_files "
"WHERE file_hash = %s OR file_name = %s ORDER BY (file_hash = %s) DESC LIMIT 1")
.format(sql.Identifier(schema)), (file_hash, file_name, file_hash))
existing = cur.fetchone()
return {
"status": "duplicate_file",
"reason": "content" if existing and existing[3] == file_hash else "name",
"existingFileName": existing[0] if existing else file_name,
"tableName": existing[1] if existing else f"csv_{file_hash[:16]}",
"existingRowCount": (existing[2] or 0) if existing else 0,
"logs": logs,
}


def _do_upload(
file_name: str,
types: list[str] | None,
Expand Down
53 changes: 53 additions & 0 deletions csv-table-hub-main/src/__tests__/export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,3 +305,56 @@ describe("downloadAuditReport", () => {
expect(text).toContain("Imported");
});
});
// ─── XLSX formula guard — the cell is stored as text (#193) ───────────────────
// The tests above read values back through sheet_to_json. What actually stops a
// spreadsheet evaluating a cell is that it is stored as TEXT with no formula, so
// assert that directly, for each payload class the issue names, and for the audit
// report as well as the error report.

describe("XLSX formula guard: cell type", () => {
let cap: ReturnType<typeof captureBlob>;

beforeEach(() => {
cap = captureBlob();
});
afterEach(() => {
cap.reset();
});

async function cellAt(addr: string, sheet: string): Promise<XLSX.CellObject> {
const wb = XLSX.read(await cap.blob!.arrayBuffer(), { type: "array" });
return wb.Sheets[sheet][addr];
}

it.each(["=cmd", "+cmd", "-cmd", "@cmd", "\t=cmd"])(
"error report: %j is a text cell with no formula, carrying the guard prefix",
async (value) => {
const rows = buildErrorRows("d.csv", [{ rowNumber: 1, column: "c", value, reason: "bad" }]);
downloadErrorReport("d.csv", rows, "xlsx");
const cell = await cellAt("D2", "Errors"); // ERROR_HEADERS: file,row_number,column,value,...
expect(cell.t).toBe("s");
expect(cell.f).toBeUndefined();
expect(cell.v).toBe(`'${value}`);
},
);

it("audit report: a formula-like file name is guarded too", async () => {
downloadAuditReport(
[
{
file: "=cmd",
status: "Imported",
imported_at: "x",
total_rows: 1,
inserted_rows: 1,
duplicate_rows_skipped: 0,
failed_rows: 0,
table_name: "t",
},
],
"xlsx",
);
const cell = await cellAt("A2", "Audit");
expect([cell.t, cell.f, cell.v]).toEqual(["s", undefined, "'=cmd"]);
});
});
Loading
Loading