Twenty test servers picked a port with `NNNN + Math.floor(Math.random() * 500)`
and hoped it was free. Several files guessed inside the *same* range —
sdk.test.ts and gates.test.ts both on 19500, three files on 22000, and
webrtc-integration.test.ts used 22000 twice within itself — so with test files
serving in parallel the collision was not unlikely, it was scheduled.
Found from the outside, in Nova, where Shade is vendored: across eight runs of
an unchanged tree, two had failures and six were clean. The visible error was
Failed to start server. Is port 24287 in use? EADDRINUSE
and then a second, louder one: `teardownRig` ran on a rig that setup had never
finished building and died on `rig.alice`, so the TypeError from cleanup is
what you read first and the real cause scrolled past above it.
`Bun.serve({ port: 0 })` lets the OS hand out a free port and `server.port`
reads it back. There is no range to collide inside.
teardownRig now tolerates a rig that was never built. Cleanup must never be the
loudest thing in a failing test.
A suite that fails a quarter of the time is worse than a suite that fails: it
teaches everyone to re-run it, and then a real regression looks like the flake.
Verified: 1198 pass / 0 fail here, and the same fix is going into Nova's
vendored copy so the next sync does not bring the guessing back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014489bKUtUEY1Zgs9xN9mt7
223 lines
6.3 KiB
TypeScript
223 lines
6.3 KiB
TypeScript
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
import {
|
|
createProfileNamespace,
|
|
profilePlaintextToString,
|
|
deriveBlobSlotId,
|
|
deriveBlobKey,
|
|
deriveBlobSigningSeed,
|
|
ed25519PublicKeyFromSeed,
|
|
slotIdToHex,
|
|
} from '../src/index.js';
|
|
import {
|
|
createInboxServer,
|
|
MemoryInboxStore,
|
|
MemoryBlobStore,
|
|
} from '@shade/inbox-server';
|
|
import { SubtleCryptoProvider } from '@shade/crypto-web';
|
|
import { ShadeError } from '@shade/core';
|
|
|
|
const crypto = new SubtleCryptoProvider();
|
|
|
|
function randBytes(n: number): Uint8Array {
|
|
const buf = new Uint8Array(n);
|
|
globalThis.crypto.getRandomValues(buf);
|
|
return buf;
|
|
}
|
|
|
|
interface ServerHandle {
|
|
url: string;
|
|
stop: () => void;
|
|
}
|
|
|
|
async function startServer(): Promise<ServerHandle> {
|
|
const app = createInboxServer({
|
|
crypto,
|
|
store: new MemoryInboxStore(),
|
|
blobStore: new MemoryBlobStore(),
|
|
disableRateLimit: true,
|
|
});
|
|
// Port 0 lets the OS hand out a free one. This guessed inside a 500-wide
|
|
// range and hoped — and several files here guessed inside the SAME range,
|
|
// so with test files serving in parallel the collision arrived about a
|
|
// quarter of the time as EADDRINUSE.
|
|
const handle = Bun.serve({ port: 0, fetch: app.fetch });
|
|
const port = handle.port;
|
|
return {
|
|
url: `http://localhost:${port}`,
|
|
stop: () => handle.stop(true),
|
|
};
|
|
}
|
|
|
|
describe('SDK Profile namespace (V4.9)', () => {
|
|
let server: ServerHandle;
|
|
let masterKey: Uint8Array;
|
|
|
|
beforeEach(async () => {
|
|
server = await startServer();
|
|
masterKey = randBytes(32);
|
|
});
|
|
|
|
afterEach(() => {
|
|
server.stop();
|
|
});
|
|
|
|
test('credential-only round trip: create, read, update, delete', async () => {
|
|
const profile = createProfileNamespace({
|
|
baseUrl: server.url,
|
|
crypto,
|
|
masterKey,
|
|
app: 'test-profile-v1',
|
|
});
|
|
|
|
// Empty slot.
|
|
expect(await profile.get()).toBeNull();
|
|
|
|
// Create.
|
|
const payload = JSON.stringify({ hosts: ['device:abc'], v: 1 });
|
|
const created = await profile.put(payload);
|
|
expect(created.created).toBe(true);
|
|
|
|
// Read back.
|
|
const got1 = await profile.get();
|
|
expect(got1).not.toBeNull();
|
|
expect(profilePlaintextToString(got1!)).toBe(payload);
|
|
expect(got1!.etag).toBe(created.etag);
|
|
|
|
// CAS update with the etag we just read.
|
|
const next = JSON.stringify({ hosts: ['device:abc', 'device:def'], v: 2 });
|
|
const updated = await profile.put(next, { ifMatch: got1!.etag });
|
|
expect(updated.created).toBe(false);
|
|
expect(Number(updated.etag)).toBeGreaterThan(Number(created.etag));
|
|
|
|
// Stale CAS fails.
|
|
await expect(
|
|
profile.put(JSON.stringify({ hosts: [] }), { ifMatch: created.etag }),
|
|
).rejects.toThrow(ShadeError);
|
|
|
|
// Delete.
|
|
const removed = await profile.delete();
|
|
expect(removed).toBe(true);
|
|
expect(await profile.get()).toBeNull();
|
|
});
|
|
|
|
test('different app namespaces map to different slots', async () => {
|
|
const a = createProfileNamespace({
|
|
baseUrl: server.url,
|
|
crypto,
|
|
masterKey,
|
|
app: 'app-a',
|
|
});
|
|
const b = createProfileNamespace({
|
|
baseUrl: server.url,
|
|
crypto,
|
|
masterKey,
|
|
app: 'app-b',
|
|
});
|
|
expect(a.slotIdHex).not.toBe(b.slotIdHex);
|
|
});
|
|
|
|
test('different master keys map to different slots', async () => {
|
|
const km2 = randBytes(32);
|
|
const a = createProfileNamespace({
|
|
baseUrl: server.url,
|
|
crypto,
|
|
masterKey,
|
|
app: 'shared',
|
|
});
|
|
const b = createProfileNamespace({
|
|
baseUrl: server.url,
|
|
crypto,
|
|
masterKey: km2,
|
|
app: 'shared',
|
|
});
|
|
expect(a.slotIdHex).not.toBe(b.slotIdHex);
|
|
});
|
|
|
|
test('a fresh client with the same master + app reads the existing blob', async () => {
|
|
const writer = createProfileNamespace({
|
|
baseUrl: server.url,
|
|
crypto,
|
|
masterKey,
|
|
app: 'shared',
|
|
});
|
|
await writer.put('hello world');
|
|
|
|
// Brand-new namespace instance — simulates "log in from a new
|
|
// device". Uses *only* the master key + app namespace; nothing
|
|
// else carried over.
|
|
const reader = createProfileNamespace({
|
|
baseUrl: server.url,
|
|
crypto,
|
|
masterKey,
|
|
app: 'shared',
|
|
});
|
|
const got = await reader.get();
|
|
expect(got).not.toBeNull();
|
|
expect(profilePlaintextToString(got!)).toBe('hello world');
|
|
});
|
|
|
|
test('without ifMatch on populated slot is a SHADE_CONFLICT error', async () => {
|
|
const profile = createProfileNamespace({
|
|
baseUrl: server.url,
|
|
crypto,
|
|
masterKey,
|
|
app: 'conflict-test',
|
|
});
|
|
await profile.put('first');
|
|
try {
|
|
await profile.put('second');
|
|
throw new Error('expected put to throw');
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(ShadeError);
|
|
expect((err as ShadeError).code).toBe('SHADE_CONFLICT');
|
|
}
|
|
});
|
|
|
|
test('stale ifMatch is a SHADE_PRECONDITION_FAILED error', async () => {
|
|
const profile = createProfileNamespace({
|
|
baseUrl: server.url,
|
|
crypto,
|
|
masterKey,
|
|
app: 'precondition-test',
|
|
});
|
|
const first = await profile.put('first');
|
|
await profile.put('second', { ifMatch: first.etag });
|
|
try {
|
|
await profile.put('third', { ifMatch: first.etag });
|
|
throw new Error('expected put to throw');
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(ShadeError);
|
|
expect((err as ShadeError).code).toBe('SHADE_PRECONDITION_FAILED');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('KDF helpers (V4.9)', () => {
|
|
test('derivations are deterministic per (masterKey, app)', () => {
|
|
const km = randBytes(32);
|
|
const a1 = deriveBlobSlotId(km, 'x');
|
|
const a2 = deriveBlobSlotId(km, 'x');
|
|
expect(a1).toEqual(a2);
|
|
expect(deriveBlobSlotId(km, 'y')).not.toEqual(a1);
|
|
expect(deriveBlobKey(km, 'x')).not.toEqual(a1);
|
|
expect(deriveBlobSigningSeed(km, 'x')).not.toEqual(deriveBlobKey(km, 'x'));
|
|
});
|
|
|
|
test('signing seed → pubkey is deterministic and 32 bytes', () => {
|
|
const km = randBytes(32);
|
|
const seed = deriveBlobSigningSeed(km, 'p');
|
|
const pk1 = ed25519PublicKeyFromSeed(seed);
|
|
const pk2 = ed25519PublicKeyFromSeed(seed);
|
|
expect(pk1).toEqual(pk2);
|
|
expect(pk1.length).toBe(32);
|
|
});
|
|
|
|
test('slotIdToHex round-trips through hex form', () => {
|
|
const km = randBytes(32);
|
|
const id = deriveBlobSlotId(km, 'rt');
|
|
const hex = slotIdToHex(id);
|
|
expect(hex.length).toBe(64);
|
|
expect(/^[0-9a-f]{64}$/.test(hex)).toBe(true);
|
|
});
|
|
});
|