mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-08-11 21:20:26 +02:00
release: prepare v0.9.7
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function readSource(relativePath: string): string {
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
return fs.readFileSync(path.resolve(here, relativePath), 'utf-8');
|
||||
}
|
||||
|
||||
describe('Sprint 6 DM compatibility sunset policy', () => {
|
||||
it('keeps receive-side MeshChat request parsing off ambient legacy agent-id lookup', () => {
|
||||
const controller = readSource('../../components/MeshChat/useMeshChatController.ts');
|
||||
|
||||
expect(controller).toMatch(
|
||||
/fetchDmPublicKey\(\s*API_BASE,\s*m\.sender_id,\s*senderContact\?\.invitePinnedPrekeyLookupHandle/s,
|
||||
);
|
||||
expect(controller).not.toMatch(
|
||||
/fetchDmPublicKey\(\s*API_BASE,\s*m\.sender_id,[\s\S]{0,200}allowLegacyAgentId:\s*true/s,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps MessagesView receive-side contact parsing off ambient legacy agent-id lookup', () => {
|
||||
const messagesView = readSource('../../components/InfonetTerminal/MessagesView.tsx');
|
||||
|
||||
expect(messagesView).toMatch(
|
||||
/fetchDmPublicKey\(\s*API_BASE,\s*senderId,\s*existingContact\?\.invitePinnedPrekeyLookupHandle/s,
|
||||
);
|
||||
expect(messagesView).not.toMatch(
|
||||
/fetchDmPublicKey\(\s*API_BASE,\s*senderId,[\s\S]{0,200}allowLegacyAgentId:\s*true/s,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps MeshTerminal legacy lookup limited to explicit migration commands', () => {
|
||||
const terminal = readSource('../../components/MeshTerminal.tsx');
|
||||
|
||||
expect(terminal).not.toMatch(
|
||||
/fetchDmPublicKey\(\s*API,\s*message\.sender_id,[\s\S]{0,200}allowLegacyAgentId:/s,
|
||||
);
|
||||
expect(terminal).not.toMatch(
|
||||
/fetchDmPublicKey\(\s*API,\s*m\.sender_id,[\s\S]{0,200}allowLegacyAgentId:/s,
|
||||
);
|
||||
|
||||
const legacyLookupMatches = terminal.match(/allowLegacyAgentId:\s*true/g) || [];
|
||||
expect(legacyLookupMatches).toHaveLength(1);
|
||||
expect(terminal).toContain("only for legacy migration");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
function makeStorage() {
|
||||
const values = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => void values.set(key, value),
|
||||
removeItem: (key: string) => void values.delete(key),
|
||||
clear: () => void values.clear(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('dmPollScheduler', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: makeStorage(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'sessionStorage', {
|
||||
value: makeStorage(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe('jitteredPollDelay', () => {
|
||||
it('returns a value within the default jitter band', async () => {
|
||||
const { jitteredPollDelay } = await import('@/lib/dmPollScheduler');
|
||||
const base = 12_000;
|
||||
// r=0 → factor=0.8 → 9600; r=1 → factor=1.4 → 16800
|
||||
expect(jitteredPollDelay(base, { profile: 'default', random: 0 })).toBe(9600);
|
||||
expect(jitteredPollDelay(base, { profile: 'default', random: 1 })).toBe(16800);
|
||||
});
|
||||
|
||||
it('high-privacy band is wider than default', async () => {
|
||||
const { jitteredPollDelay } = await import('@/lib/dmPollScheduler');
|
||||
const base = 12_000;
|
||||
const defaultMin = jitteredPollDelay(base, { profile: 'default', random: 0 });
|
||||
const defaultMax = jitteredPollDelay(base, { profile: 'default', random: 1 });
|
||||
const highMin = jitteredPollDelay(base, { profile: 'high', random: 0 });
|
||||
const highMax = jitteredPollDelay(base, { profile: 'high', random: 1 });
|
||||
|
||||
const defaultRange = defaultMax - defaultMin;
|
||||
const highRange = highMax - highMin;
|
||||
expect(highRange).toBeGreaterThan(defaultRange);
|
||||
});
|
||||
|
||||
it('never returns the exact base interval across random inputs', async () => {
|
||||
const { jitteredPollDelay } = await import('@/lib/dmPollScheduler');
|
||||
const base = 12_000;
|
||||
const samples = Array.from({ length: 100 }, (_, i) =>
|
||||
jitteredPollDelay(base, { profile: 'default', random: i / 100 }),
|
||||
);
|
||||
// At most one value could accidentally equal base; the set should be diverse
|
||||
const unique = new Set(samples);
|
||||
expect(unique.size).toBeGreaterThan(50);
|
||||
// The exact base value corresponds to r ≈ 0.333...; verify it's the only one
|
||||
const exactBaseCount = samples.filter((v) => v === base).length;
|
||||
expect(exactBaseCount).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('reads privacy profile from browser storage when no override', async () => {
|
||||
sessionStorage.setItem('sb_privacy_profile', 'high');
|
||||
const { jitteredPollDelay } = await import('@/lib/dmPollScheduler');
|
||||
const base = 10_000;
|
||||
// r=0 with high profile → factor=0.5 → 5000
|
||||
expect(jitteredPollDelay(base, { random: 0 })).toBe(5000);
|
||||
});
|
||||
|
||||
it('returns positive value for any base and profile', async () => {
|
||||
const { jitteredPollDelay } = await import('@/lib/dmPollScheduler');
|
||||
for (const profile of ['default', 'high', 'unknown']) {
|
||||
for (const r of [0, 0.25, 0.5, 0.75, 1]) {
|
||||
const delay = jitteredPollDelay(15_000, { profile, random: r });
|
||||
expect(delay).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('catchUpDelay', () => {
|
||||
it('returns a value within the default catch-up band', async () => {
|
||||
const { catchUpDelay } = await import('@/lib/dmPollScheduler');
|
||||
// default: min=2000, max=5000
|
||||
expect(catchUpDelay({ profile: 'default', random: 0 })).toBe(2000);
|
||||
expect(catchUpDelay({ profile: 'default', random: 1 })).toBe(5000);
|
||||
});
|
||||
|
||||
it('high-privacy catch-up delay is longer than default', async () => {
|
||||
const { catchUpDelay } = await import('@/lib/dmPollScheduler');
|
||||
const defaultMid = catchUpDelay({ profile: 'default', random: 0.5 });
|
||||
const highMid = catchUpDelay({ profile: 'high', random: 0.5 });
|
||||
expect(highMid).toBeGreaterThan(defaultMid);
|
||||
});
|
||||
|
||||
it('catch-up delay is always shorter than normal poll delay', async () => {
|
||||
const { jitteredPollDelay, catchUpDelay } = await import('@/lib/dmPollScheduler');
|
||||
// Worst-case catch-up (r=1, high) vs best-case normal poll (r=0, default, base=12000)
|
||||
const maxCatchUp = catchUpDelay({ profile: 'high', random: 1 });
|
||||
const minNormal = jitteredPollDelay(12_000, { profile: 'default', random: 0 });
|
||||
expect(maxCatchUp).toBeLessThan(minNormal);
|
||||
});
|
||||
|
||||
it('catch-up delay is never zero', async () => {
|
||||
const { catchUpDelay } = await import('@/lib/dmPollScheduler');
|
||||
for (const profile of ['default', 'high']) {
|
||||
const delay = catchUpDelay({ profile, random: 0 });
|
||||
expect(delay).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('MAX_CATCHUP_POLLS', () => {
|
||||
it('is a small positive integer bounding catch-up bursts', async () => {
|
||||
const { MAX_CATCHUP_POLLS } = await import('@/lib/dmPollScheduler');
|
||||
expect(MAX_CATCHUP_POLLS).toBeGreaterThanOrEqual(1);
|
||||
expect(MAX_CATCHUP_POLLS).toBeLessThanOrEqual(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyTick', () => {
|
||||
it('catch-up tick skips count refresh', async () => {
|
||||
const { classifyTick } = await import('@/lib/dmPollScheduler');
|
||||
const result = classifyTick(true, 3, 12_000, { profile: 'default', random: 0.5 });
|
||||
expect(result.refreshCount).toBe(false);
|
||||
expect(result.newBudget).toBe(2);
|
||||
});
|
||||
|
||||
it('normal tick includes count refresh', async () => {
|
||||
const { classifyTick } = await import('@/lib/dmPollScheduler');
|
||||
const result = classifyTick(false, 3, 12_000, { profile: 'default', random: 0.5 });
|
||||
expect(result.refreshCount).toBe(true);
|
||||
});
|
||||
|
||||
it('budget exhaustion falls back to normal with count', async () => {
|
||||
const { classifyTick } = await import('@/lib/dmPollScheduler');
|
||||
// has_more=true but budget=0 → normal tick
|
||||
const result = classifyTick(true, 0, 12_000, { profile: 'default', random: 0.5 });
|
||||
expect(result.refreshCount).toBe(true);
|
||||
});
|
||||
|
||||
it('budget resets after fallback to normal', async () => {
|
||||
const { classifyTick, MAX_CATCHUP_POLLS } = await import('@/lib/dmPollScheduler');
|
||||
const result = classifyTick(false, 1, 12_000, { profile: 'default', random: 0.5 });
|
||||
expect(result.newBudget).toBe(MAX_CATCHUP_POLLS);
|
||||
});
|
||||
|
||||
it('catch-up delay is used during catch-up ticks', async () => {
|
||||
const { classifyTick, catchUpDelay } = await import('@/lib/dmPollScheduler');
|
||||
const opts = { profile: 'default' as const, random: 0.5 };
|
||||
const result = classifyTick(true, 2, 12_000, opts);
|
||||
expect(result.delay).toBe(catchUpDelay(opts));
|
||||
});
|
||||
|
||||
it('normal delay is used during normal ticks', async () => {
|
||||
const { classifyTick, jitteredPollDelay } = await import('@/lib/dmPollScheduler');
|
||||
const opts = { profile: 'default' as const, random: 0.5 };
|
||||
const result = classifyTick(false, 3, 12_000, opts);
|
||||
expect(result.delay).toBe(jitteredPollDelay(12_000, opts));
|
||||
});
|
||||
});
|
||||
|
||||
describe('scheduling contract', () => {
|
||||
it('simulated poll loop uses classifyTick for cadence and count decisions', async () => {
|
||||
const { classifyTick, catchUpDelay, jitteredPollDelay, MAX_CATCHUP_POLLS } =
|
||||
await import('@/lib/dmPollScheduler');
|
||||
|
||||
const ticks: Array<{ delay: number; refreshCount: boolean }> = [];
|
||||
let budget = MAX_CATCHUP_POLLS;
|
||||
const hasMoreSequence = [true, true, true, true, false, false, true, false];
|
||||
const opts = { profile: 'default' as const, random: 0.5 };
|
||||
|
||||
for (const hasMore of hasMoreSequence) {
|
||||
const result = classifyTick(hasMore, budget, 12_000, opts);
|
||||
budget = result.newBudget;
|
||||
ticks.push({ delay: result.delay, refreshCount: result.refreshCount });
|
||||
}
|
||||
|
||||
const catchUpValue = catchUpDelay(opts);
|
||||
const normalValue = jitteredPollDelay(12_000, opts);
|
||||
|
||||
// First MAX_CATCHUP_POLLS catch-up ticks: short delay, no count
|
||||
for (let i = 0; i < MAX_CATCHUP_POLLS; i++) {
|
||||
expect(ticks[i].delay).toBe(catchUpValue);
|
||||
expect(ticks[i].refreshCount).toBe(false);
|
||||
}
|
||||
// 4th has_more exceeds budget → normal with count
|
||||
expect(ticks[MAX_CATCHUP_POLLS].delay).toBe(normalValue);
|
||||
expect(ticks[MAX_CATCHUP_POLLS].refreshCount).toBe(true);
|
||||
// Non-has_more ticks: normal with count
|
||||
expect(ticks[4].delay).toBe(normalValue);
|
||||
expect(ticks[4].refreshCount).toBe(true);
|
||||
expect(ticks[5].delay).toBe(normalValue);
|
||||
expect(ticks[5].refreshCount).toBe(true);
|
||||
});
|
||||
|
||||
it('count is never refreshed during catch-up across a full backlog drain', async () => {
|
||||
const { classifyTick, MAX_CATCHUP_POLLS } = await import('@/lib/dmPollScheduler');
|
||||
|
||||
let budget = MAX_CATCHUP_POLLS;
|
||||
const countRefreshes: boolean[] = [];
|
||||
|
||||
// Simulate: has_more for exactly budget ticks, then two normal ticks
|
||||
const hasMoreSequence = [
|
||||
...Array(MAX_CATCHUP_POLLS).fill(true),
|
||||
false,
|
||||
false,
|
||||
];
|
||||
for (const hasMore of hasMoreSequence) {
|
||||
const result = classifyTick(hasMore, budget, 12_000, { profile: 'default', random: 0.5 });
|
||||
budget = result.newBudget;
|
||||
countRefreshes.push(result.refreshCount);
|
||||
}
|
||||
|
||||
// Catch-up ticks should not refresh count
|
||||
for (let i = 0; i < MAX_CATCHUP_POLLS; i++) {
|
||||
expect(countRefreshes[i]).toBe(false);
|
||||
}
|
||||
// Normal ticks after catch-up do refresh count
|
||||
expect(countRefreshes[MAX_CATCHUP_POLLS]).toBe(true);
|
||||
expect(countRefreshes[MAX_CATCHUP_POLLS + 1]).toBe(true);
|
||||
});
|
||||
|
||||
it('no fixed cadence is reintroduced by classifyTick', async () => {
|
||||
const { classifyTick } = await import('@/lib/dmPollScheduler');
|
||||
const delays = new Set<number>();
|
||||
for (let r = 0; r < 20; r++) {
|
||||
const result = classifyTick(false, 3, 12_000, { profile: 'default', random: r / 20 });
|
||||
delays.add(result.delay);
|
||||
}
|
||||
// All 20 random inputs should produce diverse delays, not a fixed value
|
||||
expect(delays.size).toBeGreaterThan(10);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const controlPlaneJson = vi.fn();
|
||||
|
||||
vi.mock('@/lib/controlPlane', () => ({
|
||||
controlPlaneJson,
|
||||
}));
|
||||
|
||||
describe('DM selftest client', () => {
|
||||
beforeEach(() => {
|
||||
controlPlaneJson.mockReset();
|
||||
});
|
||||
|
||||
it('runs the local DM selftest without requiring an admin browser session', async () => {
|
||||
controlPlaneJson.mockResolvedValue({ ok: true });
|
||||
|
||||
const { runWormholeDmSelftest } = await import('@/mesh/wormholeIdentityClient');
|
||||
|
||||
await runWormholeDmSelftest('probe');
|
||||
|
||||
expect(controlPlaneJson).toHaveBeenCalledWith(
|
||||
'/api/wormhole/dm/selftest',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
requireAdminSession: false,
|
||||
body: JSON.stringify({ message: 'probe' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const controlPlaneJson = vi.fn();
|
||||
const hasLocalControlBridge = vi.fn(() => false);
|
||||
const getGateSessionStreamAccessHeaders = vi.fn();
|
||||
|
||||
vi.mock('@/lib/controlPlane', () => ({
|
||||
controlPlaneJson,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/localControlTransport', () => ({
|
||||
hasLocalControlBridge,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/gateSessionStream', () => ({
|
||||
getGateSessionStreamAccessHeaders,
|
||||
}));
|
||||
|
||||
describe('gateAccessProof cache', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
controlPlaneJson.mockReset();
|
||||
hasLocalControlBridge.mockReset();
|
||||
hasLocalControlBridge.mockReturnValue(false);
|
||||
getGateSessionStreamAccessHeaders.mockReset();
|
||||
getGateSessionStreamAccessHeaders.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
it('caches browser/web gate proofs just under the backend validity window', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-05T22:40:00.000Z'));
|
||||
try {
|
||||
controlPlaneJson.mockResolvedValue({
|
||||
node_id: '!sb_gate',
|
||||
ts: 1712345678,
|
||||
proof: 'proof-a',
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gateAccessProof');
|
||||
|
||||
await expect(mod.buildGateAccessHeaders('finance')).resolves.toEqual({
|
||||
'X-Wormhole-Node-Id': '!sb_gate',
|
||||
'X-Wormhole-Gate-Proof': 'proof-a',
|
||||
'X-Wormhole-Gate-Ts': '1712345678',
|
||||
});
|
||||
await expect(mod.buildGateAccessHeaders('finance')).resolves.toEqual({
|
||||
'X-Wormhole-Node-Id': '!sb_gate',
|
||||
'X-Wormhole-Gate-Proof': 'proof-a',
|
||||
'X-Wormhole-Gate-Ts': '1712345678',
|
||||
});
|
||||
|
||||
expect(controlPlaneJson).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(52_001);
|
||||
|
||||
await mod.buildGateAccessHeaders('finance');
|
||||
expect(controlPlaneJson).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses a shorter proof cache window on native runtimes', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-05T22:40:00.000Z'));
|
||||
try {
|
||||
hasLocalControlBridge.mockReturnValue(true);
|
||||
controlPlaneJson.mockResolvedValue({
|
||||
node_id: '!sb_gate',
|
||||
ts: 1712345678,
|
||||
proof: 'proof-native',
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gateAccessProof');
|
||||
|
||||
await mod.buildGateAccessHeaders('finance');
|
||||
vi.advanceTimersByTime(35_001);
|
||||
await mod.buildGateAccessHeaders('finance');
|
||||
|
||||
expect(controlPlaneJson).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('coalesces concurrent proof requests for the same gate into one control-plane call', async () => {
|
||||
let release: ((value: { node_id: string; ts: number; proof: string }) => void) | null = null;
|
||||
controlPlaneJson.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
release = resolve as typeof release;
|
||||
}),
|
||||
);
|
||||
|
||||
const mod = await import('@/mesh/gateAccessProof');
|
||||
|
||||
const first = mod.buildGateAccessHeaders('finance');
|
||||
const second = mod.buildGateAccessHeaders('finance');
|
||||
|
||||
expect(controlPlaneJson).toHaveBeenCalledTimes(1);
|
||||
|
||||
release?.({
|
||||
node_id: '!sb_gate',
|
||||
ts: 1712345678,
|
||||
proof: 'proof-a',
|
||||
});
|
||||
|
||||
await expect(first).resolves.toEqual({
|
||||
'X-Wormhole-Node-Id': '!sb_gate',
|
||||
'X-Wormhole-Gate-Proof': 'proof-a',
|
||||
'X-Wormhole-Gate-Ts': '1712345678',
|
||||
});
|
||||
await expect(second).resolves.toEqual({
|
||||
'X-Wormhole-Node-Id': '!sb_gate',
|
||||
'X-Wormhole-Gate-Proof': 'proof-a',
|
||||
'X-Wormhole-Gate-Ts': '1712345678',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses stream bootstrap access headers before falling back to the gate proof endpoint', async () => {
|
||||
getGateSessionStreamAccessHeaders.mockReturnValue({
|
||||
'X-Wormhole-Node-Id': '!sb_stream',
|
||||
'X-Wormhole-Gate-Proof': 'proof-stream',
|
||||
'X-Wormhole-Gate-Ts': '1712345678',
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gateAccessProof');
|
||||
|
||||
await expect(mod.buildGateAccessHeaders('finance', { mode: 'session_stream' })).resolves.toEqual({
|
||||
'X-Wormhole-Node-Id': '!sb_stream',
|
||||
'X-Wormhole-Gate-Proof': 'proof-stream',
|
||||
'X-Wormhole-Gate-Ts': '1712345678',
|
||||
});
|
||||
expect(getGateSessionStreamAccessHeaders).toHaveBeenCalledWith('finance');
|
||||
expect(controlPlaneJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reuses a fresh-enough proof longer for held wait requests than for ordinary reads', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-05T22:40:00.000Z'));
|
||||
try {
|
||||
const firstTs = Math.floor(Date.now() / 1000);
|
||||
const secondTs = Math.floor((Date.now() + 55_000) / 1000);
|
||||
controlPlaneJson
|
||||
.mockResolvedValueOnce({
|
||||
node_id: '!sb_gate',
|
||||
ts: firstTs,
|
||||
proof: 'proof-a',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
node_id: '!sb_gate',
|
||||
ts: secondTs,
|
||||
proof: 'proof-b',
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gateAccessProof');
|
||||
|
||||
await expect(mod.buildGateAccessHeaders('finance')).resolves.toEqual({
|
||||
'X-Wormhole-Node-Id': '!sb_gate',
|
||||
'X-Wormhole-Gate-Proof': 'proof-a',
|
||||
'X-Wormhole-Gate-Ts': String(firstTs),
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(55_000);
|
||||
|
||||
await expect(mod.buildGateAccessHeaders('finance', { mode: 'wait' })).resolves.toEqual({
|
||||
'X-Wormhole-Node-Id': '!sb_gate',
|
||||
'X-Wormhole-Gate-Proof': 'proof-a',
|
||||
'X-Wormhole-Gate-Ts': String(firstTs),
|
||||
});
|
||||
expect(controlPlaneJson).toHaveBeenCalledTimes(1);
|
||||
|
||||
await expect(mod.buildGateAccessHeaders('finance')).resolves.toEqual({
|
||||
'X-Wormhole-Node-Id': '!sb_gate',
|
||||
'X-Wormhole-Gate-Proof': 'proof-b',
|
||||
'X-Wormhole-Gate-Ts': String(secondTs),
|
||||
});
|
||||
expect(controlPlaneJson).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('reuses a fresh-enough proof longer for session-stream refreshes than for ordinary reads', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-05T22:40:00.000Z'));
|
||||
try {
|
||||
const firstTs = Math.floor(Date.now() / 1000);
|
||||
const secondTs = Math.floor((Date.now() + 55_000) / 1000);
|
||||
controlPlaneJson
|
||||
.mockResolvedValueOnce({
|
||||
node_id: '!sb_gate',
|
||||
ts: firstTs,
|
||||
proof: 'proof-a',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
node_id: '!sb_gate',
|
||||
ts: secondTs,
|
||||
proof: 'proof-b',
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gateAccessProof');
|
||||
|
||||
await expect(mod.buildGateAccessHeaders('finance')).resolves.toEqual({
|
||||
'X-Wormhole-Node-Id': '!sb_gate',
|
||||
'X-Wormhole-Gate-Proof': 'proof-a',
|
||||
'X-Wormhole-Gate-Ts': String(firstTs),
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(55_000);
|
||||
|
||||
await expect(mod.buildGateAccessHeaders('finance', { mode: 'session_stream' })).resolves.toEqual({
|
||||
'X-Wormhole-Node-Id': '!sb_gate',
|
||||
'X-Wormhole-Gate-Proof': 'proof-a',
|
||||
'X-Wormhole-Gate-Ts': String(firstTs),
|
||||
});
|
||||
expect(controlPlaneJson).toHaveBeenCalledTimes(1);
|
||||
|
||||
await expect(mod.buildGateAccessHeaders('finance')).resolves.toEqual({
|
||||
'X-Wormhole-Node-Id': '!sb_gate',
|
||||
'X-Wormhole-Gate-Proof': 'proof-b',
|
||||
'X-Wormhole-Gate-Ts': String(secondTs),
|
||||
});
|
||||
expect(controlPlaneJson).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const hasLocalControlBridge = vi.fn(() => false);
|
||||
|
||||
vi.mock('@/lib/localControlTransport', () => ({
|
||||
hasLocalControlBridge,
|
||||
}));
|
||||
|
||||
describe('gateCatalogSnapshot cache', () => {
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
fetchMock.mockReset();
|
||||
hasLocalControlBridge.mockReset();
|
||||
hasLocalControlBridge.mockReturnValue(false);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
it('coarsens browser/web gate catalog reads through a short shared cache window', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-05T23:10:00.000Z'));
|
||||
try {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
gates: [{ gate_id: 'infonet', display_name: 'Infonet Commons' }],
|
||||
}),
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gateCatalogSnapshot');
|
||||
|
||||
await expect(mod.fetchGateCatalogSnapshot()).resolves.toEqual([
|
||||
{ gate_id: 'infonet', display_name: 'Infonet Commons' },
|
||||
]);
|
||||
await expect(mod.fetchGateCatalogSnapshot()).resolves.toEqual([
|
||||
{ gate_id: 'infonet', display_name: 'Infonet Commons' },
|
||||
]);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(18_001);
|
||||
|
||||
await mod.fetchGateCatalogSnapshot();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses a shorter cache window for native gate catalog/detail snapshots', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-05T23:10:00.000Z'));
|
||||
try {
|
||||
hasLocalControlBridge.mockReturnValue(true);
|
||||
fetchMock
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
gates: [{ gate_id: 'finance', display_name: 'Finance' }],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
gates: [{ gate_id: 'finance', display_name: 'Finance' }],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
gate_id: 'finance',
|
||||
display_name: 'Finance',
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
gate_id: 'finance',
|
||||
display_name: 'Finance',
|
||||
}),
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gateCatalogSnapshot');
|
||||
|
||||
await mod.fetchGateCatalogSnapshot();
|
||||
vi.advanceTimersByTime(6_001);
|
||||
await mod.fetchGateCatalogSnapshot();
|
||||
|
||||
await mod.fetchGateDetailSnapshot('finance');
|
||||
vi.advanceTimersByTime(5_001);
|
||||
await mod.fetchGateDetailSnapshot('finance');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('invalidates cached gate detail snapshots explicitly', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
gate_id: 'infonet',
|
||||
display_name: 'Infonet Commons',
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
gate_id: 'infonet',
|
||||
display_name: 'Infonet Commons v2',
|
||||
}),
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gateCatalogSnapshot');
|
||||
|
||||
await expect(mod.fetchGateDetailSnapshot('infonet')).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
gate_id: 'infonet',
|
||||
display_name: 'Infonet Commons',
|
||||
}),
|
||||
);
|
||||
mod.invalidateGateDetailSnapshot('infonet');
|
||||
await expect(mod.fetchGateDetailSnapshot('infonet')).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
gate_id: 'infonet',
|
||||
display_name: 'Infonet Commons v2',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,737 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
|
||||
import React from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
controlPlaneJson: vi.fn(),
|
||||
approveGateCompatFallback: vi.fn(),
|
||||
decryptWormholeGateMessages: vi.fn(),
|
||||
fetchWormholeGateKeyStatus: vi.fn(),
|
||||
hasGateCompatFallbackApproval: vi.fn(() => false),
|
||||
postWormholeGateMessage: vi.fn(),
|
||||
prepareWormholeInteractiveLane: vi.fn(async () => ({
|
||||
ready: true,
|
||||
settingsEnabled: true,
|
||||
transportTier: 'private_transitional',
|
||||
identity: null,
|
||||
})),
|
||||
revokeGateCompatFallback: vi.fn(),
|
||||
syncBrowserWormholeGateState: vi.fn(async () => true),
|
||||
getGateSessionStreamStatus: vi.fn(() => ({
|
||||
enabled: false,
|
||||
phase: 'idle',
|
||||
transport: 'sse',
|
||||
sessionId: '',
|
||||
subscriptions: [],
|
||||
heartbeatS: 0,
|
||||
batchMs: 0,
|
||||
lastEventType: '',
|
||||
lastEventAt: 0,
|
||||
detail: '',
|
||||
})),
|
||||
retainGateSessionStreamGate: vi.fn(() => vi.fn()),
|
||||
subscribeGateSessionStreamEvents: vi.fn(() => vi.fn()),
|
||||
subscribeGateSessionStreamStatus: vi.fn((listener: (status: unknown) => void) => {
|
||||
listener(mocks.getGateSessionStreamStatus());
|
||||
return vi.fn();
|
||||
}),
|
||||
getGateSessionStreamAccessHeaders: vi.fn(() => undefined),
|
||||
getGateSessionStreamKeyStatus: vi.fn(() => null),
|
||||
invalidateGateSessionStreamGateContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
API_BASE: 'http://test.local',
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/controlPlane', () => ({
|
||||
controlPlaneJson: mocks.controlPlaneJson,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshIdentity', () => ({
|
||||
nextSequence: vi.fn(() => 1),
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/wormholeIdentityClient', () => ({
|
||||
approveGateCompatFallback: mocks.approveGateCompatFallback,
|
||||
decryptWormholeGateMessages: mocks.decryptWormholeGateMessages,
|
||||
fetchWormholeGateKeyStatus: mocks.fetchWormholeGateKeyStatus,
|
||||
hasGateCompatFallbackApproval: mocks.hasGateCompatFallbackApproval,
|
||||
postWormholeGateMessage: mocks.postWormholeGateMessage,
|
||||
prepareWormholeInteractiveLane: mocks.prepareWormholeInteractiveLane,
|
||||
revokeGateCompatFallback: mocks.revokeGateCompatFallback,
|
||||
signMeshEvent: vi.fn(),
|
||||
syncBrowserWormholeGateState: mocks.syncBrowserWormholeGateState,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/gateEnvelope', () => ({
|
||||
gateEnvelopeDisplayText: vi.fn(() => 'sealed'),
|
||||
gateEnvelopeState: vi.fn(() => 'sealed'),
|
||||
isEncryptedGateEnvelope: vi.fn((message: { ciphertext?: string }) => Boolean(message?.ciphertext)),
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshSchema', () => ({
|
||||
validateEventPayload: vi.fn(() => ({ ok: true })),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useGateSSE', () => ({
|
||||
useGateSSE: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/gateSessionStream', () => ({
|
||||
getGateSessionStreamAccessHeaders: mocks.getGateSessionStreamAccessHeaders,
|
||||
getGateSessionStreamKeyStatus: mocks.getGateSessionStreamKeyStatus,
|
||||
getGateSessionStreamStatus: mocks.getGateSessionStreamStatus,
|
||||
invalidateGateSessionStreamGateContext: mocks.invalidateGateSessionStreamGateContext,
|
||||
retainGateSessionStreamGate: mocks.retainGateSessionStreamGate,
|
||||
subscribeGateSessionStreamEvents: mocks.subscribeGateSessionStreamEvents,
|
||||
subscribeGateSessionStreamStatus: mocks.subscribeGateSessionStreamStatus,
|
||||
}));
|
||||
|
||||
describe('GateView compat-decrypt UX', () => {
|
||||
let streamStatusListeners: Array<(status: unknown) => void> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
streamStatusListeners = [];
|
||||
mocks.controlPlaneJson.mockReset();
|
||||
mocks.approveGateCompatFallback.mockReset();
|
||||
mocks.decryptWormholeGateMessages.mockReset();
|
||||
mocks.fetchWormholeGateKeyStatus.mockReset();
|
||||
mocks.hasGateCompatFallbackApproval.mockReset();
|
||||
mocks.hasGateCompatFallbackApproval.mockReturnValue(false);
|
||||
mocks.postWormholeGateMessage.mockReset();
|
||||
mocks.prepareWormholeInteractiveLane.mockReset();
|
||||
mocks.prepareWormholeInteractiveLane.mockResolvedValue({
|
||||
ready: true,
|
||||
settingsEnabled: true,
|
||||
transportTier: 'private_transitional',
|
||||
identity: null,
|
||||
});
|
||||
mocks.revokeGateCompatFallback.mockReset();
|
||||
mocks.syncBrowserWormholeGateState.mockReset();
|
||||
mocks.getGateSessionStreamStatus.mockReset();
|
||||
mocks.retainGateSessionStreamGate.mockReset();
|
||||
mocks.subscribeGateSessionStreamEvents.mockReset();
|
||||
mocks.subscribeGateSessionStreamStatus.mockReset();
|
||||
mocks.getGateSessionStreamAccessHeaders.mockReset();
|
||||
mocks.getGateSessionStreamAccessHeaders.mockReturnValue(undefined);
|
||||
mocks.getGateSessionStreamKeyStatus.mockReset();
|
||||
mocks.getGateSessionStreamKeyStatus.mockReturnValue(null);
|
||||
mocks.invalidateGateSessionStreamGateContext.mockReset();
|
||||
mocks.syncBrowserWormholeGateState.mockResolvedValue(true);
|
||||
mocks.getGateSessionStreamStatus.mockReturnValue({
|
||||
enabled: false,
|
||||
phase: 'idle',
|
||||
transport: 'sse',
|
||||
sessionId: '',
|
||||
subscriptions: [],
|
||||
heartbeatS: 0,
|
||||
batchMs: 0,
|
||||
lastEventType: '',
|
||||
lastEventAt: 0,
|
||||
detail: '',
|
||||
});
|
||||
mocks.retainGateSessionStreamGate.mockReturnValue(vi.fn());
|
||||
mocks.subscribeGateSessionStreamEvents.mockReturnValue(vi.fn());
|
||||
mocks.subscribeGateSessionStreamStatus.mockImplementation((listener: (status: unknown) => void) => {
|
||||
streamStatusListeners.push(listener);
|
||||
listener(mocks.getGateSessionStreamStatus());
|
||||
return vi.fn();
|
||||
});
|
||||
|
||||
mocks.fetchWormholeGateKeyStatus.mockResolvedValue({
|
||||
ok: true,
|
||||
has_local_access: true,
|
||||
identity_scope: 'gate',
|
||||
});
|
||||
mocks.controlPlaneJson.mockResolvedValue({
|
||||
node_id: '!sb_local',
|
||||
proof: 'proof-token',
|
||||
ts: 1712345678,
|
||||
});
|
||||
mocks.decryptWormholeGateMessages.mockResolvedValue({
|
||||
ok: true,
|
||||
results: [
|
||||
{
|
||||
ok: true,
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
plaintext: 'sealed',
|
||||
identity_scope: 'browser_privacy_core',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (input: string | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes('/api/mesh/infonet/messages')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: [
|
||||
{
|
||||
event_id: 'evt-1',
|
||||
timestamp: 1712345678,
|
||||
payload: {
|
||||
gate: 'infonet',
|
||||
ciphertext: 'ciphertext-1',
|
||||
nonce: 'nonce-1',
|
||||
sender_ref: 'sender-ref-1',
|
||||
format: 'mls1',
|
||||
gate_envelope: 'gate-envelope-1',
|
||||
envelope_hash: 'hash-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (url.includes('/api/mesh/reputation/batch')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ reputations: {} }),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected fetch url: ${url}`);
|
||||
}),
|
||||
);
|
||||
|
||||
Object.defineProperty(Element.prototype, 'scrollIntoView', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const emitStreamStatus = (status: {
|
||||
enabled: boolean;
|
||||
phase: 'idle' | 'connecting' | 'open' | 'closed' | 'disabled' | 'error';
|
||||
transport: 'sse';
|
||||
sessionId: string;
|
||||
subscriptions: string[];
|
||||
heartbeatS: number;
|
||||
batchMs: number;
|
||||
lastEventType: string;
|
||||
lastEventAt: number;
|
||||
detail: string;
|
||||
}) => {
|
||||
mocks.getGateSessionStreamStatus.mockReturnValue(status);
|
||||
streamStatusListeners.forEach((listener) => listener(status));
|
||||
};
|
||||
|
||||
it('shows a clear room error when browser-local gate runtime is required', async () => {
|
||||
mocks.decryptWormholeGateMessages.mockRejectedValue(
|
||||
new Error('gate_local_runtime_required:browser_gate_state_resync_required:infonet'),
|
||||
);
|
||||
|
||||
const { default: GateView } = await import('@/components/InfonetTerminal/GateView');
|
||||
|
||||
render(
|
||||
<GateView
|
||||
gateName="infonet"
|
||||
persona="!sb_local"
|
||||
onBack={() => {}}
|
||||
onNavigateGate={() => {}}
|
||||
availableGates={['infonet']}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'Local infonet state needs a resync on this device. Use native desktop or resync local gate state.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'ENABLE FOR ROOM' })).not.toBeInTheDocument();
|
||||
expect(mocks.syncBrowserWormholeGateState).toHaveBeenCalledWith('infonet');
|
||||
expect(mocks.decryptWormholeGateMessages).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
gate_id: 'infonet',
|
||||
ciphertext: 'ciphertext-1',
|
||||
envelope_hash: 'hash-1',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps recovery-only decrypt failures out of the red room-error path', async () => {
|
||||
mocks.decryptWormholeGateMessages.mockResolvedValue({
|
||||
ok: true,
|
||||
results: [
|
||||
{
|
||||
ok: false,
|
||||
detail: 'gate_backend_decrypt_recovery_only',
|
||||
gate_id: 'infonet',
|
||||
compat_requested: true,
|
||||
compat_effective: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { default: GateView } = await import('@/components/InfonetTerminal/GateView');
|
||||
|
||||
render(
|
||||
<GateView
|
||||
gateName="infonet"
|
||||
persona="!sb_local"
|
||||
onBack={() => {}}
|
||||
onNavigateGate={() => {}}
|
||||
availableGates={['infonet']}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(mocks.decryptWormholeGateMessages).toHaveBeenCalled());
|
||||
expect(screen.queryByText('COMPAT MODE')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(
|
||||
'Service-side gate decrypt is disabled on this runtime. Use native desktop or an explicit recovery path.',
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText('sealed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a friendly room message instead of a raw transport-tier gate post failure', async () => {
|
||||
mocks.postWormholeGateMessage.mockRejectedValue(new Error('transport tier insufficient'));
|
||||
|
||||
const { default: GateView } = await import('@/components/InfonetTerminal/GateView');
|
||||
|
||||
render(
|
||||
<GateView
|
||||
gateName="infonet"
|
||||
persona="!sb_local"
|
||||
onBack={() => {}}
|
||||
onNavigateGate={() => {}}
|
||||
availableGates={['infonet']}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('sealed')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(mocks.prepareWormholeInteractiveLane).toHaveBeenCalledWith({
|
||||
minimumTransportTier: 'private_control_only',
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Post into this gate...'), {
|
||||
target: { value: 'hello' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /post/i }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'The obfuscated lane is still warming up in the background. Stay in the room and posting should unlock shortly.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a friendly room message instead of a raw gate-envelope post failure', async () => {
|
||||
mocks.postWormholeGateMessage.mockRejectedValue(new Error('gate_envelope_required'));
|
||||
|
||||
const { default: GateView } = await import('@/components/InfonetTerminal/GateView');
|
||||
|
||||
render(
|
||||
<GateView
|
||||
gateName="infonet"
|
||||
persona="!sb_local"
|
||||
onBack={() => {}}
|
||||
onNavigateGate={() => {}}
|
||||
availableGates={['infonet']}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('sealed')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(mocks.prepareWormholeInteractiveLane).toHaveBeenCalledWith({
|
||||
minimumTransportTier: 'private_control_only',
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Post into this gate...'), {
|
||||
target: { value: 'hello' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /post/i }));
|
||||
|
||||
expect(
|
||||
await screen.findByText('Local gate sealing is warming up. Your draft is still here.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does one initial gate fetch and then switches to wait-for-change reads', async () => {
|
||||
const fetchMock = vi.fn(async (input: string | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes('/api/mesh/infonet/messages/wait?')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
gate: 'infonet',
|
||||
changed: false,
|
||||
cursor: 1,
|
||||
messages: [
|
||||
{
|
||||
event_id: 'evt-1',
|
||||
timestamp: 1712345678,
|
||||
payload: {
|
||||
gate: 'infonet',
|
||||
ciphertext: 'ciphertext-1',
|
||||
nonce: 'nonce-1',
|
||||
sender_ref: 'sender-ref-1',
|
||||
format: 'mls1',
|
||||
gate_envelope: 'gate-envelope-1',
|
||||
envelope_hash: 'hash-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (url.includes('/api/mesh/infonet/messages?gate=')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
cursor: 1,
|
||||
messages: [
|
||||
{
|
||||
event_id: 'evt-1',
|
||||
timestamp: 1712345678,
|
||||
payload: {
|
||||
gate: 'infonet',
|
||||
ciphertext: 'ciphertext-1',
|
||||
nonce: 'nonce-1',
|
||||
sender_ref: 'sender-ref-1',
|
||||
format: 'mls1',
|
||||
gate_envelope: 'gate-envelope-1',
|
||||
envelope_hash: 'hash-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (url.includes('/api/mesh/reputation/batch')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ reputations: {} }),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected fetch url: ${url}`);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const gateSnapshotModule = await import('@/mesh/gateMessageSnapshot');
|
||||
const fetchSnapshotSpy = vi.spyOn(gateSnapshotModule, 'fetchGateMessageSnapshotState');
|
||||
const waitSnapshotSpy = vi.spyOn(gateSnapshotModule, 'waitForGateMessageSnapshot');
|
||||
gateSnapshotModule.invalidateGateMessageSnapshot('infonet');
|
||||
const { default: GateView } = await import('@/components/InfonetTerminal/GateView');
|
||||
|
||||
render(
|
||||
<GateView
|
||||
gateName="infonet"
|
||||
persona="!sb_local"
|
||||
onBack={() => {}}
|
||||
onNavigateGate={() => {}}
|
||||
availableGates={['infonet']}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('sealed')).toBeInTheDocument();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
fetchMock.mock.calls.some(([input]) =>
|
||||
String(input).includes('/api/mesh/infonet/messages/wait?gate=infonet&after=1'),
|
||||
),
|
||||
).toBe(true),
|
||||
);
|
||||
expect(fetchSnapshotSpy).toHaveBeenCalledWith('infonet', 40, expect.any(Object));
|
||||
expect(waitSnapshotSpy).toHaveBeenCalledWith(
|
||||
'infonet',
|
||||
1,
|
||||
40,
|
||||
expect.objectContaining({ timeoutMs: expect.any(Number), signal: expect.any(Object) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses stream-driven room updates as the steady-state path when the gate session stream is open', async () => {
|
||||
const streamEventListeners: Array<(event: { event: string; data: unknown }) => void> = [];
|
||||
mocks.getGateSessionStreamAccessHeaders.mockReturnValue({
|
||||
'X-Wormhole-Node-Id': '!sb_stream',
|
||||
'X-Wormhole-Gate-Proof': 'proof-stream',
|
||||
'X-Wormhole-Gate-Ts': '1712360000',
|
||||
});
|
||||
emitStreamStatus({
|
||||
enabled: true,
|
||||
phase: 'open',
|
||||
transport: 'sse',
|
||||
sessionId: 'sess-1',
|
||||
subscriptions: ['infonet'],
|
||||
heartbeatS: 20,
|
||||
batchMs: 1500,
|
||||
lastEventType: 'hello',
|
||||
lastEventAt: 1712360000,
|
||||
detail: '',
|
||||
});
|
||||
mocks.subscribeGateSessionStreamStatus.mockImplementation((listener: (status: unknown) => void) => {
|
||||
streamStatusListeners.push(listener);
|
||||
listener(mocks.getGateSessionStreamStatus());
|
||||
return vi.fn();
|
||||
});
|
||||
mocks.subscribeGateSessionStreamEvents.mockImplementation((listener: (event: { event: string; data: unknown }) => void) => {
|
||||
streamEventListeners.push(listener);
|
||||
return vi.fn();
|
||||
});
|
||||
|
||||
const fetchMock = vi.fn(async (input: string | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes('/api/mesh/infonet/messages?gate=')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
cursor: url.includes('force') ? 2 : 1,
|
||||
messages: [
|
||||
{
|
||||
event_id: url.includes('force') ? 'evt-2' : 'evt-1',
|
||||
timestamp: 1712345678,
|
||||
payload: {
|
||||
gate: 'infonet',
|
||||
ciphertext: 'ciphertext-1',
|
||||
nonce: 'nonce-1',
|
||||
sender_ref: 'sender-ref-1',
|
||||
format: 'mls1',
|
||||
gate_envelope: 'gate-envelope-1',
|
||||
envelope_hash: 'hash-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (url.includes('/api/mesh/reputation/batch')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ reputations: {} }),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected fetch url: ${url}`);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const gateSnapshotModule = await import('@/mesh/gateMessageSnapshot');
|
||||
const fetchSnapshotSpy = vi.spyOn(gateSnapshotModule, 'fetchGateMessageSnapshotState');
|
||||
const waitSnapshotSpy = vi.spyOn(gateSnapshotModule, 'waitForGateMessageSnapshot');
|
||||
gateSnapshotModule.invalidateGateMessageSnapshot('infonet');
|
||||
|
||||
const { default: GateView } = await import('@/components/InfonetTerminal/GateView');
|
||||
|
||||
render(
|
||||
<GateView
|
||||
gateName="infonet"
|
||||
persona="!sb_local"
|
||||
onBack={() => {}}
|
||||
onNavigateGate={() => {}}
|
||||
availableGates={['infonet']}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('sealed')).toBeInTheDocument();
|
||||
expect(mocks.subscribeGateSessionStreamEvents).toHaveBeenCalled();
|
||||
expect(mocks.fetchWormholeGateKeyStatus).toHaveBeenCalledWith(
|
||||
'infonet',
|
||||
expect.objectContaining({ mode: 'session_stream' }),
|
||||
);
|
||||
expect(mocks.controlPlaneJson).not.toHaveBeenCalled();
|
||||
waitSnapshotSpy.mockClear();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(waitSnapshotSpy).not.toHaveBeenCalled();
|
||||
|
||||
streamEventListeners.forEach((listener) =>
|
||||
listener({
|
||||
event: 'gate_update',
|
||||
data: {
|
||||
session_id: 'sess-1',
|
||||
updates: [{ gate_id: 'infonet', cursor: 2 }],
|
||||
ts: 1712360001,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(fetchSnapshotSpy).toHaveBeenCalledWith(
|
||||
'infonet',
|
||||
40,
|
||||
expect.objectContaining({ force: true, proofMode: 'session_stream' }),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
fetchMock.mock.calls.some(([input]) =>
|
||||
String(input).includes('/api/mesh/infonet/messages/wait?'),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(mocks.controlPlaneJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to wait-for-change on stream loss and hands control back after reconnect', async () => {
|
||||
const streamEventListeners: Array<(event: { event: string; data: unknown }) => void> = [];
|
||||
emitStreamStatus({
|
||||
enabled: true,
|
||||
phase: 'open',
|
||||
transport: 'sse',
|
||||
sessionId: 'sess-2',
|
||||
subscriptions: ['infonet'],
|
||||
heartbeatS: 20,
|
||||
batchMs: 1500,
|
||||
lastEventType: 'hello',
|
||||
lastEventAt: 1712360100,
|
||||
detail: '',
|
||||
});
|
||||
mocks.subscribeGateSessionStreamEvents.mockImplementation((listener: (event: { event: string; data: unknown }) => void) => {
|
||||
streamEventListeners.push(listener);
|
||||
return vi.fn();
|
||||
});
|
||||
|
||||
const fetchMock = vi.fn(async (input: string | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes('/api/mesh/infonet/messages/wait?')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
gate: 'infonet',
|
||||
changed: false,
|
||||
cursor: 1,
|
||||
messages: [
|
||||
{
|
||||
event_id: 'evt-1',
|
||||
timestamp: 1712345678,
|
||||
payload: {
|
||||
gate: 'infonet',
|
||||
ciphertext: 'ciphertext-1',
|
||||
nonce: 'nonce-1',
|
||||
sender_ref: 'sender-ref-1',
|
||||
format: 'mls1',
|
||||
gate_envelope: 'gate-envelope-1',
|
||||
envelope_hash: 'hash-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (url.includes('/api/mesh/infonet/messages?gate=')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
cursor: 1,
|
||||
messages: [
|
||||
{
|
||||
event_id: 'evt-1',
|
||||
timestamp: 1712345678,
|
||||
payload: {
|
||||
gate: 'infonet',
|
||||
ciphertext: 'ciphertext-1',
|
||||
nonce: 'nonce-1',
|
||||
sender_ref: 'sender-ref-1',
|
||||
format: 'mls1',
|
||||
gate_envelope: 'gate-envelope-1',
|
||||
envelope_hash: 'hash-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (url.includes('/api/mesh/reputation/batch')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ reputations: {} }),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected fetch url: ${url}`);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const gateSnapshotModule = await import('@/mesh/gateMessageSnapshot');
|
||||
const fetchSnapshotSpy = vi.spyOn(gateSnapshotModule, 'fetchGateMessageSnapshotState');
|
||||
const waitSnapshotSpy = vi.spyOn(gateSnapshotModule, 'waitForGateMessageSnapshot');
|
||||
gateSnapshotModule.invalidateGateMessageSnapshot('infonet');
|
||||
|
||||
const { default: GateView } = await import('@/components/InfonetTerminal/GateView');
|
||||
|
||||
render(
|
||||
<GateView
|
||||
gateName="infonet"
|
||||
persona="!sb_local"
|
||||
onBack={() => {}}
|
||||
onNavigateGate={() => {}}
|
||||
availableGates={['infonet']}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('sealed')).toBeInTheDocument();
|
||||
waitSnapshotSpy.mockClear();
|
||||
|
||||
emitStreamStatus({
|
||||
enabled: false,
|
||||
phase: 'closed',
|
||||
transport: 'sse',
|
||||
sessionId: 'sess-2',
|
||||
subscriptions: ['infonet'],
|
||||
heartbeatS: 20,
|
||||
batchMs: 1500,
|
||||
lastEventType: 'heartbeat',
|
||||
lastEventAt: 1712360200,
|
||||
detail: 'gate_session_stream_closed',
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(waitSnapshotSpy).toHaveBeenCalledWith(
|
||||
'infonet',
|
||||
1,
|
||||
40,
|
||||
expect.objectContaining({ timeoutMs: expect.any(Number), signal: expect.any(Object) }),
|
||||
),
|
||||
);
|
||||
|
||||
waitSnapshotSpy.mockClear();
|
||||
fetchSnapshotSpy.mockClear();
|
||||
|
||||
emitStreamStatus({
|
||||
enabled: true,
|
||||
phase: 'open',
|
||||
transport: 'sse',
|
||||
sessionId: 'sess-3',
|
||||
subscriptions: ['infonet'],
|
||||
heartbeatS: 20,
|
||||
batchMs: 1500,
|
||||
lastEventType: 'hello',
|
||||
lastEventAt: 1712360300,
|
||||
detail: '',
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(waitSnapshotSpy).not.toHaveBeenCalled();
|
||||
|
||||
streamEventListeners.forEach((listener) =>
|
||||
listener({
|
||||
event: 'gate_update',
|
||||
data: {
|
||||
session_id: 'sess-3',
|
||||
updates: [{ gate_id: 'infonet', cursor: 2 }],
|
||||
ts: 1712360301,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(fetchSnapshotSpy).toHaveBeenCalledWith(
|
||||
'infonet',
|
||||
40,
|
||||
expect.objectContaining({ force: true }),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const getNodeIdentity = vi.fn(() => null);
|
||||
const getWormholeIdentityDescriptor = vi.fn(() => ({ nodeId: '!sb_scope_a' }));
|
||||
|
||||
vi.mock('@/mesh/meshIdentity', () => ({
|
||||
getNodeIdentity,
|
||||
getWormholeIdentityDescriptor,
|
||||
}));
|
||||
|
||||
describe('gateCompatTelemetry', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
window.localStorage.clear();
|
||||
window.sessionStorage.clear();
|
||||
getNodeIdentity.mockReset();
|
||||
getNodeIdentity.mockReturnValue(null);
|
||||
getWormholeIdentityDescriptor.mockReset();
|
||||
getWormholeIdentityDescriptor.mockReturnValue({ nodeId: '!sb_scope_a' });
|
||||
});
|
||||
|
||||
it('records required and used compat events with reason summaries', async () => {
|
||||
const mod = await import('@/mesh/gateCompatTelemetry');
|
||||
|
||||
mod.recordGateCompatTelemetry({
|
||||
gateId: 'infonet',
|
||||
action: 'decrypt',
|
||||
reason: 'browser_gate_state_resync_required:infonet',
|
||||
kind: 'required',
|
||||
at: 1712500000000,
|
||||
});
|
||||
mod.recordGateCompatTelemetry({
|
||||
gateId: 'infonet',
|
||||
action: 'decrypt',
|
||||
reason: 'browser_gate_state_resync_required:infonet',
|
||||
kind: 'used',
|
||||
at: 1712500005000,
|
||||
});
|
||||
|
||||
const snapshot = mod.getGateCompatTelemetrySnapshot();
|
||||
|
||||
expect(snapshot.totalRequired).toBe(1);
|
||||
expect(snapshot.totalUsed).toBe(1);
|
||||
expect(snapshot.reasons[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
reason: 'browser_gate_state_resync_required:infonet',
|
||||
requiredCount: 1,
|
||||
usedCount: 1,
|
||||
recentGates: ['infonet'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps telemetry scoped to the current browser profile across reloads', async () => {
|
||||
const mod = await import('@/mesh/gateCompatTelemetry');
|
||||
|
||||
mod.recordGateCompatTelemetry({
|
||||
gateId: 'infonet',
|
||||
action: 'compose',
|
||||
reason: 'browser_gate_worker_unavailable',
|
||||
kind: 'required',
|
||||
at: 1712501000000,
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
getWormholeIdentityDescriptor.mockReturnValue({ nodeId: '!sb_scope_a' });
|
||||
|
||||
const reloaded = await import('@/mesh/gateCompatTelemetry');
|
||||
expect(reloaded.getGateCompatTelemetrySnapshot().totalRequired).toBe(1);
|
||||
|
||||
vi.resetModules();
|
||||
getWormholeIdentityDescriptor.mockReturnValue({ nodeId: '!sb_scope_b' });
|
||||
|
||||
const otherScope = await import('@/mesh/gateCompatTelemetry');
|
||||
expect(otherScope.getGateCompatTelemetrySnapshot().totalRequired).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -62,7 +62,13 @@ describe('gate envelope display', () => {
|
||||
|
||||
expect(isEncryptedGateEnvelope(encrypted)).toBe(true);
|
||||
expect(gateEnvelopeState(encrypted)).toBe('locked');
|
||||
expect(gateEnvelopeDisplayText(encrypted)).toBe('ENCRYPTED GATE MESSAGE - KEY UNAVAILABLE');
|
||||
expect(gateEnvelopeDisplayText(encrypted)).toBe('Sealed message - durable gate envelope was not stored.');
|
||||
expect(
|
||||
gateEnvelopeDisplayText({
|
||||
...encrypted,
|
||||
gate_envelope: 'opaque-envelope',
|
||||
}),
|
||||
).toBe('Sealed message - waiting for local gate decrypt.');
|
||||
expect(
|
||||
gateEnvelopeState({
|
||||
...encrypted,
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* P5A: End-to-end gate envelope hash binding on the live decrypt path.
|
||||
*
|
||||
* Tests prove:
|
||||
* - normalizeInfoNetMessage preserves envelope_hash from payload
|
||||
* - normalizeInfoNetMessage preserves top-level envelope_hash
|
||||
* - legacy messages without envelope_hash are not broken
|
||||
* - WormholeGateDecryptPayload shape includes envelope_hash
|
||||
* - decryptWormholeGateMessage single-message helper accepts integrity fields
|
||||
* - MeshTerminal normalizer pattern preserves gate_envelope and envelope_hash
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { InfoNetMessage } from '@/components/MeshChat/types';
|
||||
import { normalizeInfoNetMessage } from '@/components/MeshChat/utils';
|
||||
import {
|
||||
decryptWormholeGateMessage,
|
||||
type WormholeGateDecryptPayload,
|
||||
} from '@/mesh/wormholeIdentityClient';
|
||||
|
||||
describe('normalizeInfoNetMessage preserves envelope_hash', () => {
|
||||
it('extracts envelope_hash from nested payload', () => {
|
||||
const raw: InfoNetMessage = {
|
||||
event_id: 'e1',
|
||||
timestamp: 1000,
|
||||
payload: {
|
||||
gate: 'finance',
|
||||
ciphertext: 'ct',
|
||||
nonce: 'n1',
|
||||
sender_ref: 'sr1',
|
||||
format: 'mls1',
|
||||
envelope_hash: 'abc123hash',
|
||||
},
|
||||
};
|
||||
const normalized = normalizeInfoNetMessage(raw);
|
||||
expect(normalized.envelope_hash).toBe('abc123hash');
|
||||
});
|
||||
|
||||
it('preserves top-level envelope_hash over payload', () => {
|
||||
const raw: InfoNetMessage = {
|
||||
event_id: 'e2',
|
||||
timestamp: 2000,
|
||||
envelope_hash: 'top-level-hash',
|
||||
payload: {
|
||||
gate: 'finance',
|
||||
ciphertext: 'ct',
|
||||
nonce: 'n2',
|
||||
sender_ref: 'sr2',
|
||||
format: 'mls1',
|
||||
envelope_hash: 'payload-hash',
|
||||
},
|
||||
};
|
||||
const normalized = normalizeInfoNetMessage(raw);
|
||||
expect(normalized.envelope_hash).toBe('top-level-hash');
|
||||
});
|
||||
|
||||
it('returns empty string when no envelope_hash present', () => {
|
||||
const raw: InfoNetMessage = {
|
||||
event_id: 'e3',
|
||||
timestamp: 3000,
|
||||
payload: {
|
||||
gate: 'finance',
|
||||
ciphertext: 'ct',
|
||||
nonce: 'n3',
|
||||
sender_ref: 'sr3',
|
||||
format: 'mls1',
|
||||
},
|
||||
};
|
||||
const normalized = normalizeInfoNetMessage(raw);
|
||||
expect(normalized.envelope_hash).toBe('');
|
||||
});
|
||||
|
||||
it('does not break messages without payload', () => {
|
||||
const raw: InfoNetMessage = {
|
||||
event_id: 'e4',
|
||||
timestamp: 4000,
|
||||
ciphertext: 'ct',
|
||||
gate_envelope: 'env',
|
||||
envelope_hash: 'hash4',
|
||||
};
|
||||
const normalized = normalizeInfoNetMessage(raw);
|
||||
// No payload → returns message as-is, envelope_hash untouched
|
||||
expect(normalized.envelope_hash).toBe('hash4');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WormholeGateDecryptPayload supports envelope_hash', () => {
|
||||
it('accepts envelope_hash in the payload type', () => {
|
||||
const payload: WormholeGateDecryptPayload = {
|
||||
gate_id: 'gate1',
|
||||
ciphertext: 'ct',
|
||||
nonce: 'n1',
|
||||
sender_ref: 'sr1',
|
||||
format: 'mls1',
|
||||
gate_envelope: 'env',
|
||||
envelope_hash: 'abc123hash',
|
||||
};
|
||||
expect(payload.envelope_hash).toBe('abc123hash');
|
||||
});
|
||||
|
||||
it('allows omitting envelope_hash for legacy compatibility', () => {
|
||||
const payload: WormholeGateDecryptPayload = {
|
||||
gate_id: 'gate1',
|
||||
ciphertext: 'ct',
|
||||
};
|
||||
expect(payload.envelope_hash).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('decrypt caller payload construction includes envelope_hash', () => {
|
||||
it('builds decrypt payload with envelope_hash when present on message', () => {
|
||||
// Simulates the payload construction pattern used in GateView and useMeshChatController
|
||||
const message = {
|
||||
gate: 'finance',
|
||||
epoch: 2,
|
||||
ciphertext: 'ct',
|
||||
nonce: 'n1',
|
||||
sender_ref: 'sr1',
|
||||
format: 'mls1',
|
||||
gate_envelope: 'env-data',
|
||||
envelope_hash: 'sha256-hex-hash',
|
||||
};
|
||||
|
||||
const decryptPayload: WormholeGateDecryptPayload = {
|
||||
gate_id: String(message.gate || ''),
|
||||
epoch: Number(message.epoch || 0),
|
||||
ciphertext: String(message.ciphertext || ''),
|
||||
nonce: String(message.nonce || ''),
|
||||
sender_ref: String(message.sender_ref || ''),
|
||||
format: String(message.format || 'mls1'),
|
||||
gate_envelope: String(message.gate_envelope || ''),
|
||||
envelope_hash: String(message.envelope_hash || ''),
|
||||
};
|
||||
|
||||
expect(decryptPayload.envelope_hash).toBe('sha256-hex-hash');
|
||||
expect(decryptPayload.gate_envelope).toBe('env-data');
|
||||
});
|
||||
|
||||
it('builds decrypt payload with empty envelope_hash for legacy messages', () => {
|
||||
const message = {
|
||||
gate: 'finance',
|
||||
ciphertext: 'ct',
|
||||
nonce: 'n1',
|
||||
sender_ref: 'sr1',
|
||||
format: 'mls1',
|
||||
gate_envelope: 'env-data',
|
||||
};
|
||||
|
||||
const decryptPayload: WormholeGateDecryptPayload = {
|
||||
gate_id: String(message.gate || ''),
|
||||
epoch: 0,
|
||||
ciphertext: String(message.ciphertext || ''),
|
||||
nonce: String(message.nonce || ''),
|
||||
sender_ref: String(message.sender_ref || ''),
|
||||
format: String(message.format || 'mls1'),
|
||||
gate_envelope: String(message.gate_envelope || ''),
|
||||
envelope_hash: String((message as Record<string, unknown>).envelope_hash || ''),
|
||||
};
|
||||
|
||||
expect(decryptPayload.envelope_hash).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('single-message decryptWormholeGateMessage accepts integrity fields', () => {
|
||||
it('function signature accepts gate_envelope and envelope_hash', async () => {
|
||||
// Verify the function exists and accepts the extended signature.
|
||||
// We cannot call it without a running backend, but we can verify
|
||||
// the function shape by checking it is callable with 7 args.
|
||||
expect(typeof decryptWormholeGateMessage).toBe('function');
|
||||
expect(decryptWormholeGateMessage.length).toBeLessThanOrEqual(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MeshTerminal normalizeInfonetMessageRecord equivalent pattern', () => {
|
||||
it('preserves gate_envelope and envelope_hash from nested payload', () => {
|
||||
// Simulate the normalizeInfonetMessageRecord pattern from MeshTerminal
|
||||
const message: Record<string, unknown> = {
|
||||
event_id: 'e1',
|
||||
timestamp: 1000,
|
||||
payload: {
|
||||
gate: 'finance',
|
||||
ciphertext: 'ct',
|
||||
nonce: 'n1',
|
||||
sender_ref: 'sr1',
|
||||
format: 'mls1',
|
||||
gate_envelope: 'env-payload',
|
||||
envelope_hash: 'hash-payload',
|
||||
},
|
||||
};
|
||||
const payload = message.payload as Record<string, string> | undefined;
|
||||
const normalized = {
|
||||
...message,
|
||||
gate: String(message.gate ?? payload?.gate ?? ''),
|
||||
ciphertext: String(message.ciphertext ?? payload?.ciphertext ?? ''),
|
||||
nonce: String(message.nonce ?? payload?.nonce ?? ''),
|
||||
sender_ref: String(message.sender_ref ?? payload?.sender_ref ?? ''),
|
||||
format: String(message.format ?? payload?.format ?? ''),
|
||||
gate_envelope: String(message.gate_envelope ?? payload?.gate_envelope ?? ''),
|
||||
envelope_hash: String(message.envelope_hash ?? payload?.envelope_hash ?? ''),
|
||||
};
|
||||
expect(normalized.gate_envelope).toBe('env-payload');
|
||||
expect(normalized.envelope_hash).toBe('hash-payload');
|
||||
});
|
||||
|
||||
it('single decrypt call site passes integrity fields through', () => {
|
||||
const normalized = {
|
||||
gate: 'finance',
|
||||
epoch: 2,
|
||||
ciphertext: 'ct',
|
||||
nonce: 'n1',
|
||||
sender_ref: 'sr1',
|
||||
gate_envelope: 'env-data',
|
||||
envelope_hash: 'sha256-hex',
|
||||
};
|
||||
// Matches the call pattern in describeGateMessage
|
||||
const args = [
|
||||
String(normalized.gate || ''),
|
||||
Number(normalized.epoch || 0),
|
||||
String(normalized.ciphertext || ''),
|
||||
String(normalized.nonce || ''),
|
||||
String(normalized.sender_ref || ''),
|
||||
String(normalized.gate_envelope || ''),
|
||||
String(normalized.envelope_hash || ''),
|
||||
];
|
||||
expect(args[5]).toBe('env-data');
|
||||
expect(args[6]).toBe('sha256-hex');
|
||||
});
|
||||
|
||||
it('legacy message without integrity fields produces empty strings', () => {
|
||||
const normalized = {
|
||||
gate: 'finance',
|
||||
epoch: 1,
|
||||
ciphertext: 'ct',
|
||||
nonce: 'n1',
|
||||
sender_ref: 'sr1',
|
||||
};
|
||||
const args = [
|
||||
String(normalized.gate || ''),
|
||||
Number(normalized.epoch || 0),
|
||||
String(normalized.ciphertext || ''),
|
||||
String(normalized.nonce || ''),
|
||||
String(normalized.sender_ref || ''),
|
||||
String((normalized as any).gate_envelope || ''),
|
||||
String((normalized as any).envelope_hash || ''),
|
||||
];
|
||||
expect(args[5]).toBe('');
|
||||
expect(args[6]).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const hasLocalControlBridge = vi.fn(() => false);
|
||||
const buildGateAccessHeaders = vi.fn();
|
||||
|
||||
vi.mock('@/lib/localControlTransport', () => ({
|
||||
hasLocalControlBridge,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/gateAccessProof', () => ({
|
||||
buildGateAccessHeaders,
|
||||
}));
|
||||
|
||||
describe('gateMessageSnapshot cache', () => {
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
fetchMock.mockReset();
|
||||
buildGateAccessHeaders.mockReset();
|
||||
hasLocalControlBridge.mockReset();
|
||||
hasLocalControlBridge.mockReturnValue(false);
|
||||
buildGateAccessHeaders.mockResolvedValue({
|
||||
'X-Wormhole-Node-Id': '!sb_gate',
|
||||
'X-Wormhole-Gate-Proof': 'proof',
|
||||
'X-Wormhole-Gate-Ts': '1712345678',
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
it('coarsens browser/web gate message reads through a short shared cache window', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-05T23:45:00.000Z'));
|
||||
try {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: [{ event_id: 'evt-1', gate: 'infonet', timestamp: 1712360000 }],
|
||||
}),
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gateMessageSnapshot');
|
||||
|
||||
await expect(mod.fetchGateMessageSnapshot('infonet', 20)).resolves.toEqual([
|
||||
expect.objectContaining({ event_id: 'evt-1', gate: 'infonet' }),
|
||||
]);
|
||||
await mod.fetchGateMessageSnapshot('infonet', 20);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(10_001);
|
||||
|
||||
await mod.fetchGateMessageSnapshot('infonet', 20);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('reuses a larger cached limit for smaller reads without another fetch', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: Array.from({ length: 8 }, (_, index) => ({
|
||||
event_id: `evt-${index + 1}`,
|
||||
gate: 'finance',
|
||||
timestamp: 1712360000 + index,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gateMessageSnapshot');
|
||||
|
||||
await expect(mod.fetchGateMessageSnapshot('finance', 8)).resolves.toHaveLength(8);
|
||||
await expect(mod.fetchGateMessageSnapshot('finance', 4)).resolves.toHaveLength(4);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uses session-stream proof reuse for stream-owned snapshot refreshes', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: [{ event_id: 'evt-1', gate: 'finance', timestamp: 1712360000 }],
|
||||
cursor: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gateMessageSnapshot');
|
||||
|
||||
await expect(
|
||||
mod.fetchGateMessageSnapshotState('finance', 20, { proofMode: 'session_stream' }),
|
||||
).resolves.toEqual({
|
||||
messages: [expect.objectContaining({ event_id: 'evt-1', gate: 'finance' })],
|
||||
cursor: 1,
|
||||
});
|
||||
|
||||
expect(buildGateAccessHeaders).toHaveBeenCalledWith('finance', { mode: 'session_stream' });
|
||||
});
|
||||
|
||||
it('reuses a larger in-flight snapshot fetch for a smaller concurrent read', async () => {
|
||||
let releaseFetch:
|
||||
| ((value: {
|
||||
ok: true;
|
||||
json: () => Promise<{
|
||||
messages: Array<{ event_id: string; gate: string; timestamp: number }>;
|
||||
cursor: number;
|
||||
}>;
|
||||
}) => void)
|
||||
| null = null;
|
||||
fetchMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
releaseFetch = resolve as typeof releaseFetch;
|
||||
}),
|
||||
);
|
||||
|
||||
const mod = await import('@/mesh/gateMessageSnapshot');
|
||||
|
||||
const larger = mod.fetchGateMessageSnapshotState('infonet', 40);
|
||||
const smaller = mod.fetchGateMessageSnapshotState('infonet', 20);
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(String(fetchMock.mock.calls[0]?.[0] || '')).toContain('/api/mesh/infonet/messages?gate=infonet&limit=40');
|
||||
|
||||
releaseFetch?.({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: Array.from({ length: 3 }, (_, index) => ({
|
||||
event_id: `evt-${index + 1}`,
|
||||
gate: 'infonet',
|
||||
timestamp: 1712360000 + index,
|
||||
})),
|
||||
cursor: 3,
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(larger).resolves.toEqual({
|
||||
messages: [
|
||||
expect.objectContaining({ event_id: 'evt-1', gate: 'infonet' }),
|
||||
expect.objectContaining({ event_id: 'evt-2', gate: 'infonet' }),
|
||||
expect.objectContaining({ event_id: 'evt-3', gate: 'infonet' }),
|
||||
],
|
||||
cursor: 3,
|
||||
});
|
||||
await expect(smaller).resolves.toEqual({
|
||||
messages: [
|
||||
expect.objectContaining({ event_id: 'evt-1', gate: 'infonet' }),
|
||||
expect.objectContaining({ event_id: 'evt-2', gate: 'infonet' }),
|
||||
expect.objectContaining({ event_id: 'evt-3', gate: 'infonet' }),
|
||||
],
|
||||
cursor: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses a shorter native cache window and supports explicit invalidation', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-05T23:45:00.000Z'));
|
||||
try {
|
||||
hasLocalControlBridge.mockReturnValue(true);
|
||||
fetchMock
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: [{ event_id: 'evt-1', gate: 'ops', timestamp: 1712360000 }],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: [{ event_id: 'evt-2', gate: 'ops', timestamp: 1712360010 }],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: [{ event_id: 'evt-3', gate: 'ops', timestamp: 1712360020 }],
|
||||
}),
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gateMessageSnapshot');
|
||||
|
||||
await expect(mod.fetchGateMessageSnapshot('ops', 6)).resolves.toEqual([
|
||||
expect.objectContaining({ event_id: 'evt-1' }),
|
||||
]);
|
||||
vi.advanceTimersByTime(3_001);
|
||||
await expect(mod.fetchGateMessageSnapshot('ops', 6)).resolves.toEqual([
|
||||
expect.objectContaining({ event_id: 'evt-2' }),
|
||||
]);
|
||||
|
||||
mod.invalidateGateMessageSnapshot('ops');
|
||||
await expect(mod.fetchGateMessageSnapshot('ops', 6)).resolves.toEqual([
|
||||
expect.objectContaining({ event_id: 'evt-3' }),
|
||||
]);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('tracks cursors and waits for gate changes without re-reading the ordinary route', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: [{ event_id: 'evt-1', gate: 'infonet', timestamp: 1712360000 }],
|
||||
cursor: 1,
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: [
|
||||
{ event_id: 'evt-2', gate: 'infonet', timestamp: 1712360010 },
|
||||
{ event_id: 'evt-1', gate: 'infonet', timestamp: 1712360000 },
|
||||
],
|
||||
cursor: 2,
|
||||
changed: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gateMessageSnapshot');
|
||||
|
||||
await expect(mod.fetchGateMessageSnapshotState('infonet', 20)).resolves.toEqual({
|
||||
messages: [expect.objectContaining({ event_id: 'evt-1', gate: 'infonet' })],
|
||||
cursor: 1,
|
||||
});
|
||||
await expect(mod.waitForGateMessageSnapshot('infonet', 1, 20, { timeoutMs: 18_000 })).resolves.toEqual({
|
||||
messages: [
|
||||
expect.objectContaining({ event_id: 'evt-2', gate: 'infonet' }),
|
||||
expect.objectContaining({ event_id: 'evt-1', gate: 'infonet' }),
|
||||
],
|
||||
cursor: 2,
|
||||
changed: true,
|
||||
});
|
||||
expect(mod.getGateMessageSnapshotCursor('infonet')).toBe(2);
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toContain('/api/mesh/infonet/messages/wait?gate=infonet&after=1');
|
||||
});
|
||||
|
||||
it('coalesces concurrent gate wait requests for the same gate cursor', async () => {
|
||||
let releaseWait:
|
||||
| ((value: { ok: true; json: () => Promise<{ messages: Array<{ event_id: string; gate: string; timestamp: number }>; cursor: number; changed: boolean }> }) => void)
|
||||
| null = null;
|
||||
fetchMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
releaseWait = resolve as typeof releaseWait;
|
||||
}),
|
||||
);
|
||||
|
||||
const mod = await import('@/mesh/gateMessageSnapshot');
|
||||
|
||||
const first = mod.waitForGateMessageSnapshot('infonet', 4, 20, { timeoutMs: 18_000 });
|
||||
const second = mod.waitForGateMessageSnapshot('infonet', 4, 20, { timeoutMs: 24_000 });
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(String(fetchMock.mock.calls[0]?.[0] || '')).toContain('/api/mesh/infonet/messages/wait?gate=infonet&after=4');
|
||||
|
||||
releaseWait?.({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: [{ event_id: 'evt-5', gate: 'infonet', timestamp: 1712360050 }],
|
||||
cursor: 5,
|
||||
changed: true,
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(first).resolves.toEqual({
|
||||
messages: [expect.objectContaining({ event_id: 'evt-5', gate: 'infonet' })],
|
||||
cursor: 5,
|
||||
changed: true,
|
||||
});
|
||||
await expect(second).resolves.toEqual({
|
||||
messages: [expect.objectContaining({ event_id: 'evt-5', gate: 'infonet' })],
|
||||
cursor: 5,
|
||||
changed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('reuses a larger in-flight gate wait for a smaller concurrent consumer', async () => {
|
||||
let releaseWait:
|
||||
| ((value: {
|
||||
ok: true;
|
||||
json: () => Promise<{
|
||||
messages: Array<{ event_id: string; gate: string; timestamp: number }>;
|
||||
cursor: number;
|
||||
changed: boolean;
|
||||
}>;
|
||||
}) => void)
|
||||
| null = null;
|
||||
fetchMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
releaseWait = resolve as typeof releaseWait;
|
||||
}),
|
||||
);
|
||||
|
||||
const mod = await import('@/mesh/gateMessageSnapshot');
|
||||
|
||||
const larger = mod.waitForGateMessageSnapshot('infonet', 4, 40, { timeoutMs: 18_000 });
|
||||
const smaller = mod.waitForGateMessageSnapshot('infonet', 4, 20, { timeoutMs: 24_000 });
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(String(fetchMock.mock.calls[0]?.[0] || '')).toContain('/api/mesh/infonet/messages/wait?gate=infonet&after=4&limit=40');
|
||||
|
||||
releaseWait?.({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: [
|
||||
{ event_id: 'evt-8', gate: 'infonet', timestamp: 1712360080 },
|
||||
{ event_id: 'evt-7', gate: 'infonet', timestamp: 1712360070 },
|
||||
],
|
||||
cursor: 8,
|
||||
changed: true,
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(larger).resolves.toEqual({
|
||||
messages: [
|
||||
expect.objectContaining({ event_id: 'evt-8', gate: 'infonet' }),
|
||||
expect.objectContaining({ event_id: 'evt-7', gate: 'infonet' }),
|
||||
],
|
||||
cursor: 8,
|
||||
changed: true,
|
||||
});
|
||||
await expect(smaller).resolves.toEqual({
|
||||
messages: [
|
||||
expect.objectContaining({ event_id: 'evt-8', gate: 'infonet' }),
|
||||
expect.objectContaining({ event_id: 'evt-7', gate: 'infonet' }),
|
||||
],
|
||||
cursor: 8,
|
||||
changed: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const hasLocalControlBridge = vi.fn(() => false);
|
||||
|
||||
vi.mock('@/lib/localControlTransport', () => ({
|
||||
hasLocalControlBridge,
|
||||
}));
|
||||
|
||||
describe('gate metadata timing policy', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
hasLocalControlBridge.mockReset();
|
||||
});
|
||||
|
||||
it('jittered browser/web polling avoids an exact cadence', async () => {
|
||||
hasLocalControlBridge.mockReturnValue(false);
|
||||
const mod = await import('@/mesh/gateMetadataTiming');
|
||||
const pollDelays = Array.from({ length: 12 }, () => mod.nextGateMessagesPollDelayMs());
|
||||
expect(pollDelays.every((delay) => delay >= 24_000 && delay <= 36_000)).toBe(true);
|
||||
expect(new Set(pollDelays).size).toBeGreaterThan(1);
|
||||
const waitTimeouts = Array.from({ length: 12 }, () => mod.nextGateMessagesWaitTimeoutMs());
|
||||
expect(waitTimeouts.every((delay) => delay >= 26_000 && delay <= 38_000)).toBe(true);
|
||||
expect(new Set(waitTimeouts).size).toBeGreaterThan(1);
|
||||
const rearmDelays = Array.from({ length: 12 }, () => mod.nextGateMessagesWaitRearmDelayMs());
|
||||
expect(rearmDelays.every((delay) => delay >= 3_000 && delay <= 4_200)).toBe(true);
|
||||
expect(new Set(rearmDelays).size).toBeGreaterThan(1);
|
||||
const refreshDelays = Array.from({ length: 12 }, () => mod.nextGateActivityRefreshDelayMs());
|
||||
expect(refreshDelays.every((delay) => delay >= 4_500 && delay <= 9_500)).toBe(true);
|
||||
expect(new Set(refreshDelays).size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('native desktop keeps the tighter poll/send timing path', async () => {
|
||||
hasLocalControlBridge.mockReturnValue(true);
|
||||
const mod = await import('@/mesh/gateMetadataTiming');
|
||||
expect(mod.shouldJitterGateMetadataTiming()).toBe(false);
|
||||
expect(mod.nextGateMessagesPollDelayMs()).toBe(30_000);
|
||||
expect(mod.nextGateMessagesWaitTimeoutMs()).toBe(20_000);
|
||||
expect(mod.nextGateMessagesWaitRearmDelayMs()).toBe(750);
|
||||
expect(mod.nextGateActivityRefreshDelayMs()).toBe(0);
|
||||
});
|
||||
|
||||
it('coarsens hidden browser tab gate polling further', async () => {
|
||||
hasLocalControlBridge.mockReturnValue(false);
|
||||
const originalVisibility = Object.getOwnPropertyDescriptor(document, 'visibilityState');
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
configurable: true,
|
||||
value: 'hidden',
|
||||
});
|
||||
try {
|
||||
const mod = await import('@/mesh/gateMetadataTiming');
|
||||
const pollDelays = Array.from({ length: 12 }, () => mod.nextGateMessagesPollDelayMs());
|
||||
expect(pollDelays.every((delay) => delay >= 48_000 && delay <= 72_000)).toBe(true);
|
||||
const waitTimeouts = Array.from({ length: 12 }, () => mod.nextGateMessagesWaitTimeoutMs());
|
||||
expect(waitTimeouts.every((delay) => delay >= 60_000 && delay <= 84_000)).toBe(true);
|
||||
const rearmDelays = Array.from({ length: 12 }, () => mod.nextGateMessagesWaitRearmDelayMs());
|
||||
expect(rearmDelays.every((delay) => delay >= 6_000 && delay <= 12_000)).toBe(true);
|
||||
const refreshDelays = Array.from({ length: 12 }, () => mod.nextGateActivityRefreshDelayMs());
|
||||
expect(refreshDelays.every((delay) => delay >= 14_000 && delay <= 22_000)).toBe(true);
|
||||
} finally {
|
||||
if (originalVisibility) {
|
||||
Object.defineProperty(document, 'visibilityState', originalVisibility);
|
||||
} else {
|
||||
delete (document as Document & { visibilityState?: string }).visibilityState;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const hasLocalControlBridge = vi.fn(() => false);
|
||||
const buildGateAccessHeaders = vi.fn();
|
||||
const decryptWormholeGateMessage = vi.fn();
|
||||
|
||||
vi.mock('@/lib/localControlTransport', () => ({
|
||||
hasLocalControlBridge,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/gateAccessProof', () => ({
|
||||
buildGateAccessHeaders,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/wormholeIdentityClient', () => ({
|
||||
decryptWormholeGateMessage,
|
||||
}));
|
||||
|
||||
describe('gatePreviewSnapshot cache', () => {
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
fetchMock.mockReset();
|
||||
buildGateAccessHeaders.mockReset();
|
||||
decryptWormholeGateMessage.mockReset();
|
||||
hasLocalControlBridge.mockReset();
|
||||
hasLocalControlBridge.mockReturnValue(false);
|
||||
buildGateAccessHeaders.mockResolvedValue({
|
||||
'X-Wormhole-Node-Id': '!sb_gate',
|
||||
'X-Wormhole-Gate-Proof': 'proof',
|
||||
'X-Wormhole-Gate-Ts': '1712345678',
|
||||
});
|
||||
decryptWormholeGateMessage.mockResolvedValue({
|
||||
ok: true,
|
||||
plaintext: 'sealed preview',
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
it('coarsens browser/web gate preview fetches through a short cache window', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-05T23:30:00.000Z'));
|
||||
try {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: [
|
||||
{
|
||||
event_id: 'evt-1',
|
||||
event_type: 'gate_message',
|
||||
node_id: '!sb_sender',
|
||||
gate: 'infonet',
|
||||
epoch: 7,
|
||||
ciphertext: 'ct',
|
||||
nonce: 'nonce',
|
||||
sender_ref: 'sender-ref',
|
||||
format: 'mls1',
|
||||
gate_envelope: 'env',
|
||||
envelope_hash: 'hash',
|
||||
timestamp: Math.floor(Date.now() / 1000) - 60,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gatePreviewSnapshot');
|
||||
|
||||
await expect(mod.fetchGateThreadPreviewSnapshot('infonet')).resolves.toEqual([
|
||||
{
|
||||
nodeId: '!sb_sender',
|
||||
age: '1m ago',
|
||||
text: 'sealed preview',
|
||||
encrypted: true,
|
||||
},
|
||||
]);
|
||||
await mod.fetchGateThreadPreviewSnapshot('infonet');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(decryptWormholeGateMessage).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(12_001);
|
||||
|
||||
await mod.fetchGateThreadPreviewSnapshot('infonet');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses a shorter preview cache window on native runtimes', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-05T23:30:00.000Z'));
|
||||
try {
|
||||
hasLocalControlBridge.mockReturnValue(true);
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: [
|
||||
{
|
||||
event_id: 'evt-1',
|
||||
node_id: '!sb_sender',
|
||||
message: 'plain preview',
|
||||
timestamp: Math.floor(Date.now() / 1000) - 60,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gatePreviewSnapshot');
|
||||
|
||||
await mod.fetchGateThreadPreviewSnapshot('infonet');
|
||||
vi.advanceTimersByTime(4_001);
|
||||
await mod.fetchGateThreadPreviewSnapshot('infonet');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('invalidates cached gate previews explicitly', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: [
|
||||
{
|
||||
event_id: 'evt-1',
|
||||
node_id: '!sb_sender',
|
||||
message: 'plain preview',
|
||||
timestamp: 1712360000,
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: [
|
||||
{
|
||||
event_id: 'evt-2',
|
||||
node_id: '!sb_sender',
|
||||
message: 'updated preview',
|
||||
timestamp: 1712360100,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gatePreviewSnapshot');
|
||||
|
||||
await expect(mod.fetchGateThreadPreviewSnapshot('infonet')).resolves.toEqual([
|
||||
expect.objectContaining({ text: 'plain preview' }),
|
||||
]);
|
||||
mod.invalidateGateThreadPreviewSnapshot('infonet');
|
||||
await expect(mod.fetchGateThreadPreviewSnapshot('infonet')).resolves.toEqual([
|
||||
expect.objectContaining({ text: 'updated preview' }),
|
||||
]);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,292 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const controlPlaneFetch = vi.fn();
|
||||
|
||||
vi.mock('@/lib/controlPlane', () => ({
|
||||
controlPlaneFetch,
|
||||
}));
|
||||
|
||||
describe('gateSessionStream manager', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
controlPlaneFetch.mockReset();
|
||||
});
|
||||
|
||||
it('marks the stream disabled when the backend feature flag is off', async () => {
|
||||
controlPlaneFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: false, detail: 'gate_session_stream_disabled' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
|
||||
const mod = await import('@/mesh/gateSessionStream');
|
||||
|
||||
mod.connectGateSessionStream();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(mod.getGateSessionStreamStatus()).toMatchObject({
|
||||
enabled: false,
|
||||
phase: 'disabled',
|
||||
detail: 'gate_session_stream_disabled',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses hello and heartbeat events from the session stream skeleton', async () => {
|
||||
const encoder = new TextEncoder();
|
||||
controlPlaneFetch.mockResolvedValue(
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
[
|
||||
'event: hello',
|
||||
'data: {"session_id":"sess-1","subscriptions":["alpha","beta"],"heartbeat_s":20,"batch_ms":1500,"transport":"sse","gate_access":{"alpha":{"node_id":"!node_alpha","proof":"proof-alpha","ts":"1712360000"}},"gate_key_status":{"alpha":{"ok":true,"gate_id":"alpha","current_epoch":7,"has_local_access":true}}}',
|
||||
'',
|
||||
'event: heartbeat',
|
||||
'data: {"session_id":"sess-1","ts":1712360000}',
|
||||
'',
|
||||
].join('\n'),
|
||||
),
|
||||
);
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const mod = await import('@/mesh/gateSessionStream');
|
||||
|
||||
mod.setGateSessionStreamSubscriptions(['Alpha', 'beta']);
|
||||
mod.connectGateSessionStream();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(controlPlaneFetch).toHaveBeenCalledWith(
|
||||
'/api/mesh/infonet/session-stream?gates=alpha%2Cbeta',
|
||||
expect.objectContaining({
|
||||
requireAdminSession: true,
|
||||
cache: 'no-store',
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
}),
|
||||
);
|
||||
expect(mod.getGateSessionStreamStatus()).toMatchObject({
|
||||
enabled: false,
|
||||
phase: 'closed',
|
||||
sessionId: 'sess-1',
|
||||
subscriptions: ['alpha', 'beta'],
|
||||
heartbeatS: 20,
|
||||
batchMs: 1500,
|
||||
lastEventType: 'heartbeat',
|
||||
});
|
||||
expect(mod.getGateSessionStreamAccessHeaders('alpha')).toEqual({
|
||||
'X-Wormhole-Node-Id': '!node_alpha',
|
||||
'X-Wormhole-Gate-Proof': 'proof-alpha',
|
||||
'X-Wormhole-Gate-Ts': '1712360000',
|
||||
});
|
||||
expect(mod.getGateSessionStreamKeyStatus('alpha')).toEqual({
|
||||
ok: true,
|
||||
gate_id: 'alpha',
|
||||
current_epoch: 7,
|
||||
has_local_access: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('retains one shared subscription set across multiple same-gate consumers', async () => {
|
||||
controlPlaneFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: false, detail: 'gate_session_stream_disabled' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
|
||||
const mod = await import('@/mesh/gateSessionStream');
|
||||
|
||||
const releaseA = mod.retainGateSessionStreamGate('Alpha');
|
||||
const releaseB = mod.retainGateSessionStreamGate('alpha');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(controlPlaneFetch).toHaveBeenCalledTimes(1);
|
||||
expect(mod.getGateSessionStreamStatus().subscriptions).toEqual(['alpha']);
|
||||
|
||||
releaseA();
|
||||
expect(mod.getGateSessionStreamStatus().subscriptions).toEqual(['alpha']);
|
||||
|
||||
releaseB();
|
||||
expect(mod.getGateSessionStreamStatus()).toMatchObject({
|
||||
phase: 'idle',
|
||||
subscriptions: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('can invalidate cached per-gate stream bootstrap context without dropping the stream status', async () => {
|
||||
const encoder = new TextEncoder();
|
||||
controlPlaneFetch.mockResolvedValue(
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
[
|
||||
'event: hello',
|
||||
'data: {"session_id":"sess-ctx","subscriptions":["alpha"],"heartbeat_s":20,"batch_ms":1500,"transport":"sse","gate_access":{"alpha":{"node_id":"!node_alpha","proof":"proof-alpha","ts":"1712360000"}},"gate_key_status":{"alpha":{"ok":true,"gate_id":"alpha","current_epoch":7,"has_local_access":true}}}',
|
||||
'',
|
||||
].join('\n'),
|
||||
),
|
||||
);
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const mod = await import('@/mesh/gateSessionStream');
|
||||
|
||||
mod.retainGateSessionStreamGate('alpha');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(mod.getGateSessionStreamAccessHeaders('alpha')).toBeDefined();
|
||||
expect(mod.getGateSessionStreamKeyStatus('alpha')).toBeTruthy();
|
||||
|
||||
mod.invalidateGateSessionStreamGateContext('alpha');
|
||||
|
||||
expect(mod.getGateSessionStreamAccessHeaders('alpha')).toBeUndefined();
|
||||
expect(mod.getGateSessionStreamKeyStatus('alpha')).toBeNull();
|
||||
expect(mod.getGateSessionStreamStatus().sessionId).toBe('sess-ctx');
|
||||
});
|
||||
|
||||
it('emits parsed gate_update events to stream event listeners', async () => {
|
||||
const encoder = new TextEncoder();
|
||||
controlPlaneFetch.mockResolvedValue(
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
[
|
||||
'event: hello',
|
||||
'data: {"session_id":"sess-2","subscriptions":["alpha"],"heartbeat_s":20,"batch_ms":1500,"transport":"sse"}',
|
||||
'',
|
||||
'event: gate_update',
|
||||
'data: {"session_id":"sess-2","updates":[{"gate_id":"alpha","cursor":3}],"ts":1712360001}',
|
||||
'',
|
||||
].join('\n'),
|
||||
),
|
||||
);
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const mod = await import('@/mesh/gateSessionStream');
|
||||
const events: Array<{ event: string; data: unknown }> = [];
|
||||
const unsubscribe = mod.subscribeGateSessionStreamEvents((event) => {
|
||||
events.push({ event: event.event, data: event.data });
|
||||
});
|
||||
|
||||
mod.retainGateSessionStreamGate('alpha');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
unsubscribe();
|
||||
|
||||
expect(events.some((event) => event.event === 'hello')).toBe(true);
|
||||
expect(events).toContainEqual({
|
||||
event: 'gate_update',
|
||||
data: {
|
||||
session_id: 'sess-2',
|
||||
updates: [{ gate_id: 'alpha', cursor: 3 }],
|
||||
ts: 1712360001,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('reconnects with retained subscriptions after the stream closes', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const encoder = new TextEncoder();
|
||||
let callCount = 0;
|
||||
controlPlaneFetch.mockImplementation(async () => {
|
||||
callCount += 1;
|
||||
if (callCount === 1) {
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
[
|
||||
'event: hello',
|
||||
'data: {"session_id":"sess-3","subscriptions":["alpha"],"heartbeat_s":20,"batch_ms":1500,"transport":"sse"}',
|
||||
'',
|
||||
].join('\n'),
|
||||
),
|
||||
);
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
},
|
||||
);
|
||||
}
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
[
|
||||
'event: hello',
|
||||
'data: {"session_id":"sess-4","subscriptions":["alpha"],"heartbeat_s":20,"batch_ms":1500,"transport":"sse"}',
|
||||
'',
|
||||
].join('\n'),
|
||||
),
|
||||
);
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gateSessionStream');
|
||||
const release = mod.retainGateSessionStreamGate('alpha');
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(controlPlaneFetch).toHaveBeenCalledTimes(2);
|
||||
expect(mod.getGateSessionStreamStatus()).toMatchObject({
|
||||
enabled: true,
|
||||
subscriptions: ['alpha'],
|
||||
});
|
||||
expect(['connecting', 'open']).toContain(mod.getGateSessionStreamStatus().phase);
|
||||
|
||||
release();
|
||||
mod.disconnectGateSessionStream();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -155,4 +155,14 @@ describe('mailbox claim privacy padding', () => {
|
||||
expect(decoyTokens).toEqual(['decoy-0', 'decoy-1']);
|
||||
expect(decoyTokens.every((token) => !realSharedTokens.includes(token))).toBe(true);
|
||||
});
|
||||
|
||||
it('can build mailbox claims from a prepared Wormhole identity override', async () => {
|
||||
deadDropTokensForContacts.mockResolvedValue([]);
|
||||
|
||||
const mod = await import('@/mesh/meshDmClient');
|
||||
await mod.buildMailboxClaims({}, { nodeId: '!sb_wormhole_dm' });
|
||||
|
||||
expect(mailboxClaimToken).toHaveBeenCalledWith('self', '!sb_wormhole_dm');
|
||||
expect(mailboxClaimToken).toHaveBeenCalledWith('requests', '!sb_wormhole_dm');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isDmPollBlocked, isGateSendBlocked, shouldQueueDmSend } from '@/lib/meshChatPolicies';
|
||||
|
||||
function readSource(relativePath: string): string {
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
return fs.readFileSync(path.resolve(here, relativePath), 'utf-8');
|
||||
}
|
||||
|
||||
describe('MeshChat behavior - shouldQueueDmSend', () => {
|
||||
it('returns false for default privacy profile', () => {
|
||||
expect(shouldQueueDmSend('default')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for high privacy profile', () => {
|
||||
expect(shouldQueueDmSend('high')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MeshChat behavior - isGateSendBlocked', () => {
|
||||
it('blocks when on infonet tab with gate selected but access not ready', () => {
|
||||
expect(isGateSendBlocked('infonet', true, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not block when gate access is ready', () => {
|
||||
expect(isGateSendBlocked('infonet', true, true)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not block when no gate is selected', () => {
|
||||
expect(isGateSendBlocked('infonet', false, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not block on non-infonet tabs', () => {
|
||||
expect(isGateSendBlocked('dms', true, false)).toBe(false);
|
||||
expect(isGateSendBlocked('meshtastic', true, false)).toBe(false);
|
||||
expect(isGateSendBlocked('mesh', true, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not block when all conditions are false', () => {
|
||||
expect(isGateSendBlocked('dms', false, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MeshChat behavior - isDmPollBlocked', () => {
|
||||
it('blocks when wormhole is enabled but not ready', () => {
|
||||
expect(isDmPollBlocked(true, false, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks when anonymous DM is blocked', () => {
|
||||
expect(isDmPollBlocked(false, false, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks when both wormhole not ready and anonymous blocked', () => {
|
||||
expect(isDmPollBlocked(true, false, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not block when wormhole is ready and anonymous is not blocked', () => {
|
||||
expect(isDmPollBlocked(true, true, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not block when wormhole is disabled and anonymous is not blocked', () => {
|
||||
expect(isDmPollBlocked(false, false, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not block when wormhole is disabled and ready', () => {
|
||||
expect(isDmPollBlocked(false, true, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MeshChat behavior - policy wiring', () => {
|
||||
it('controller imports all three policy functions from meshChatPolicies', () => {
|
||||
const controller = readSource('../../components/MeshChat/useMeshChatController.ts');
|
||||
expect(controller).toMatch(
|
||||
/import\s*\{[^}]*shouldQueueDmSend[^}]*\}\s*from\s+['"]@\/lib\/meshChatPolicies['"]/,
|
||||
);
|
||||
expect(controller).toMatch(
|
||||
/import\s*\{[^}]*isGateSendBlocked[^}]*\}\s*from\s+['"]@\/lib\/meshChatPolicies['"]/,
|
||||
);
|
||||
expect(controller).toMatch(
|
||||
/import\s*\{[^}]*isDmPollBlocked[^}]*\}\s*from\s+['"]@\/lib\/meshChatPolicies['"]/,
|
||||
);
|
||||
});
|
||||
|
||||
it('controller calls shouldQueueDmSend in enqueueDmSend', () => {
|
||||
const controller = readSource('../../components/MeshChat/useMeshChatController.ts');
|
||||
expect(controller).toContain('shouldQueueDmSend(privacyProfile)');
|
||||
});
|
||||
|
||||
it('controller calls isGateSendBlocked in handleSend', () => {
|
||||
const controller = readSource('../../components/MeshChat/useMeshChatController.ts');
|
||||
expect(controller).toContain('isGateSendBlocked(');
|
||||
});
|
||||
|
||||
it('controller calls isDmPollBlocked in DM poll effects', () => {
|
||||
const controller = readSource('../../components/MeshChat/useMeshChatController.ts');
|
||||
expect(controller).toContain(
|
||||
'isDmPollBlocked(wormholeEnabled, wormholeReadyState, anonymousDmBlocked)',
|
||||
);
|
||||
});
|
||||
|
||||
it('controller suppresses unread-count polling while the DMS tab owns mailbox refresh', () => {
|
||||
const controller = readSource('../../components/MeshChat/useMeshChatController.ts');
|
||||
expect(controller).toContain("if (!hasId || !getDMNotify() || (expanded && activeTab === 'dms')) return;");
|
||||
expect(controller).toContain("jitteredPollDelay(baseDelay, { profile: privacyProfile })");
|
||||
});
|
||||
|
||||
it('controller uses the shared DM poll scheduler for live mailbox refresh cadence', () => {
|
||||
const controller = readSource('../../components/MeshChat/useMeshChatController.ts');
|
||||
expect(controller).toContain('classifyTick(hasMore, catchUpBudget, DM_MESSAGES_POLL_MS');
|
||||
expect(controller).toContain('timer = setTimeout(() => void poll(classification.refreshCount), classification.delay);');
|
||||
});
|
||||
|
||||
it('dead-drop UI distinguishes invite-pinned trust from TOFU-only', () => {
|
||||
const index = readSource('../../components/MeshChat/index.tsx');
|
||||
expect(index).toContain('getContactTrustSummary');
|
||||
expect(index).toContain('INVITE PINNED');
|
||||
expect(index).toContain('TOFU ONLY');
|
||||
expect(index).toContain('anchored by an imported signed invite');
|
||||
expect(index).toContain('rootWitnessContinuityLabel');
|
||||
expect(index).toContain('RECOVER ROOT');
|
||||
expect(index).toContain('!selectedContactTrustSummary?.rootMismatch');
|
||||
});
|
||||
|
||||
it('request UI does not route ordinary request flow through legacy add-contact lookup', () => {
|
||||
const index = readSource('../../components/MeshChat/index.tsx');
|
||||
expect(index).toContain('handleRequestComposerAction');
|
||||
expect(index).not.toContain('handleAddContact().catch(() =>');
|
||||
expect(index).toContain('dm add');
|
||||
expect(index).toContain('legacy migration');
|
||||
});
|
||||
|
||||
it('controller blocks trust-new-key when the stable root changed', () => {
|
||||
const controller = readSource('../../components/MeshChat/useMeshChatController.ts');
|
||||
expect(controller).toContain('contactInfo?.remotePrekeyRootMismatch');
|
||||
expect(controller).toContain('stable root changed; use RECOVER ROOT or replace the signed invite');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Sprint 4A regression tests — MeshChat decomposition boundary checks.
|
||||
*
|
||||
* These tests validate the frozen contract:
|
||||
* 1. High-privacy DM queueing lives in the controller
|
||||
* 2. selectedGateAccessReady gating lives in the controller
|
||||
* 3. DM polling trust-mutation code lives in the controller
|
||||
* 4. Gate refresh is controller-owned via authenticated poll (SSE removed in S3A)
|
||||
* 5. Identity persistence stays in meshIdentity.ts (not in presentational code)
|
||||
* 6. No direct trust-mutating imports in presentational components
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const MESH_CHAT_DIR = path.resolve(__dirname, '../../components/MeshChat');
|
||||
|
||||
function readFile(name: string): string {
|
||||
return fs.readFileSync(path.join(MESH_CHAT_DIR, name), 'utf-8');
|
||||
}
|
||||
|
||||
// ─── Trust-mutation isolation ───────────────────────────────────────────────
|
||||
|
||||
const TRUST_MUTATING_IMPORTS = [
|
||||
'addContact',
|
||||
'updateContact',
|
||||
'blockContact',
|
||||
'purgeBrowserSigningMaterial',
|
||||
'purgeBrowserContactGraph',
|
||||
'purgeBrowserDmState',
|
||||
];
|
||||
|
||||
describe('MeshChat decomposition — trust mutation isolation', () => {
|
||||
it('controller imports all trust-mutating functions', () => {
|
||||
const controller = readFile('useMeshChatController.ts');
|
||||
for (const fn of TRUST_MUTATING_IMPORTS) {
|
||||
expect(controller).toContain(fn);
|
||||
}
|
||||
});
|
||||
|
||||
it('presentational index.tsx does NOT import trust-mutating functions directly', () => {
|
||||
const index = readFile('index.tsx');
|
||||
for (const fn of TRUST_MUTATING_IMPORTS) {
|
||||
// Check that none of these appear in import statements
|
||||
const importPattern = new RegExp(
|
||||
`import\\s*\\{[^}]*\\b${fn}\\b[^}]*\\}\\s*from`,
|
||||
);
|
||||
expect(index).not.toMatch(importPattern);
|
||||
}
|
||||
});
|
||||
|
||||
it('presentational index.tsx does not import from meshIdentity', () => {
|
||||
const index = readFile('index.tsx');
|
||||
expect(index).not.toMatch(/from\s+['"]@\/mesh\/meshIdentity['"]/);
|
||||
});
|
||||
|
||||
it('presentational index.tsx does not import from meshDmWorkerClient', () => {
|
||||
const index = readFile('index.tsx');
|
||||
expect(index).not.toMatch(/from\s+['"]@\/mesh\/meshDmWorkerClient['"]/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Controller owns required-cohesion items ────────────────────────────────
|
||||
|
||||
describe('MeshChat decomposition — controller required-cohesion', () => {
|
||||
const controller = readFile('useMeshChatController.ts');
|
||||
|
||||
it('controller exports enqueueDmSend (high-privacy DM queueing)', () => {
|
||||
expect(controller).toMatch(/enqueueDmSend/);
|
||||
// Also in the return block
|
||||
expect(controller).toMatch(/return\s*\{[\s\S]*enqueueDmSend[\s\S]*\}/);
|
||||
});
|
||||
|
||||
it('controller exports flushDmQueue (high-privacy DM queueing)', () => {
|
||||
expect(controller).toMatch(/flushDmQueue/);
|
||||
expect(controller).toMatch(/return\s*\{[\s\S]*flushDmQueue[\s\S]*\}/);
|
||||
});
|
||||
|
||||
it('controller exports selectedGateAccessReady', () => {
|
||||
expect(controller).toMatch(/selectedGateAccessReady/);
|
||||
expect(controller).toMatch(/return\s*\{[\s\S]*selectedGateAccessReady[\s\S]*\}/);
|
||||
});
|
||||
|
||||
it('controller exports selectedGateKeyStatus', () => {
|
||||
expect(controller).toMatch(/selectedGateKeyStatus/);
|
||||
expect(controller).toMatch(/return\s*\{[\s\S]*selectedGateKeyStatus[\s\S]*\}/);
|
||||
});
|
||||
|
||||
it('controller exports native gate resync state and handler', () => {
|
||||
expect(controller).toMatch(/gateResyncTarget/);
|
||||
expect(controller).toMatch(/gateResyncBusy/);
|
||||
expect(controller).toMatch(/handleResyncGateState/);
|
||||
expect(controller).toMatch(/return\s*\{[\s\S]*gateResyncTarget[\s\S]*\}/);
|
||||
expect(controller).toMatch(/return\s*\{[\s\S]*gateResyncBusy[\s\S]*\}/);
|
||||
expect(controller).toMatch(/return\s*\{[\s\S]*handleResyncGateState[\s\S]*\}/);
|
||||
});
|
||||
|
||||
it('controller exports secureDmBlocked', () => {
|
||||
expect(controller).toMatch(/secureDmBlocked/);
|
||||
expect(controller).toMatch(/return\s*\{[\s\S]*secureDmBlocked[\s\S]*\}/);
|
||||
});
|
||||
|
||||
it('controller exports privacyProfile', () => {
|
||||
expect(controller).toMatch(/privacyProfile/);
|
||||
expect(controller).toMatch(/return\s*\{[\s\S]*privacyProfile[\s\S]*\}/);
|
||||
});
|
||||
|
||||
it('controller exports hasId and hasPublicLaneIdentity', () => {
|
||||
expect(controller).toMatch(/return\s*\{[\s\S]*hasId[\s\S]*\}/);
|
||||
expect(controller).toMatch(/return\s*\{[\s\S]*hasPublicLaneIdentity[\s\S]*\}/);
|
||||
});
|
||||
|
||||
it('controller exports publicMeshBlockedByWormhole', () => {
|
||||
expect(controller).toMatch(/return\s*\{[\s\S]*publicMeshBlockedByWormhole[\s\S]*\}/);
|
||||
});
|
||||
|
||||
it('controller exports anonymousPublicBlocked and anonymousDmBlocked', () => {
|
||||
expect(controller).toMatch(/return\s*\{[\s\S]*anonymousPublicBlocked[\s\S]*\}/);
|
||||
expect(controller).toMatch(/return\s*\{[\s\S]*anonymousDmBlocked[\s\S]*\}/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Gate refresh is controller-owned (SSE removed in S3A) ────────────────
|
||||
|
||||
describe('MeshChat decomposition — gate refresh ownership', () => {
|
||||
it('controller does NOT import useGateSSE (removed in S3A)', () => {
|
||||
const controller = readFile('useMeshChatController.ts');
|
||||
expect(controller).not.toMatch(/import.*useGateSSE.*from/);
|
||||
expect(controller).not.toMatch(/useGateSSE\(/);
|
||||
});
|
||||
|
||||
it('controller owns gate message polling via authenticated fetch', () => {
|
||||
const controller = readFile('useMeshChatController.ts');
|
||||
// The controller polls /api/mesh/infonet/messages for gate refresh
|
||||
expect(controller).toMatch(/\/api\/mesh\/infonet\/messages/);
|
||||
expect(controller).toMatch(/setInterval\(poll/);
|
||||
});
|
||||
|
||||
it('useGateSSE is NOT imported in the presentational shell', () => {
|
||||
const index = readFile('index.tsx');
|
||||
expect(index).not.toMatch(/useGateSSE/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── DM polling trust unit controller-owned ─────────────────────────────────
|
||||
|
||||
describe('MeshChat decomposition — DM poll sequence in controller', () => {
|
||||
const controller = readFile('useMeshChatController.ts');
|
||||
|
||||
it('DM polling (pollDmMailboxes) is in the controller', () => {
|
||||
expect(controller).toMatch(/pollDmMailboxes/);
|
||||
});
|
||||
|
||||
it('decryptDM is called in the controller (DM decrypt)', () => {
|
||||
expect(controller).toMatch(/decryptDM/);
|
||||
});
|
||||
|
||||
it('ratchetDecryptDM is in the controller', () => {
|
||||
expect(controller).toMatch(/ratchetDecryptDM/);
|
||||
});
|
||||
|
||||
it('sender seal decryption is in the controller via storage import', () => {
|
||||
expect(controller).toMatch(/decryptSenderSealForContact/);
|
||||
});
|
||||
|
||||
it('contact mutation (addContact/updateContact) happens only in controller', () => {
|
||||
const index = readFile('index.tsx');
|
||||
// These should not appear as direct function calls in the view
|
||||
expect(index).not.toMatch(/\baddContact\s*\(/);
|
||||
expect(index).not.toMatch(/\bupdateContact\s*\(/);
|
||||
expect(index).not.toMatch(/\bblockContact\s*\(/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Identity persistence through meshIdentity.ts ───────────────────────────
|
||||
|
||||
describe('MeshChat decomposition — identity persistence', () => {
|
||||
it('controller imports identity functions from meshIdentity', () => {
|
||||
const controller = readFile('useMeshChatController.ts');
|
||||
expect(controller).toMatch(/from\s+['"]@\/mesh\/meshIdentity['"]/);
|
||||
expect(controller).toMatch(/getNodeIdentity/);
|
||||
expect(controller).toMatch(/generateNodeKeys/);
|
||||
expect(controller).toMatch(/signEvent/);
|
||||
});
|
||||
|
||||
it('storage module imports from meshIdentity for seal operations', () => {
|
||||
const storage = readFile('storage.ts');
|
||||
expect(storage).toMatch(/from\s+['"]@\/mesh\/meshIdentity['"]/);
|
||||
});
|
||||
|
||||
it('types module re-exports Contact and NodeIdentity from meshIdentity', () => {
|
||||
const types = readFile('types.ts');
|
||||
expect(types).toMatch(/Contact/);
|
||||
expect(types).toMatch(/NodeIdentity/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Re-export stability ────────────────────────────────────────────────────
|
||||
|
||||
describe('MeshChat decomposition — export stability', () => {
|
||||
it('MeshChat.tsx re-exports default from MeshChat/index', () => {
|
||||
const reExport = fs.readFileSync(
|
||||
path.resolve(MESH_CHAT_DIR, '../MeshChat.tsx'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(reExport).toMatch(/export\s*\{\s*default\s*\}\s*from\s+['"]\.\/MeshChat\/index['"]/);
|
||||
});
|
||||
|
||||
it('MeshChat.tsx re-exports MeshChatProps type', () => {
|
||||
const reExport = fs.readFileSync(
|
||||
path.resolve(MESH_CHAT_DIR, '../MeshChat.tsx'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(reExport).toMatch(/export\s+type\s*\{\s*MeshChatProps\s*\}/);
|
||||
});
|
||||
|
||||
it('index.tsx exports default MeshChat component', () => {
|
||||
const index = readFile('index.tsx');
|
||||
expect(index).toMatch(/export\s+default\s+MeshChat/);
|
||||
});
|
||||
|
||||
it('presentational shell exposes the gate resync affordance', () => {
|
||||
const index = readFile('index.tsx');
|
||||
expect(index).toContain('RESYNC GATE STATE');
|
||||
expect(index).toContain('handleResyncGateState(selectedGate)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Phase 6A: Residual Backlog & Hygiene Closeout tests.
|
||||
*
|
||||
* Validates:
|
||||
* 1. DECOY_KEY removal from types.ts causes no import/runtime regression
|
||||
* 2. build_controller.py and build_index.py are deleted
|
||||
* 3. promotePendingAlias no longer calls updateContact from storage.ts
|
||||
* 4. Alias-promotion behavior unchanged after controller applies returned delta
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const MESH_CHAT_DIR = path.resolve(__dirname, '../../components/MeshChat');
|
||||
|
||||
function readFile(name: string): string {
|
||||
return fs.readFileSync(path.join(MESH_CHAT_DIR, name), 'utf-8');
|
||||
}
|
||||
|
||||
function fileExists(name: string): boolean {
|
||||
return fs.existsSync(path.join(MESH_CHAT_DIR, name));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. DECOY_KEY removal causes no import/runtime regression
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('DECOY_KEY deduplication', () => {
|
||||
it('types.ts does NOT export DECOY_KEY', () => {
|
||||
const types = readFile('types.ts');
|
||||
expect(types).not.toMatch(/export\s+(const|let|var)\s+DECOY_KEY/);
|
||||
});
|
||||
|
||||
it('storage.ts still exports DECOY_KEY as canonical location', () => {
|
||||
const storage = readFile('storage.ts');
|
||||
expect(storage).toMatch(/export\s+const\s+DECOY_KEY/);
|
||||
});
|
||||
|
||||
it('DECOY_KEY is importable from storage at runtime', async () => {
|
||||
const { DECOY_KEY } = await import('../../components/MeshChat/storage');
|
||||
expect(DECOY_KEY).toBe('sb_dm_decoy');
|
||||
});
|
||||
|
||||
it('no file imports DECOY_KEY from types', () => {
|
||||
const files = fs.readdirSync(MESH_CHAT_DIR).filter((f) => f.endsWith('.ts') || f.endsWith('.tsx'));
|
||||
for (const file of files) {
|
||||
const content = readFile(file);
|
||||
const importFromTypes = content.match(/import\s*\{[^}]*DECOY_KEY[^}]*\}\s*from\s*['"]\.\/types['"]/);
|
||||
expect(importFromTypes, `${file} should not import DECOY_KEY from types`).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. build_controller.py and build_index.py are deleted
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('stale generator scripts removed', () => {
|
||||
it('build_controller.py does not exist', () => {
|
||||
expect(fileExists('build_controller.py')).toBe(false);
|
||||
});
|
||||
|
||||
it('build_index.py does not exist', () => {
|
||||
expect(fileExists('build_index.py')).toBe(false);
|
||||
});
|
||||
|
||||
it('no build/test config references build_controller.py or build_index.py', () => {
|
||||
const packageJson = fs.readFileSync(
|
||||
path.resolve(MESH_CHAT_DIR, '../../../package.json'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(packageJson).not.toContain('build_controller.py');
|
||||
expect(packageJson).not.toContain('build_index.py');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. promotePendingAlias no longer calls updateContact from storage.ts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('promotePendingAlias decoupled from updateContact', () => {
|
||||
it('storage.ts does not import updateContact', () => {
|
||||
const storage = readFile('storage.ts');
|
||||
expect(storage).not.toMatch(/import\s*\{[^}]*updateContact[^}]*\}\s*from/);
|
||||
});
|
||||
|
||||
it('storage.ts does not import getContacts', () => {
|
||||
const storage = readFile('storage.ts');
|
||||
expect(storage).not.toMatch(/import\s*\{[^}]*getContacts[^}]*\}\s*from/);
|
||||
});
|
||||
|
||||
it('promotePendingAlias does not call updateContact', () => {
|
||||
const storage = readFile('storage.ts');
|
||||
// Extract the promotePendingAlias function body
|
||||
const fnStart = storage.indexOf('export function promotePendingAlias');
|
||||
expect(fnStart).toBeGreaterThan(-1);
|
||||
const fnBody = storage.slice(fnStart, storage.indexOf('\n}', fnStart) + 2);
|
||||
expect(fnBody).not.toContain('updateContact(');
|
||||
});
|
||||
|
||||
it('promotePendingAlias does not call getContacts', () => {
|
||||
const storage = readFile('storage.ts');
|
||||
const fnStart = storage.indexOf('export function promotePendingAlias');
|
||||
const fnBody = storage.slice(fnStart, storage.indexOf('\n}', fnStart) + 2);
|
||||
expect(fnBody).not.toContain('getContacts(');
|
||||
});
|
||||
|
||||
it('controller call sites apply updateContact after promotePendingAlias', () => {
|
||||
const controller = readFile('useMeshChatController.ts');
|
||||
// Both call sites should follow pattern: promotePendingAlias → updateContact
|
||||
const promotionCalls = controller.match(/const promotion = promotePendingAlias\(/g);
|
||||
expect(promotionCalls?.length).toBeGreaterThanOrEqual(2);
|
||||
const updateAfterPromotion = controller.match(
|
||||
/if \(promotion\) updateContact\([^,]+, promotion\.delta\.updates\)/g,
|
||||
);
|
||||
expect(updateAfterPromotion?.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Alias-promotion behavior unchanged (delta structure)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('alias-promotion delta correctness', () => {
|
||||
it('returns null when contact has no pendingSharedAlias', async () => {
|
||||
const { promotePendingAlias } = await import('../../components/MeshChat/storage');
|
||||
const contact = { sharedAlias: 'abc' } as any;
|
||||
const result = promotePendingAlias('test-id', contact);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when grace period has not expired', async () => {
|
||||
const { promotePendingAlias } = await import('../../components/MeshChat/storage');
|
||||
const contact = {
|
||||
pendingSharedAlias: 'new-alias',
|
||||
sharedAlias: 'old-alias',
|
||||
sharedAliasGraceUntil: Date.now() + 60_000,
|
||||
} as any;
|
||||
const result = promotePendingAlias('test-id', contact);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns delta with promoted contact when grace period expired', async () => {
|
||||
const { promotePendingAlias } = await import('../../components/MeshChat/storage');
|
||||
const contact = {
|
||||
pendingSharedAlias: 'new-alias',
|
||||
sharedAlias: 'old-alias',
|
||||
sharedAliasGraceUntil: Date.now() - 1000,
|
||||
previousSharedAliases: [],
|
||||
} as any;
|
||||
const result = promotePendingAlias('test-id', contact);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.delta.updates.sharedAlias).toBe('new-alias');
|
||||
expect(result!.delta.updates.pendingSharedAlias).toBeUndefined();
|
||||
expect(result!.delta.updates.sharedAliasGraceUntil).toBeUndefined();
|
||||
expect(result!.delta.updates.sharedAliasRotatedAt).toBeGreaterThan(0);
|
||||
expect(result!.delta.updates.previousSharedAliases).toContain('old-alias');
|
||||
expect(result!.promoted.sharedAlias).toBe('new-alias');
|
||||
expect(result!.promoted.pendingSharedAlias).toBeUndefined();
|
||||
});
|
||||
|
||||
it('promoted contact merges updates onto original contact', async () => {
|
||||
const { promotePendingAlias } = await import('../../components/MeshChat/storage');
|
||||
const contact = {
|
||||
dhPubKey: 'some-key',
|
||||
pendingSharedAlias: 'next',
|
||||
sharedAlias: 'current',
|
||||
sharedAliasGraceUntil: 0,
|
||||
previousSharedAliases: ['older'],
|
||||
} as any;
|
||||
const result = promotePendingAlias('test-id', contact);
|
||||
expect(result).not.toBeNull();
|
||||
// Original fields preserved
|
||||
expect(result!.promoted.dhPubKey).toBe('some-key');
|
||||
// Alias history includes both old aliases
|
||||
expect(result!.promoted.previousSharedAliases).toContain('current');
|
||||
expect(result!.promoted.previousSharedAliases).toContain('older');
|
||||
});
|
||||
});
|
||||
@@ -121,6 +121,11 @@ describe('meshIdentity contact storage hardening', () => {
|
||||
remotePrekeySequence: 3,
|
||||
remotePrekeySignedAt: 444,
|
||||
remotePrekeyMismatch: false,
|
||||
remotePrekeyTransparencyHead: 'head-1',
|
||||
remotePrekeyTransparencySize: 2,
|
||||
remotePrekeyTransparencySeenAt: 555,
|
||||
remotePrekeyTransparencyConflict: false,
|
||||
remotePrekeyLookupMode: 'legacy_agent_id',
|
||||
});
|
||||
const stored = await waitForEncryptedContacts();
|
||||
expect(String(stored ?? '')).toMatch(/^enc:/);
|
||||
@@ -137,6 +142,11 @@ describe('meshIdentity contact storage hardening', () => {
|
||||
expect(hydrated.alice.remotePrekeySequence).toBe(3);
|
||||
expect(hydrated.alice.remotePrekeySignedAt).toBe(444);
|
||||
expect(hydrated.alice.remotePrekeyMismatch).toBe(false);
|
||||
expect(hydrated.alice.remotePrekeyTransparencyHead).toBe('head-1');
|
||||
expect(hydrated.alice.remotePrekeyTransparencySize).toBe(2);
|
||||
expect(hydrated.alice.remotePrekeyTransparencySeenAt).toBe(555);
|
||||
expect(hydrated.alice.remotePrekeyTransparencyConflict).toBe(false);
|
||||
expect(hydrated.alice.remotePrekeyLookupMode).toBe('legacy_agent_id');
|
||||
});
|
||||
|
||||
it('migrates legacy plaintext contacts to encrypted storage on first hydrate', async () => {
|
||||
@@ -241,4 +251,17 @@ describe('meshIdentity contact storage hardening', () => {
|
||||
const rotated = await mailboxClaimToken('requests', '!sb_contacts123456');
|
||||
expect(rotated).not.toBe(first);
|
||||
});
|
||||
|
||||
it('rotates mailbox claim tokens across mailbox epochs', async () => {
|
||||
const { mailboxClaimToken } = await import('@/mesh/meshMailbox');
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
await provisionLocalIdentity(mod);
|
||||
|
||||
const first = await mailboxClaimToken('requests', '!sb_contacts123456', 100);
|
||||
const second = await mailboxClaimToken('requests', '!sb_contacts123456', 100);
|
||||
const rotated = await mailboxClaimToken('requests', '!sb_contacts123456', 21_700);
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(rotated).not.toBe(first);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const deriveWormholeDeadDropTokens = vi.fn();
|
||||
const deriveWormholeDeadDropTokenPair = vi.fn();
|
||||
const isWormholeReady = vi.fn();
|
||||
|
||||
vi.mock('@/mesh/wormholeIdentityClient', () => ({
|
||||
deriveWormholeDeadDropTokens,
|
||||
deriveWormholeDeadDropTokenPair,
|
||||
isWormholeReady,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshIdentity', () => ({
|
||||
deriveSharedSecret: vi.fn(),
|
||||
getStoredNodeDescriptor: vi.fn(() => ({ nodeId: 'local-node' })),
|
||||
}));
|
||||
|
||||
describe('mesh dead-drop alias hygiene', () => {
|
||||
beforeEach(() => {
|
||||
deriveWormholeDeadDropTokens.mockReset();
|
||||
deriveWormholeDeadDropTokenPair.mockReset();
|
||||
isWormholeReady.mockReset();
|
||||
});
|
||||
|
||||
it('sends alias refs instead of the stable peer id when mailbox aliases exist', async () => {
|
||||
isWormholeReady.mockResolvedValue(true);
|
||||
deriveWormholeDeadDropTokens.mockResolvedValue({
|
||||
ok: true,
|
||||
tokens: [
|
||||
{ peer_id: 'peer_alpha', peer_ref: 'dmx_alpha', current: 'tok1', previous: 'tok0', epoch: 7 },
|
||||
],
|
||||
});
|
||||
|
||||
const { deadDropTokensForContacts } = await import('@/mesh/meshDeadDrop');
|
||||
const tokens = await deadDropTokensForContacts(
|
||||
{
|
||||
peer_alpha: {
|
||||
blocked: false,
|
||||
dhPubKey: 'dhpub_alpha',
|
||||
sharedAlias: 'dmx_alpha',
|
||||
previousSharedAliases: ['dmx_prev_alpha'],
|
||||
} as any,
|
||||
},
|
||||
24,
|
||||
);
|
||||
|
||||
expect(tokens).toEqual(['tok1', 'tok0']);
|
||||
expect(deriveWormholeDeadDropTokens).toHaveBeenCalledWith(
|
||||
[
|
||||
{
|
||||
peer_id: 'peer_alpha',
|
||||
peer_dh_pub: 'dhpub_alpha',
|
||||
peer_refs: ['dmx_alpha', 'dmx_prev_alpha'],
|
||||
},
|
||||
],
|
||||
24,
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the stable peer id only when no alias history exists', async () => {
|
||||
isWormholeReady.mockResolvedValue(true);
|
||||
deriveWormholeDeadDropTokens.mockResolvedValue({
|
||||
ok: true,
|
||||
tokens: [
|
||||
{ peer_id: 'peer_bravo', peer_ref: 'peer_bravo', current: 'tok2', previous: 'tok1', epoch: 8 },
|
||||
],
|
||||
});
|
||||
|
||||
const { deadDropTokensForContacts } = await import('@/mesh/meshDeadDrop');
|
||||
await deadDropTokensForContacts(
|
||||
{
|
||||
peer_bravo: {
|
||||
blocked: false,
|
||||
dhPubKey: 'dhpub_bravo',
|
||||
} as any,
|
||||
},
|
||||
24,
|
||||
);
|
||||
|
||||
expect(deriveWormholeDeadDropTokens).toHaveBeenCalledWith(
|
||||
[
|
||||
{
|
||||
peer_id: 'peer_bravo',
|
||||
peer_dh_pub: 'dhpub_bravo',
|
||||
peer_refs: ['peer_bravo'],
|
||||
},
|
||||
],
|
||||
24,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('fetchDmPublicKey lookup posture', () => {
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('does not use legacy agent-id lookup unless explicitly allowed', async () => {
|
||||
const mod = await import('@/mesh/meshDmClient');
|
||||
|
||||
const result = await mod.fetchDmPublicKey('http://localhost:8000', '!sb_legacy');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses invite lookup handles without enabling legacy agent-id lookup', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
json: async () => ({ ok: true, dh_pub_key: 'peer-dh', lookup_mode: 'invite_lookup_handle' }),
|
||||
});
|
||||
const mod = await import('@/mesh/meshDmClient');
|
||||
|
||||
const result = await mod.fetchDmPublicKey(
|
||||
'http://localhost:8000',
|
||||
'!sb_peer',
|
||||
'invite-handle-123',
|
||||
);
|
||||
|
||||
expect(result?.dh_pub_key).toBe('peer-dh');
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8000/api/mesh/dm/pubkey?lookup_token=invite-handle-123',
|
||||
);
|
||||
});
|
||||
|
||||
it('still supports explicit legacy agent-id lookup for migration-only paths', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
json: async () => ({ ok: true, dh_pub_key: 'peer-dh', lookup_mode: 'legacy_agent_id' }),
|
||||
});
|
||||
const mod = await import('@/mesh/meshDmClient');
|
||||
|
||||
const result = await mod.fetchDmPublicKey('http://localhost:8000', '!sb_legacy', undefined, {
|
||||
allowLegacyAgentId: true,
|
||||
});
|
||||
|
||||
expect(result?.dh_pub_key).toBe('peer-dh');
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8000/api/mesh/dm/pubkey?agent_id=%21sb_legacy',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
allDmPeerIds,
|
||||
mailboxPeerRefs,
|
||||
buildAliasRotateMessage,
|
||||
buildAccessGrantedMessage,
|
||||
buildContactAcceptMessage,
|
||||
@@ -59,6 +60,17 @@ describe('mesh DM consent helpers', () => {
|
||||
expect(allDmPeerIds('node_public', { sharedAlias: 'node_public' })).toEqual(['node_public']);
|
||||
});
|
||||
|
||||
it('prefers alias history for mailbox refs and drops stable public id once aliasing exists', () => {
|
||||
expect(
|
||||
mailboxPeerRefs('node_public', {
|
||||
sharedAlias: 'dmx_current',
|
||||
pendingSharedAlias: 'dmx_next',
|
||||
previousSharedAliases: ['dmx_prev'],
|
||||
}),
|
||||
).toEqual(['dmx_current', 'dmx_next', 'dmx_prev']);
|
||||
expect(mailboxPeerRefs('node_public', { sharedAlias: '' })).toEqual(['node_public']);
|
||||
});
|
||||
|
||||
it('builds and parses alias rotation control payloads', () => {
|
||||
const message = buildAliasRotateMessage('dmx_next');
|
||||
expect(parseAliasRotateMessage(message)).toEqual({ shared_alias: 'dmx_next' });
|
||||
@@ -76,10 +88,38 @@ describe('mesh DM consent helpers', () => {
|
||||
});
|
||||
|
||||
it('keeps alias history compact and unique', () => {
|
||||
expect(mergeAliasHistory(['dmx_a', 'dmx_b', 'dmx_a', 'dmx_c', 'dmx_d'])).toEqual([
|
||||
'dmx_a',
|
||||
'dmx_b',
|
||||
]);
|
||||
expect(mergeAliasHistory(['dmx_a', 'dmx_b', 'dmx_a', 'dmx_c', 'dmx_d'], 3)).toEqual([
|
||||
'dmx_a',
|
||||
'dmx_b',
|
||||
'dmx_c',
|
||||
]);
|
||||
});
|
||||
|
||||
it('bounds mailbox peer refs to 4 and excludes long tail', () => {
|
||||
expect(
|
||||
mailboxPeerRefs('node_public', {
|
||||
sharedAlias: 'dmx_current',
|
||||
pendingSharedAlias: 'dmx_next',
|
||||
previousSharedAliases: ['dmx_prev1', 'dmx_prev2', 'dmx_prev3'],
|
||||
}),
|
||||
).toEqual(['dmx_current', 'dmx_next', 'dmx_prev1', 'dmx_prev2']);
|
||||
});
|
||||
|
||||
it('bounds allDmPeerIds previous alias enumeration to 2', () => {
|
||||
const ids = allDmPeerIds('node_public', {
|
||||
sharedAlias: 'dmx_current',
|
||||
pendingSharedAlias: 'dmx_next',
|
||||
previousSharedAliases: ['dmx_prev1', 'dmx_prev2', 'dmx_prev3'],
|
||||
});
|
||||
// current + pending + at most 2 previous + peerId
|
||||
expect(ids).toContain('dmx_current');
|
||||
expect(ids).toContain('dmx_next');
|
||||
expect(ids).toContain('dmx_prev1');
|
||||
expect(ids).toContain('dmx_prev2');
|
||||
expect(ids).not.toContain('dmx_prev3');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const signMeshEvent = vi.fn();
|
||||
const issueWormholeDmSenderToken = vi.fn();
|
||||
const issueWormholeDmSenderTokens = vi.fn();
|
||||
const registerWormholeDmKey = vi.fn();
|
||||
const validateEventPayload = vi.fn(() => ({ ok: true, reason: 'ok' }));
|
||||
const nextSequence = vi.fn(() => 42);
|
||||
|
||||
vi.mock('@/mesh/meshDeadDrop', () => ({
|
||||
deadDropToken: vi.fn(async () => 'shared-token'),
|
||||
deadDropTokensForContacts: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshMailbox', () => ({
|
||||
mailboxClaimToken: vi.fn(async (type: string) => `${type}-token`),
|
||||
mailboxDecoySharedToken: vi.fn(async (index: number) => `decoy-${index}`),
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshIdentity', () => ({
|
||||
deriveSenderSealKey: vi.fn(),
|
||||
ensureDhKeysFresh: vi.fn(),
|
||||
deriveSharedKey: vi.fn(),
|
||||
encryptDM: vi.fn(),
|
||||
getDHAlgo: vi.fn(() => 'X25519'),
|
||||
getNodeIdentity: vi.fn(() => ({ nodeId: '!sb_self', publicKey: 'pub' })),
|
||||
getPublicKeyAlgo: vi.fn(() => 'Ed25519'),
|
||||
nextSequence,
|
||||
verifyNodeIdBindingFromPublicKey: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/wormholeIdentityClient', () => ({
|
||||
buildWormholeSenderSeal: vi.fn(),
|
||||
getActiveSigningContext: vi.fn(async () => null),
|
||||
isWormholeSecureRequired: vi.fn(async () => false),
|
||||
issueWormholeDmSenderToken,
|
||||
issueWormholeDmSenderTokens,
|
||||
registerWormholeDmKey,
|
||||
signRawMeshMessage: vi.fn(),
|
||||
signMeshEvent,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshSchema', () => ({
|
||||
validateEventPayload,
|
||||
}));
|
||||
|
||||
describe('DM transport lock signing', () => {
|
||||
const fetchMock = vi.fn();
|
||||
const identity = {
|
||||
nodeId: '!sb_self',
|
||||
publicKey: 'pub',
|
||||
privateKey: 'priv',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
validateEventPayload.mockClear();
|
||||
nextSequence.mockClear();
|
||||
signMeshEvent.mockReset();
|
||||
issueWormholeDmSenderToken.mockReset();
|
||||
issueWormholeDmSenderTokens.mockReset();
|
||||
registerWormholeDmKey.mockReset();
|
||||
signMeshEvent.mockResolvedValue({
|
||||
context: {
|
||||
nodeId: '!sb_self',
|
||||
publicKey: 'pub',
|
||||
publicKeyAlgo: 'Ed25519',
|
||||
},
|
||||
signature: 'sig',
|
||||
sequence: 42,
|
||||
protocolVersion: 'infonet/2',
|
||||
});
|
||||
issueWormholeDmSenderTokens.mockResolvedValue({ tokens: [] });
|
||||
issueWormholeDmSenderToken.mockResolvedValue({ sender_token: 'sender-token' });
|
||||
registerWormholeDmKey.mockResolvedValue({ ok: true });
|
||||
fetchMock.mockResolvedValue({ json: async () => ({ ok: true }) });
|
||||
});
|
||||
|
||||
it('signs and sends private_strong on DM sends', async () => {
|
||||
const mod = await import('@/mesh/meshDmClient');
|
||||
|
||||
await mod.sendDmMessage({
|
||||
apiBase: 'http://localhost:8000',
|
||||
identity,
|
||||
recipientId: '!sb_peer',
|
||||
ciphertext: 'sealed',
|
||||
msgId: 'dm-test-1',
|
||||
timestamp: 123,
|
||||
deliveryClass: 'request',
|
||||
});
|
||||
|
||||
expect(signMeshEvent).toHaveBeenCalledWith(
|
||||
'dm_message',
|
||||
expect.objectContaining({ transport_lock: 'private_strong' }),
|
||||
42,
|
||||
);
|
||||
const body = JSON.parse(fetchMock.mock.calls.at(-1)?.[1]?.body as string);
|
||||
expect(body.transport_lock).toBe('private_strong');
|
||||
});
|
||||
|
||||
it('signs and sends private_strong on DM poll/count', async () => {
|
||||
const mod = await import('@/mesh/meshDmClient');
|
||||
const claims = [{ type: 'requests' as const, token: 'request-token' }];
|
||||
|
||||
await mod.pollDmMailboxes('http://localhost:8000', identity, claims);
|
||||
await mod.countDmMailboxes('http://localhost:8000', identity, claims);
|
||||
|
||||
expect(signMeshEvent).toHaveBeenCalledWith(
|
||||
'dm_poll',
|
||||
expect.objectContaining({ transport_lock: 'private_strong' }),
|
||||
42,
|
||||
);
|
||||
expect(signMeshEvent).toHaveBeenCalledWith(
|
||||
'dm_count',
|
||||
expect.objectContaining({ transport_lock: 'private_strong' }),
|
||||
42,
|
||||
);
|
||||
const pollBody = JSON.parse(fetchMock.mock.calls[0][1].body as string);
|
||||
const countBody = JSON.parse(fetchMock.mock.calls[1][1].body as string);
|
||||
expect(pollBody.transport_lock).toBe('private_strong');
|
||||
expect(countBody.transport_lock).toBe('private_strong');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,328 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const controlPlaneJson = vi.fn();
|
||||
const probeInlineGateCryptoSupport = vi.fn(async () => ({ supported: true, reason: '' }));
|
||||
const adoptInlineGateState = vi.fn(async (snapshot) => snapshot);
|
||||
const composeInlineGateMessage = vi.fn(async () => ({
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
ciphertext: 'inline-ciphertext',
|
||||
nonce: 'inline-nonce',
|
||||
}));
|
||||
const decryptInlineGateMessages = vi.fn(async () => [
|
||||
{
|
||||
ok: true,
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
plaintext: 'sealed',
|
||||
reply_to: '',
|
||||
identity_scope: 'browser_privacy_core',
|
||||
},
|
||||
]);
|
||||
const forgetInlineGateState = vi.fn(async () => {});
|
||||
|
||||
vi.mock('@/lib/controlPlane', () => ({
|
||||
controlPlaneJson,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshGateLocalRuntime', () => ({
|
||||
probeInlineGateCryptoSupport,
|
||||
adoptInlineGateState,
|
||||
composeInlineGateMessage,
|
||||
decryptInlineGateMessages,
|
||||
forgetInlineGateState,
|
||||
}));
|
||||
|
||||
describe('meshGateWorkerClient inline fallback', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
controlPlaneJson.mockReset();
|
||||
probeInlineGateCryptoSupport.mockReset();
|
||||
adoptInlineGateState.mockReset();
|
||||
composeInlineGateMessage.mockReset();
|
||||
decryptInlineGateMessages.mockReset();
|
||||
forgetInlineGateState.mockReset();
|
||||
|
||||
probeInlineGateCryptoSupport.mockResolvedValue({ supported: true, reason: '' });
|
||||
adoptInlineGateState.mockImplementation(async (snapshot) => snapshot);
|
||||
composeInlineGateMessage.mockResolvedValue({
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
ciphertext: 'inline-ciphertext',
|
||||
nonce: 'inline-nonce',
|
||||
});
|
||||
decryptInlineGateMessages.mockResolvedValue([
|
||||
{
|
||||
ok: true,
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
plaintext: 'sealed',
|
||||
reply_to: '',
|
||||
identity_scope: 'browser_privacy_core',
|
||||
},
|
||||
]);
|
||||
forgetInlineGateState.mockResolvedValue(undefined);
|
||||
|
||||
Object.defineProperty(globalThis, 'Worker', {
|
||||
value: undefined,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the inline runtime when the Worker transport is unavailable', async () => {
|
||||
controlPlaneJson
|
||||
.mockResolvedValueOnce({
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
rust_state_blob_b64: 'blob',
|
||||
members: [],
|
||||
active_identity_scope: 'anonymous',
|
||||
active_persona_id: '',
|
||||
active_node_id: '!sb_local',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
gate_id: 'infonet',
|
||||
sender_id: '!sb_gate',
|
||||
public_key: 'pub',
|
||||
public_key_algo: 'ed25519',
|
||||
protocol_version: 'sb-test',
|
||||
sequence: 3,
|
||||
signature: 'sig',
|
||||
epoch: 7,
|
||||
ciphertext: 'inline-ciphertext',
|
||||
nonce: 'inline-nonce',
|
||||
sender_ref: 'sender-ref',
|
||||
format: 'mls1',
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/meshGateWorkerClient');
|
||||
|
||||
await expect(mod.syncBrowserGateState('infonet', { force: true })).resolves.toBe(true);
|
||||
await expect(mod.composeBrowserGateMessage('infonet', 'hello')).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
ok: true,
|
||||
gate_id: 'infonet',
|
||||
ciphertext: 'inline-ciphertext',
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
mod.decryptBrowserGateMessages([
|
||||
{
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
ciphertext: 'inline-ciphertext',
|
||||
},
|
||||
]),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
results: [
|
||||
expect.objectContaining({
|
||||
ok: true,
|
||||
gate_id: 'infonet',
|
||||
plaintext: 'sealed',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(probeInlineGateCryptoSupport).toHaveBeenCalled();
|
||||
expect(adoptInlineGateState).toHaveBeenCalled();
|
||||
expect(composeInlineGateMessage).toHaveBeenCalledWith('infonet', 'hello', '');
|
||||
expect(decryptInlineGateMessages).toHaveBeenCalledWith([
|
||||
{
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
ciphertext: 'inline-ciphertext',
|
||||
},
|
||||
]);
|
||||
expect(mod.getBrowserGateLocalRuntimeStatus()).toEqual(
|
||||
expect.objectContaining({
|
||||
mode: 'inline',
|
||||
health: 'active',
|
||||
reason: 'browser_gate_worker_unavailable',
|
||||
}),
|
||||
);
|
||||
expect(mod.describeBrowserGateLocalRuntimeStatus(mod.getBrowserGateLocalRuntimeStatus())).toBe(
|
||||
'INLINE local gate runtime active (worker unavailable)',
|
||||
);
|
||||
expect(controlPlaneJson).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/wormhole/gate/state/export',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(controlPlaneJson).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/wormhole/gate/message/sign-encrypted',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
ciphertext: 'inline-ciphertext',
|
||||
nonce: 'inline-nonce',
|
||||
format: 'mls1',
|
||||
reply_to: '',
|
||||
compat_reply_to: false,
|
||||
recovery_plaintext: 'hello',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to backend sealing when browser signing cannot return a durable gate envelope', async () => {
|
||||
controlPlaneJson
|
||||
.mockResolvedValueOnce({
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
rust_state_blob_b64: 'blob',
|
||||
members: [],
|
||||
active_identity_scope: 'anonymous',
|
||||
active_persona_id: '',
|
||||
active_node_id: '!sb_local',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
gate_id: 'infonet',
|
||||
sender_id: '!sb_gate',
|
||||
public_key: 'pub',
|
||||
public_key_algo: 'ed25519',
|
||||
protocol_version: 'sb-test',
|
||||
sequence: 3,
|
||||
signature: 'sig',
|
||||
epoch: 7,
|
||||
ciphertext: 'inline-ciphertext',
|
||||
nonce: 'inline-nonce',
|
||||
sender_ref: 'sender-ref',
|
||||
format: 'mls1',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
event_id: 'evt-backend-sealed',
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/meshGateWorkerClient');
|
||||
|
||||
await expect(mod.syncBrowserGateState('infonet', { force: true })).resolves.toBe(true);
|
||||
await expect(mod.postBrowserGateMessage('infonet', 'hello durable', 'evt-parent-1')).resolves.toEqual({
|
||||
ok: true,
|
||||
event_id: 'evt-backend-sealed',
|
||||
});
|
||||
|
||||
expect(controlPlaneJson).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'/api/wormhole/gate/message/post',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
gate_id: 'infonet',
|
||||
plaintext: 'hello durable',
|
||||
reply_to: 'evt-parent-1',
|
||||
compat_plaintext: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('marks the selected inline runtime as degraded when a later local compose fails', async () => {
|
||||
controlPlaneJson.mockResolvedValueOnce({
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
rust_state_blob_b64: 'blob',
|
||||
members: [],
|
||||
active_identity_scope: 'anonymous',
|
||||
active_persona_id: '',
|
||||
active_node_id: '!sb_local',
|
||||
});
|
||||
composeInlineGateMessage.mockRejectedValueOnce(new Error('worker_gate_wrap_key_missing'));
|
||||
|
||||
const mod = await import('@/mesh/meshGateWorkerClient');
|
||||
|
||||
await expect(mod.syncBrowserGateState('infonet', { force: true })).resolves.toBe(true);
|
||||
await expect(mod.composeBrowserGateMessage('infonet', 'hello')).resolves.toBeNull();
|
||||
|
||||
expect(mod.getBrowserGateCryptoFailureReason('infonet', 'compose')).toBe('worker_gate_wrap_key_missing');
|
||||
expect(mod.getBrowserGateLocalRuntimeStatus()).toEqual(
|
||||
expect.objectContaining({
|
||||
mode: 'inline',
|
||||
health: 'degraded',
|
||||
reason: 'worker_gate_wrap_key_missing',
|
||||
}),
|
||||
);
|
||||
expect(mod.describeBrowserGateLocalRuntimeStatus(mod.getBrowserGateLocalRuntimeStatus())).toBe(
|
||||
'INLINE local gate runtime degraded (secure storage unavailable)',
|
||||
);
|
||||
});
|
||||
|
||||
it('reuses self-authored plaintext when local gate decrypt cannot reopen the just-posted ciphertext', async () => {
|
||||
controlPlaneJson
|
||||
.mockResolvedValueOnce({
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
rust_state_blob_b64: 'blob',
|
||||
members: [],
|
||||
active_identity_scope: 'anonymous',
|
||||
active_persona_id: '',
|
||||
active_node_id: '!sb_local',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
gate_id: 'infonet',
|
||||
sender_id: '!sb_gate',
|
||||
public_key: 'pub',
|
||||
public_key_algo: 'ed25519',
|
||||
protocol_version: 'sb-test',
|
||||
sequence: 3,
|
||||
signature: 'sig',
|
||||
epoch: 7,
|
||||
ciphertext: 'inline-ciphertext',
|
||||
nonce: 'inline-nonce',
|
||||
sender_ref: 'sender-ref',
|
||||
format: 'mls1',
|
||||
});
|
||||
decryptInlineGateMessages.mockResolvedValueOnce([
|
||||
{
|
||||
ok: false,
|
||||
gate_id: 'infonet',
|
||||
detail: 'gate_mls_decrypt_failed',
|
||||
},
|
||||
]);
|
||||
|
||||
const mod = await import('@/mesh/meshGateWorkerClient');
|
||||
|
||||
await expect(mod.syncBrowserGateState('infonet', { force: true })).resolves.toBe(true);
|
||||
await expect(mod.composeBrowserGateMessage('infonet', 'hello self', 'evt-parent-7')).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
ok: true,
|
||||
gate_id: 'infonet',
|
||||
ciphertext: 'inline-ciphertext',
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
mod.decryptBrowserGateMessages([
|
||||
{
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
ciphertext: 'inline-ciphertext',
|
||||
},
|
||||
]),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
results: [
|
||||
expect.objectContaining({
|
||||
ok: true,
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
plaintext: 'hello self',
|
||||
reply_to: 'evt-parent-7',
|
||||
identity_scope: 'browser_self_echo',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(mod.getBrowserGateLocalRuntimeStatus()).toEqual(
|
||||
expect.objectContaining({
|
||||
mode: 'inline',
|
||||
health: 'active',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
type StoreRecord = Map<string, unknown>;
|
||||
type DbRecord = {
|
||||
version: number;
|
||||
stores: Map<string, StoreRecord>;
|
||||
};
|
||||
|
||||
const databases = new Map<string, DbRecord>();
|
||||
const deletedDatabases: string[] = [];
|
||||
|
||||
function domStringList(record: DbRecord): DOMStringList {
|
||||
return {
|
||||
contains: (name: string) => record.stores.has(name),
|
||||
item: (index: number) => Array.from(record.stores.keys())[index] ?? null,
|
||||
get length() {
|
||||
return record.stores.size;
|
||||
},
|
||||
} as DOMStringList;
|
||||
}
|
||||
|
||||
function makeRequest<T>(
|
||||
executor: (request: IDBRequest<T>) => void,
|
||||
tx?: IDBTransaction,
|
||||
): IDBRequest<T> {
|
||||
const request = {} as IDBRequest<T>;
|
||||
queueMicrotask(() => {
|
||||
executor(request);
|
||||
tx?.oncomplete?.(new Event('complete') as Event);
|
||||
});
|
||||
return request;
|
||||
}
|
||||
|
||||
function makeObjectStore(record: DbRecord, name: string, tx: IDBTransaction): IDBObjectStore {
|
||||
const store = record.stores.get(name);
|
||||
if (!store) throw new Error(`missing object store ${name}`);
|
||||
return {
|
||||
get(key: IDBValidKey) {
|
||||
return makeRequest((request) => {
|
||||
(request as { result?: unknown }).result = store.get(String(key));
|
||||
request.onsuccess?.(new Event('success') as Event);
|
||||
}, tx);
|
||||
},
|
||||
put(value: unknown, key?: IDBValidKey) {
|
||||
return makeRequest((request) => {
|
||||
store.set(String(key ?? ''), value);
|
||||
(request as { result?: unknown }).result = key;
|
||||
request.onsuccess?.(new Event('success') as Event);
|
||||
}, tx);
|
||||
},
|
||||
delete(key: IDBValidKey) {
|
||||
return makeRequest((request) => {
|
||||
store.delete(String(key));
|
||||
request.onsuccess?.(new Event('success') as Event);
|
||||
}, tx);
|
||||
},
|
||||
clear() {
|
||||
return makeRequest((request) => {
|
||||
store.clear();
|
||||
request.onsuccess?.(new Event('success') as Event);
|
||||
}, tx);
|
||||
},
|
||||
} as unknown as IDBObjectStore;
|
||||
}
|
||||
|
||||
function makeTransaction(record: DbRecord): IDBTransaction {
|
||||
const tx = {
|
||||
oncomplete: null,
|
||||
onerror: null,
|
||||
onabort: null,
|
||||
objectStore: (name: string) => makeObjectStore(record, name, tx as unknown as IDBTransaction),
|
||||
} as unknown as IDBTransaction;
|
||||
return tx;
|
||||
}
|
||||
|
||||
function makeDb(name: string, record: DbRecord): IDBDatabase {
|
||||
return {
|
||||
name,
|
||||
version: record.version,
|
||||
objectStoreNames: domStringList(record),
|
||||
createObjectStore(storeName: string) {
|
||||
if (!record.stores.has(storeName)) {
|
||||
record.stores.set(storeName, new Map());
|
||||
}
|
||||
return {} as IDBObjectStore;
|
||||
},
|
||||
transaction(_storeName: string | string[]) {
|
||||
return makeTransaction(record);
|
||||
},
|
||||
close() {
|
||||
/* noop */
|
||||
},
|
||||
} as unknown as IDBDatabase;
|
||||
}
|
||||
|
||||
function createFakeIndexedDb() {
|
||||
return {
|
||||
open(name: string, version?: number) {
|
||||
const request = {} as IDBOpenDBRequest;
|
||||
queueMicrotask(() => {
|
||||
const resolvedVersion = Number(version || 1);
|
||||
let record = databases.get(name);
|
||||
const upgrading = !record || resolvedVersion > record.version;
|
||||
if (!record) {
|
||||
record = { version: resolvedVersion, stores: new Map() };
|
||||
databases.set(name, record);
|
||||
}
|
||||
if (upgrading) {
|
||||
record.version = resolvedVersion;
|
||||
(request as { result?: IDBDatabase }).result = makeDb(name, record);
|
||||
request.onupgradeneeded?.(new Event('upgradeneeded') as IDBVersionChangeEvent);
|
||||
}
|
||||
(request as { result?: IDBDatabase }).result = makeDb(name, record);
|
||||
request.onsuccess?.(new Event('success') as Event);
|
||||
});
|
||||
return request;
|
||||
},
|
||||
deleteDatabase(name: string) {
|
||||
const request = {} as IDBOpenDBRequest;
|
||||
queueMicrotask(() => {
|
||||
deletedDatabases.push(name);
|
||||
databases.delete(name);
|
||||
request.onsuccess?.(new Event('success') as Event);
|
||||
});
|
||||
return request;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function ensureStore(name: string, version: number, storeName: string): StoreRecord {
|
||||
let record = databases.get(name);
|
||||
if (!record) {
|
||||
record = { version, stores: new Map() };
|
||||
databases.set(name, record);
|
||||
}
|
||||
record.version = Math.max(record.version, version);
|
||||
if (!record.stores.has(storeName)) {
|
||||
record.stores.set(storeName, new Map());
|
||||
}
|
||||
return record.stores.get(storeName)!;
|
||||
}
|
||||
|
||||
function getStoredValue(name: string, storeName: string, key: string): unknown {
|
||||
return databases.get(name)?.stores.get(storeName)?.get(key);
|
||||
}
|
||||
|
||||
describe('gate worker vault hardening', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
databases.clear();
|
||||
deletedDatabases.length = 0;
|
||||
Object.defineProperty(globalThis, 'indexedDB', {
|
||||
value: createFakeIndexedDb(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('persists worker gate state as an encrypted blob instead of raw state', async () => {
|
||||
const mod = await import('@/mesh/meshGateWorkerVault');
|
||||
const sample = {
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
rust_state_blob_b64: 'blob-private',
|
||||
members: [
|
||||
{
|
||||
persona_id: 'persona-a',
|
||||
node_id: '!sb_gate',
|
||||
identity_scope: 'persona',
|
||||
group_handle: 11,
|
||||
},
|
||||
],
|
||||
active_identity_scope: 'persona',
|
||||
active_persona_id: 'persona-a',
|
||||
active_node_id: '!sb_gate',
|
||||
};
|
||||
|
||||
await mod.writeWorkerGateState(sample);
|
||||
|
||||
const raw = getStoredValue(mod.WORKER_GATE_DB, 'gate_state', 'infonet');
|
||||
expect(typeof raw).toBe('string');
|
||||
expect(String(raw)).not.toContain('blob-private');
|
||||
expect(String(raw)).not.toContain('persona-a');
|
||||
|
||||
const loaded = await mod.readWorkerGateState('infonet');
|
||||
expect(loaded).toEqual(sample);
|
||||
});
|
||||
|
||||
it('migrates legacy plaintext gate state into encrypted storage on read', async () => {
|
||||
const legacyStore = ensureStore('sb_mesh_gate_worker', 1, 'gate_state');
|
||||
ensureStore('sb_mesh_gate_worker', 1, 'meta');
|
||||
legacyStore.set('infonet', {
|
||||
gate_id: 'infonet',
|
||||
epoch: 4,
|
||||
rust_state_blob_b64: 'legacy-blob',
|
||||
members: [],
|
||||
active_identity_scope: 'anonymous',
|
||||
active_persona_id: '',
|
||||
active_node_id: '!sb_legacy',
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/meshGateWorkerVault');
|
||||
const loaded = await mod.readWorkerGateState('infonet');
|
||||
const raw = getStoredValue(mod.WORKER_GATE_DB, 'gate_state', 'infonet');
|
||||
|
||||
expect(loaded?.rust_state_blob_b64).toBe('legacy-blob');
|
||||
expect(typeof raw).toBe('string');
|
||||
expect(String(raw)).not.toContain('legacy-blob');
|
||||
});
|
||||
|
||||
it('drops stale encrypted gate state when the wrap key is missing so the room can resync cleanly', async () => {
|
||||
const gateStore = ensureStore('sb_mesh_gate_worker', 1, 'gate_state');
|
||||
gateStore.set('infonet', 'encrypted-state-that-cannot-be-opened');
|
||||
ensureStore('sb_mesh_gate_worker', 1, 'meta');
|
||||
|
||||
const mod = await import('@/mesh/meshGateWorkerVault');
|
||||
await expect(mod.readWorkerGateState('infonet')).resolves.toBeNull();
|
||||
expect(getStoredValue(mod.WORKER_GATE_DB, 'gate_state', 'infonet')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('deleteWorkerGateDatabase removes the persisted gate vault', async () => {
|
||||
const mod = await import('@/mesh/meshGateWorkerVault');
|
||||
await mod.deleteWorkerGateDatabase();
|
||||
expect(deletedDatabases).toContain('sb_mesh_gate_worker');
|
||||
});
|
||||
});
|
||||
@@ -80,10 +80,11 @@ describe('mesh identity storage separation', () => {
|
||||
expect(mod.getWormholeIdentityDescriptor()).toBeNull();
|
||||
});
|
||||
|
||||
it('migrates legacy browser and Wormhole node ids to the current format', async () => {
|
||||
it('migrates stored browser and Wormhole node ids from 8-hex and 16-hex forms', async () => {
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
const publicKey = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=';
|
||||
const currentNodeId = await mod.deriveNodeIdFromPublicKey(publicKey);
|
||||
const compatNodeId = currentNodeId.slice(0, '!sb_'.length + 16);
|
||||
|
||||
mod.cachePublicIdentity({
|
||||
nodeId: '!sb_deadbeef',
|
||||
@@ -108,5 +109,42 @@ describe('mesh identity storage separation', () => {
|
||||
publicKey,
|
||||
publicKeyAlgo: 'Ed25519',
|
||||
});
|
||||
|
||||
mod.cachePublicIdentity({
|
||||
nodeId: compatNodeId,
|
||||
publicKey,
|
||||
publicKeyAlgo: 'Ed25519',
|
||||
});
|
||||
mod.cacheWormholeIdentityDescriptor({
|
||||
nodeId: compatNodeId,
|
||||
publicKey,
|
||||
publicKeyAlgo: 'Ed25519',
|
||||
});
|
||||
|
||||
await mod.migrateLegacyNodeIds();
|
||||
|
||||
expect(mod.getStoredNodeDescriptor()).toEqual({
|
||||
nodeId: currentNodeId,
|
||||
publicKey,
|
||||
publicKeyAlgo: 'Ed25519',
|
||||
});
|
||||
expect(mod.getWormholeIdentityDescriptor()).toEqual({
|
||||
nodeId: currentNodeId,
|
||||
publicKey,
|
||||
publicKeyAlgo: 'Ed25519',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts 32-hex current node ids and 16-hex compatibility ids, but not 8-hex ids', async () => {
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
const publicKey = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=';
|
||||
const currentNodeId = await mod.deriveNodeIdFromPublicKey(publicKey);
|
||||
const compatNodeId = currentNodeId.slice(0, '!sb_'.length + 16);
|
||||
|
||||
await expect(mod.verifyNodeIdBindingFromPublicKey(publicKey, currentNodeId)).resolves.toBe(true);
|
||||
await expect(mod.verifyNodeIdBindingFromPublicKey(publicKey, compatNodeId)).resolves.toBe(true);
|
||||
await expect(mod.verifyNodeIdBindingFromPublicKey(publicKey, '!sb_deadbeef')).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,11 @@ import {
|
||||
buildDmTrustHint,
|
||||
buildPrivateLaneHint,
|
||||
dmTrustPrimaryActionLabel,
|
||||
hasKnownFirstContactAnchor,
|
||||
hasVerifiedFirstContactAnchor,
|
||||
isInvitePinnedFirstContact,
|
||||
isFirstContactTrustOnly,
|
||||
requiresVerifiedFirstContact,
|
||||
shortTrustFingerprint,
|
||||
shouldAutoRevealSasForTrust,
|
||||
} from '@/mesh/meshPrivacyHints';
|
||||
@@ -61,6 +65,149 @@ describe('meshPrivacyHints', () => {
|
||||
expect(shouldAutoRevealSasForTrust(contact)).toBe(true);
|
||||
});
|
||||
|
||||
it('treats invite-pinned first contact as stronger than TOFU', () => {
|
||||
const contact = {
|
||||
trustSummary: {
|
||||
state: 'invite_pinned',
|
||||
label: 'INVITE PINNED',
|
||||
severity: 'warn',
|
||||
detail: 'anchored by signed invite',
|
||||
verifiedFirstContact: true,
|
||||
recommendedAction: 'show_sas',
|
||||
legacyLookup: false,
|
||||
inviteAttested: true,
|
||||
rootAttested: true,
|
||||
rootWitnessed: true,
|
||||
rootDistributionState: 'quorum_witnessed',
|
||||
rootWitnessThreshold: 2,
|
||||
rootWitnessCount: 2,
|
||||
rootWitnessDomainCount: 1,
|
||||
rootWitnessProvenanceState: 'local_quorum',
|
||||
rootWitnessIndependentQuorumMet: false,
|
||||
rootMismatch: false,
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
};
|
||||
|
||||
expect(isInvitePinnedFirstContact(contact)).toBe(true);
|
||||
expect(isFirstContactTrustOnly(contact)).toBe(false);
|
||||
expect(buildDmTrustHint(contact)).toEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'warn',
|
||||
title: 'ROOT LOCAL QUORUM',
|
||||
}),
|
||||
);
|
||||
expect(buildDmTrustHint(contact)?.detail).toContain('co-resident in one trust domain');
|
||||
expect(dmTrustPrimaryActionLabel(contact)).toBe('SHOW SAS');
|
||||
expect(shouldAutoRevealSasForTrust(contact)).toBe(false);
|
||||
});
|
||||
|
||||
it('distinguishes independent quorum provenance from local quorum', () => {
|
||||
const contact = {
|
||||
trustSummary: {
|
||||
state: 'invite_pinned',
|
||||
label: 'INVITE PINNED',
|
||||
severity: 'warn',
|
||||
detail: 'anchored by signed invite on independent quorum root',
|
||||
verifiedFirstContact: true,
|
||||
recommendedAction: 'show_sas',
|
||||
legacyLookup: false,
|
||||
inviteAttested: true,
|
||||
rootAttested: true,
|
||||
rootWitnessed: true,
|
||||
rootDistributionState: 'quorum_witnessed',
|
||||
rootWitnessThreshold: 2,
|
||||
rootWitnessCount: 2,
|
||||
rootWitnessDomainCount: 2,
|
||||
rootWitnessProvenanceState: 'independent_quorum',
|
||||
rootWitnessIndependentQuorumMet: true,
|
||||
rootMismatch: false,
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildDmTrustHint(contact)).toEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'warn',
|
||||
title: 'ROOT INDEPENDENT QUORUM',
|
||||
}),
|
||||
);
|
||||
expect(buildDmTrustHint(contact)?.detail).toContain('independently quorum-witnessed');
|
||||
});
|
||||
|
||||
it('requires verified first-contact anchors before secure bootstrap', () => {
|
||||
expect(requiresVerifiedFirstContact(undefined)).toBe(true);
|
||||
expect(hasKnownFirstContactAnchor(undefined)).toBe(false);
|
||||
expect(hasVerifiedFirstContactAnchor(undefined)).toBe(false);
|
||||
|
||||
expect(
|
||||
requiresVerifiedFirstContact({
|
||||
trustSummary: {
|
||||
state: 'invite_pinned',
|
||||
label: 'INVITE PINNED',
|
||||
severity: 'warn',
|
||||
detail: 'anchored by signed invite',
|
||||
verifiedFirstContact: true,
|
||||
recommendedAction: 'show_sas',
|
||||
legacyLookup: false,
|
||||
inviteAttested: true,
|
||||
rootWitnessed: true,
|
||||
rootDistributionState: 'quorum_witnessed',
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
hasVerifiedFirstContactAnchor({
|
||||
trustSummary: {
|
||||
state: 'invite_pinned',
|
||||
label: 'INVITE PINNED',
|
||||
severity: 'warn',
|
||||
detail: 'anchored by signed invite',
|
||||
verifiedFirstContact: true,
|
||||
recommendedAction: 'show_sas',
|
||||
legacyLookup: false,
|
||||
inviteAttested: true,
|
||||
rootWitnessed: true,
|
||||
rootDistributionState: 'quorum_witnessed',
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
requiresVerifiedFirstContact({
|
||||
remotePrekeyFingerprint: 'abc123',
|
||||
remotePrekeyPinnedAt: 123,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasVerifiedFirstContactAnchor({
|
||||
remotePrekeyFingerprint: 'abc123',
|
||||
remotePrekeyPinnedAt: 123,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
requiresVerifiedFirstContact({
|
||||
verified: true,
|
||||
verify_inband: true,
|
||||
verify_registry: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasVerifiedFirstContactAnchor({
|
||||
verified: true,
|
||||
verify_inband: true,
|
||||
verify_registry: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('auto-reveals SAS for trust hazards but keeps ordinary verified contacts quiet', () => {
|
||||
expect(
|
||||
shouldAutoRevealSasForTrust({
|
||||
@@ -74,20 +221,295 @@ describe('meshPrivacyHints', () => {
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldAutoRevealSasForTrust({
|
||||
verified: true,
|
||||
verify_inband: true,
|
||||
verify_registry: true,
|
||||
trustSummary: {
|
||||
state: 'sas_verified',
|
||||
label: 'SAS VERIFIED',
|
||||
severity: 'good',
|
||||
detail: 'sas verified',
|
||||
verifiedFirstContact: true,
|
||||
recommendedAction: 'show_sas',
|
||||
legacyLookup: false,
|
||||
inviteAttested: false,
|
||||
rootDistributionState: 'none',
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
dmTrustPrimaryActionLabel({
|
||||
verified: true,
|
||||
verify_inband: true,
|
||||
verify_registry: true,
|
||||
trustSummary: {
|
||||
state: 'sas_verified',
|
||||
label: 'SAS VERIFIED',
|
||||
severity: 'good',
|
||||
detail: 'sas verified',
|
||||
verifiedFirstContact: true,
|
||||
recommendedAction: 'show_sas',
|
||||
legacyLookup: false,
|
||||
inviteAttested: false,
|
||||
rootDistributionState: 'none',
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
}),
|
||||
).toBe('SHOW SAS');
|
||||
});
|
||||
|
||||
it('maps import-invite and reverify actions to distinct labels', () => {
|
||||
expect(
|
||||
dmTrustPrimaryActionLabel({
|
||||
trustSummary: {
|
||||
state: 'unpinned',
|
||||
label: 'UNVERIFIED',
|
||||
severity: 'warn',
|
||||
detail: 'invite required',
|
||||
verifiedFirstContact: false,
|
||||
recommendedAction: 'import_invite',
|
||||
legacyLookup: false,
|
||||
inviteAttested: false,
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
}),
|
||||
).toBe('IMPORT INVITE');
|
||||
expect(
|
||||
dmTrustPrimaryActionLabel({
|
||||
trustSummary: {
|
||||
state: 'continuity_broken',
|
||||
label: 'CONTINUITY BROKEN',
|
||||
severity: 'danger',
|
||||
detail: 'reverify',
|
||||
verifiedFirstContact: false,
|
||||
recommendedAction: 'reverify',
|
||||
legacyLookup: false,
|
||||
inviteAttested: true,
|
||||
registryMismatch: true,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
}),
|
||||
).toBe('REVERIFY NOW');
|
||||
});
|
||||
|
||||
it('surfaces stable root mismatch as a continuity hazard', () => {
|
||||
const contact = {
|
||||
trustSummary: {
|
||||
state: 'continuity_broken',
|
||||
label: 'CONTINUITY BROKEN',
|
||||
severity: 'danger',
|
||||
detail: 'root changed',
|
||||
verifiedFirstContact: false,
|
||||
recommendedAction: 'reverify',
|
||||
legacyLookup: false,
|
||||
inviteAttested: true,
|
||||
rootAttested: true,
|
||||
rootWitnessed: true,
|
||||
rootDistributionState: 'quorum_witnessed',
|
||||
rootMismatch: true,
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildDmTrustHint(contact)).toEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'danger',
|
||||
title: 'CONTINUITY BROKEN',
|
||||
}),
|
||||
);
|
||||
expect(buildDmTrustHint(contact)?.detail).toContain('stable root identity');
|
||||
});
|
||||
|
||||
it('treats legacy lookup on an otherwise verified contact as an invite-import migration state', () => {
|
||||
const contact = {
|
||||
trustSummary: {
|
||||
state: 'sas_verified',
|
||||
label: 'SAS VERIFIED',
|
||||
severity: 'good',
|
||||
detail: 'sas verified but still legacy lookup',
|
||||
verifiedFirstContact: true,
|
||||
recommendedAction: 'import_invite',
|
||||
legacyLookup: true,
|
||||
inviteAttested: false,
|
||||
rootDistributionState: 'none',
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
};
|
||||
|
||||
expect(dmTrustPrimaryActionLabel(contact)).toBe('IMPORT INVITE');
|
||||
expect(buildDmTrustHint(contact)).toEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'warn',
|
||||
title: 'LEGACY LOOKUP',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces internal-only root continuity as an invite refresh state', () => {
|
||||
const contact = {
|
||||
trustSummary: {
|
||||
state: 'invite_pinned',
|
||||
label: 'INVITE PINNED',
|
||||
severity: 'warn',
|
||||
detail: 'invite pinned on internal root only',
|
||||
verifiedFirstContact: true,
|
||||
recommendedAction: 'import_invite',
|
||||
legacyLookup: false,
|
||||
inviteAttested: true,
|
||||
rootAttested: true,
|
||||
rootWitnessed: false,
|
||||
rootDistributionState: 'internal_only',
|
||||
rootMismatch: false,
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
};
|
||||
|
||||
expect(dmTrustPrimaryActionLabel(contact)).toBe('IMPORT INVITE');
|
||||
expect(buildDmTrustHint(contact)).toEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'warn',
|
||||
title: 'ROOT INTERNAL ONLY',
|
||||
}),
|
||||
);
|
||||
expect(buildDmTrustHint(contact)?.detail).toContain('witnessed root');
|
||||
});
|
||||
|
||||
it('surfaces single-witness root continuity as a weaker witnessed state', () => {
|
||||
const contact = {
|
||||
trustSummary: {
|
||||
state: 'invite_pinned',
|
||||
label: 'INVITE PINNED',
|
||||
severity: 'warn',
|
||||
detail: 'invite pinned on single witness root',
|
||||
verifiedFirstContact: true,
|
||||
recommendedAction: 'import_invite',
|
||||
legacyLookup: false,
|
||||
inviteAttested: true,
|
||||
rootAttested: true,
|
||||
rootWitnessed: true,
|
||||
rootDistributionState: 'single_witness',
|
||||
rootWitnessCount: 1,
|
||||
rootWitnessThreshold: 1,
|
||||
rootWitnessQuorumMet: true,
|
||||
rootMismatch: false,
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
};
|
||||
|
||||
expect(dmTrustPrimaryActionLabel(contact)).toBe('IMPORT INVITE');
|
||||
expect(buildDmTrustHint(contact)).toEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'warn',
|
||||
title: 'ROOT SINGLE WITNESS',
|
||||
}),
|
||||
);
|
||||
expect(buildDmTrustHint(contact)?.detail).toContain('quorum witness provenance');
|
||||
});
|
||||
|
||||
it('surfaces unproven witnessed root rotation as a hard invite refresh state', () => {
|
||||
const contact = {
|
||||
trustSummary: {
|
||||
state: 'invite_pinned',
|
||||
label: 'INVITE PINNED',
|
||||
severity: 'warn',
|
||||
detail: 'invite pinned on witnessed root without rotation proof',
|
||||
verifiedFirstContact: false,
|
||||
recommendedAction: 'import_invite',
|
||||
legacyLookup: false,
|
||||
inviteAttested: true,
|
||||
rootAttested: true,
|
||||
rootWitnessed: true,
|
||||
rootDistributionState: 'quorum_witnessed',
|
||||
rootManifestGeneration: 2,
|
||||
rootRotationProven: false,
|
||||
rootMismatch: false,
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
};
|
||||
|
||||
expect(dmTrustPrimaryActionLabel(contact)).toBe('IMPORT INVITE');
|
||||
expect(buildDmTrustHint(contact)).toEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'danger',
|
||||
title: 'ROOT ROTATION UNPROVEN',
|
||||
}),
|
||||
);
|
||||
expect(buildDmTrustHint(contact)?.detail).toContain('previous-root proof');
|
||||
});
|
||||
|
||||
it('surfaces unsatisfied witness policy as a hard invite refresh state', () => {
|
||||
const contact = {
|
||||
trustSummary: {
|
||||
state: 'invite_pinned',
|
||||
label: 'INVITE PINNED',
|
||||
severity: 'warn',
|
||||
detail: 'invite pinned on root missing witness quorum',
|
||||
verifiedFirstContact: false,
|
||||
recommendedAction: 'import_invite',
|
||||
legacyLookup: false,
|
||||
inviteAttested: true,
|
||||
rootAttested: true,
|
||||
rootWitnessed: true,
|
||||
rootDistributionState: 'witness_policy_not_met',
|
||||
rootWitnessCount: 1,
|
||||
rootWitnessThreshold: 2,
|
||||
rootWitnessQuorumMet: false,
|
||||
rootMismatch: false,
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
};
|
||||
|
||||
expect(dmTrustPrimaryActionLabel(contact)).toBe('IMPORT INVITE');
|
||||
expect(buildDmTrustHint(contact)).toEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'danger',
|
||||
title: 'ROOT WITNESS POLICY NOT MET',
|
||||
}),
|
||||
);
|
||||
expect(buildDmTrustHint(contact)?.detail).toContain('witness policy');
|
||||
});
|
||||
|
||||
it('transitional lane hint separates gate posture from DM posture', () => {
|
||||
const hint = buildPrivateLaneHint({
|
||||
activeTab: 'infonet',
|
||||
privateInfonetReady: true,
|
||||
privateInfonetTransportReady: false,
|
||||
});
|
||||
|
||||
expect(hint).toEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'warn',
|
||||
title: 'TRANSITIONAL PRIVATE LANE',
|
||||
}),
|
||||
);
|
||||
// Must explicitly mention gate is on a transitional lane
|
||||
expect(hint?.detail).toContain('transitional');
|
||||
// Must explicitly mention DM requires a stronger tier
|
||||
expect(hint?.detail).toContain('Dead Drop');
|
||||
expect(hint?.detail).toMatch(/PRIVATE \/ STRONG/i);
|
||||
// Must not imply gate and DM share the same posture
|
||||
expect(hint?.detail).toContain('weaker than DM');
|
||||
});
|
||||
|
||||
it('relay delivery hint is specific to Dead Drop, not gate', () => {
|
||||
const hint = buildPrivateLaneHint({
|
||||
activeTab: 'dms',
|
||||
dmTransportMode: 'relay',
|
||||
});
|
||||
|
||||
expect(hint).toEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'warn',
|
||||
title: 'RELAY DELIVERY ACTIVE',
|
||||
}),
|
||||
);
|
||||
expect(hint?.detail).toContain('Dead Drop');
|
||||
});
|
||||
|
||||
it('shortens long trust fingerprints for display', () => {
|
||||
expect(shortTrustFingerprint('abcdef0123456789fedcba9876543210')).toBe('abcdef01..543210');
|
||||
expect(shortTrustFingerprint('abcd1234')).toBe('abcd1234');
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
@@ -32,10 +35,59 @@ describe('mesh terminal policy', () => {
|
||||
expect(isMeshTerminalWriteCommand('send', ['broadcast', 'hello'])).toBe(true);
|
||||
});
|
||||
|
||||
it('wormhole active lock reason distinguishes gate and DM posture', () => {
|
||||
const reason = getMeshTerminalWriteLockReason({
|
||||
wormholeRequired: true,
|
||||
wormholeReady: true,
|
||||
anonymousMode: false,
|
||||
anonymousModeReady: false,
|
||||
});
|
||||
|
||||
// Must mention gate as transitional lane
|
||||
expect(reason).toContain('gate chat (transitional lane)');
|
||||
// Must mention Dead Drop as the stronger lane
|
||||
expect(reason).toContain('Dead Drop (stronger private lane)');
|
||||
// Must NOT use "hardened private actions" which flattens both
|
||||
expect(reason).not.toContain('hardened private actions');
|
||||
});
|
||||
|
||||
it('anonymous mode lock reason distinguishes gate and DM posture', () => {
|
||||
const reason = getMeshTerminalWriteLockReason({
|
||||
wormholeRequired: true,
|
||||
wormholeReady: true,
|
||||
anonymousMode: true,
|
||||
anonymousModeReady: true,
|
||||
});
|
||||
|
||||
expect(reason).toContain('gate chat (transitional lane)');
|
||||
expect(reason).toContain('Dead Drop (stronger private lane)');
|
||||
expect(reason).not.toContain('hardened');
|
||||
});
|
||||
|
||||
it('keeps read-only terminal commands available', () => {
|
||||
expect(isMeshTerminalWriteCommand('status', [])).toBe(false);
|
||||
expect(isMeshTerminalWriteCommand('signals', ['10'])).toBe(false);
|
||||
expect(isMeshTerminalWriteCommand('mesh', ['listen', '20'])).toBe(false);
|
||||
expect(isMeshTerminalWriteCommand('messages', [])).toBe(false);
|
||||
});
|
||||
|
||||
it('MeshTerminal does not use raw agent-id fetch as the ordinary DM send path', () => {
|
||||
const terminal = fs.readFileSync(
|
||||
path.resolve(__dirname, '../../components/MeshTerminal.tsx'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(terminal).toContain('fetchDmPublicKey');
|
||||
expect(terminal).toContain("only for legacy migration");
|
||||
expect(terminal).not.toContain('/api/mesh/dm/pubkey?agent_id=');
|
||||
});
|
||||
|
||||
it('MeshTerminal inbox surface owns mailbox refresh instead of racing the unread poll loop', () => {
|
||||
const terminal = fs.readFileSync(
|
||||
path.resolve(__dirname, '../../components/MeshTerminal.tsx'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(terminal).toContain("if (!isOpen || !nodeIdentity || !hasSovereignty() || !getDMNotify() || surfacePanel === 'inbox') return;");
|
||||
expect(terminal).toContain('classifyTick(hasMore, catchUpBudget, 15_000)');
|
||||
expect(terminal).toContain('() => void loadInboxSurface(classification.refreshCount)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,569 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
|
||||
import React from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
let contactsState: Record<string, any> = {};
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
buildMailboxClaims: vi.fn(async () => []),
|
||||
countDmMailboxes: vi.fn(async () => ({ ok: true, count: 0 })),
|
||||
ensureRegisteredDmKey: vi.fn(async () => ({ dhPubKey: 'local-dh', dhAlgo: 'X25519' })),
|
||||
fetchDmPublicKey: vi.fn(async () => ({ dh_pub_key: 'peer-dh', dh_algo: 'X25519' })),
|
||||
pollDmMailboxes: vi.fn(async () => ({ ok: true, messages: [] })),
|
||||
sendDmMessage: vi.fn(async () => ({ ok: true, transport: 'relay' })),
|
||||
sendOffLedgerConsentMessage: vi.fn(async () => ({ ok: true, transport: 'relay' })),
|
||||
sharedMailboxToken: vi.fn(async () => 'shared-token'),
|
||||
buildContactAcceptMessage: vi.fn(() => 'accept'),
|
||||
buildContactDenyMessage: vi.fn(() => 'deny'),
|
||||
buildContactOfferMessage: vi.fn(() => 'offer'),
|
||||
generateSharedAlias: vi.fn(() => 'alias-123'),
|
||||
mergeAliasHistory: vi.fn((history?: string[]) => history || []),
|
||||
parseAliasRotateMessage: vi.fn(() => null),
|
||||
parseDmConsentMessage: vi.fn(() => null),
|
||||
preferredDmPeerId: vi.fn((peerId: string) => peerId),
|
||||
allDmPeerIds: vi.fn(() => []),
|
||||
purgeBrowserDmState: vi.fn(async () => {}),
|
||||
ratchetDecryptDM: vi.fn(async () => {
|
||||
throw new Error('no_ratchet_state');
|
||||
}),
|
||||
ratchetEncryptDM: vi.fn(async () => 'ratchet-ciphertext'),
|
||||
addContact: vi.fn(),
|
||||
blockContact: vi.fn(),
|
||||
decryptDM: vi.fn(async () => 'plaintext'),
|
||||
decryptSenderSealPayloadLocally: vi.fn(async () => ''),
|
||||
deriveSharedKey: vi.fn(async () => ({})),
|
||||
encryptDM: vi.fn(async () => 'ciphertext'),
|
||||
getContacts: vi.fn(() => contactsState),
|
||||
getDHAlgo: vi.fn(() => 'X25519'),
|
||||
getNodeIdentity: vi.fn(() => ({
|
||||
nodeId: '!sb_local',
|
||||
publicKey: 'local-pub',
|
||||
privateKey: 'local-priv',
|
||||
})),
|
||||
hasSovereignty: vi.fn(() => true),
|
||||
hydrateWormholeContacts: vi.fn(async () => contactsState),
|
||||
purgeBrowserContactGraph: vi.fn(),
|
||||
purgeBrowserSigningMaterial: vi.fn(),
|
||||
removeContact: vi.fn(),
|
||||
unblockContact: vi.fn(),
|
||||
unwrapSenderSealPayload: vi.fn(() => ({ version: 'v2', ephemeralPub: '' })),
|
||||
updateContact: vi.fn(),
|
||||
verifyNodeIdBindingFromPublicKey: vi.fn(async () => true),
|
||||
verifyRawSignature: vi.fn(async () => true),
|
||||
getSenderRecoveryState: vi.fn(() => 'verified'),
|
||||
recoverSenderSealWithFallback: vi.fn(async () => null),
|
||||
requiresSenderRecovery: vi.fn(() => false),
|
||||
shouldKeepUnresolvedRequestVisible: vi.fn(() => false),
|
||||
shouldPromoteRecoveredSenderForBootstrap: vi.fn(() => false),
|
||||
shouldPromoteRecoveredSenderForKnownContact: vi.fn(() => false),
|
||||
bootstrapDecryptAccessRequest: vi.fn(async () => 'offer'),
|
||||
bootstrapEncryptAccessRequest: vi.fn(async () => 'x3dh1:bootstrap'),
|
||||
canUseWormholeBootstrap: vi.fn(async () => false),
|
||||
fetchWormholeStatus: vi.fn(async () => ({ ready: true, transport_tier: 'private_strong' })),
|
||||
fetchWormholeIdentity: vi.fn(async () => ({ node_id: '!sb_local', public_key: 'local-pub' })),
|
||||
prepareWormholeInteractiveLane: vi.fn(async () => ({
|
||||
ready: true,
|
||||
settingsEnabled: true,
|
||||
transportTier: 'private_transitional',
|
||||
identity: { node_id: '!sb_local', public_key: 'local-pub' },
|
||||
})),
|
||||
importWormholeDmInvite: vi.fn(async () => ({
|
||||
ok: true,
|
||||
peer_id: '!sb_imported',
|
||||
trust_fingerprint: 'invitefp',
|
||||
trust_level: 'invite_pinned',
|
||||
})),
|
||||
isWormholeReady: vi.fn(async () => true),
|
||||
isWormholeSecureRequired: vi.fn(async () => false),
|
||||
issueWormholePairwiseAlias: vi.fn(async () => ({ ok: true, shared_alias: 'alias-123' })),
|
||||
openWormholeSenderSeal: vi.fn(async () => ({ sender_id: '!sb_peer', seal_verified: true })),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
API_BASE: 'http://localhost:8000',
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshDmClient', () => ({
|
||||
buildMailboxClaims: mocks.buildMailboxClaims,
|
||||
countDmMailboxes: mocks.countDmMailboxes,
|
||||
ensureRegisteredDmKey: mocks.ensureRegisteredDmKey,
|
||||
fetchDmPublicKey: mocks.fetchDmPublicKey,
|
||||
pollDmMailboxes: mocks.pollDmMailboxes,
|
||||
sendDmMessage: mocks.sendDmMessage,
|
||||
sendOffLedgerConsentMessage: mocks.sendOffLedgerConsentMessage,
|
||||
sharedMailboxToken: mocks.sharedMailboxToken,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshDmConsent', () => ({
|
||||
allDmPeerIds: mocks.allDmPeerIds,
|
||||
buildContactAcceptMessage: mocks.buildContactAcceptMessage,
|
||||
buildContactDenyMessage: mocks.buildContactDenyMessage,
|
||||
buildContactOfferMessage: mocks.buildContactOfferMessage,
|
||||
generateSharedAlias: mocks.generateSharedAlias,
|
||||
mergeAliasHistory: mocks.mergeAliasHistory,
|
||||
parseAliasRotateMessage: mocks.parseAliasRotateMessage,
|
||||
parseDmConsentMessage: mocks.parseDmConsentMessage,
|
||||
preferredDmPeerId: mocks.preferredDmPeerId,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshDmWorkerClient', () => ({
|
||||
purgeBrowserDmState: mocks.purgeBrowserDmState,
|
||||
ratchetDecryptDM: mocks.ratchetDecryptDM,
|
||||
ratchetEncryptDM: mocks.ratchetEncryptDM,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshIdentity', () => ({
|
||||
addContact: mocks.addContact,
|
||||
blockContact: mocks.blockContact,
|
||||
decryptDM: mocks.decryptDM,
|
||||
decryptSenderSealPayloadLocally: mocks.decryptSenderSealPayloadLocally,
|
||||
deriveSharedKey: mocks.deriveSharedKey,
|
||||
encryptDM: mocks.encryptDM,
|
||||
getContacts: mocks.getContacts,
|
||||
getDHAlgo: mocks.getDHAlgo,
|
||||
getNodeIdentity: mocks.getNodeIdentity,
|
||||
hasSovereignty: mocks.hasSovereignty,
|
||||
hydrateWormholeContacts: mocks.hydrateWormholeContacts,
|
||||
purgeBrowserContactGraph: mocks.purgeBrowserContactGraph,
|
||||
purgeBrowserSigningMaterial: mocks.purgeBrowserSigningMaterial,
|
||||
removeContact: mocks.removeContact,
|
||||
unblockContact: mocks.unblockContact,
|
||||
unwrapSenderSealPayload: mocks.unwrapSenderSealPayload,
|
||||
updateContact: mocks.updateContact,
|
||||
verifyNodeIdBindingFromPublicKey: mocks.verifyNodeIdBindingFromPublicKey,
|
||||
verifyRawSignature: mocks.verifyRawSignature,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/requestSenderRecovery', () => ({
|
||||
getSenderRecoveryState: mocks.getSenderRecoveryState,
|
||||
recoverSenderSealWithFallback: mocks.recoverSenderSealWithFallback,
|
||||
requiresSenderRecovery: mocks.requiresSenderRecovery,
|
||||
shouldKeepUnresolvedRequestVisible: mocks.shouldKeepUnresolvedRequestVisible,
|
||||
shouldPromoteRecoveredSenderForBootstrap: mocks.shouldPromoteRecoveredSenderForBootstrap,
|
||||
shouldPromoteRecoveredSenderForKnownContact: mocks.shouldPromoteRecoveredSenderForKnownContact,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/wormholeDmBootstrapClient', () => ({
|
||||
bootstrapDecryptAccessRequest: mocks.bootstrapDecryptAccessRequest,
|
||||
bootstrapEncryptAccessRequest: mocks.bootstrapEncryptAccessRequest,
|
||||
canUseWormholeBootstrap: mocks.canUseWormholeBootstrap,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/wormholeIdentityClient', () => ({
|
||||
fetchWormholeStatus: mocks.fetchWormholeStatus,
|
||||
fetchWormholeIdentity: mocks.fetchWormholeIdentity,
|
||||
prepareWormholeInteractiveLane: mocks.prepareWormholeInteractiveLane,
|
||||
getWormholeDmInviteImportErrorResult: (error: unknown) =>
|
||||
error && typeof error === 'object' && 'result' in (error as Record<string, unknown>)
|
||||
? (((error as Record<string, unknown>).result as Record<string, unknown>) || null)
|
||||
: null,
|
||||
importWormholeDmInvite: mocks.importWormholeDmInvite,
|
||||
isWormholeReady: mocks.isWormholeReady,
|
||||
isWormholeSecureRequired: mocks.isWormholeSecureRequired,
|
||||
issueWormholePairwiseAlias: mocks.issueWormholePairwiseAlias,
|
||||
openWormholeSenderSeal: mocks.openWormholeSenderSeal,
|
||||
}));
|
||||
|
||||
import MessagesView from '@/components/InfonetTerminal/MessagesView';
|
||||
|
||||
function renderMessagesView(options?: {
|
||||
onOpenDeadDrop?: (peerId: string, opts?: { showSas?: boolean }) => void;
|
||||
}) {
|
||||
return render(<MessagesView onBack={() => {}} onOpenDeadDrop={options?.onOpenDeadDrop} />);
|
||||
}
|
||||
|
||||
async function openComposeForRecipient(recipient: string, body: string) {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'COMPOSE' }));
|
||||
fireEvent.change(screen.getByLabelText(/Recipient agent ID/i), {
|
||||
target: { value: recipient },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/Message/i), {
|
||||
target: { value: body },
|
||||
});
|
||||
await screen.findByLabelText(/Recipient agent ID/i);
|
||||
}
|
||||
|
||||
describe('MessagesView first-contact trust UX', () => {
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
contactsState = {};
|
||||
vi.clearAllMocks();
|
||||
|
||||
mocks.getContacts.mockImplementation(() => contactsState);
|
||||
mocks.hydrateWormholeContacts.mockImplementation(async () => contactsState);
|
||||
mocks.fetchWormholeStatus.mockResolvedValue({ ready: true, transport_tier: 'private_strong' });
|
||||
mocks.prepareWormholeInteractiveLane.mockResolvedValue({
|
||||
ready: true,
|
||||
settingsEnabled: true,
|
||||
transportTier: 'private_transitional',
|
||||
identity: { node_id: '!sb_local', public_key: 'local-pub' },
|
||||
});
|
||||
mocks.isWormholeSecureRequired.mockResolvedValue(false);
|
||||
mocks.getNodeIdentity.mockReturnValue({
|
||||
nodeId: '!sb_local',
|
||||
publicKey: 'local-pub',
|
||||
privateKey: 'local-priv',
|
||||
});
|
||||
mocks.hasSovereignty.mockReturnValue(true);
|
||||
mocks.buildMailboxClaims.mockResolvedValue([]);
|
||||
mocks.pollDmMailboxes.mockResolvedValue({ ok: true, messages: [] });
|
||||
mocks.countDmMailboxes.mockResolvedValue({ ok: true, count: 0 });
|
||||
mocks.ensureRegisteredDmKey.mockResolvedValue({ dhPubKey: 'local-dh', dhAlgo: 'X25519' });
|
||||
mocks.fetchDmPublicKey.mockResolvedValue({ dh_pub_key: 'peer-dh', dh_algo: 'X25519' });
|
||||
mocks.sendOffLedgerConsentMessage.mockResolvedValue({ ok: true, transport: 'relay' });
|
||||
mocks.canUseWormholeBootstrap.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('blocks unknown first contact until a signed invite is imported', async () => {
|
||||
renderMessagesView();
|
||||
await openComposeForRecipient('!sb_unknown', 'hello from first contact');
|
||||
|
||||
expect(await screen.findByText('Verified First Contact Required')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Secure request bootstrap is blocked until you import a signed invite/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Send Secure Mail' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('can jump directly from the downgrade warning into invite import flow', async () => {
|
||||
renderMessagesView();
|
||||
await openComposeForRecipient('!sb_unknown', 'hello from first contact');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Import Signed Invite' }));
|
||||
|
||||
expect(await screen.findByText('Import Verified Invite')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Local Alias/i)).toHaveValue('!sb_unknown');
|
||||
});
|
||||
|
||||
it('does not expose a TOFU downgrade button for first contact anymore', async () => {
|
||||
renderMessagesView();
|
||||
await openComposeForRecipient('!sb_unknown', 'hello from first contact');
|
||||
|
||||
expect(screen.queryByRole('button', { name: /Explicitly Allow TOFU/i })).not.toBeInTheDocument();
|
||||
expect(mocks.sendOffLedgerConsentMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not require the TOFU override when the contact is invite-pinned already', async () => {
|
||||
contactsState = {
|
||||
'!sb_invited': {
|
||||
alias: 'Pinned Peer',
|
||||
blocked: false,
|
||||
trust_level: 'invite_pinned',
|
||||
invitePinnedTrustFingerprint: 'abcdef123456',
|
||||
invitePinnedRootFingerprint: 'rootabcdef123456',
|
||||
invitePinnedRootManifestFingerprint: 'manifestabcdef123456',
|
||||
invitePinnedRootWitnessPolicyFingerprint: 'policyabcdef123456',
|
||||
invitePinnedRootWitnessThreshold: 2,
|
||||
invitePinnedRootWitnessCount: 3,
|
||||
invitePinnedRootManifestGeneration: 1,
|
||||
invitePinnedRootRotationProven: true,
|
||||
invitePinnedAt: 123,
|
||||
remotePrekeyFingerprint: 'abcdef123456',
|
||||
remotePrekeyRootFingerprint: 'rootabcdef123456',
|
||||
remotePrekeyRootManifestFingerprint: 'manifestabcdef123456',
|
||||
remotePrekeyRootWitnessPolicyFingerprint: 'policyabcdef123456',
|
||||
remotePrekeyRootWitnessThreshold: 2,
|
||||
remotePrekeyRootWitnessCount: 3,
|
||||
remotePrekeyRootManifestGeneration: 1,
|
||||
remotePrekeyRootRotationProven: true,
|
||||
},
|
||||
};
|
||||
|
||||
renderMessagesView();
|
||||
await openComposeForRecipient('!sb_invited', 'hello to pinned peer');
|
||||
|
||||
expect(screen.queryByText('Unverified First Contact')).not.toBeInTheDocument();
|
||||
expect(await screen.findByText('ROOT LOCAL QUORUM')).toBeInTheDocument();
|
||||
expect(await screen.findByText(/Local quorum root rootabcd\.\.123456/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Send Secure Mail' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('warms the private lane in the background before sending secure mail', async () => {
|
||||
contactsState = {
|
||||
'!sb_pinned': {
|
||||
alias: 'Pinned Peer',
|
||||
blocked: false,
|
||||
trust_level: 'invite_pinned',
|
||||
dhPubKey: 'peer-dh',
|
||||
remotePrekeyFingerprint: 'abcdef123456',
|
||||
},
|
||||
};
|
||||
mocks.fetchWormholeStatus.mockResolvedValue({ ready: false, transport_tier: 'public_degraded' });
|
||||
|
||||
renderMessagesView();
|
||||
await openComposeForRecipient('!sb_pinned', 'hello after warmup');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Send Secure Mail' }));
|
||||
|
||||
await screen.findByText(/Mail delivered to Pinned Peer/i);
|
||||
expect(mocks.prepareWormholeInteractiveLane).toHaveBeenCalled();
|
||||
expect(mocks.sendDmMessage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not flatten witness policy not met into a generic witnessed root label', async () => {
|
||||
contactsState = {
|
||||
'!sb_policy': {
|
||||
alias: 'Policy Peer',
|
||||
blocked: false,
|
||||
trust_level: 'invite_pinned',
|
||||
invitePinnedTrustFingerprint: 'policyfingerprint123456',
|
||||
invitePinnedRootFingerprint: 'rootpolicyabcdef123456',
|
||||
invitePinnedRootManifestFingerprint: 'manifestpolicyabcdef123456',
|
||||
invitePinnedRootWitnessPolicyFingerprint: 'policyabcdef123456',
|
||||
invitePinnedRootWitnessThreshold: 2,
|
||||
invitePinnedRootWitnessCount: 1,
|
||||
invitePinnedRootManifestGeneration: 1,
|
||||
invitePinnedRootRotationProven: true,
|
||||
invitePinnedAt: 123,
|
||||
remotePrekeyFingerprint: 'policyfingerprint123456',
|
||||
remotePrekeyRootFingerprint: 'rootpolicyabcdef123456',
|
||||
remotePrekeyRootManifestFingerprint: 'manifestpolicyabcdef123456',
|
||||
remotePrekeyRootWitnessPolicyFingerprint: 'policyabcdef123456',
|
||||
remotePrekeyRootWitnessThreshold: 2,
|
||||
remotePrekeyRootWitnessCount: 1,
|
||||
remotePrekeyRootManifestGeneration: 1,
|
||||
remotePrekeyRootRotationProven: true,
|
||||
},
|
||||
};
|
||||
|
||||
renderMessagesView();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
|
||||
|
||||
expect(await screen.findByText(/Witness-policy root rootpoli\.\.123456/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Witnessed root rootpoli\.\.123456/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an import-invite shortcut for unpinned contacts in the contact list', async () => {
|
||||
contactsState = {
|
||||
'!sb_unpinned': {
|
||||
alias: 'Weak Peer',
|
||||
blocked: false,
|
||||
dhPubKey: 'peer-dh',
|
||||
trust_level: 'unpinned',
|
||||
},
|
||||
};
|
||||
|
||||
renderMessagesView();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
|
||||
|
||||
const importButton = await screen.findByRole('button', { name: 'Import Invite' });
|
||||
fireEvent.click(importButton);
|
||||
expect(screen.getByLabelText(/Local Alias/i)).toHaveValue('!sb_unpinned');
|
||||
});
|
||||
|
||||
it('routes continuity reverify from Secure Messages into Dead Drop with SAS visible', async () => {
|
||||
contactsState = {
|
||||
'!sb_reverify': {
|
||||
alias: 'Broken Root Peer',
|
||||
blocked: false,
|
||||
trust_level: 'continuity_broken',
|
||||
remotePrekeyObservedFingerprint: 'observed123456',
|
||||
remotePrekeyObservedRootFingerprint: 'rootobserved123456',
|
||||
remotePrekeyRootMismatch: true,
|
||||
},
|
||||
};
|
||||
const onOpenDeadDrop = vi.fn();
|
||||
|
||||
renderMessagesView({ onOpenDeadDrop });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
|
||||
|
||||
const reverifyButton = await screen.findByRole('button', { name: 'REVERIFY NOW' });
|
||||
fireEvent.click(reverifyButton);
|
||||
|
||||
expect(onOpenDeadDrop).toHaveBeenCalledWith('!sb_reverify', { showSas: true });
|
||||
});
|
||||
|
||||
it('still blocks first contact when legacy verified flags and a dh key are seeded on an unpinned contact', async () => {
|
||||
contactsState = {
|
||||
'!sb_seeded': {
|
||||
alias: 'Seeded Peer',
|
||||
blocked: false,
|
||||
dhPubKey: 'forged-dh',
|
||||
verify_inband: true,
|
||||
verify_registry: true,
|
||||
verified: true,
|
||||
trust_level: 'unpinned',
|
||||
trustSummary: {
|
||||
state: 'unpinned',
|
||||
label: 'UNVERIFIED',
|
||||
severity: 'warn',
|
||||
detail: 'invite required',
|
||||
verifiedFirstContact: false,
|
||||
recommendedAction: 'import_invite',
|
||||
legacyLookup: false,
|
||||
inviteAttested: false,
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderMessagesView();
|
||||
await openComposeForRecipient('!sb_seeded', 'hello from forged first contact');
|
||||
|
||||
expect(await screen.findByText('Verified First Contact Required')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Secure request bootstrap is blocked until you import a signed invite/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Send Secure Mail' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('blocks ambient legacy lookup for verified contacts that still lack an invite handle', async () => {
|
||||
contactsState = {
|
||||
'!sb_legacy': {
|
||||
alias: 'Legacy Peer',
|
||||
blocked: false,
|
||||
trust_level: 'sas_verified',
|
||||
remotePrekeyLookupMode: 'legacy_agent_id',
|
||||
trustSummary: {
|
||||
state: 'sas_verified',
|
||||
label: 'SAS VERIFIED',
|
||||
severity: 'good',
|
||||
detail: 'legacy lookup still active',
|
||||
verifiedFirstContact: true,
|
||||
recommendedAction: 'import_invite',
|
||||
legacyLookup: true,
|
||||
inviteAttested: false,
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderMessagesView();
|
||||
await openComposeForRecipient('!sb_legacy', 'hello from a legacy lookup contact');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Send Secure Mail' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
/Import or re-import a signed invite before sending a contact request; legacy direct lookup is disabled\./i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(mocks.fetchDmPublicKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('announces attested invite imports as INVITE PINNED', async () => {
|
||||
mocks.importWormholeDmInvite.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
peer_id: '!sb_attested',
|
||||
trust_fingerprint: 'invitefp-attested',
|
||||
trust_level: 'invite_pinned',
|
||||
contact: {},
|
||||
});
|
||||
|
||||
renderMessagesView();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
|
||||
expect(await screen.findByText('Import Verified Invite')).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Signed Invite JSON/i), {
|
||||
target: { value: JSON.stringify({ invite: { event_type: 'dm_invite', payload: {} } }) },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Import Signed Invite' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(/INVITE PINNED for !sb_attested \(invitefp\.\.tested\)\./i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('announces compat invite imports as TOFU PINNED with backend detail', async () => {
|
||||
mocks.importWormholeDmInvite.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
peer_id: '!sb_compat',
|
||||
trust_fingerprint: 'invitefp-compat',
|
||||
trust_level: 'tofu_pinned',
|
||||
detail: 'legacy invite imported as tofu_pinned; SAS verification required before first contact',
|
||||
contact: {},
|
||||
});
|
||||
|
||||
renderMessagesView();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
|
||||
expect(await screen.findByText('Import Verified Invite')).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Signed Invite JSON/i), {
|
||||
target: { value: JSON.stringify({ invite: { event_type: 'dm_invite', payload: {} } }) },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Import Signed Invite' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(/TOFU PINNED for !sb_compat \(invitefp\.\.compat\)\./i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/legacy invite imported as tofu_pinned; SAS verification required before first contact/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces stable root continuity breaks on invite re-import', async () => {
|
||||
contactsState = {
|
||||
'!sb_attested': {
|
||||
alias: 'Pinned Peer',
|
||||
blocked: false,
|
||||
trust_level: 'continuity_broken',
|
||||
invitePinnedTrustFingerprint: 'oldfingerprint123456',
|
||||
invitePinnedRootFingerprint: 'rootold123456',
|
||||
remotePrekeyFingerprint: 'newfingerprint654321',
|
||||
remotePrekeyObservedFingerprint: 'newfingerprint654321',
|
||||
remotePrekeyRootFingerprint: 'rootold123456',
|
||||
remotePrekeyObservedRootFingerprint: 'rootnew654321',
|
||||
remotePrekeyRootMismatch: true,
|
||||
},
|
||||
};
|
||||
const error = Object.assign(
|
||||
new Error(
|
||||
'signed invite root continuity mismatch; re-verify SAS or replace the signed invite before trusting this root change',
|
||||
),
|
||||
{
|
||||
result: {
|
||||
ok: false,
|
||||
peer_id: '!sb_attested',
|
||||
trust_level: 'continuity_broken',
|
||||
detail:
|
||||
'signed invite root continuity mismatch; re-verify SAS or replace the signed invite before trusting this root change',
|
||||
contact: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
mocks.importWormholeDmInvite.mockRejectedValueOnce(error);
|
||||
|
||||
renderMessagesView();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
|
||||
expect(await screen.findByText('Import Verified Invite')).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Signed Invite JSON/i), {
|
||||
target: { value: JSON.stringify({ invite: { event_type: 'dm_invite', payload: {} } }) },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Import Signed Invite' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(/CONTINUITY BROKEN for Pinned Peer\. Stable root continuity changed\./i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/re-verify SAS in Dead Drop or replace the signed invite before trusting this contact again/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses non-blocking secure-mail startup language while the DM lane warms', async () => {
|
||||
mocks.fetchWormholeStatus.mockResolvedValueOnce({ ready: false, transport_tier: 'public_degraded' });
|
||||
mocks.prepareWormholeInteractiveLane.mockImplementation(
|
||||
() =>
|
||||
new Promise(() => {
|
||||
/* keep background warm-up pending for this assertion */
|
||||
}),
|
||||
);
|
||||
|
||||
renderMessagesView();
|
||||
|
||||
expect(
|
||||
await screen.findByText(/Preparing secure mail in the background/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(/LOCKED/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/enter the Wormhole/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import React from 'react';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const deferred = <T,>() => {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
const fetchWormholeStatus = vi.fn(async () => ({
|
||||
ready: false,
|
||||
running: false,
|
||||
transport_tier: 'public_degraded',
|
||||
transport_active: 'public_degraded',
|
||||
}));
|
||||
const prepareWormholeInteractiveLane = vi.fn();
|
||||
const fetchWormholeSettings = vi.fn(async () => ({
|
||||
enabled: false,
|
||||
anonymous_mode: false,
|
||||
}));
|
||||
const purgeBrowserContactGraph = vi.fn();
|
||||
const purgeBrowserSigningMaterial = vi.fn();
|
||||
const setSecureModeCached = vi.fn();
|
||||
const getNodeIdentity = vi.fn(() => null);
|
||||
const generateNodeKeys = vi.fn(async () => ({}));
|
||||
const purgeBrowserDmState = vi.fn(async () => {});
|
||||
const fetchInfonetNodeStatusSnapshot = vi.fn(async () => ({
|
||||
enabled: false,
|
||||
peers_ready: false,
|
||||
identity_ready: false,
|
||||
}));
|
||||
const requestMeshTerminalOpen = vi.fn();
|
||||
const subscribeSecureMeshTerminalLauncherOpen = vi.fn(() => () => {});
|
||||
const classifyUpdateRuntime = vi.fn(() => ({
|
||||
action: 'auto_apply',
|
||||
detail: 'test',
|
||||
}));
|
||||
const getDesktopUpdateContext = vi.fn(() => ({
|
||||
packaged: false,
|
||||
ownsLocalBackend: false,
|
||||
}));
|
||||
const getPreferredManualUpdateUrl = vi.fn(() => 'https://example.test/releases/latest');
|
||||
const getUpdateAction = vi.fn(() => 'auto_apply');
|
||||
const controlPlaneFetch = vi.fn();
|
||||
|
||||
vi.mock('@/mesh/wormholeIdentityClient', () => ({
|
||||
fetchWormholeStatus,
|
||||
prepareWormholeInteractiveLane,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/wormholeClient', () => ({
|
||||
fetchWormholeSettings,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshIdentity', () => ({
|
||||
purgeBrowserContactGraph,
|
||||
purgeBrowserSigningMaterial,
|
||||
setSecureModeCached,
|
||||
getNodeIdentity,
|
||||
generateNodeKeys,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshDmWorkerClient', () => ({
|
||||
purgeBrowserDmState,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/controlPlaneStatusClient', () => ({
|
||||
fetchInfonetNodeStatusSnapshot,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/meshTerminalLauncher', () => ({
|
||||
requestMeshTerminalOpen,
|
||||
subscribeSecureMeshTerminalLauncherOpen,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/updateRuntime', () => ({
|
||||
classifyUpdateRuntime,
|
||||
getDesktopUpdateContext,
|
||||
getPreferredManualUpdateUrl,
|
||||
getUpdateAction,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/controlPlane', () => ({
|
||||
controlPlaneFetch,
|
||||
}));
|
||||
|
||||
describe('TopRightControls terminal launcher', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
fetchWormholeStatus.mockResolvedValue({
|
||||
ready: false,
|
||||
running: false,
|
||||
transport_tier: 'public_degraded',
|
||||
transport_active: 'public_degraded',
|
||||
});
|
||||
fetchWormholeSettings.mockResolvedValue({
|
||||
enabled: false,
|
||||
anonymous_mode: false,
|
||||
});
|
||||
fetchInfonetNodeStatusSnapshot.mockResolvedValue({
|
||||
enabled: false,
|
||||
peers_ready: false,
|
||||
identity_ready: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('opens the terminal immediately while Wormhole prep continues in the background', async () => {
|
||||
const prep = deferred<{
|
||||
ready: boolean;
|
||||
settingsEnabled: boolean;
|
||||
transportTier: string;
|
||||
identity: null;
|
||||
}>();
|
||||
prepareWormholeInteractiveLane.mockReturnValue(prep.promise);
|
||||
|
||||
const { default: TopRightControls } = await import('@/components/TopRightControls');
|
||||
const onTerminalToggle = vi.fn();
|
||||
|
||||
render(<TopRightControls onTerminalToggle={onTerminalToggle} />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /terminal/i }));
|
||||
expect(await screen.findByRole('button', { name: /activate wormhole/i })).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /activate wormhole/i }));
|
||||
|
||||
await waitFor(() => expect(onTerminalToggle).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('button', { name: /activate wormhole/i })).toBeNull(),
|
||||
);
|
||||
expect(prepareWormholeInteractiveLane).toHaveBeenCalledWith({ bootstrapIdentity: true });
|
||||
|
||||
prep.resolve({
|
||||
ready: true,
|
||||
settingsEnabled: true,
|
||||
transportTier: 'private_control_only',
|
||||
identity: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
formatLegacyCompatibilitySeenAt,
|
||||
hasLegacyCompatibilityActivity,
|
||||
summarizeLegacyCompatibility,
|
||||
type LegacyCompatibilitySnapshot,
|
||||
} from '@/mesh/wormholeCompatibility';
|
||||
|
||||
describe('wormholeCompatibility helpers', () => {
|
||||
it('summarizes empty snapshots with zeroed metrics', () => {
|
||||
const items = summarizeLegacyCompatibility(undefined);
|
||||
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0]).toMatchObject({
|
||||
key: 'legacy_node_id_binding',
|
||||
blocked: false,
|
||||
count: 0,
|
||||
blockedCount: 0,
|
||||
targetVersion: 'n/a',
|
||||
targetDate: 'n/a',
|
||||
recentTargets: [],
|
||||
});
|
||||
expect(items[1]).toMatchObject({
|
||||
key: 'legacy_agent_id_lookup',
|
||||
blocked: false,
|
||||
count: 0,
|
||||
blockedCount: 0,
|
||||
targetVersion: 'n/a',
|
||||
targetDate: 'n/a',
|
||||
recentTargets: [],
|
||||
});
|
||||
expect(hasLegacyCompatibilityActivity(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('formats legacy usage, block state, and recent targets', () => {
|
||||
const snapshot: LegacyCompatibilitySnapshot = {
|
||||
sunset: {
|
||||
legacy_node_id_binding: {
|
||||
target_version: '0.10.0',
|
||||
target_date: '2026-06-01',
|
||||
blocked: true,
|
||||
},
|
||||
legacy_agent_id_lookup: {
|
||||
target_version: '0.10.0',
|
||||
target_date: '2026-06-01',
|
||||
blocked: false,
|
||||
},
|
||||
},
|
||||
usage: {
|
||||
legacy_node_id_binding: {
|
||||
count: 4,
|
||||
blocked_count: 2,
|
||||
last_seen_at: 1712345678,
|
||||
recent_targets: [
|
||||
{
|
||||
node_id: 'abcdef0123456789',
|
||||
current_node_id: 'fedcba9876543210abcdef0123456789',
|
||||
},
|
||||
],
|
||||
},
|
||||
legacy_agent_id_lookup: {
|
||||
count: 3,
|
||||
blocked_count: 1,
|
||||
last_seen_at: 1712345000,
|
||||
recent_targets: [
|
||||
{
|
||||
agent_id: 'agent-xyz-0123456789',
|
||||
lookup_kinds: ['prekey_bundle', 'dh_pubkey'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const items = summarizeLegacyCompatibility(snapshot);
|
||||
|
||||
expect(items[0]).toMatchObject({
|
||||
blocked: true,
|
||||
count: 4,
|
||||
blockedCount: 2,
|
||||
targetVersion: '0.10.0',
|
||||
targetDate: '2026-06-01',
|
||||
});
|
||||
expect(items[0].recentTargets[0]).toContain('abcdef0123...');
|
||||
expect(items[0].recentTargets[0]).toContain('fedcba9876...');
|
||||
expect(items[1]).toMatchObject({
|
||||
blocked: false,
|
||||
count: 3,
|
||||
blockedCount: 1,
|
||||
targetVersion: '0.10.0',
|
||||
targetDate: '2026-06-01',
|
||||
});
|
||||
expect(items[1].recentTargets[0]).toContain('agent-xyz-...');
|
||||
expect(items[1].recentTargets[0]).toContain('prekey_bundle, dh_pubkey');
|
||||
expect(hasLegacyCompatibilityActivity(snapshot)).toBe(true);
|
||||
});
|
||||
|
||||
it('formats seen timestamps as stable UTC text', () => {
|
||||
expect(formatLegacyCompatibilitySeenAt(0)).toBe('never');
|
||||
expect(formatLegacyCompatibilitySeenAt(1712345678)).toBe('2024-04-05 19:34Z');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user