120 lines
4.3 KiB
TypeScript
120 lines
4.3 KiB
TypeScript
|
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||
|
|
import { setupFileRig, type FileTestRig } from '../integration/helpers/rig.js';
|
||
|
|
import { ConflictError, NotFoundError, type FileEntry } from '../../src/index.js';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Replay-window: the dispatcher rejects requests where `signedAt` is more
|
||
|
|
* than ±5 min from the server clock. Plus: idempotent retries on the same
|
||
|
|
* mutation key produce a single side-effect even with stale signedAt.
|
||
|
|
*/
|
||
|
|
|
||
|
|
describe('Replay window + idempotent retry', () => {
|
||
|
|
let rig: FileTestRig;
|
||
|
|
let writeCount = 0;
|
||
|
|
const blobs = new Map<string, Uint8Array>();
|
||
|
|
|
||
|
|
beforeAll(async () => {
|
||
|
|
rig = await setupFileRig({
|
||
|
|
mkdir: async (ctx) => {
|
||
|
|
if (blobs.has(ctx.path)) throw new ConflictError('exists');
|
||
|
|
blobs.set(ctx.path, new Uint8Array(0));
|
||
|
|
const e: FileEntry = {
|
||
|
|
name: ctx.path.split('/').filter(Boolean).pop() ?? '',
|
||
|
|
kind: 'dir', size: 0, mtime: Date.now(), metadata: {},
|
||
|
|
};
|
||
|
|
return { entry: e };
|
||
|
|
},
|
||
|
|
write: async (ctx) => {
|
||
|
|
writeCount++;
|
||
|
|
if (ctx.args.content.kind !== 'inline') throw new Error('inline expected');
|
||
|
|
blobs.set(ctx.args.path, ctx.args.content.bytes);
|
||
|
|
const e: FileEntry = {
|
||
|
|
name: ctx.args.path.split('/').filter(Boolean).pop() ?? '',
|
||
|
|
kind: 'file',
|
||
|
|
size: ctx.args.content.bytes.byteLength,
|
||
|
|
mtime: Date.now(),
|
||
|
|
metadata: { sha256: ctx.args.content.sha256 },
|
||
|
|
};
|
||
|
|
return { entry: e };
|
||
|
|
},
|
||
|
|
stat: async (ctx) => {
|
||
|
|
if (!blobs.has(ctx.path)) throw new NotFoundError(ctx.path);
|
||
|
|
return {
|
||
|
|
name: ctx.path.split('/').filter(Boolean).pop() ?? '',
|
||
|
|
kind: 'file', size: 0, mtime: 0, metadata: {},
|
||
|
|
};
|
||
|
|
},
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
afterAll(async () => {
|
||
|
|
await rig.teardown();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('idempotent retry: same key + same args → single side-effect', async () => {
|
||
|
|
writeCount = 0;
|
||
|
|
// Idempotency keys are exactly 22 chars, base64url alphabet.
|
||
|
|
const key = 'replay_key_1234567890A';
|
||
|
|
const data = new Uint8Array([1, 2, 3]);
|
||
|
|
const r1 = await rig.fs.write('/replay.bin', data, { idempotencyKey: key });
|
||
|
|
const r2 = await rig.fs.write('/replay.bin', data, { idempotencyKey: key });
|
||
|
|
const r3 = await rig.fs.write('/replay.bin', data, { idempotencyKey: key });
|
||
|
|
expect(writeCount).toBe(1);
|
||
|
|
expect(r1.entry.size).toBe(3);
|
||
|
|
expect(r2.entry.size).toBe(3);
|
||
|
|
expect(r3.entry.size).toBe(3);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('out-of-window signedAt → InvalidSignatureError (skew rejection)', async () => {
|
||
|
|
// Build a custom client that LIES about signedAt. We hand-craft an
|
||
|
|
// RpcRequest envelope and ship it via the underlying channel.
|
||
|
|
const {
|
||
|
|
ShadeFileRpcChannel,
|
||
|
|
PendingRpcRegistry,
|
||
|
|
attachClientRouting,
|
||
|
|
KIND_STAT_V1,
|
||
|
|
generateRequestId,
|
||
|
|
} = await import('../../src/index.js');
|
||
|
|
const aliceChannel = new ShadeFileRpcChannel(rig.alice);
|
||
|
|
const alicePending = new PendingRpcRegistry();
|
||
|
|
attachClientRouting(aliceChannel, alicePending);
|
||
|
|
|
||
|
|
const requestId = generateRequestId();
|
||
|
|
const stalePromise = alicePending.register<unknown>(requestId, { timeoutMs: 3000 });
|
||
|
|
await aliceChannel.send('bob', {
|
||
|
|
kind: KIND_STAT_V1,
|
||
|
|
id: requestId,
|
||
|
|
args: { path: '/replay.bin' },
|
||
|
|
sig: 'unsigned',
|
||
|
|
signedAt: Date.now() - 10 * 60 * 1000, // 10 min in the past — outside ±5 min window
|
||
|
|
});
|
||
|
|
await expect(stalePromise).rejects.toThrow(/replay window|signature/i);
|
||
|
|
aliceChannel.destroy();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('signedAt far in the future → also rejected', async () => {
|
||
|
|
const {
|
||
|
|
ShadeFileRpcChannel,
|
||
|
|
PendingRpcRegistry,
|
||
|
|
attachClientRouting,
|
||
|
|
KIND_STAT_V1,
|
||
|
|
generateRequestId,
|
||
|
|
} = await import('../../src/index.js');
|
||
|
|
const aliceChannel = new ShadeFileRpcChannel(rig.alice);
|
||
|
|
const alicePending = new PendingRpcRegistry();
|
||
|
|
attachClientRouting(aliceChannel, alicePending);
|
||
|
|
|
||
|
|
const requestId = generateRequestId();
|
||
|
|
const promise = alicePending.register<unknown>(requestId, { timeoutMs: 3000 });
|
||
|
|
await aliceChannel.send('bob', {
|
||
|
|
kind: KIND_STAT_V1,
|
||
|
|
id: requestId,
|
||
|
|
args: { path: '/replay.bin' },
|
||
|
|
sig: 'unsigned',
|
||
|
|
signedAt: Date.now() + 10 * 60 * 1000,
|
||
|
|
});
|
||
|
|
await expect(promise).rejects.toThrow(/replay window|signature/i);
|
||
|
|
aliceChannel.destroy();
|
||
|
|
});
|
||
|
|
});
|