2026-07-05 23:51:11 +03:00
|
|
|
import { describe, expect, mock, test } from 'bun:test';
|
|
|
|
|
|
2026-07-08 03:44:02 +03:00
|
|
|
import { loadMobileConnections, upsertMobileConnection, validateMobileConnectionSession, type MobileRelayConfig } from './mobileConnections';
|
2026-07-05 23:51:11 +03:00
|
|
|
|
|
|
|
|
const originalFetch = globalThis.fetch;
|
|
|
|
|
const originalWindow = globalThis.window;
|
|
|
|
|
|
2026-07-08 03:44:02 +03:00
|
|
|
const createLocalStorageStub = () => {
|
|
|
|
|
const store = new Map<string, string>();
|
|
|
|
|
return {
|
|
|
|
|
getItem: (key: string) => store.get(key) ?? null,
|
|
|
|
|
setItem: (key: string, value: string) => { store.set(key, value); },
|
|
|
|
|
removeItem: (key: string) => { store.delete(key); },
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-05 23:51:11 +03:00
|
|
|
const installTestWindow = () => {
|
|
|
|
|
Object.defineProperty(globalThis, 'window', {
|
|
|
|
|
configurable: true,
|
|
|
|
|
value: {
|
|
|
|
|
setTimeout: globalThis.setTimeout.bind(globalThis),
|
|
|
|
|
clearTimeout: globalThis.clearTimeout.bind(globalThis),
|
|
|
|
|
location: { protocol: 'https:' },
|
2026-07-08 03:44:02 +03:00
|
|
|
localStorage: createLocalStorageStub(),
|
2026-07-05 23:51:11 +03:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const restoreGlobals = () => {
|
|
|
|
|
globalThis.fetch = originalFetch;
|
|
|
|
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-08 03:44:02 +03:00
|
|
|
const STORAGE_KEY = 'openchamber.mobile.connections.v1';
|
|
|
|
|
|
|
|
|
|
const testRelay: MobileRelayConfig = {
|
|
|
|
|
relayUrl: 'wss://relay.example/tunnel',
|
|
|
|
|
serverId: 'srv_test123',
|
|
|
|
|
hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'eHhY', y: 'eVlZ' },
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
describe('mobile connection storage', () => {
|
2026-07-10 00:12:33 +03:00
|
|
|
test('entries persisted before candidates migrate to a single direct candidate', async () => {
|
2026-07-08 03:44:02 +03:00
|
|
|
try {
|
|
|
|
|
installTestWindow();
|
|
|
|
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify([
|
|
|
|
|
{ id: 'a', label: 'Home', url: 'http://192.168.1.10:2606', lastUsedAt: 10, clientToken: 'tok-a' },
|
|
|
|
|
{ id: 'b', label: 'Work', url: 'http://work.example', lastUsedAt: 5 },
|
|
|
|
|
]));
|
|
|
|
|
|
|
|
|
|
const connections = await loadMobileConnections();
|
|
|
|
|
expect(connections).toHaveLength(2);
|
2026-07-10 00:12:33 +03:00
|
|
|
const home = connections.find((c) => c.id === 'a')!;
|
|
|
|
|
expect(home.candidates).toEqual([{ kind: 'direct', url: 'http://192.168.1.10:2606' }]);
|
|
|
|
|
expect(home.clientToken).toBe('tok-a');
|
2026-07-08 03:44:02 +03:00
|
|
|
} finally {
|
|
|
|
|
restoreGlobals();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-10 00:12:33 +03:00
|
|
|
test('a relay device round-trips its candidate + token', async () => {
|
2026-07-08 03:44:02 +03:00
|
|
|
try {
|
|
|
|
|
installTestWindow();
|
|
|
|
|
|
|
|
|
|
await upsertMobileConnection({
|
|
|
|
|
label: 'My Desktop',
|
2026-07-10 00:12:33 +03:00
|
|
|
candidates: [{ kind: 'relay', relay: testRelay }],
|
2026-07-08 03:44:02 +03:00
|
|
|
clientToken: 'oc_client_secret',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const connections = await loadMobileConnections();
|
|
|
|
|
expect(connections).toHaveLength(1);
|
|
|
|
|
const saved = connections[0]!;
|
2026-07-10 00:12:33 +03:00
|
|
|
expect(saved.candidates).toEqual([{ kind: 'relay', relay: testRelay }]);
|
2026-07-08 03:44:02 +03:00
|
|
|
// Web surface: token stays inline like direct connections.
|
|
|
|
|
expect(saved.clientToken).toBe('oc_client_secret');
|
|
|
|
|
|
2026-07-10 00:12:33 +03:00
|
|
|
// Persisted metadata carries only the three transport fields — no grant/token.
|
2026-07-08 03:44:02 +03:00
|
|
|
const raw = JSON.parse(window.localStorage.getItem(STORAGE_KEY) || '[]') as Array<Record<string, unknown>>;
|
2026-07-10 00:12:33 +03:00
|
|
|
const rawCandidate = (raw[0]?.candidates as Array<Record<string, unknown>>)[0];
|
|
|
|
|
expect(rawCandidate.kind).toBe('relay');
|
|
|
|
|
expect(Object.keys(rawCandidate.relay as object).sort()).toEqual(['hostEncPubJwk', 'relayUrl', 'serverId']);
|
2026-07-08 03:44:02 +03:00
|
|
|
} finally {
|
|
|
|
|
restoreGlobals();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-10 00:12:33 +03:00
|
|
|
test('a multi-transport device persists all candidates in order (LAN then relay)', async () => {
|
|
|
|
|
try {
|
|
|
|
|
installTestWindow();
|
|
|
|
|
await upsertMobileConnection({
|
|
|
|
|
label: 'Both',
|
|
|
|
|
candidates: [{ kind: 'direct', url: 'http://192.168.1.5:2606' }, { kind: 'relay', relay: testRelay }],
|
|
|
|
|
clientToken: 'tok',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const connections = await loadMobileConnections();
|
|
|
|
|
expect(connections[0]?.candidates.map((c) => c.kind)).toEqual(['direct', 'relay']);
|
|
|
|
|
} finally {
|
|
|
|
|
restoreGlobals();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a legacy relay entry with malformed transport config is dropped, direct entries survive', async () => {
|
2026-07-08 03:44:02 +03:00
|
|
|
try {
|
|
|
|
|
installTestWindow();
|
|
|
|
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify([
|
2026-07-10 00:12:33 +03:00
|
|
|
{ id: 'bad', label: 'Broken', lastUsedAt: 20, mode: 'relay', relay: { relayUrl: 'wss://relay.example' } },
|
2026-07-08 03:44:02 +03:00
|
|
|
{ id: 'ok', label: 'Home', url: 'http://192.168.1.10:2606', lastUsedAt: 10 },
|
|
|
|
|
]));
|
|
|
|
|
|
|
|
|
|
const connections = await loadMobileConnections();
|
|
|
|
|
expect(connections).toHaveLength(1);
|
|
|
|
|
expect(connections[0]?.id).toBe('ok');
|
2026-07-10 00:12:33 +03:00
|
|
|
expect(connections[0]?.candidates[0]?.kind).toBe('direct');
|
2026-07-08 03:44:02 +03:00
|
|
|
} finally {
|
|
|
|
|
restoreGlobals();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-10 00:12:33 +03:00
|
|
|
test('relay and direct devices dedupe independently by candidate identity', async () => {
|
2026-07-08 03:44:02 +03:00
|
|
|
try {
|
|
|
|
|
installTestWindow();
|
2026-07-10 00:12:33 +03:00
|
|
|
await upsertMobileConnection({ label: 'Direct', candidates: [{ kind: 'direct', url: 'http://host.example' }] });
|
|
|
|
|
await upsertMobileConnection({ label: 'Relay', candidates: [{ kind: 'relay', relay: testRelay }] });
|
|
|
|
|
await upsertMobileConnection({ label: 'Relay renamed', candidates: [{ kind: 'relay', relay: testRelay }] });
|
2026-07-08 03:44:02 +03:00
|
|
|
|
|
|
|
|
const connections = await loadMobileConnections();
|
|
|
|
|
expect(connections).toHaveLength(2);
|
2026-07-10 00:12:33 +03:00
|
|
|
const relayEntries = connections.filter((c) => c.candidates.some((x) => x.kind === 'relay'));
|
|
|
|
|
expect(relayEntries).toHaveLength(1);
|
|
|
|
|
expect(relayEntries[0]?.label).toBe('Relay renamed');
|
2026-07-08 03:44:02 +03:00
|
|
|
} finally {
|
|
|
|
|
restoreGlobals();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-05 23:51:11 +03:00
|
|
|
describe('validateMobileConnectionSession', () => {
|
|
|
|
|
test('accepts a reachable authenticated runtime', async () => {
|
|
|
|
|
const fetchMock = mock(async (input: RequestInfo | URL) => {
|
|
|
|
|
const url = String(input);
|
|
|
|
|
if (url.endsWith('/health')) return Response.json({ ok: true });
|
|
|
|
|
if (url.endsWith('/auth/session')) return Response.json({ authenticated: true, scope: 'client' });
|
|
|
|
|
return new Response(null, { status: 404 });
|
|
|
|
|
});
|
|
|
|
|
try {
|
|
|
|
|
installTestWindow();
|
|
|
|
|
globalThis.fetch = fetchMock as typeof fetch;
|
|
|
|
|
|
|
|
|
|
const result = await validateMobileConnectionSession({ url: 'https://runtime.example', clientToken: 'token' });
|
|
|
|
|
expect(result).toBe(true);
|
|
|
|
|
} finally {
|
|
|
|
|
restoreGlobals();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('rejects unreachable runtimes', async () => {
|
|
|
|
|
try {
|
|
|
|
|
installTestWindow();
|
|
|
|
|
globalThis.fetch = mock(async () => new Response(null, { status: 503 })) as typeof fetch;
|
|
|
|
|
|
|
|
|
|
const result = await validateMobileConnectionSession({ url: 'https://runtime.example', clientToken: 'token' });
|
|
|
|
|
expect(result).toBe(false);
|
|
|
|
|
} finally {
|
|
|
|
|
restoreGlobals();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('rejects invalid or unauthenticated sessions', async () => {
|
|
|
|
|
const fetchMock = mock(async (input: RequestInfo | URL) => {
|
|
|
|
|
const url = String(input);
|
|
|
|
|
if (url.endsWith('/health')) return Response.json({ ok: true });
|
|
|
|
|
return Response.json({ authenticated: false }, { status: 401 });
|
|
|
|
|
});
|
|
|
|
|
try {
|
|
|
|
|
installTestWindow();
|
|
|
|
|
globalThis.fetch = fetchMock as typeof fetch;
|
|
|
|
|
|
|
|
|
|
const result = await validateMobileConnectionSession({ url: 'https://runtime.example', clientToken: 'expired' });
|
|
|
|
|
expect(result).toBe(false);
|
|
|
|
|
} finally {
|
|
|
|
|
restoreGlobals();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|