Skip to content
Merged
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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Changed
- Repaired the cross-platform frontend test entry point so both `npm test` and
the repository's historical `npm test -- --run` audit command execute the
complete Vitest and Playwright gates instead of forwarding an invalid
`--run` option to Playwright. Unknown extra arguments still fail closed.
- Replaced three unprovenanced faux-Longmont files with the independently
authored, CC0-licensed Redstone Valley v1 Records fixture. Eleven
deterministic scenarios now cover ordinary, scanned, tabular, email,
malformed, prompt-injection, PII-review, ambiguous-exemption, duplicate,
notification-failure, and recovery behavior. Every artifact is visibly
watermarked and pinned by byte size and SHA-256 in a validated manifest.
- Preserved connector provenance through both manual-drop and retry/new-record
byte-ingestion paths by forwarding the fetched `source_path` and `metadata`
to CivicCore's ingestion pipeline.
- Renamed the module's public name from CivicRecords AI to **CivicSunshine**
(sunshine laws being exactly this module's domain), avoiding confusion with
an unrelated commercial records-request product marketed as
Expand Down
4 changes: 4 additions & 0 deletions backend/app/ingestion/sync_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ async def run_connector_sync_with_retry(
filename=fetched.filename,
file_type=fetched.file_type,
source_id=source.id,
source_path=fetched.source_path,
metadata=fetched.metadata,
)

failure.status = "resolved"
Expand Down Expand Up @@ -142,6 +144,8 @@ async def run_connector_sync_with_retry(
filename=fetched.filename,
file_type=fetched.file_type,
source_id=source.id,
source_path=fetched.source_path,
metadata=fetched.metadata,
)

succeeded += 1
Expand Down
7 changes: 6 additions & 1 deletion backend/app/ingestion/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,8 @@ async def _ingest_manual_drop_source(session, source, user_id: str | None) -> di
filename=fetched.filename,
file_type=fetched.file_type,
source_id=source.id,
source_path=fetched.source_path,
metadata=fetched.metadata,
)
if doc:
ingested += 1
Expand Down Expand Up @@ -223,6 +225,8 @@ async def ingest_file_from_bytes(
filename: str,
file_type: str,
source_id: uuid.UUID,
source_path: str | None = None,
metadata: dict | None = None,
) -> object | None:
"""Ingest a document from raw bytes through the CivicCore pipeline."""
import logging
Expand All @@ -235,7 +239,8 @@ async def ingest_file_from_bytes(
content=content,
filename=filename,
source_id=source_id,
source_path=filename,
source_path=source_path or filename,
metadata=metadata,
)
except Exception as exc:
logger.error("Failed to ingest %s: %s", filename, exc)
Expand Down
48 changes: 48 additions & 0 deletions backend/tests/test_manual_drop.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import pytest
import tempfile
import uuid
from pathlib import Path

from app.connectors.manual_drop import (
Expand Down Expand Up @@ -283,11 +284,13 @@ async def test_ingest_manual_drop_dispatch(drop_dir):
source_path=str(drop_dir / "budget.pdf"),
filename="budget.pdf", file_type="pdf",
content=b"%PDF content", file_size=12,
metadata={"sha256": "budget-sha256"},
),
FetchedDocument(
source_path=str(drop_dir / "notes.txt"),
filename="notes.txt", file_type="txt",
content=b"Meeting notes", file_size=13,
metadata={"sha256": "notes-sha256"},
),
])
# archive_file is sync
Expand All @@ -310,6 +313,51 @@ async def test_ingest_manual_drop_dispatch(drop_dir):
mock_connector.discover.assert_called_once()
assert mock_connector.fetch.call_count == 2
assert mock_connector.archive_file.call_count == 2
first_ingest = mock_ingest.await_args_list[0].kwargs
assert first_ingest["source_path"] == str(drop_dir / "budget.pdf")
assert first_ingest["metadata"] == {"sha256": "budget-sha256"}
second_ingest = mock_ingest.await_args_list[1].kwargs
assert second_ingest["source_path"] == str(drop_dir / "notes.txt")
assert second_ingest["metadata"] == {"sha256": "notes-sha256"}


@pytest.mark.asyncio
async def test_ingest_file_from_bytes_forwards_provenance_to_civiccore():
"""Byte ingestion preserves the connector's source path and metadata."""
from unittest.mock import AsyncMock, MagicMock, patch
from app.ingestion.tasks import ingest_file_from_bytes

source_id = uuid.uuid4()
session = MagicMock()
document = MagicMock()
metadata = {
"sha256": "a" * 64,
"relative_path": "clerk/inbox/request.eml",
}

with patch(
"app.ingestion.tasks.ingest_bytes",
new=AsyncMock(return_value=document),
) as mock_ingest:
result = await ingest_file_from_bytes(
session=session,
content=b"From: resident@example.test\nSubject: Records request",
filename="request.eml",
file_type="eml",
source_id=source_id,
source_path="manual-drop://clerk/inbox/request.eml",
metadata=metadata,
)

assert result is document
mock_ingest.assert_awaited_once_with(
session=session,
content=b"From: resident@example.test\nSubject: Records request",
filename="request.eml",
source_id=source_id,
source_path="manual-drop://clerk/inbox/request.eml",
metadata=metadata,
)


@pytest.mark.asyncio
Expand Down
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "vitest run src tests && playwright test e2e/skip-link-no-occlude.spec.ts",
"test": "node scripts/run-test-gates.mjs",
"generate:types": "openapi-typescript ../docs/openapi.json -o src/generated/api.ts",
"test:e2e": "playwright test",
"a11y:ci": "node scripts/a11y-ci.mjs"
Expand Down
40 changes: 40 additions & 0 deletions frontend/scripts/run-test-gates.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) The CivicSuite Authors

import { spawnSync } from "node:child_process";
import path from "node:path";
import process from "node:process";

const gates = [
["vitest", path.resolve("node_modules", "vitest", "vitest.mjs"), ["run", "src", "tests"]],
[
"playwright",
path.resolve("node_modules", "@playwright", "test", "cli.js"),
["test", "e2e/skip-link-no-occlude.spec.ts"]
]
];

// Some historical audit instructions invoke `npm test -- --run`. Vitest is
// already pinned to run mode above; accepting and intentionally ignoring that
// redundant argument prevents npm from forwarding it to Playwright, where it
// is not a valid option.
const unsupported = process.argv.slice(2).filter((argument) => argument !== "--run");
if (unsupported.length > 0) {
console.error(`Unsupported test-gate arguments: ${unsupported.join(" ")}`);
process.exit(2);
}

for (const [name, entryPoint, args] of gates) {
const result = spawnSync(process.execPath, [entryPoint, ...args], {
cwd: process.cwd(),
env: process.env,
stdio: "inherit"
});
if (result.error) {
console.error(`Could not start ${name}: ${result.error.message}`);
process.exit(1);
}
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
Loading