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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ See **`docs/ENV_VARIABLES.md`** for the full reference. Key variables:
| `PGPASSWORD` | _(empty)_ | PostgreSQL password |
| `PGDATABASE` | `te_mgmt_dev` | Target database |
| `API_KEY` | _(empty)_ | Shared secret for `X-API-Key` header (empty = unauthenticated) |
| `CORS_ORIGINS` | `http://localhost:5173,...` | Allowed frontend origins |
| `CORS_ORIGINS` | `http://localhost:8080,...` | Allowed frontend origins |
| `TE_SCHEMA` | `te_dev` | Schema for the 12 T&E tables |
| `API_ALLOW_DESTRUCTIVE` | `false` | Enable `DELETE /api/csv/files/{id}` |

Expand Down
6 changes: 4 additions & 2 deletions api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@ class Settings:
# Schema holding the fixed T&E tables (deploy_all.sh dev creates te_dev).
TE_SCHEMA: str = os.environ.get("TE_SCHEMA", "te_dev")

# CORS origins for the frontend dev server.
# CORS origins for the frontend dev server. 8080 is the port the Vite dev
# server (scripts/start-frontend.ps1) actually listens on; 5173/3000 are
# kept for other local setups.
CORS_ORIGINS: list = os.environ.get(
"CORS_ORIGINS", "http://localhost:5173,http://localhost:3000"
"CORS_ORIGINS", "http://localhost:8080,http://localhost:5173,http://localhost:3000"
).split(",")

MAX_UPLOAD_BYTES: int = int(os.environ.get("MAX_UPLOAD_BYTES", str(50 * 1024 * 1024)))
Expand Down
55 changes: 55 additions & 0 deletions csv-table-hub-main/src/__tests__/preview-contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Regression tests for issue #42: the frontend must accept every response
// shape /api/csv/preview can return — including reason "parse_failed", which
// the API sends (with a message) when its CSV parser throws.
import { afterEach, describe, expect, it, vi } from "vitest";
import { previewCsv } from "@/lib/csv.functions";

function mockFetchJson(payload: unknown, status = 200) {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(payload), {
status,
headers: { "content-type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}

afterEach(() => {
vi.unstubAllGlobals();
});

describe("previewCsv response contract (issue #42)", () => {
it("accepts reason parse_failed and keeps the server message", async () => {
const message = "The CSV couldn't be parsed. Check the file's structure and try again.";
mockFetchJson({ status: "invalid_structure", reason: "parse_failed", message });

const result = await previewCsv("broken.csv", "a\n1\n");

expect(result.status).toBe("invalid_structure");
if (result.status !== "invalid_structure") throw new Error("unreachable");
expect(result.reason).toBe("parse_failed");
expect(result.message).toBe(message);
});

it.each(["empty", "header_only", "no_columns"] as const)(
"still accepts reason %s without a message",
async (reason) => {
mockFetchJson({ status: "invalid_structure", reason });
await expect(previewCsv("f.csv", "x")).resolves.toEqual({ status: "invalid_structure", reason });
},
);

it("still rejects a reason the API never sends", async () => {
mockFetchJson({ status: "invalid_structure", reason: "not_a_reason" });
await expect(previewCsv("f.csv", "x")).rejects.toThrow(/Unexpected response shape/);
});

it("posts fileName and content to /api/csv/preview", async () => {
const fetchMock = mockFetchJson({ status: "invalid_structure", reason: "empty" });
await previewCsv("f.csv", "a\n");
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toMatch(/\/api\/csv\/preview$/);
expect(JSON.parse(init.body)).toEqual({ fileName: "f.csv", content: "a\n" });
});
});
5 changes: 4 additions & 1 deletion csv-table-hub-main/src/lib/csv.functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,10 @@ const csvPreviewResultSchema = z.discriminatedUnion("status", [
}),
z.object({
status: z.literal("invalid_structure"),
reason: z.enum(["empty", "header_only", "no_columns"]),
// "parse_failed" is returned (with a message) when the server-side parser
// throws — see the BUG-025 handler in api/routers/csv_routes.py.
reason: z.enum(["empty", "header_only", "no_columns", "parse_failed"]),
message: z.string().optional(),
}),
]);
export type CsvPreviewResult = z.infer<typeof csvPreviewResultSchema>;
Expand Down
4 changes: 2 additions & 2 deletions docs/ENV_VARIABLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Read by `api/config.py`. Set these in the shell before running
| `API_MAX_ROWS` | `100000` | No | Maximum data rows accepted per upload. Files over this limit are rejected before any per-row work. |
| `API_MAX_ROW_ERRORS` | `200` | No | Maximum row errors included in the JSON response body. Summary counts still reflect the true total. |
| `API_POOL_GETCONN_TIMEOUT` | `5.0` | No | Seconds a request waits to borrow a database connection from the pool. Exceeded = 503 response. |
| `CORS_ORIGINS` | `http://localhost:5173,http://localhost:3000` | Yes | Comma-separated list of allowed CORS origins. Set to your frontend URL in production. |
| `CORS_ORIGINS` | `http://localhost:8080,http://localhost:5173,http://localhost:3000` | Yes | Comma-separated list of allowed CORS origins. Set to your frontend URL in production. |
| `CSV_UPLOADS_SCHEMA` | `csv_uploads` | No | Schema where per-CSV tables and the file registry live. |
| `TE_SCHEMA` | `te_dev` | No | Schema holding the 12 fixed T&E tables. Switch to `te_test`, `te_staging`, or `te_prod` per environment. |
| `MAX_UPLOAD_BYTES` | `52428800` (50 MB) | No | Maximum file size accepted by the upload endpoint. |
Expand Down Expand Up @@ -134,7 +134,7 @@ Change them by editing the source file directly.
| `5.0` | `api/config.py` | Default pool connection timeout (seconds) |
| `52428800` | `api/config.py` | Default max upload size (50 MB) |
| `8000` | `scripts/start-api.ps1` | API port |
| `5173` | `api/config.py` (CORS default) | Frontend dev server port |
| `8080` | `api/config.py` (CORS default) | Frontend dev server port (also allows `5173`, `3000`) |
| `organisations, personnel, …` | `api/config.py` (TE_TABLES) | The 12 fixed T&E table names |

---
Expand Down
2 changes: 1 addition & 1 deletion env.dev.example
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ CSV_UPLOADS_SCHEMA=csv_uploads
TE_SCHEMA=te_dev

# CORS — frontend dev server ports
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
CORS_ORIGINS=http://localhost:8080,http://localhost:5173,http://localhost:3000

# Upload limits
MAX_UPLOAD_BYTES=52428800
Expand Down
4 changes: 2 additions & 2 deletions scripts/start-frontend.ps1
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# start-frontend.ps1 - Terminal 2: run the React frontend on http://localhost:5173
# start-frontend.ps1 - Terminal 2: run the React frontend on http://localhost:8080
# First run installs node_modules automatically.

$ErrorActionPreference = "Stop"
Expand All @@ -10,5 +10,5 @@ if (-not (Test-Path "node_modules")) {
npm install
}

Write-Host "Frontend starting on http://localhost:5173" -ForegroundColor Green
Write-Host "Frontend starting on http://localhost:8080" -ForegroundColor Green
npm run dev
6 changes: 6 additions & 0 deletions tests/test_api_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@ def test_cors_allows_configured_origin(self):
self.assertEqual(
r.headers.get("access-control-allow-origin"), "http://localhost:5173")

def test_default_origins_include_vite_dev_port(self):
"""Issue #43: the Vite dev server runs on :8080 and must be allowed by default."""
r = client.get("/api/health", headers={"Origin": "http://localhost:8080"})
self.assertEqual(
r.headers.get("access-control-allow-origin"), "http://localhost:8080")

def test_cors_rejects_unconfigured_origin(self):
r = client.get("/api/health", headers={"Origin": "http://evil.example.com"})
self.assertNotEqual(
Expand Down
Loading