2026-07-20 03:20:08 +09:00
|
|
|
import assert from 'node:assert/strict';
|
|
|
|
|
import { readFile } from 'node:fs/promises';
|
|
|
|
|
import test from 'node:test';
|
|
|
|
|
import {
|
|
|
|
|
BrowserProductClient,
|
|
|
|
|
requireValue
|
|
|
|
|
} from '../src/transport.ts';
|
|
|
|
|
|
|
|
|
|
const fixtures = JSON.parse(
|
|
|
|
|
await readFile(new URL('./fixtures/contract.json', import.meta.url), 'utf8')
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
test('transport derives routes, credentials, and encoded queries from the contract', async () => {
|
|
|
|
|
const calls = [];
|
|
|
|
|
const client = new BrowserProductClient(
|
|
|
|
|
async (input, init) => {
|
|
|
|
|
calls.push({ input: String(input), init });
|
|
|
|
|
return jsonResponse(fixtures.browse_response);
|
|
|
|
|
},
|
|
|
|
|
'https://nuthatch.test/'
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const response = requireValue(
|
|
|
|
|
await client.browse({
|
|
|
|
|
root: 'shared',
|
|
|
|
|
prefix: '문서/a b',
|
|
|
|
|
q: 'needle',
|
|
|
|
|
continuation: 'next/token'
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
assert.deepEqual(response, fixtures.browse_response);
|
|
|
|
|
assert.equal(calls.length, 1);
|
|
|
|
|
assert.equal(calls[0].init.credentials, 'same-origin');
|
|
|
|
|
assert.equal(calls[0].init.method, 'GET');
|
|
|
|
|
const url = new URL(calls[0].input);
|
|
|
|
|
assert.equal(url.pathname, '/api/browse');
|
|
|
|
|
assert.equal(url.searchParams.get('root'), 'shared');
|
|
|
|
|
assert.equal(url.searchParams.get('prefix'), '문서/a b');
|
|
|
|
|
assert.equal(url.searchParams.get('q'), 'needle');
|
|
|
|
|
assert.equal(url.searchParams.get('continuation'), 'next/token');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('binary upload preserves the Blob and required generated headers', async () => {
|
|
|
|
|
const calls = [];
|
|
|
|
|
const client = new BrowserProductClient(async (input, init) => {
|
|
|
|
|
calls.push({ input: String(input), init });
|
|
|
|
|
return jsonResponse(fixtures.upload_response);
|
|
|
|
|
});
|
|
|
|
|
const body = new Blob(['payload']);
|
|
|
|
|
|
|
|
|
|
const response = requireValue(
|
|
|
|
|
await client.upload({
|
|
|
|
|
root: 'personal',
|
|
|
|
|
path: 'folder/문서.txt',
|
|
|
|
|
operationId: 'operation-1',
|
|
|
|
|
declaredSize: body.size,
|
|
|
|
|
contentSha256: 'a'.repeat(64),
|
|
|
|
|
csrfToken: 'b'.repeat(43),
|
|
|
|
|
body
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert.deepEqual(response, fixtures.upload_response);
|
|
|
|
|
assert.equal(calls.length, 1);
|
|
|
|
|
assert.equal(calls[0].init.body, body);
|
|
|
|
|
assert.equal(calls[0].init.headers.get('Content-Type'), 'application/octet-stream');
|
|
|
|
|
assert.equal(calls[0].init.headers.get('X-Declared-Size'), '7');
|
|
|
|
|
assert.equal(calls[0].init.headers.get('X-Content-SHA256'), 'a'.repeat(64));
|
|
|
|
|
assert.equal(calls[0].init.headers.get('X-CSRF-Token'), 'b'.repeat(43));
|
|
|
|
|
const url = new URL(calls[0].input, 'https://nuthatch.test');
|
|
|
|
|
assert.equal(url.pathname, '/api/upload');
|
|
|
|
|
assert.equal(url.searchParams.get('operation_id'), 'operation-1');
|
|
|
|
|
assert.equal(url.searchParams.get('path'), 'folder/문서.txt');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('download exposes its stream and declared response headers', async () => {
|
|
|
|
|
const client = new BrowserProductClient(async () =>
|
|
|
|
|
new Response(new Uint8Array([1, 2, 3]), {
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Disposition': 'attachment; filename="file.bin"',
|
|
|
|
|
'X-Content-Type-Options': 'nosniff',
|
|
|
|
|
'Content-Security-Policy': "default-src 'none'",
|
|
|
|
|
'Content-Length': '3'
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const download = requireValue(
|
|
|
|
|
await client.download({ root: 'shared', path: 'file.bin' })
|
|
|
|
|
);
|
|
|
|
|
assert.deepEqual(
|
|
|
|
|
new Uint8Array(await new Response(download.body).arrayBuffer()),
|
|
|
|
|
new Uint8Array([1, 2, 3])
|
|
|
|
|
);
|
|
|
|
|
assert.equal(download.contentDisposition, 'attachment; filename="file.bin"');
|
|
|
|
|
assert.equal(download.xContentTypeOptions, 'nosniff');
|
|
|
|
|
assert.equal(download.contentSecurityPolicy, "default-src 'none'");
|
|
|
|
|
assert.equal(download.contentLength, 3);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('shared fixtures decode through every JSON transport method', async () => {
|
|
|
|
|
const responses = [
|
|
|
|
|
fixtures.session_response,
|
|
|
|
|
fixtures.delete_response,
|
|
|
|
|
fixtures.delete_plan_response,
|
|
|
|
|
fixtures.delete_batch_response
|
|
|
|
|
];
|
|
|
|
|
const client = new BrowserProductClient(async () =>
|
|
|
|
|
jsonResponse(responses.shift())
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert.deepEqual(requireValue(await client.session({})), fixtures.session_response);
|
|
|
|
|
assert.deepEqual(
|
|
|
|
|
requireValue(await client.delete({ body: fixtures.delete_request })),
|
|
|
|
|
fixtures.delete_response
|
|
|
|
|
);
|
|
|
|
|
assert.deepEqual(
|
|
|
|
|
requireValue(
|
|
|
|
|
await client.deletePlan({
|
|
|
|
|
root: 'shared',
|
|
|
|
|
path: 'folder/문서.txt',
|
|
|
|
|
continuation: 'next-page'
|
|
|
|
|
})
|
|
|
|
|
),
|
|
|
|
|
fixtures.delete_plan_response
|
|
|
|
|
);
|
|
|
|
|
assert.deepEqual(
|
|
|
|
|
requireValue(
|
|
|
|
|
await client.deleteBatch({ body: fixtures.delete_batch_request })
|
|
|
|
|
),
|
|
|
|
|
fixtures.delete_batch_response
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-20 21:34:25 +09:00
|
|
|
test('delete batch decoding enforces the typed item outcome', async () => {
|
|
|
|
|
const failureClient = new BrowserProductClient(async () =>
|
|
|
|
|
jsonResponse({
|
|
|
|
|
results: [
|
|
|
|
|
{
|
|
|
|
|
path: 'folder/file.txt',
|
|
|
|
|
operation_id: 'operation-1',
|
|
|
|
|
result: null,
|
|
|
|
|
error: 'database_unavailable'
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
const failure = requireValue(
|
|
|
|
|
await failureClient.deleteBatch({ body: fixtures.delete_batch_request })
|
|
|
|
|
);
|
|
|
|
|
assert.equal(failure.results[0].error, 'database_unavailable');
|
|
|
|
|
|
|
|
|
|
const invalidClient = new BrowserProductClient(async () =>
|
|
|
|
|
jsonResponse({
|
|
|
|
|
results: [
|
|
|
|
|
{
|
|
|
|
|
path: 'folder/file.txt',
|
|
|
|
|
operation_id: 'operation-1',
|
|
|
|
|
result: 'succeeded',
|
|
|
|
|
error: 'object_store_unavailable'
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
const invalid = await invalidClient.deleteBatch({
|
|
|
|
|
body: fixtures.delete_batch_request
|
|
|
|
|
});
|
|
|
|
|
assert.equal(invalid.ok, false);
|
|
|
|
|
assert.equal(invalid.failure.kind, 'unexpected-http');
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-20 03:20:08 +09:00
|
|
|
test('declared, undeclared, and network failures remain distinct without retries', async () => {
|
|
|
|
|
const declaredClient = new BrowserProductClient(async () =>
|
|
|
|
|
jsonResponse(
|
|
|
|
|
{
|
|
|
|
|
error: 'csrf_validation_failed',
|
|
|
|
|
message: 'CSRF validation failed',
|
|
|
|
|
details: null
|
|
|
|
|
},
|
|
|
|
|
403
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
const declared = await declaredClient.delete({
|
|
|
|
|
body: fixtures.delete_request
|
|
|
|
|
});
|
|
|
|
|
assert.equal(declared.ok, false);
|
|
|
|
|
assert.equal(declared.failure.kind, 'http');
|
|
|
|
|
assert.equal(declared.failure.error.error, 'csrf_validation_failed');
|
|
|
|
|
|
|
|
|
|
const undeclaredClient = new BrowserProductClient(async () =>
|
|
|
|
|
jsonResponse(
|
|
|
|
|
{ error: 'unknown_error', message: 'unknown', details: null },
|
|
|
|
|
500
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
const undeclared = await undeclaredClient.session({});
|
|
|
|
|
assert.equal(undeclared.ok, false);
|
|
|
|
|
assert.equal(undeclared.failure.kind, 'unexpected-http');
|
|
|
|
|
|
|
|
|
|
let attempts = 0;
|
|
|
|
|
const networkClient = new BrowserProductClient(async () => {
|
|
|
|
|
attempts += 1;
|
|
|
|
|
throw new Error('offline');
|
|
|
|
|
});
|
|
|
|
|
const network = await networkClient.browse({});
|
|
|
|
|
assert.equal(network.ok, false);
|
|
|
|
|
assert.equal(network.failure.kind, 'network');
|
|
|
|
|
assert.equal(attempts, 1);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-20 03:37:35 +09:00
|
|
|
test('logout keeps session mutation policy in the transport', async () => {
|
|
|
|
|
const calls = [];
|
|
|
|
|
const client = new BrowserProductClient(async (input, init) => {
|
|
|
|
|
calls.push({ input, init });
|
|
|
|
|
return new Response(null, { status: 204 });
|
|
|
|
|
});
|
|
|
|
|
await client.logout('csrf-token');
|
|
|
|
|
assert.equal(calls.length, 1);
|
|
|
|
|
assert.equal(calls[0].input, '/logout');
|
|
|
|
|
assert.equal(calls[0].init.method, 'POST');
|
|
|
|
|
assert.equal(calls[0].init.credentials, 'same-origin');
|
|
|
|
|
assert.equal(calls[0].init.headers['X-CSRF-Token'], 'csrf-token');
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-20 03:20:08 +09:00
|
|
|
test('checked generated artifact contains no transport policy', async () => {
|
|
|
|
|
const generated = await readFile(
|
|
|
|
|
new URL('../src/generated/contract.ts', import.meta.url),
|
|
|
|
|
'utf8'
|
|
|
|
|
);
|
|
|
|
|
assert.doesNotMatch(generated, /\bfetch\s*\(/);
|
|
|
|
|
assert.doesNotMatch(generated, /\bcredentials\b/);
|
|
|
|
|
assert.doesNotMatch(generated, /\bretry\b/i);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function jsonResponse(value, status = 200) {
|
|
|
|
|
return new Response(JSON.stringify(value), {
|
|
|
|
|
status,
|
|
|
|
|
headers: { 'Content-Type': 'application/json' }
|
|
|
|
|
});
|
|
|
|
|
}
|