test,docs(zip): browse/archive zip-transmittal coverage + fixture + docs
- tests/browse.spec.js: expand a .zip in the file tree (offline), drill into a member subdir, preview a text member — exercises shared/zip-source.js and the migrated offline path end to end. - tests/archive.spec.js: a .zip whose name parses as a transmittal folder is scanned like an uncompressed one — members land in the file list with tracking numbers parsed, tied to the zip transmittal's folder. - tests/fixtures/mock-fs-api.js: __setMockDirectoryTree now keeps binary leaf values (Uint8Array/ArrayBuffer/Blob) intact instead of String()-ing them — needed to feed real zip bytes through the mock FS. - tests/data/test-archive.sh: each party gets one transmittal delivered as a single .zip in received/, so the bitnest fixture exercises the zip-as-virtual-directory path. - ARCHITECTURE.md / AGENTS.md: document .zip-as-navigable-directory (server route + ACL model + shared client adapter + the one-level nesting limit). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2dc2d032a0
commit
db1f44cf74
6 changed files with 128 additions and 1 deletions
|
|
@ -491,6 +491,8 @@ ZDDC_ROOT=/path/to/your/archive ZDDC_TLS_CERT=none ZDDC_ADDR=:8080 \
|
|||
|
||||
**Audit log captures the as-typed path.** `AccessLogMiddleware` snapshots `r.URL.Path` before dispatch rewrites it; the audit record's `path` field is what the client sent. When canonicalization changed it, a `resolved_path` field is added.
|
||||
|
||||
**`.zip` files are navigable directories.** `GET …/Foo.zip/` → JSON listing of the zip's members (or browse HTML); `GET …/Foo.zip/sub/doc.pdf` → that one member, extracted and streamed (Range/ETag via `http.ServeContent`); `GET …/Foo.zip` (no slash) → the raw `.zip` download, unchanged. Write methods to a path inside a `.zip` → 405 (read-only). ACL = the chain of the directory *containing* the zip (a zip has no `.zddc`, like `.archive`). Code: `internal/zipfs` (member listing/extraction with a zip-slip guard) + `handler.ServeZip`, routed by `splitZipPath` in `dispatch` *before* the file-API branch (gated by a cheap `.zip/` substring check so ordinary requests don't pay an `os.Stat`-per-segment walk). Client-side, `shared/zip-source.js` (`ZipDirectoryHandle`/`ZipFileHandle` over JSZip) gives the archive and browse tools the same navigation offline. Archive treats a `.zip` whose name minus `.zip` parses as a transmittal-folder name as that transmittal folder (`isTransmittalFolderZip` in `archive/js/parser.js`); browse expands any `.zip`. Nested zips: the server serves one level (`…/Foo.zip/inner.zip` is the inner zip's bytes; `…/Foo.zip/inner.zip/` isn't a listing) — clients that need deeper nesting fetch the inner zip whole and recurse with JSZip.
|
||||
|
||||
### Client mode (proxy / cache / mirror)
|
||||
|
||||
When `--upstream <url>` is set, the binary runs as a **downstream client** of another zddc-server instead of a master. `cmd/zddc-server/main.go` short-circuits to `runClient(cfg)`, which builds a `*cache.Cache` from `zddc/internal/cache/` and uses it as the entire request handler — no archive index, no apps server, no watcher, no OPA decider, no ACL middleware, no token store.
|
||||
|
|
|
|||
|
|
@ -711,6 +711,8 @@ The schema keys that drive built-in behavior:
|
|||
|
||||
**Slash / no-slash URL routing.** Every directory URL has two forms: `<dir>/` serves `dir_tool` (defaults to `browse`, the file-tree navigator) and `<dir>` serves `default_tool` (the specialized app — `archive` under `archive/`, `transmittal` under `staging/`, `mdedit` under `working/`, `tables` at `archive/<party>/mdl`). A folder with no `default_tool` 302s the no-slash form to the slash form, so you land on `dir_tool`. JSON listing requests ignore both keys — the raw listing is always served, so the browse SPA can enumerate entries regardless. The dispatcher's `serveSpecializedNoSlash` (in `cmd/zddc-server/main.go`) is the single chokepoint for the no-slash side; `handler.ServeDirectory` (via `zddc.DirToolAt`) handles the slash side.
|
||||
|
||||
**Zip-backed directories.** A `.zip` file is also a navigable directory: `GET …/Foo.zip/` returns a JSON listing of the zip's members (or the browse SPA for an HTML request) and `GET …/Foo.zip/sub/doc.pdf` extracts and streams that one member — so a client navigating a zipped transmittal folder never downloads the whole archive. `GET …/Foo.zip` (no trailing slash) is unchanged: the raw `.zip` download. Read-only: `PUT`/`DELETE`/`POST` to a path inside a `.zip` is rejected (405). ACL is the chain of the directory *containing* the zip — a zip carries no `.zddc` of its own, the same model as the `.archive` virtual surface. Implemented by `internal/zipfs` + `handler.ServeZip`, routed via `splitZipPath` in the dispatcher (before the file-API branch). Offline tools (archive's scanner, browse's tree) get the same capability client-side via `shared/zip-source.js` — a `ZipDirectoryHandle`/`ZipFileHandle` pair over JSZip that mimics the File-System-Access surface. The archive tool treats a `.zip` whose name minus `.zip` parses as a transmittal-folder name as that transmittal folder; the browse tool expands *any* `.zip`.
|
||||
|
||||
**WORM** (write-once-read-many). A `worm: [principal...]` list on a `.zddc` marks that path (and descendants) immutable: `w`/`d`/`a` are stripped for everyone non-admin; `c` survives only for the listed principals (who get read + write-once-create); `r` for outsiders is whatever the normal ACL granted (the worm list doesn't itself confer read). Admins (root / subtree) bypass entirely — the escape hatch for mis-filed documents. `defaults.zddc.yaml` puts `worm: [document_controller]` on `archive/<party>/{received,issued}`, so the canonical immutable-archive convention is unchanged; the difference is an operator can mark any path WORM, or rename `received`/`issued`, without a code change.
|
||||
|
||||
**Standard roles.** `defaults.zddc.yaml` references two roles (both shipped empty — a fresh deployment grants nothing until an operator populates them): `document_controller` (read/write across a project, `rwc` at `archive/`, subtree-admin of `working/` and `staging/`, the WORM-create principal in `received/issued`, `rwcd` at `incoming/` for the QC-and-transfer workflow) and `project_team` (read-only across the project; their own `working/<email>/` home and anything they create under `incoming/` get a creator-owned auto-own `.zddc` that wins via deepest-match, so "read-only except what I own" falls out of the cascade with no special rule).
|
||||
|
|
|
|||
|
|
@ -87,6 +87,56 @@ test.describe('Archive Browser', () => {
|
|||
expect(fileCountText).toBeTruthy();
|
||||
});
|
||||
|
||||
test('a .zip transmittal folder is scanned like an uncompressed one', async ({ page }) => {
|
||||
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
|
||||
|
||||
// One plain transmittal folder + one delivered as a single .zip
|
||||
// whose name parses as a transmittal-folder name. The zip's
|
||||
// members should land in the file list exactly like the plain
|
||||
// folder's files. (JSZip is bundled into archive.html.)
|
||||
await page.evaluate(async () => {
|
||||
const zip = new window.JSZip();
|
||||
zip.file('789012-ME-CAL-0001_A (IFA) - Calculation.pdf', '%PDF calc');
|
||||
zip.file('sub/789012-ME-DRW-0001_A (IFA) - Detail.dwg', 'DWG detail');
|
||||
const buf = await zip.generateAsync({ type: 'uint8array' });
|
||||
window.__setMockDirectoryTree('test-project', {
|
||||
'2025-01-15_123456-EM-TRN-0001 (IFC) - First Transmittal': {
|
||||
'123456-EL-SPC-2623_A (IFC) - Specification.pdf': '%PDF',
|
||||
},
|
||||
'2025-02-10_123456-EM-TRN-0002 (IFC) - Second Transmittal.zip': buf,
|
||||
});
|
||||
});
|
||||
|
||||
await page.locator('#addDirectoryBtn').click();
|
||||
await page.waitForFunction(() => window.app && Array.isArray(window.app.files) && window.app.files.length >= 3, { timeout: 10000 });
|
||||
|
||||
// 1 file from the plain folder + 2 from inside the zip.
|
||||
const fileCount = await page.evaluate(() => window.app.files.length);
|
||||
expect(fileCount).toBeGreaterThanOrEqual(3);
|
||||
|
||||
// The zip is surfaced as a transmittal folder, named without ".zip".
|
||||
const zipFolder = await page.evaluate(() =>
|
||||
window.app.transmittalFolders.find(f => /Second Transmittal$/.test(f.name)) || null);
|
||||
expect(zipFolder).toBeTruthy();
|
||||
expect(zipFolder.name).not.toMatch(/\.zip$/i);
|
||||
|
||||
// The zip's members are parsed like normal archive files (tracking
|
||||
// numbers extracted) and tied to the zip transmittal's folder.
|
||||
const memberTracking = await page.evaluate(() =>
|
||||
window.app.files.filter(f => f.folderPath && /\.zip$/i.test(f.folderPath)).map(f => f.trackingNumber).sort());
|
||||
expect(memberTracking).toEqual(['789012-ME-CAL-0001', '789012-ME-DRW-0001']);
|
||||
|
||||
// Select all grouping folders + render the table; zip members show.
|
||||
await page.evaluate(() => {
|
||||
const cb = document.getElementById('selectAllGroupingCheckbox');
|
||||
if (cb && !cb.checked) cb.click();
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
const rowCount = await page.locator('#filesTableBody tr').count();
|
||||
expect(rowCount).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
test('Mode 1: ?projects=A,B enters each project\'s Archive subfolder', async ({ page }) => {
|
||||
// Multi-project layout: server root holds project folders, each containing an
|
||||
// Archive/ subfolder with third-party folders. ?projects=A,B (set as
|
||||
|
|
|
|||
|
|
@ -105,4 +105,46 @@ test.describe('Browse', () => {
|
|||
// Source hint reflects local FS-API mode.
|
||||
await expect(page.locator('.md-shell__source')).toHaveText(/local/i);
|
||||
});
|
||||
|
||||
test('expands a .zip transmittal folder and previews a member', async ({ page }) => {
|
||||
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'load' });
|
||||
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
|
||||
|
||||
// Build a real zip in-browser (JSZip is bundled into browse.html)
|
||||
// and hand its bytes to the mock FS as a single .zip file whose
|
||||
// name parses as a transmittal folder.
|
||||
await page.evaluate(async () => {
|
||||
const zip = new window.JSZip();
|
||||
zip.file('DOC-001 (IFI) - Spec.pdf', '%PDF-1.4 fixture body');
|
||||
zip.file('sub/note.txt', 'a note inside the zip');
|
||||
const buf = await zip.generateAsync({ type: 'uint8array' });
|
||||
window.__setMockDirectory('PartyA', [
|
||||
{ name: '2025-05-12_DOC-001 (IFI) - Title.zip', content: buf, size: buf.length },
|
||||
]);
|
||||
});
|
||||
await page.locator('#addDirectoryBtn').click();
|
||||
await page.waitForSelector('#treeBody .tree-row[data-iszip="true"]', { timeout: 10000 });
|
||||
|
||||
// Expand the .zip — it should list its members like a folder.
|
||||
await page.locator('#treeBody .tree-row[data-iszip="true"]').first().click();
|
||||
await page.waitForFunction(
|
||||
() => document.querySelectorAll('#treeBody .tree-row').length >= 3,
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
const labels = await page.locator('#treeBody .tree-name__label').allTextContents();
|
||||
expect(labels.join('|')).toContain('DOC-001 (IFI) - Spec.pdf');
|
||||
expect(labels.join('|')).toContain('sub');
|
||||
|
||||
// Drill into the subdir, then preview the text member.
|
||||
await page.locator('#treeBody .tree-row[data-isdir="true"]').last().click();
|
||||
await page.waitForFunction(
|
||||
() => Array.from(document.querySelectorAll('#treeBody .tree-name__label'))
|
||||
.some(el => el.textContent === 'note.txt'),
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
const noteRow = page.locator('#treeBody .tree-row', { has: page.locator('.tree-name__label', { hasText: /^note\.txt$/ }) });
|
||||
await noteRow.click();
|
||||
await expect(page.locator('#previewTitle')).toHaveText(/note\.txt/);
|
||||
await expect(page.locator('#previewBody')).toContainText('a note inside the zip');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -517,6 +517,32 @@ cmd_build() {
|
|||
fi
|
||||
done
|
||||
done
|
||||
|
||||
# One transmittal delivered as a single .zip (rather than an
|
||||
# uncompressed folder): archive treats it like a normal
|
||||
# transmittal folder, and zddc-server serves "<…>.zip/" as a
|
||||
# virtual directory whose members are extracted on demand.
|
||||
z_track=$(make_tracking "$party")
|
||||
z_status=$(pick_word "$STATUSES")
|
||||
z_title=$(random_title)
|
||||
z_date=$(random_date)
|
||||
z_dest="$party_dir/received/${z_date}_${z_track} (${z_status}) - ${z_title}.zip"
|
||||
z_tmp=$(mktemp -d)
|
||||
for z_ext in pdf md yaml; do
|
||||
m_track=$(make_tracking "$party")
|
||||
m_rev=$(pick_word "$REVISIONS")
|
||||
m_status=$(pick_word "$STATUSES")
|
||||
m_title=$(random_title)
|
||||
m_name="${m_track}_${m_rev} (${m_status}) - ${m_title}.${z_ext}"
|
||||
render_file "$z_ext" "$m_track" "$m_rev" "$m_status" "$m_title" "$z_tmp/$m_name"
|
||||
file_count=$((file_count + 1))
|
||||
if [ "$z_ext" = "pdf" ]; then
|
||||
pdf_count=$((pdf_count + 1))
|
||||
fi
|
||||
done
|
||||
( cd "$z_tmp" && zip -q "$z_dest" ./* )
|
||||
rm -rf "$z_tmp"
|
||||
chmod 0666 "$z_dest" 2>/dev/null || true
|
||||
done
|
||||
done
|
||||
|
||||
|
|
|
|||
7
tests/fixtures/mock-fs-api.js
vendored
7
tests/fixtures/mock-fs-api.js
vendored
|
|
@ -149,7 +149,12 @@ export const MOCK_FS_INIT_SCRIPT = `
|
|||
function buildEntries(obj) {
|
||||
const entries = [];
|
||||
for (const [name, value] of Object.entries(obj)) {
|
||||
if (typeof value === 'object' && value !== null && !ArrayBuffer.isView(value)) {
|
||||
if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer || value instanceof Blob) {
|
||||
// Binary file content (e.g. a zip's bytes) — keep it intact;
|
||||
// String()-ing a Uint8Array would corrupt it.
|
||||
const len = value.byteLength != null ? value.byteLength : (value.size || 0);
|
||||
entries.push(new MockFileHandle(name, value, len));
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
entries.push(new MockDirectoryHandle(name, buildEntries(value)));
|
||||
} else {
|
||||
entries.push(new MockFileHandle(name, String(value), String(value).length));
|
||||
|
|
|
|||
Loading…
Reference in a new issue