From db1f44cf744eefb05dcc5a7516688afc02a71f2a Mon Sep 17 00:00:00 2001 From: ZDDC Date: Tue, 12 May 2026 12:35:48 -0500 Subject: [PATCH] test,docs(zip): browse/archive zip-transmittal coverage + fixture + docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- AGENTS.md | 2 ++ ARCHITECTURE.md | 2 ++ tests/archive.spec.js | 50 +++++++++++++++++++++++++++++++++++ tests/browse.spec.js | 42 +++++++++++++++++++++++++++++ tests/data/test-archive.sh | 26 ++++++++++++++++++ tests/fixtures/mock-fs-api.js | 7 ++++- 6 files changed, 128 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 1064c9b..c02c28f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ` 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. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2499616..534f1c6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -711,6 +711,8 @@ The schema keys that drive built-in behavior: **Slash / no-slash URL routing.** Every directory URL has two forms: `/` serves `dir_tool` (defaults to `browse`, the file-tree navigator) and `` serves `default_tool` (the specialized app — `archive` under `archive/`, `transmittal` under `staging/`, `mdedit` under `working/`, `tables` at `archive//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//{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//` 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). diff --git a/tests/archive.spec.js b/tests/archive.spec.js index 84ab2de..2553c4d 100644 --- a/tests/archive.spec.js +++ b/tests/archive.spec.js @@ -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 diff --git a/tests/browse.spec.js b/tests/browse.spec.js index 2da725a..97b782b 100644 --- a/tests/browse.spec.js +++ b/tests/browse.spec.js @@ -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'); + }); }); diff --git a/tests/data/test-archive.sh b/tests/data/test-archive.sh index 088804a..4b3dab4 100755 --- a/tests/data/test-archive.sh +++ b/tests/data/test-archive.sh @@ -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 diff --git a/tests/fixtures/mock-fs-api.js b/tests/fixtures/mock-fs-api.js index 3a4a674..e122359 100644 --- a/tests/fixtures/mock-fs-api.js +++ b/tests/fixtures/mock-fs-api.js @@ -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));