feat(workspace): add shared Drive browsing editing and Worker grants

This commit is contained in:
2026-10-09 16:40:50 +09:00
parent c79506b60f
commit e4daa4aaa7
46 changed files with 7957 additions and 35 deletions
@@ -0,0 +1,47 @@
# Real Drive API/Web roundtrip (not the browser fixture)
This harness uses a fresh RAII temporary directory, real Server SQLite records, FeatureStorage Drive nodes and `object_store::local::LocalFileSystem` blobs. It creates a Workspace through T-717's actual repositoryless `/api/workspaces` endpoint. No repositories, user's data, live Server, environment-variable test configuration, Tools or WIP are involved.
The ordinary Rust test exercises loopback HTTP signed Worker ingress: explicit read-only/read-write grants, rejection of read-only writes, Worker-created bytes visible through Web/user API, and immediate grant revocation (even when the request also carries an owner's bearer token). Only the Runtime worker-spawn boundary uses the existing deterministic workdirless fixture runtime.
The explicit process test starts a loopback Axum Server and launches Deno against it. `roundtrip.ts` imports the production `src/lib/workspace/drive/api.ts` adapter, not a copy or mock. Its typed `fetchFn` resolves relative paths only to the bound loopback origin and adds fixture bearer authorization. Tokens travel over stdin, not command-line arguments or logs; Rust failure diagnostics defensively redact both fixture bearer tokens. Deno has network permission only for the exact loopback port, uses no npm dependencies, and runs with `--cached-only`. Each listener is abort-on-drop, child processes are kill-on-drop, and the child has a 60-second upper timeout, without sleeps or readiness polling.
Coverage: Worker file list/read, Japanese folder/Markdown names, create/read/edit/download, empty files, actual PNG upload/authenticated image/download and binary update, publication receipt lookup, paged list, two authenticated accounts racing a common revision (one commit/one typed conflict, no hidden retry), stable latest URL through rename and move, deleted URL and same-name recreation, and unauthorized latest URLs. The exact Workers-page `drive-grants/api.ts` adapter also lists persisted grants, creates both access levels, revokes them and re-reads durable state, and rejects member administration. Its global browser fetch is temporarily origin/auth-bound in this isolated child; network responses are never mocked. This tests real authenticated HTTP with test bearer credentials, not a Passkey/cookie browser login or a full Runtime process. UI dirty-draft retention, delayed response handling, visual review and browser cancellation belong to the **separate browser fixture**, not this API test.
## Registration
The inline `mod tests` block in `crates/workspace-server/src/server.rs` registers the module alongside the existing Drive API tests:
```rust
mod drive_web_roundtrip_tests;
```
No product routing modification is needed. The scoped production router is cached by the harness without starting unrelated Orchestrator hook tasks; production server ingress, authorization and API/storage handlers still run.
## Run
From the repository root:
```sh
cargo test -p yoi-workspace-server --lib drive_web_roundtrip_tests
cargo test -p yoi-workspace-server --lib drive_web_roundtrip_tests -- --ignored
```
The second command requires Deno on PATH. Its ignored marker is an explicit process/E2E prerequisite, not an environment-variable gate or silent fallback. An absent Deno fails the explicit test. The ordinary real HTTP test does not require Deno.
The script is harness-only: it receives fresh credentials and Workspace identity from the parent over stdin; do not invoke it against a live Server. Type-check it without starting the Server:
```sh
deno check --config web/workspace/test/drive-real-api/deno.json \
web/workspace/test/drive-real-api/roundtrip.ts
```
## Validation evidence
Validated against the production Server endpoints and Web adapter, not the browser mock routes:
- `cargo test -p yoi-workspace-server --lib drive_web_roundtrip_tests -- --test-threads=4`: **1 passed, 1 explicitly ignored** (the process prerequisite).
- `cargo test -p yoi-workspace-server --lib drive_web_roundtrip_tests -- --ignored --test-threads=4`: **1 passed**; the actual Deno adapter process completed against real SQLite/LocalFileSystem HTTP.
- `cargo test -p yoi-workspace-server -- --test-threads=4`: **693 library tests passed, 1 ignored; 11 binary tests passed; doc-tests passed**.
The repositoryless fixture was adjusted to retain the production runtime trust gate's exact normalized binding snapshot and to scope the reusable Runtime observation to the newly bootstrapped Workspace. Neither fix changes the API, Drive authorization policy, Tools, WIP, or browser fixtures.
@@ -0,0 +1,7 @@
{
"nodeModulesDir": "none",
"lock": false,
"imports": {
"#lib/": "../../src/lib/"
}
}
@@ -0,0 +1,448 @@
// Invoked only by the Rust loopback harness. No mocked responses, live server,
// browser fixtures, environment variables, or bearer credentials in argv/logs.
import {
createDriveClient,
type DriveClient,
driveDownloadUrl,
type DriveEntry,
type DriveMutation,
DriveRequestError,
readBoundedDriveBytes,
} from "../../src/lib/workspace/drive/api.ts";
import {
createDriveGrant,
DriveGrantError,
listDriveGrants,
revokeDriveGrant,
} from "../../src/lib/workspace/drive-grants/api.ts";
// Preserve the real network implementation before temporarily binding the grant
// adapter's same-origin global fetch to this isolated child/loopback authority.
const networkFetch = globalThis.fetch;
interface Fixture {
origin: string;
workspace_id: string;
owner_token: string;
second_token: string;
worker_entry: DriveEntry;
}
function assert(value: unknown, message: string): asserts value {
if (!value) throw new Error(message);
}
function equal(actual: unknown, expected: unknown, message: string): void {
assert(JSON.stringify(actual) === JSON.stringify(expected), message);
}
async function rejected(
call: () => Promise<unknown>,
code: string,
): Promise<void> {
try {
await call();
} catch (error) {
assert(
error instanceof DriveRequestError,
"rejection must preserve typed Drive API error",
);
equal(error.code, code, `expected typed ${code} rejection`);
equal(
error.classification,
"not_committed",
"rejected request must not look successful/unknown",
);
return;
}
throw new Error(`expected ${code} rejection`);
}
function authenticatedFetch(origin: string, token: string): typeof fetch {
const endpoint = new URL(origin);
assert(
endpoint.protocol === "http:" && endpoint.hostname === "127.0.0.1",
"fixture must be loopback only",
);
return (input, init) => {
const path = input instanceof Request ? input.url : String(input);
const url = new URL(path, endpoint);
assert(
url.origin === endpoint.origin && url.pathname.startsWith("/api/"),
"never send fixture credentials off-origin",
);
const headers = new Headers(
input instanceof Request ? input.headers : undefined,
);
new Headers(init && "headers" in init ? init.headers : undefined).forEach((
value,
key,
) => headers.set(key, value));
headers.set("authorization", `Bearer ${token}`);
return networkFetch(url, { ...init, headers, redirect: "error" });
};
}
async function saved(
client: DriveClient,
ws: string,
request: string,
mutation: DriveMutation,
): Promise<DriveEntry> {
const result = await client.mutate(ws, request, mutation);
assert(result.entry !== null, "creation/update must return live entry");
return result.entry;
}
async function download(
fetchFn: typeof fetch,
ws: string,
entry: DriveEntry,
): Promise<Uint8Array> {
const response = await fetchFn(
driveDownloadUrl(ws, entry.entry, entry.revision),
);
assert(response.status === 200, "download must succeed at exact revision");
equal(
response.headers.get("x-content-type-options"),
"nosniff",
"downloads must not sniff active content",
);
assert(
response.headers.get("content-disposition")?.startsWith("attachment;"),
"download must be attachment",
);
return readBoundedDriveBytes(response, 16 * 1024 * 1024);
}
// Credentials arrive exclusively over the child pipe from a fresh temporary DB.
const fixture: Fixture = JSON.parse(
await new Response(Deno.stdin.readable).text(),
);
const ws = fixture.workspace_id;
const fetchOwner = authenticatedFetch(fixture.origin, fixture.owner_token);
const owner = createDriveClient(fetchOwner);
const second = createDriveClient(
authenticatedFetch(fixture.origin, fixture.second_token),
);
const root = await owner.root(ws);
const initial = await owner.list(ws, root.entry);
equal(initial.entries.map((entry) => entry.entry.node_id), [
fixture.worker_entry.entry.node_id,
], "Worker-created file must appear in the real Web adapter list");
equal(
(await owner.readText(ws, fixture.worker_entry.entry)).text,
"# Worker artifact\n",
"Web adapter must read Worker bytes from LocalFileSystem",
);
// Exercise the exact grant adapter used by the Workers page, not a hand-written
// HTTP facsimile. Only URL resolution/auth are bound; every response is real DB/API.
try {
globalThis.fetch = fetchOwner;
const grants = await listDriveGrants(ws);
assert(
grants.length === 2,
"real Worker grants must be listed by the Web adapter",
);
const worker = grants[0];
for (const access of ["read_only", "read_write"] as const) {
const created = await createDriveGrant(ws, {
runtime_id: worker.runtime_id,
worker_id: worker.worker_id,
access,
});
equal(
created.access,
access,
"grant access must not be silently downgraded",
);
assert(
(await listDriveGrants(ws)).some((grant) =>
grant.grant_id === created.grant_id && !grant.revoked
),
"grant must be durably visible",
);
const revoked = await revokeDriveGrant(ws, created);
assert(
revoked.revoked &&
(await listDriveGrants(ws)).some((grant) =>
grant.grant_id === created.grant_id && grant.revoked
),
"revoke must be durably visible",
);
}
globalThis.fetch = authenticatedFetch(fixture.origin, fixture.second_token);
try {
await listDriveGrants(ws);
throw new Error("member must not manage grants");
} catch (error) {
assert(
error instanceof DriveGrantError && error.outcome === "not_committed",
"member grant read must be a real authorized rejection",
);
}
} finally {
globalThis.fetch = networkFetch;
}
const folder = await saved(owner, ws, "web-folder", {
operation: "create_folder",
parent: root.entry,
name: "共有資料",
});
equal(
(await owner.list(ws, folder.entry)).entries,
[],
"new folder must be empty",
);
const markdown = await saved(owner, ws, "web-markdown", {
operation: "create_text",
parent: folder.entry,
name: "日本語.md",
text: "# 初版\n",
content_type: "text/markdown",
});
equal(
(await owner.readText(ws, markdown.entry)).text,
"# 初版\n",
"real Markdown read must match created bytes",
);
const updated = await saved(owner, ws, "web-edit", {
operation: "update_text",
id: markdown.entry,
expected_revision: markdown.revision,
text: "# 更新\n",
content_type: "text/markdown",
});
equal(
new TextDecoder().decode(await download(fetchOwner, ws, updated)),
"# 更新\n",
"updated Markdown download must use real blob bytes",
);
const empty = await saved(owner, ws, "web-empty", {
operation: "create_text",
parent: folder.entry,
name: "empty.txt",
text: "",
content_type: "text/plain",
});
equal(
(await owner.readText(ws, empty.entry)).text,
"",
"empty file is not a missing/error response",
);
equal(
(await download(fetchOwner, ws, empty)).length,
0,
"empty file download must succeed with zero bytes",
);
// A real 1x1 PNG, not a claimed media type over arbitrary text.
const png = Uint8Array.from(
atob(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/l9sAAAAASUVORK5CYII=",
),
(character) => character.charCodeAt(0),
);
const uploaded = await owner.upload(
ws,
"web-png",
new Blob([png], { type: "image/png" }),
{
operation: "create",
parent: folder.entry,
name: "画像.png",
content_type: "image/png",
},
);
assert(uploaded.entry !== null, "PNG upload must publish metadata");
equal(
uploaded.entry.size,
png.length,
"binary byte count must survive actual upload",
);
equal(
[
...new Uint8Array(
await (await owner.image(ws, uploaded.entry)).arrayBuffer(),
),
],
[...png],
"authenticated image bytes must survive upload, storage and Web adapter download",
);
equal(
[...(await download(fetchOwner, ws, uploaded.entry))],
[...png],
"PNG download must match original bytes",
);
const binaryUpdated = await owner.upload(
ws,
"web-empty-to-png",
new Blob([png]),
{
operation: "update",
id: empty.entry,
expected_revision: empty.revision,
content_type: "image/png",
},
);
assert(binaryUpdated.entry !== null, "binary update must publish metadata");
equal(
[...await download(fetchOwner, ws, binaryUpdated.entry)],
[...png],
"binary update must replace zero-length file bytes",
);
const status = await owner.status(ws, "web-png");
equal(
status.state,
"committed",
"adapter must distinguish publication from transmission",
);
equal(
status.response,
uploaded,
"status must refer to exact real upload receipt",
);
const firstPage = await owner.list(ws, folder.entry, { limit: 1 });
assert(
firstPage.next_after !== null && firstPage.entries.length === 1,
"real list must expose a service-issued page cursor",
);
const following = await owner.list(ws, folder.entry, {
after: firstPage.next_after,
});
equal(
new Set(
[...firstPage.entries, ...following.entries].map((entry) =>
entry.entry.node_id
),
).size,
3,
"paged traversal must contain each file once",
);
// Two authenticated accounts read the same revision, then race real HTTP writes.
const secondRead = await second.readText(ws, updated.entry);
equal(
secondRead.entry.revision,
updated.revision,
"second actor must observe same revision",
);
const drafts = ["# actor A draft\n", "# actor B draft\n"];
const race = await Promise.allSettled(
[owner, second].map((client, index) =>
saved(client, ws, `web-race-${index}`, {
operation: "update_text",
id: updated.entry,
expected_revision: updated.revision,
text: drafts[index],
content_type: "text/markdown",
})
),
);
equal(
race.filter((result) => result.status === "fulfilled").length,
1,
"same-revision two-actor CAS must have exactly one winner",
);
const loser = race.find((result) => result.status === "rejected");
assert(
loser?.status === "rejected" && loser.reason instanceof DriveRequestError,
"loser must expose typed conflict, not silently retry",
);
equal(loser.reason.code, "conflict", "CAS loser must remain a conflict");
const winnerIndex = race.findIndex((result) => result.status === "fulfilled");
const latest = await owner.readText(ws, updated.entry);
equal(
latest.text,
drafts[winnerIndex],
"durable content must be the winning actor's draft",
);
// This API boundary proves no automatic retry/overwrite. UI dirty-draft
// retention is deliberately verified by the separate browser fixture.
// Latest references bind node IDs, not names/parents, and never become public.
const stableUrl = markdown.latest_url;
const renamed = await saved(owner, ws, "web-rename", {
operation: "relocate",
id: latest.entry.entry,
expected_revision: latest.entry.revision,
parent: folder.entry,
name: "改名.md",
});
const moved = await saved(owner, ws, "web-move", {
operation: "relocate",
id: renamed.entry,
expected_revision: renamed.revision,
parent: root.entry,
name: renamed.name,
});
equal(moved.entry, markdown.entry, "rename/move must preserve node identity");
equal(moved.latest_url, stableUrl, "rename/move must preserve latest URL");
equal(
(await owner.metadata(ws, markdown.entry)).parent,
root.entry,
"original ref must resolve new parent",
);
equal(
await (await fetchOwner(stableUrl)).text(),
drafts[winnerIndex],
"original latest URL must resolve current bytes",
);
const unauthenticated = await fetch(new URL(stableUrl, fixture.origin), {
redirect: "error",
});
assert(
unauthenticated.status === 403 || unauthenticated.status === 401,
"possessing a latest URL must not authorize reads",
);
await unauthenticated.body?.cancel();
await rejected(
() =>
owner.metadata(ws, { workspace_id: ws, node_id: "9223372036854775807" }),
"not_found",
);
const deleted = await owner.mutate(ws, "web-delete", {
operation: "delete",
id: moved.entry,
expected_revision: moved.revision,
});
equal(deleted.entry, null, "delete must remove live metadata");
await rejected(() => owner.readText(ws, moved.entry), "not_found");
const oldUrl = await fetchOwner(stableUrl);
equal(
oldUrl.status,
404,
"deleted stable URL must not resolve a recreated same-name file",
);
await oldUrl.body?.cancel();
const recreated = await saved(owner, ws, "web-recreate", {
operation: "create_text",
parent: root.entry,
name: moved.name,
text: "recreated",
content_type: "text/markdown",
});
assert(
recreated.entry.node_id !== moved.entry.node_id &&
recreated.latest_url !== stableUrl,
"same-name recreate must allocate a new ID and latest URL",
);
// The decoded text bound is independent of JSON escape expansion on the wire.
const escapedText = "\u0000".repeat(64 * 1024);
const escaped = await saved(owner, ws, "web-escaped-text", {
operation: "create_text",
parent: root.entry,
name: "escaped.txt",
text: escapedText,
content_type: "text/plain",
});
assert(
(await owner.readText(ws, escaped.entry)).text === escapedText,
"real API 64 KiB escaped text must roundtrip without a false response-limit failure",
);
// The adapter also handles real current authorization, not just fixture promises.
await rejected(
() =>
createDriveClient(
authenticatedFetch(fixture.origin, "invalid-fixture-token"),
).readText(ws, fixture.worker_entry.entry),
"denied",
);
console.log(
"real Web adapter roundtrip passed: Worker visibility, folder/Markdown/PNG/empty bytes, CAS, stable URL lifecycle, authorization",
);