Skip to content

Commit a9c10a6

Browse files
committed
feat(e-audit-01): DataInspector parquet file picker + /upload/parquet
Closes bd cppmega-mlx-zcbs (P0 BLOCKING). Pre-fix DataInspector path field was text-only (DataInspector.tsx:194-198) — users could only reference fixture paths inside the repo, not bring their own parquet. Backend: - cppmega_v4/jsonrpc/uploads.py: save_upload() writes body to /tmp/vbgui_uploads/<uuid>.parquet, cleanup_stale() drops files older than 24 h, _enforce_cap() caps at 100 files total. Cleanup + cap run on every save so /tmp can't grow unbounded. - POST /upload/parquet endpoint in server.py: 400 on non-.parquet extension or empty body; returns {path, bytes, filename}. UI: - <input type=file accept=.parquet> with testid data-inspector-file-upload alongside the existing text path field. On change posts FormData to /upload/parquet, writes the returned absolute path back into the path input. - data-inspector-file-upload-error span surfaces network/HTTP errors. Tests (8 total): - 5 pytest: persistence under /tmp + uuid name, non-parquet 400, empty-body 400, TTL cleanup drops aged file, save_upload runs cleanup per call. - 3 vitest: input testid + accept attribute, fetch POST + auto-populate path, HTTP error surfaces into error span. - 1 Playwright regression at vbgui/e2e/scenarios/ 91_data_inspector_file_upload.spec.ts: setInputFiles against a real fixture parquet, assert path field regex-matches /tmp/vbgui_uploads/<uuid>.parquet.
1 parent d844270 commit a9c10a6

6 files changed

Lines changed: 366 additions & 1 deletion

File tree

cppmega_v4/jsonrpc/server.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616
import json
1717
from contextlib import asynccontextmanager
1818

19-
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
19+
from fastapi import (
20+
FastAPI, File, HTTPException, UploadFile, WebSocket, WebSocketDisconnect,
21+
)
2022
from fastapi.middleware.cors import CORSMiddleware
2123

2224
from cppmega_v4.jsonrpc.cache import LRUCache
@@ -74,6 +76,23 @@ def cache_clear() -> dict[str, str]:
7476
cache.clear()
7577
return {"status": "cleared"}
7678

79+
@app.post("/upload/parquet")
80+
async def upload_parquet(file: UploadFile = File(...)) -> dict[str, str | int]:
81+
"""E-AUDIT-01: persist an uploaded parquet to /tmp/vbgui_uploads
82+
and return its absolute path so DataInspector can populate the
83+
path field. 24 h TTL + 100-file cap applied on every upload."""
84+
from cppmega_v4.jsonrpc.uploads import save_upload
85+
if not file.filename or not file.filename.endswith(".parquet"):
86+
raise HTTPException(
87+
status_code=400, detail="only .parquet uploads accepted")
88+
body = await file.read()
89+
if not body:
90+
raise HTTPException(
91+
status_code=400, detail="empty upload body")
92+
path = save_upload(body)
93+
return {"path": path, "bytes": len(body),
94+
"filename": file.filename}
95+
7796
@app.post("/rpc")
7897
async def rpc(payload: dict) -> dict:
7998
response = await _dispatch(payload, cache)

cppmega_v4/jsonrpc/uploads.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""E-AUDIT-01: parquet upload sink + 24h TTL cleanup.
2+
3+
DataInspector previously only accepted text paths to on-disk shards
4+
inside the repo. Users couldn't bring their own parquet without
5+
copying it into the working tree first. This module backs a single
6+
POST /upload/parquet endpoint that:
7+
8+
* writes the uploaded body to ``/tmp/vbgui_uploads/<uuid>.parquet``,
9+
* tracks upload timestamps,
10+
* cleans entries older than 24 h on every new upload (cheap),
11+
* caps total upload count at 100 (drops oldest first) so a runaway
12+
client can't fill /tmp.
13+
14+
The endpoint is intentionally simple — no auth, no chunked uploads.
15+
Local-dev scope only; production must front this with a real
16+
auth/scan layer.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import pathlib
22+
import time
23+
import uuid
24+
from threading import Lock
25+
26+
UPLOAD_ROOT = pathlib.Path("/tmp/vbgui_uploads")
27+
TTL_SECONDS: int = 24 * 60 * 60
28+
MAX_FILES: int = 100
29+
30+
_LOCK = Lock()
31+
32+
33+
def _ensure_root() -> pathlib.Path:
34+
UPLOAD_ROOT.mkdir(parents=True, exist_ok=True)
35+
return UPLOAD_ROOT
36+
37+
38+
def cleanup_stale(now: float | None = None) -> int:
39+
"""Drop files older than TTL_SECONDS; return number removed."""
40+
root = _ensure_root()
41+
now = now if now is not None else time.time()
42+
removed = 0
43+
with _LOCK:
44+
for p in sorted(root.iterdir()):
45+
try:
46+
age = now - p.stat().st_mtime
47+
except OSError:
48+
continue
49+
if age > TTL_SECONDS:
50+
try:
51+
p.unlink()
52+
removed += 1
53+
except OSError:
54+
pass
55+
return removed
56+
57+
58+
def _enforce_cap() -> int:
59+
"""Drop oldest files past MAX_FILES; return number removed."""
60+
root = _ensure_root()
61+
removed = 0
62+
with _LOCK:
63+
entries = sorted(
64+
(p for p in root.iterdir() if p.is_file()),
65+
key=lambda p: p.stat().st_mtime,
66+
)
67+
excess = len(entries) - MAX_FILES
68+
for p in entries[:max(0, excess)]:
69+
try:
70+
p.unlink()
71+
removed += 1
72+
except OSError:
73+
pass
74+
return removed
75+
76+
77+
def save_upload(body: bytes, *, suffix: str = ".parquet") -> str:
78+
"""Persist an uploaded payload; return the absolute path string.
79+
80+
A fresh uuid4 stem prevents collision between concurrent uploads."""
81+
cleanup_stale()
82+
_enforce_cap()
83+
root = _ensure_root()
84+
name = f"{uuid.uuid4().hex}{suffix}"
85+
path = root / name
86+
path.write_bytes(body)
87+
return str(path)
88+
89+
90+
__all__ = [
91+
"UPLOAD_ROOT", "TTL_SECONDS", "MAX_FILES",
92+
"save_upload", "cleanup_stale",
93+
]

tests/v4/test_upload_parquet.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
"""E-AUDIT-01: POST /upload/parquet writes to /tmp/vbgui_uploads + TTL."""
2+
3+
from __future__ import annotations
4+
5+
import io
6+
import os
7+
import pathlib
8+
import time
9+
10+
import pytest
11+
12+
from cppmega_v4.jsonrpc.server import create_app
13+
from cppmega_v4.jsonrpc.uploads import (
14+
UPLOAD_ROOT, TTL_SECONDS, cleanup_stale, save_upload,
15+
)
16+
17+
18+
@pytest.fixture
19+
def app():
20+
return create_app()
21+
22+
23+
@pytest.fixture(autouse=True)
24+
def _wipe_uploads():
25+
UPLOAD_ROOT.mkdir(parents=True, exist_ok=True)
26+
for p in UPLOAD_ROOT.iterdir():
27+
try: p.unlink()
28+
except OSError: pass
29+
yield
30+
for p in UPLOAD_ROOT.iterdir():
31+
try: p.unlink()
32+
except OSError: pass
33+
34+
35+
def test_e_audit_01_upload_persists_under_tmp_with_uuid_name(app):
36+
from fastapi.testclient import TestClient
37+
client = TestClient(app)
38+
body = b"PAR1" + b"\x00" * 64 # synthetic parquet-ish blob
39+
resp = client.post(
40+
"/upload/parquet",
41+
files={"file": ("shard.parquet", io.BytesIO(body),
42+
"application/octet-stream")},
43+
)
44+
assert resp.status_code == 200, resp.text
45+
j = resp.json()
46+
assert j["bytes"] == len(body)
47+
assert j["filename"] == "shard.parquet"
48+
assert j["path"].startswith(str(UPLOAD_ROOT))
49+
assert j["path"].endswith(".parquet")
50+
p = pathlib.Path(j["path"])
51+
assert p.is_file()
52+
assert p.read_bytes() == body
53+
54+
55+
def test_e_audit_01_rejects_non_parquet_extension(app):
56+
from fastapi.testclient import TestClient
57+
client = TestClient(app)
58+
resp = client.post(
59+
"/upload/parquet",
60+
files={"file": ("malicious.exe", io.BytesIO(b"x"),
61+
"application/octet-stream")},
62+
)
63+
assert resp.status_code == 400
64+
assert "parquet" in resp.json()["detail"].lower()
65+
66+
67+
def test_e_audit_01_rejects_empty_body(app):
68+
from fastapi.testclient import TestClient
69+
client = TestClient(app)
70+
resp = client.post(
71+
"/upload/parquet",
72+
files={"file": ("empty.parquet", io.BytesIO(b""),
73+
"application/octet-stream")},
74+
)
75+
assert resp.status_code == 400
76+
77+
78+
def test_e_audit_01_cleanup_stale_drops_old_files():
79+
"""Files older than TTL_SECONDS are removed by cleanup_stale."""
80+
old = save_upload(b"x" * 16)
81+
young = save_upload(b"y" * 16)
82+
# Backdate `old` past TTL.
83+
ancient = time.time() - TTL_SECONDS - 60
84+
os.utime(old, (ancient, ancient))
85+
86+
removed = cleanup_stale()
87+
assert removed >= 1
88+
assert not pathlib.Path(old).exists()
89+
assert pathlib.Path(young).exists()
90+
91+
92+
def test_e_audit_01_save_upload_runs_cleanup_per_call():
93+
"""save_upload() implicitly invokes cleanup_stale; an aged file
94+
from a prior call is gone after the next upload."""
95+
old = save_upload(b"a" * 8)
96+
ancient = time.time() - TTL_SECONDS - 60
97+
os.utime(old, (ancient, ancient))
98+
_new = save_upload(b"b" * 8)
99+
assert not pathlib.Path(old).exists()
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// E-AUDIT-01: Playwright setInputFiles regression — drop a fixture
2+
// parquet via the new DataInspector file picker and assert the path
3+
// field auto-populates with /tmp/vbgui_uploads/<uuid>.parquet.
4+
5+
import { test, expect } from "@playwright/test";
6+
import * as fs from "node:fs";
7+
import * as path from "node:path";
8+
import { gotoApp } from "../fixtures";
9+
10+
test("E-AUDIT-01: file upload via DataInspector picker", async ({ page }) => {
11+
test.setTimeout(30_000);
12+
await gotoApp(page);
13+
14+
// Switch to the Data tab.
15+
await page.getByTestId("app-tab-data").click();
16+
const upload = page.getByTestId("data-inspector-file-upload");
17+
await expect(upload).toBeVisible();
18+
19+
// Use any fixture parquet from tests/fixtures (the matrix builder
20+
// populates these — pick the first available .parquet by glob).
21+
const fixturesDir = path.resolve(
22+
__dirname, "..", "..", "..", "tests", "fixtures");
23+
let parquetPath: string | null = null;
24+
for (const f of fs.readdirSync(fixturesDir)) {
25+
if (f.endsWith(".parquet")) {
26+
parquetPath = path.join(fixturesDir, f);
27+
break;
28+
}
29+
}
30+
if (!parquetPath) {
31+
test.skip(true, "no .parquet fixture in tests/fixtures/");
32+
return;
33+
}
34+
await upload.setInputFiles(parquetPath);
35+
36+
// Path field auto-populates with the backend-returned tmp path.
37+
const pathInput = page.getByTestId("data-path");
38+
await expect(pathInput).toHaveValue(
39+
/\/tmp\/vbgui_uploads\/[0-9a-f]+\.parquet/, { timeout: 5000 });
40+
});

vbgui/src/components/DataInspector.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,39 @@ export function DataInspector({
196196
value={path}
197197
onChange={(e) => setPath(e.target.value)}
198198
style={{ flex: 1, fontFamily: "monospace", fontSize: 11 }} />
199+
{/* E-AUDIT-01: file upload picker. POSTs to /upload/parquet,
200+
auto-populates the path field with the returned absolute
201+
path under /tmp/vbgui_uploads/<uuid>.parquet. */}
202+
<input data-testid="data-inspector-file-upload"
203+
type="file" accept=".parquet"
204+
onChange={async (ev) => {
205+
const f = ev.target.files?.[0];
206+
if (!f) return;
207+
const fd = new FormData();
208+
fd.append("file", f);
209+
const baseUrl = (
210+
(import.meta.env.VITE_BACKEND_URL as string | undefined)
211+
?? "http://127.0.0.1:8765");
212+
try {
213+
const res = await fetch(
214+
`${baseUrl}/upload/parquet`,
215+
{ method: "POST", body: fd });
216+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
217+
const body = await res.json() as { path: string };
218+
setPath(body.path);
219+
} catch (err) {
220+
// Surface to the parent error pill — kept inline so
221+
// the upload picker has its own testable error testid.
222+
const msg = err instanceof Error
223+
? err.message : String(err);
224+
const errEl = document.querySelector(
225+
"[data-testid='data-inspector-file-upload-error']");
226+
if (errEl) errEl.textContent = msg;
227+
}
228+
}}
229+
style={{ fontSize: 11 }} />
230+
<span data-testid="data-inspector-file-upload-error"
231+
style={{ color: "#b91c1c", fontSize: 10 }} />
199232
<button data-testid="data-load" onClick={() => load(0)}>
200233
Load
201234
</button>
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// E-AUDIT-01: DataInspector file upload picker.
2+
3+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
4+
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
5+
import { DataInspector } from "@/components/DataInspector";
6+
import type { RpcClient } from "@/lib/rpc";
7+
8+
function makeRpc(): RpcClient {
9+
return { call: vi.fn(async () => ({} as never)) } as unknown as RpcClient;
10+
}
11+
12+
describe("E-AUDIT-01 DataInspector file upload", () => {
13+
let origFetch: typeof fetch | undefined;
14+
beforeEach(() => {
15+
origFetch = globalThis.fetch;
16+
});
17+
afterEach(() => {
18+
if (origFetch) globalThis.fetch = origFetch;
19+
});
20+
21+
it("renders file input with the spec'd testid + accept=.parquet", () => {
22+
render(<DataInspector rpc={makeRpc()} />);
23+
const input = screen.getByTestId(
24+
"data-inspector-file-upload") as HTMLInputElement;
25+
expect(input).toBeTruthy();
26+
expect(input.type).toBe("file");
27+
expect(input.accept).toBe(".parquet");
28+
});
29+
30+
it("uploads selected file via POST and auto-populates path field",
31+
async () => {
32+
const fetchMock = vi.fn(async () => ({
33+
ok: true,
34+
json: async () => ({ path: "/tmp/vbgui_uploads/abc123.parquet",
35+
bytes: 42, filename: "shard.parquet" }),
36+
} as Response));
37+
globalThis.fetch = fetchMock as unknown as typeof fetch;
38+
39+
render(<DataInspector rpc={makeRpc()} />);
40+
const fileInput = screen.getByTestId(
41+
"data-inspector-file-upload") as HTMLInputElement;
42+
const blob = new File([new Uint8Array(42)], "shard.parquet",
43+
{ type: "application/octet-stream" });
44+
fireEvent.change(fileInput, { target: { files: [blob] } });
45+
46+
await waitFor(() => {
47+
expect(fetchMock).toHaveBeenCalled();
48+
});
49+
const call = (fetchMock.mock.calls[0] ?? []) as
50+
[string, RequestInit];
51+
expect(call[0]).toMatch(/\/upload\/parquet$/);
52+
expect(call[1].method).toBe("POST");
53+
expect(call[1].body).toBeInstanceOf(FormData);
54+
55+
await waitFor(() => {
56+
const pathInput = screen.getByTestId("data-path") as HTMLInputElement;
57+
expect(pathInput.value).toBe("/tmp/vbgui_uploads/abc123.parquet");
58+
});
59+
});
60+
61+
it("surfaces HTTP error into the upload-error span", async () => {
62+
const fetchMock = vi.fn(async () => ({
63+
ok: false, status: 400,
64+
json: async () => ({}),
65+
} as Response));
66+
globalThis.fetch = fetchMock as unknown as typeof fetch;
67+
68+
render(<DataInspector rpc={makeRpc()} />);
69+
const fileInput = screen.getByTestId(
70+
"data-inspector-file-upload") as HTMLInputElement;
71+
const blob = new File([new Uint8Array(8)], "x.parquet",
72+
{ type: "application/octet-stream" });
73+
fireEvent.change(fileInput, { target: { files: [blob] } });
74+
75+
await waitFor(() => {
76+
const err = screen.getByTestId(
77+
"data-inspector-file-upload-error");
78+
expect(err.textContent).toContain("400");
79+
});
80+
});
81+
});

0 commit comments

Comments
 (0)