Files

450 lines
14 KiB
TypeScript

// Rust loopback harness専用。production adapterを実HTTPで検証し、応答をmockしない。
// fixture認証情報はstdinから受け取り、argvやログへ出さない。画面操作の検証とは分離する。
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;
foreign_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.last_mutation_id),
);
assert(response.status === 200, "download must succeed at exact last_mutation_id");
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.foreign_token);
try {
await listDriveGrants(ws);
throw new Error("foreign Account must not manage grants");
} catch (error) {
assert(
error instanceof DriveGrantError && error.outcome === "not_committed",
"foreign Account 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_mutation_id: markdown.last_mutation_id,
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_mutation_id: empty.last_mutation_id,
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 last_mutation_id, then race real HTTP writes.
const secondRead = await second.readText(ws, updated.entry);
equal(
secondRead.entry.last_mutation_id,
updated.last_mutation_id,
"second owner client must observe same last_mutation_id",
);
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_mutation_id: updated.last_mutation_id,
text: drafts[index],
content_type: "text/markdown",
})
),
);
equal(
race.filter((result) => result.status === "fulfilled").length,
1,
"same-last_mutation_id two-client 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 client'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_mutation_id: latest.entry.last_mutation_id,
parent: folder.entry,
name: "改名.md",
});
const moved = await saved(owner, ws, "web-move", {
operation: "relocate",
id: renamed.entry,
expected_mutation_id: renamed.last_mutation_id,
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_mutation_id: moved.last_mutation_id,
});
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",
);