mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-08-10 20:50:25 +02:00
v0.9.6: InfoNet hashchain, Wormhole gate encryption, mesh reputation, 16 community contributors
Gate messages now propagate via the Infonet hashchain as encrypted blobs — every node syncs them through normal chain sync while only Gate members with MLS keys can decrypt. Added mesh reputation system, peer push workers, voluntary Wormhole opt-in for node participation, fork recovery, killwormhole scripts, obfuscated terminology, and hardened the self-updater to protect encryption keys and chain state during updates. New features: Shodan search, train tracking, Sentinel Hub imagery, 8 new intelligence layers, CCTV expansion to 11,000+ cameras across 6 countries, Mesh Terminal CLI, prediction markets, desktop-shell scaffold, and comprehensive mesh test suite (215 frontend + backend tests passing). Community contributors: @wa1id, @AlborzNazari, @adust09, @Xpirix, @imqdcr, @csysp, @suranyami, @chr0n1x, @johan-martensson, @singularfailure, @smithbh, @OrfeoTerkuci, @deuza, @tm-const, @Elhard1, @ttulttul
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
import { GET as proxyGet } from '@/app/api/[...path]/route';
|
||||
import {
|
||||
DELETE as deleteAdminSession,
|
||||
GET as getAdminSession,
|
||||
POST as postAdminSession,
|
||||
} from '@/app/api/admin/session/route';
|
||||
|
||||
function extractSessionCookie(setCookie: string): string {
|
||||
return setCookie.split(';')[0] || '';
|
||||
}
|
||||
|
||||
describe('admin/session boundary hardening', () => {
|
||||
const originalAdminKey = process.env.ADMIN_KEY;
|
||||
const originalBackendUrl = process.env.BACKEND_URL;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.ADMIN_KEY = 'top-secret';
|
||||
process.env.BACKEND_URL = 'http://127.0.0.1:8000';
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.ADMIN_KEY = originalAdminKey;
|
||||
process.env.BACKEND_URL = originalBackendUrl;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('rejects invalid admin keys before minting a session', async () => {
|
||||
const req = new NextRequest('http://localhost/api/admin/session', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ adminKey: 'wrong-key' }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const res = await postAdminSession(req);
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(body.ok).toBe(false);
|
||||
expect(body.detail).toBe('Invalid admin key');
|
||||
expect(res.headers.get('set-cookie')).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts a verified admin key and reports the minted session as present', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const req = new NextRequest('http://localhost/api/admin/session', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ adminKey: 'top-secret' }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const res = await postAdminSession(req);
|
||||
const cookie = extractSessionCookie(res.headers.get('set-cookie') || '');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(cookie).toContain('sb_admin_session=');
|
||||
expect(res.headers.get('cache-control')).toContain('no-store');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const getReq = new NextRequest('http://localhost/api/admin/session', {
|
||||
method: 'GET',
|
||||
headers: { cookie },
|
||||
});
|
||||
const getRes = await getAdminSession(getReq);
|
||||
const getBody = await getRes.json();
|
||||
|
||||
expect(getBody.ok).toBe(true);
|
||||
expect(getBody.hasSession).toBe(true);
|
||||
expect(getRes.headers.get('cache-control')).toContain('no-store');
|
||||
|
||||
const deleteReq = new NextRequest('http://localhost/api/admin/session', {
|
||||
method: 'DELETE',
|
||||
headers: { cookie },
|
||||
});
|
||||
const deleteRes = await deleteAdminSession(deleteReq);
|
||||
expect(deleteRes.status).toBe(200);
|
||||
expect(deleteRes.headers.get('cache-control')).toContain('no-store');
|
||||
});
|
||||
|
||||
it('invalidates the previous admin session token when a new one is minted', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const firstReq = new NextRequest('http://localhost/api/admin/session', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ adminKey: 'top-secret' }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
const firstRes = await postAdminSession(firstReq);
|
||||
const firstCookie = extractSessionCookie(firstRes.headers.get('set-cookie') || '');
|
||||
|
||||
const secondReq = new NextRequest('http://localhost/api/admin/session', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ adminKey: 'top-secret' }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
cookie: firstCookie,
|
||||
},
|
||||
});
|
||||
const secondRes = await postAdminSession(secondReq);
|
||||
const secondCookie = extractSessionCookie(secondRes.headers.get('set-cookie') || '');
|
||||
|
||||
expect(secondCookie).toContain('sb_admin_session=');
|
||||
expect(secondCookie).not.toBe(firstCookie);
|
||||
|
||||
const oldSessionCheck = await getAdminSession(
|
||||
new NextRequest('http://localhost/api/admin/session', {
|
||||
method: 'GET',
|
||||
headers: { cookie: firstCookie },
|
||||
}),
|
||||
);
|
||||
const oldBody = await oldSessionCheck.json();
|
||||
expect(oldBody.hasSession).toBe(false);
|
||||
|
||||
const newSessionCheck = await getAdminSession(
|
||||
new NextRequest('http://localhost/api/admin/session', {
|
||||
method: 'GET',
|
||||
headers: { cookie: secondCookie },
|
||||
}),
|
||||
);
|
||||
const newBody = await newSessionCheck.json();
|
||||
expect(newBody.hasSession).toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('rejects session minting when frontend admin key is set but backend has no configured admin key', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ detail: 'Forbidden — admin key not configured' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const req = new NextRequest('http://localhost/api/admin/session', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ adminKey: 'top-secret' }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const res = await postAdminSession(req);
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(body.ok).toBe(false);
|
||||
expect(body.detail).toBe('Forbidden — admin key not configured');
|
||||
expect(res.headers.get('set-cookie')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not forward raw x-admin-key headers through the sensitive proxy path', async () => {
|
||||
process.env.ADMIN_KEY = '';
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const req = new NextRequest('http://localhost/api/settings/api-keys', {
|
||||
method: 'GET',
|
||||
headers: { 'x-admin-key': 'browser-supplied-key' },
|
||||
});
|
||||
|
||||
const res = await proxyGet(req, { params: Promise.resolve({ path: ['settings', 'api-keys'] }) });
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.ok).toBe(true);
|
||||
expect(res.headers.get('cache-control')).toContain('no-store');
|
||||
|
||||
const forwarded = fetchMock.mock.calls[0]?.[1];
|
||||
const forwardedHeaders = new Headers((forwarded as RequestInit | undefined)?.headers);
|
||||
expect(forwardedHeaders.get('X-Admin-Key')).toBeNull();
|
||||
});
|
||||
|
||||
it('forwards the minted admin session to sensitive proxy paths and preserves upstream errors', async () => {
|
||||
const verifyMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', verifyMock);
|
||||
|
||||
const sessionReq = new NextRequest('http://localhost/api/admin/session', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ adminKey: 'top-secret' }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
const sessionRes = await postAdminSession(sessionReq);
|
||||
const cookie = extractSessionCookie(sessionRes.headers.get('set-cookie') || '');
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ detail: 'Forbidden upstream' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const req = new NextRequest('http://localhost/api/wormhole/identity', {
|
||||
method: 'GET',
|
||||
headers: { cookie },
|
||||
});
|
||||
|
||||
const res = await proxyGet(req, { params: Promise.resolve({ path: ['wormhole', 'identity'] }) });
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(body.detail).toBe('Forbidden upstream');
|
||||
expect(res.headers.get('cache-control')).toContain('no-store');
|
||||
|
||||
const forwarded = fetchMock.mock.calls[0]?.[1];
|
||||
const forwardedHeaders = new Headers((forwarded as RequestInit | undefined)?.headers);
|
||||
expect(forwardedHeaders.get('X-Admin-Key')).toBe('top-secret');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const primeAdminSession = vi.fn();
|
||||
const localControlFetch = vi.fn();
|
||||
const hasLocalControlBridge = vi.fn();
|
||||
const canInvokeLocalControl = vi.fn();
|
||||
|
||||
vi.mock('@/lib/adminSession', () => ({
|
||||
primeAdminSession,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/localControlTransport', () => ({
|
||||
localControlFetch,
|
||||
hasLocalControlBridge,
|
||||
canInvokeLocalControl,
|
||||
}));
|
||||
|
||||
describe('controlPlane native boundary', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
primeAdminSession.mockReset();
|
||||
localControlFetch.mockReset();
|
||||
hasLocalControlBridge.mockReset();
|
||||
canInvokeLocalControl.mockReset();
|
||||
localControlFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('skips browser admin-session priming when a native bridge can invoke the request', async () => {
|
||||
hasLocalControlBridge.mockReturnValue(true);
|
||||
canInvokeLocalControl.mockReturnValue(true);
|
||||
|
||||
const mod = await import('@/lib/controlPlane');
|
||||
await mod.controlPlaneFetch('/api/wormhole/gate/message/compose', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ gate_id: 'infonet', plaintext: 'hello' }),
|
||||
});
|
||||
|
||||
expect(primeAdminSession).not.toHaveBeenCalled();
|
||||
expect(localControlFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('still primes browser admin-session when no native invoke path exists', async () => {
|
||||
hasLocalControlBridge.mockReturnValue(false);
|
||||
canInvokeLocalControl.mockReturnValue(false);
|
||||
|
||||
const mod = await import('@/lib/controlPlane');
|
||||
await mod.controlPlaneFetch('/api/wormhole/identity');
|
||||
|
||||
expect(primeAdminSession).toHaveBeenCalledTimes(1);
|
||||
expect(localControlFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
getDesktopNativeControlAuditReport,
|
||||
installDesktopControlBridge,
|
||||
} from '@/lib/desktopBridge';
|
||||
|
||||
describe('desktopBridge native audit access', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('returns the runtime audit report when available', () => {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
value: {},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
installDesktopControlBridge({
|
||||
invokeLocalControl: vi.fn(),
|
||||
getNativeControlAuditReport: vi.fn(() => ({
|
||||
totalEvents: 2,
|
||||
totalRecorded: 2,
|
||||
recent: [],
|
||||
byOutcome: { allowed: 2 },
|
||||
})),
|
||||
});
|
||||
|
||||
expect(getDesktopNativeControlAuditReport(5)).toEqual(
|
||||
expect.objectContaining({
|
||||
totalEvents: 2,
|
||||
totalRecorded: 2,
|
||||
byOutcome: { allowed: 2 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null when no runtime audit report is exposed', () => {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
value: {},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
installDesktopControlBridge({
|
||||
invokeLocalControl: vi.fn(),
|
||||
});
|
||||
|
||||
expect(getDesktopNativeControlAuditReport(5)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
describeNativeControlError,
|
||||
extractGateTargetRef,
|
||||
} from '../../lib/desktopControlContract';
|
||||
|
||||
describe('extractGateTargetRef', () => {
|
||||
it('extracts gate_id from gate key rotation payload', () => {
|
||||
expect(
|
||||
extractGateTargetRef('wormhole.gate.key.rotate', { gate_id: 'infonet', reason: 'test' }),
|
||||
).toBe('infonet');
|
||||
});
|
||||
|
||||
it('extracts gate_id from gate message compose payload', () => {
|
||||
expect(
|
||||
extractGateTargetRef('wormhole.gate.message.compose', { gate_id: 'ops', plaintext: 'hi' }),
|
||||
).toBe('ops');
|
||||
});
|
||||
|
||||
it('extracts gate_id from gate proof payload', () => {
|
||||
expect(extractGateTargetRef('wormhole.gate.proof', { gate_id: 'alpha' })).toBe('alpha');
|
||||
});
|
||||
|
||||
it('extracts gate_id from gate message post payload', () => {
|
||||
expect(
|
||||
extractGateTargetRef('wormhole.gate.message.post', { gate_id: 'ops', plaintext: 'hi' }),
|
||||
).toBe('ops');
|
||||
});
|
||||
|
||||
it('extracts gate_id from gate persona list payload', () => {
|
||||
expect(
|
||||
extractGateTargetRef('wormhole.gate.personas.get', { gate_id: 'alpha' }),
|
||||
).toBe('alpha');
|
||||
});
|
||||
|
||||
it('returns undefined for non-gate commands', () => {
|
||||
expect(extractGateTargetRef('wormhole.status', undefined)).toBeUndefined();
|
||||
expect(extractGateTargetRef('settings.news.get', undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when payload has no gate_id', () => {
|
||||
expect(extractGateTargetRef('wormhole.gate.key.rotate', { reason: 'test' })).toBeUndefined();
|
||||
expect(extractGateTargetRef('wormhole.gate.key.rotate', null)).toBeUndefined();
|
||||
expect(extractGateTargetRef('wormhole.gate.key.rotate', 'not-an-object')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when gate_id is empty string', () => {
|
||||
expect(extractGateTargetRef('wormhole.gate.key.get', { gate_id: '' })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeNativeControlError', () => {
|
||||
it('describes profile mismatch errors', () => {
|
||||
const err = new Error('native_control_profile_mismatch:settings_only:wormhole_gate_key');
|
||||
const msg = describeNativeControlError(err);
|
||||
expect(msg).toContain('Denied');
|
||||
expect(msg).toContain('session profile');
|
||||
});
|
||||
|
||||
it('describes capability denied errors', () => {
|
||||
const err = new Error('native_control_capability_denied:wormhole_gate_key');
|
||||
const msg = describeNativeControlError(err);
|
||||
expect(msg).toContain('Denied');
|
||||
expect(msg).toContain('capability');
|
||||
});
|
||||
|
||||
it('describes capability mismatch errors', () => {
|
||||
const err = new Error('native_control_capability_mismatch:wormhole_gate_content:wormhole_gate_key');
|
||||
const msg = describeNativeControlError(err);
|
||||
expect(msg).toContain('Denied');
|
||||
expect(msg).toContain('capability');
|
||||
});
|
||||
|
||||
it('describes shim enforcement inactivity errors', () => {
|
||||
const err = new Error('desktop_runtime_shim_enforcement_inactive');
|
||||
const msg = describeNativeControlError(err);
|
||||
expect(msg).toContain('Denied');
|
||||
expect(msg).toContain('native runtime');
|
||||
});
|
||||
|
||||
it('returns null for unrelated errors', () => {
|
||||
expect(describeNativeControlError(new Error('network_error'))).toBeNull();
|
||||
expect(describeNativeControlError('some string')).toBeNull();
|
||||
expect(describeNativeControlError(null)).toBeNull();
|
||||
expect(describeNativeControlError(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('handles plain string errors', () => {
|
||||
expect(
|
||||
describeNativeControlError('native_control_profile_mismatch:foo'),
|
||||
).toContain('Denied');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
commandToHttpRequest,
|
||||
httpRequestToInvokeRequest,
|
||||
} from '@/lib/desktopControlRouting';
|
||||
|
||||
describe('desktopControlRouting', () => {
|
||||
it('maps invoke commands to HTTP requests', () => {
|
||||
expect(commandToHttpRequest('wormhole.connect')).toEqual({
|
||||
path: '/api/wormhole/connect',
|
||||
method: 'POST',
|
||||
});
|
||||
expect(commandToHttpRequest('wormhole.gate.key.get', { gate_id: 'infonet' })).toEqual({
|
||||
path: '/api/wormhole/gate/infonet/key',
|
||||
method: 'GET',
|
||||
});
|
||||
expect(commandToHttpRequest('settings.news.reset')).toEqual({
|
||||
path: '/api/settings/news-feeds/reset',
|
||||
method: 'POST',
|
||||
});
|
||||
expect(commandToHttpRequest('wormhole.gate.proof', { gate_id: 'infonet' })).toEqual({
|
||||
path: '/api/wormhole/gate/proof',
|
||||
method: 'POST',
|
||||
payload: { gate_id: 'infonet' },
|
||||
});
|
||||
expect(
|
||||
commandToHttpRequest('wormhole.gate.message.post', {
|
||||
gate_id: 'ops',
|
||||
plaintext: 'hello',
|
||||
}),
|
||||
).toEqual({
|
||||
path: '/api/wormhole/gate/message/post',
|
||||
method: 'POST',
|
||||
payload: { gate_id: 'ops', plaintext: 'hello' },
|
||||
});
|
||||
});
|
||||
|
||||
it('maps HTTP settings writes back to invoke requests', () => {
|
||||
expect(
|
||||
httpRequestToInvokeRequest(
|
||||
'/api/settings/privacy-profile',
|
||||
'PUT',
|
||||
JSON.stringify({ profile: 'high' }),
|
||||
),
|
||||
).toEqual({
|
||||
command: 'settings.privacy.set',
|
||||
payload: { profile: 'high' },
|
||||
});
|
||||
expect(
|
||||
httpRequestToInvokeRequest(
|
||||
'/api/wormhole/gate/key/rotate',
|
||||
'POST',
|
||||
JSON.stringify({ gate_id: 'infonet', reason: 'operator_reset' }),
|
||||
),
|
||||
).toEqual({
|
||||
command: 'wormhole.gate.key.rotate',
|
||||
payload: { gate_id: 'infonet', reason: 'operator_reset' },
|
||||
});
|
||||
expect(
|
||||
httpRequestToInvokeRequest(
|
||||
'/api/wormhole/gate/proof',
|
||||
'POST',
|
||||
JSON.stringify({ gate_id: 'infonet' }),
|
||||
),
|
||||
).toEqual({
|
||||
command: 'wormhole.gate.proof',
|
||||
payload: { gate_id: 'infonet' },
|
||||
});
|
||||
expect(
|
||||
httpRequestToInvokeRequest(
|
||||
'/api/wormhole/gate/messages/decrypt',
|
||||
'POST',
|
||||
JSON.stringify({
|
||||
messages: [
|
||||
{
|
||||
gate_id: 'infonet',
|
||||
epoch: 3,
|
||||
ciphertext: 'ct',
|
||||
nonce: 'n',
|
||||
sender_ref: 'ref',
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
command: 'wormhole.gate.messages.decrypt',
|
||||
payload: {
|
||||
messages: [
|
||||
{
|
||||
gate_id: 'infonet',
|
||||
epoch: 3,
|
||||
ciphertext: 'ct',
|
||||
nonce: 'n',
|
||||
sender_ref: 'ref',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for unsupported paths', () => {
|
||||
expect(httpRequestToInvokeRequest('/api/mesh/status', 'GET')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createHttpBackedDesktopRuntime } from '@/lib/desktopRuntimeShim';
|
||||
|
||||
describe('desktopRuntimeShim enforcement guard', () => {
|
||||
const fetchMock = vi.fn();
|
||||
const warnMock = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('refuses strictly enforced commands in the HTTP-backed shim', async () => {
|
||||
const runtime = createHttpBackedDesktopRuntime();
|
||||
|
||||
await expect(
|
||||
runtime.invokeLocalControl?.(
|
||||
'wormhole.gate.key.rotate',
|
||||
{ gate_id: 'infonet', reason: 'operator_reset' },
|
||||
{
|
||||
capability: 'wormhole_gate_key',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
enforceProfileHint: true,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow('desktop_runtime_shim_enforcement_inactive');
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(warnMock).toHaveBeenCalledWith(
|
||||
'[desktop-shim] strict native session-profile enforcement is unavailable in the HTTP-backed shim',
|
||||
expect.objectContaining({
|
||||
command: 'wormhole.gate.key.rotate',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
}),
|
||||
);
|
||||
expect(runtime.getNativeControlAuditReport?.(5)).toEqual(
|
||||
expect.objectContaining({
|
||||
totalEvents: 1,
|
||||
totalRecorded: 1,
|
||||
byOutcome: expect.objectContaining({ shim_refused: 1 }),
|
||||
lastDenied: expect.objectContaining({
|
||||
command: 'wormhole.gate.key.rotate',
|
||||
targetRef: 'infonet',
|
||||
outcome: 'shim_refused',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('localControlTransport capability metadata', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('attaches capability intent metadata when invoking the native bridge', async () => {
|
||||
const invoke = vi.fn(async () => ({ ok: true }));
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
value: {
|
||||
__SHADOWBROKER_LOCAL_CONTROL__: {
|
||||
invoke,
|
||||
},
|
||||
},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const mod = await import('@/lib/localControlTransport');
|
||||
await mod.localControlFetch('/api/wormhole/gate/key/rotate', {
|
||||
method: 'POST',
|
||||
capabilityIntent: 'wormhole_gate_key',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
enforceProfileHint: true,
|
||||
body: JSON.stringify({ gate_id: 'infonet', reason: 'operator_reset' }),
|
||||
});
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith({
|
||||
command: 'wormhole.gate.key.rotate',
|
||||
payload: { gate_id: 'infonet', reason: 'operator_reset' },
|
||||
meta: {
|
||||
capability: 'wormhole_gate_key',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
enforceProfileHint: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to plain fetch when the HTTP-backed shim refuses strict enforcement', async () => {
|
||||
const invoke = vi.fn(async () => {
|
||||
throw new Error('desktop_runtime_shim_enforcement_inactive');
|
||||
});
|
||||
const fetchMock = vi.fn(async () =>
|
||||
new Response(JSON.stringify({ ok: true, gate_id: 'infonet' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
value: {
|
||||
__SHADOWBROKER_LOCAL_CONTROL__: {
|
||||
invoke,
|
||||
},
|
||||
},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const mod = await import('@/lib/localControlTransport');
|
||||
const res = await mod.localControlFetch('/api/wormhole/gate/proof', {
|
||||
method: 'POST',
|
||||
capabilityIntent: 'wormhole_gate_content',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
enforceProfileHint: true,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ gate_id: 'infonet' }),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
expect(invoke).toHaveBeenCalledOnce();
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/wormhole/gate/proof',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ gate_id: 'infonet' }),
|
||||
}),
|
||||
);
|
||||
expect(data).toEqual({ ok: true, gate_id: 'infonet' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createNativeControlRouter } from '../../../../desktop-shell/src/nativeControlRouter';
|
||||
|
||||
describe('nativeControlRouter capability scaffolding', () => {
|
||||
it('rejects mismatched capability intent', async () => {
|
||||
const exec = async <T = unknown>(): Promise<T> => ({ ok: true } as T);
|
||||
const router = createNativeControlRouter(
|
||||
{
|
||||
backendBaseUrl: 'http://127.0.0.1:8000',
|
||||
wormholeBaseUrl: 'http://127.0.0.1:8787',
|
||||
},
|
||||
exec,
|
||||
);
|
||||
|
||||
await expect(
|
||||
router.invoke(
|
||||
'wormhole.gate.key.rotate',
|
||||
{ gate_id: 'infonet', reason: 'operator_reset' },
|
||||
{ capability: 'wormhole_gate_content' },
|
||||
),
|
||||
).rejects.toThrow('native_control_capability_mismatch');
|
||||
});
|
||||
|
||||
it('rejects commands outside the allowed native capability set', async () => {
|
||||
const exec = async <T = unknown>(): Promise<T> => ({ ok: true } as T);
|
||||
const router = createNativeControlRouter(
|
||||
{
|
||||
backendBaseUrl: 'http://127.0.0.1:8000',
|
||||
wormholeBaseUrl: 'http://127.0.0.1:8787',
|
||||
allowedCapabilities: ['wormhole_gate_content'],
|
||||
},
|
||||
exec,
|
||||
);
|
||||
|
||||
await expect(
|
||||
router.invoke(
|
||||
'wormhole.gate.key.rotate',
|
||||
{ gate_id: 'infonet', reason: 'operator_reset' },
|
||||
{ capability: 'wormhole_gate_key' },
|
||||
),
|
||||
).rejects.toThrow('native_control_capability_denied');
|
||||
});
|
||||
|
||||
it('audits session-profile mismatch without denying by default', async () => {
|
||||
const auditControlUse = vi.fn();
|
||||
const exec = async <T = unknown>(): Promise<T> => ({ ok: true } as T);
|
||||
const router = createNativeControlRouter(
|
||||
{
|
||||
backendBaseUrl: 'http://127.0.0.1:8000',
|
||||
wormholeBaseUrl: 'http://127.0.0.1:8787',
|
||||
sessionProfile: 'settings_only',
|
||||
auditControlUse,
|
||||
},
|
||||
exec,
|
||||
);
|
||||
|
||||
const result = await router.invoke(
|
||||
'wormhole.gate.key.rotate',
|
||||
{ gate_id: 'infonet', reason: 'operator_reset' },
|
||||
{ capability: 'wormhole_gate_key', sessionProfileHint: 'gate_operator' },
|
||||
);
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(auditControlUse).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: 'wormhole.gate.key.rotate',
|
||||
expectedCapability: 'wormhole_gate_key',
|
||||
targetRef: 'infonet',
|
||||
sessionProfile: 'settings_only',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
profileAllows: false,
|
||||
enforced: false,
|
||||
outcome: 'profile_warn',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('includes targetRef in audit events for gate commands', async () => {
|
||||
const auditControlUse = vi.fn();
|
||||
const exec = async <T = unknown>(): Promise<T> => ({ ok: true } as T);
|
||||
const router = createNativeControlRouter(
|
||||
{
|
||||
backendBaseUrl: 'http://127.0.0.1:8000',
|
||||
wormholeBaseUrl: 'http://127.0.0.1:8787',
|
||||
auditControlUse,
|
||||
},
|
||||
exec,
|
||||
);
|
||||
|
||||
await router.invoke(
|
||||
'wormhole.gate.message.compose',
|
||||
{ gate_id: 'ops-room', plaintext: 'hello' },
|
||||
{ capability: 'wormhole_gate_content' },
|
||||
);
|
||||
|
||||
expect(auditControlUse).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: 'wormhole.gate.message.compose',
|
||||
targetRef: 'ops-room',
|
||||
outcome: 'allowed',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('omits targetRef for non-gate commands', async () => {
|
||||
const auditControlUse = vi.fn();
|
||||
const exec = async <T = unknown>(): Promise<T> => ({ ok: true } as T);
|
||||
const router = createNativeControlRouter(
|
||||
{
|
||||
backendBaseUrl: 'http://127.0.0.1:8000',
|
||||
wormholeBaseUrl: 'http://127.0.0.1:8787',
|
||||
auditControlUse,
|
||||
},
|
||||
exec,
|
||||
);
|
||||
|
||||
await router.invoke('wormhole.status', undefined);
|
||||
|
||||
const event = auditControlUse.mock.calls[0][0];
|
||||
expect(event.command).toBe('wormhole.status');
|
||||
expect(event.targetRef).toBeUndefined();
|
||||
});
|
||||
|
||||
it('can enforce session-profile mismatch when explicitly enabled', async () => {
|
||||
const exec = async <T = unknown>(): Promise<T> => ({ ok: true } as T);
|
||||
const router = createNativeControlRouter(
|
||||
{
|
||||
backendBaseUrl: 'http://127.0.0.1:8000',
|
||||
wormholeBaseUrl: 'http://127.0.0.1:8787',
|
||||
sessionProfile: 'settings_only',
|
||||
enforceSessionProfile: true,
|
||||
},
|
||||
exec,
|
||||
);
|
||||
|
||||
await expect(
|
||||
router.invoke(
|
||||
'wormhole.gate.key.rotate',
|
||||
{ gate_id: 'infonet', reason: 'operator_reset' },
|
||||
{ capability: 'wormhole_gate_key', sessionProfileHint: 'gate_operator' },
|
||||
),
|
||||
).rejects.toThrow('native_control_profile_mismatch');
|
||||
});
|
||||
|
||||
it('can enforce a hinted session profile for a narrow gate-key command', async () => {
|
||||
const exec = async <T = unknown>(): Promise<T> => ({ ok: true } as T);
|
||||
const router = createNativeControlRouter(
|
||||
{
|
||||
backendBaseUrl: 'http://127.0.0.1:8000',
|
||||
wormholeBaseUrl: 'http://127.0.0.1:8787',
|
||||
sessionProfile: 'settings_only',
|
||||
},
|
||||
exec,
|
||||
);
|
||||
|
||||
await expect(
|
||||
router.invoke(
|
||||
'wormhole.gate.key.rotate',
|
||||
{ gate_id: 'infonet', reason: 'operator_reset' },
|
||||
{
|
||||
capability: 'wormhole_gate_key',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
enforceProfileHint: true,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow('native_control_profile_mismatch');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createRuntimeBridge } from '../../../../desktop-shell/src/runtimeBridge';
|
||||
|
||||
describe('runtimeBridge session profile routing', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('uses the invocation session profile hint when the runtime context is unscoped', async () => {
|
||||
const auditControlUse = vi.fn();
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () =>
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const runtime = createRuntimeBridge({
|
||||
backendBaseUrl: 'http://127.0.0.1:8000',
|
||||
wormholeBaseUrl: 'http://127.0.0.1:8787',
|
||||
auditControlUse,
|
||||
});
|
||||
|
||||
await runtime.invokeLocalControl(
|
||||
'wormhole.gate.key.rotate',
|
||||
{ gate_id: 'infonet', reason: 'operator_reset' },
|
||||
{
|
||||
capability: 'wormhole_gate_key',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
enforceProfileHint: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(auditControlUse).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: 'wormhole.gate.key.rotate',
|
||||
targetRef: 'infonet',
|
||||
sessionProfile: 'gate_operator',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
enforceProfileHint: true,
|
||||
profileAllows: true,
|
||||
outcome: 'allowed',
|
||||
}),
|
||||
);
|
||||
|
||||
const report = runtime.getNativeControlAuditReport?.(5);
|
||||
expect(report).toEqual(
|
||||
expect.objectContaining({
|
||||
totalEvents: 1,
|
||||
totalRecorded: 1,
|
||||
byOutcome: expect.objectContaining({ allowed: 1 }),
|
||||
}),
|
||||
);
|
||||
expect(report?.recent[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
command: 'wormhole.gate.key.rotate',
|
||||
targetRef: 'infonet',
|
||||
sessionProfile: 'gate_operator',
|
||||
outcome: 'allowed',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves an explicitly scoped runtime session profile over the invocation hint', async () => {
|
||||
const auditControlUse = vi.fn();
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () =>
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const runtime = createRuntimeBridge({
|
||||
backendBaseUrl: 'http://127.0.0.1:8000',
|
||||
wormholeBaseUrl: 'http://127.0.0.1:8787',
|
||||
sessionProfile: 'settings_only',
|
||||
auditControlUse,
|
||||
});
|
||||
|
||||
await runtime.invokeLocalControl(
|
||||
'wormhole.gate.key.rotate',
|
||||
{ gate_id: 'infonet', reason: 'operator_reset' },
|
||||
{
|
||||
capability: 'wormhole_gate_key',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
},
|
||||
);
|
||||
|
||||
expect(auditControlUse).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: 'wormhole.gate.key.rotate',
|
||||
sessionProfile: 'settings_only',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
profileAllows: false,
|
||||
outcome: 'profile_warn',
|
||||
}),
|
||||
);
|
||||
|
||||
const report = runtime.getNativeControlAuditReport?.(5);
|
||||
expect(report).toEqual(
|
||||
expect.objectContaining({
|
||||
totalEvents: 1,
|
||||
totalRecorded: 1,
|
||||
byOutcome: expect.objectContaining({ profile_warn: 1 }),
|
||||
lastProfileMismatch: expect.objectContaining({
|
||||
command: 'wormhole.gate.key.rotate',
|
||||
sessionProfile: 'settings_only',
|
||||
outcome: 'profile_warn',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('denies a strictly hinted gate-key command when the runtime is pinned to another profile', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () =>
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const runtime = createRuntimeBridge({
|
||||
backendBaseUrl: 'http://127.0.0.1:8000',
|
||||
wormholeBaseUrl: 'http://127.0.0.1:8787',
|
||||
sessionProfile: 'settings_only',
|
||||
});
|
||||
|
||||
await expect(
|
||||
runtime.invokeLocalControl(
|
||||
'wormhole.gate.key.rotate',
|
||||
{ gate_id: 'infonet', reason: 'operator_reset' },
|
||||
{
|
||||
capability: 'wormhole_gate_key',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
enforceProfileHint: true,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow('native_control_profile_mismatch');
|
||||
|
||||
const report = runtime.getNativeControlAuditReport?.(5);
|
||||
expect(report).toEqual(
|
||||
expect.objectContaining({
|
||||
totalEvents: 1,
|
||||
totalRecorded: 1,
|
||||
byOutcome: expect.objectContaining({ profile_denied: 1 }),
|
||||
lastDenied: expect.objectContaining({
|
||||
command: 'wormhole.gate.key.rotate',
|
||||
outcome: 'profile_denied',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,33 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
buildEarthquakesGeoJSON, buildJammingGeoJSON, buildCctvGeoJSON, buildKiwisdrGeoJSON,
|
||||
buildFirmsGeoJSON, buildInternetOutagesGeoJSON, buildDataCentersGeoJSON,
|
||||
buildGdeltGeoJSON, buildLiveuaGeoJSON, buildFrontlineGeoJSON, buildMilitaryBasesGeoJSON
|
||||
buildEarthquakesGeoJSON,
|
||||
buildJammingGeoJSON,
|
||||
buildCctvGeoJSON,
|
||||
buildKiwisdrGeoJSON,
|
||||
buildFirmsGeoJSON,
|
||||
buildInternetOutagesGeoJSON,
|
||||
buildDataCentersGeoJSON,
|
||||
buildGdeltGeoJSON,
|
||||
buildLiveuaGeoJSON,
|
||||
buildFrontlineGeoJSON,
|
||||
buildScannerGeoJSON,
|
||||
buildMilitaryBasesGeoJSON,
|
||||
buildTrainsGeoJSON,
|
||||
} from '@/components/map/geoJSONBuilders';
|
||||
import type { Earthquake, GPSJammingZone, FireHotspot, InternetOutage, DataCenter, GDELTIncident, LiveUAmapIncident, CCTVCamera, KiwiSDR, MilitaryBase } from '@/types/dashboard';
|
||||
import type {
|
||||
Earthquake,
|
||||
GPSJammingZone,
|
||||
FireHotspot,
|
||||
InternetOutage,
|
||||
DataCenter,
|
||||
GDELTIncident,
|
||||
LiveUAmapIncident,
|
||||
CCTVCamera,
|
||||
KiwiSDR,
|
||||
Scanner,
|
||||
MilitaryBase,
|
||||
Train,
|
||||
} from '@/types/dashboard';
|
||||
|
||||
// ─── Military Bases ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -34,289 +57,500 @@ describe('buildMilitaryBasesGeoJSON', () => {
|
||||
// ─── Earthquakes ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildEarthquakesGeoJSON', () => {
|
||||
it('returns null for empty/undefined input', () => {
|
||||
expect(buildEarthquakesGeoJSON(undefined)).toBeNull();
|
||||
expect(buildEarthquakesGeoJSON([])).toBeNull();
|
||||
});
|
||||
it('returns null for empty/undefined input', () => {
|
||||
expect(buildEarthquakesGeoJSON(undefined)).toBeNull();
|
||||
expect(buildEarthquakesGeoJSON([])).toBeNull();
|
||||
});
|
||||
|
||||
it('builds valid FeatureCollection from earthquake data', () => {
|
||||
const earthquakes: Earthquake[] = [
|
||||
{ id: 'eq1', mag: 5.2, lat: 35.0, lng: 139.0, place: 'Japan' },
|
||||
{ id: 'eq2', mag: 3.1, lat: 40.0, lng: -120.0, place: 'California', title: 'Test Title' },
|
||||
];
|
||||
const result = buildEarthquakesGeoJSON(earthquakes);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.type).toBe('FeatureCollection');
|
||||
expect(result!.features).toHaveLength(2);
|
||||
it('builds valid FeatureCollection from earthquake data', () => {
|
||||
const earthquakes: Earthquake[] = [
|
||||
{ id: 'eq1', mag: 5.2, lat: 35.0, lng: 139.0, place: 'Japan' },
|
||||
{ id: 'eq2', mag: 3.1, lat: 40.0, lng: -120.0, place: 'California', title: 'Test Title' },
|
||||
];
|
||||
const result = buildEarthquakesGeoJSON(earthquakes);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.type).toBe('FeatureCollection');
|
||||
expect(result!.features).toHaveLength(2);
|
||||
|
||||
const f0 = result!.features[0];
|
||||
expect(f0.geometry).toEqual({ type: 'Point', coordinates: [139.0, 35.0] });
|
||||
expect(f0.properties?.type).toBe('earthquake');
|
||||
expect(f0.properties?.name).toContain('M5.2');
|
||||
expect(f0.properties?.name).toContain('Japan');
|
||||
});
|
||||
const f0 = result!.features[0];
|
||||
expect(f0.geometry).toEqual({ type: 'Point', coordinates: [139.0, 35.0] });
|
||||
expect(f0.properties?.type).toBe('earthquake');
|
||||
expect(f0.properties?.name).toContain('M5.2');
|
||||
expect(f0.properties?.name).toContain('Japan');
|
||||
});
|
||||
|
||||
it('filters out entries with null lat/lng', () => {
|
||||
const earthquakes = [
|
||||
{ id: 'eq1', mag: 5.0, lat: null as any, lng: 10.0, place: 'X' },
|
||||
{ id: 'eq2', mag: 3.0, lat: 20.0, lng: 30.0, place: 'Y' },
|
||||
];
|
||||
const result = buildEarthquakesGeoJSON(earthquakes);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
});
|
||||
it('filters out entries with null lat/lng', () => {
|
||||
const earthquakes = [
|
||||
{ id: 'eq1', mag: 5.0, lat: null as any, lng: 10.0, place: 'X' },
|
||||
{ id: 'eq2', mag: 3.0, lat: 20.0, lng: 30.0, place: 'Y' },
|
||||
];
|
||||
const result = buildEarthquakesGeoJSON(earthquakes);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('includes title when present', () => {
|
||||
const earthquakes: Earthquake[] = [
|
||||
{ id: 'eq1', mag: 4.0, lat: 10.0, lng: 20.0, place: 'Test', title: 'Big One' },
|
||||
];
|
||||
const result = buildEarthquakesGeoJSON(earthquakes);
|
||||
expect(result!.features[0].properties?.title).toBe('Big One');
|
||||
});
|
||||
it('includes title when present', () => {
|
||||
const earthquakes: Earthquake[] = [
|
||||
{ id: 'eq1', mag: 4.0, lat: 10.0, lng: 20.0, place: 'Test', title: 'Big One' },
|
||||
];
|
||||
const result = buildEarthquakesGeoJSON(earthquakes);
|
||||
expect(result!.features[0].properties?.title).toBe('Big One');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GPS Jamming ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildJammingGeoJSON', () => {
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildJammingGeoJSON(undefined)).toBeNull();
|
||||
expect(buildJammingGeoJSON([])).toBeNull();
|
||||
});
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildJammingGeoJSON(undefined)).toBeNull();
|
||||
expect(buildJammingGeoJSON([])).toBeNull();
|
||||
});
|
||||
|
||||
it('builds polygon features with correct opacity mapping', () => {
|
||||
const zones: GPSJammingZone[] = [
|
||||
{ lat: 50, lng: 30, severity: 'high', ratio: 0.8, degraded: 100, total: 125 },
|
||||
{ lat: 45, lng: 35, severity: 'medium', ratio: 0.5, degraded: 50, total: 100 },
|
||||
{ lat: 40, lng: 25, severity: 'low', ratio: 0.2, degraded: 20, total: 100 },
|
||||
];
|
||||
const result = buildJammingGeoJSON(zones);
|
||||
expect(result!.features).toHaveLength(3);
|
||||
expect(result!.features[0].properties?.opacity).toBe(0.45);
|
||||
expect(result!.features[1].properties?.opacity).toBe(0.3);
|
||||
expect(result!.features[2].properties?.opacity).toBe(0.18);
|
||||
});
|
||||
it('builds polygon features with correct opacity mapping', () => {
|
||||
const zones: GPSJammingZone[] = [
|
||||
{ lat: 50, lng: 30, severity: 'high', ratio: 0.8, degraded: 100, total: 125 },
|
||||
{ lat: 45, lng: 35, severity: 'medium', ratio: 0.5, degraded: 50, total: 100 },
|
||||
{ lat: 40, lng: 25, severity: 'low', ratio: 0.2, degraded: 20, total: 100 },
|
||||
];
|
||||
const result = buildJammingGeoJSON(zones);
|
||||
expect(result!.features).toHaveLength(3);
|
||||
expect(result!.features[0].properties?.opacity).toBe(0.45);
|
||||
expect(result!.features[1].properties?.opacity).toBe(0.3);
|
||||
expect(result!.features[2].properties?.opacity).toBe(0.18);
|
||||
});
|
||||
|
||||
it('builds correct 1°×1° polygon geometry', () => {
|
||||
const zones: GPSJammingZone[] = [
|
||||
{ lat: 50, lng: 30, severity: 'high', ratio: 0.8, degraded: 100, total: 125 },
|
||||
];
|
||||
const result = buildJammingGeoJSON(zones);
|
||||
const geom = result!.features[0].geometry;
|
||||
expect(geom.type).toBe('Polygon');
|
||||
if (geom.type === 'Polygon') {
|
||||
const ring = geom.coordinates[0];
|
||||
expect(ring).toHaveLength(5); // Closed ring
|
||||
expect(ring[0]).toEqual([29.5, 49.5]);
|
||||
expect(ring[2]).toEqual([30.5, 50.5]);
|
||||
}
|
||||
});
|
||||
it('builds correct 1°×1° polygon geometry', () => {
|
||||
const zones: GPSJammingZone[] = [
|
||||
{ lat: 50, lng: 30, severity: 'high', ratio: 0.8, degraded: 100, total: 125 },
|
||||
];
|
||||
const result = buildJammingGeoJSON(zones);
|
||||
const geom = result!.features[0].geometry;
|
||||
expect(geom.type).toBe('Polygon');
|
||||
if (geom.type === 'Polygon') {
|
||||
const ring = geom.coordinates[0];
|
||||
expect(ring).toHaveLength(5); // Closed ring
|
||||
expect(ring[0]).toEqual([29.5, 49.5]);
|
||||
expect(ring[2]).toEqual([30.5, 50.5]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── CCTV ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildCctvGeoJSON', () => {
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildCctvGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildCctvGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('builds features from camera data', () => {
|
||||
const cameras: CCTVCamera[] = [
|
||||
{ id: 'cam1', lat: 40.7, lon: -74.0, direction_facing: 'North', source_agency: 'DOT' },
|
||||
];
|
||||
const result = buildCctvGeoJSON(cameras);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
expect(result!.features[0].properties?.type).toBe('cctv');
|
||||
expect(result!.features[0].properties?.name).toBe('North');
|
||||
});
|
||||
it('builds features from camera data', () => {
|
||||
const cameras: CCTVCamera[] = [
|
||||
{ id: 'cam1', lat: 40.7, lon: -74.0, direction_facing: 'North', source_agency: 'DOT' },
|
||||
];
|
||||
const result = buildCctvGeoJSON(cameras);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
expect(result!.features[0].properties?.type).toBe('cctv');
|
||||
expect(result!.features[0].properties?.name).toBe('North');
|
||||
});
|
||||
|
||||
it('respects inView filter', () => {
|
||||
const cameras: CCTVCamera[] = [
|
||||
{ id: 'cam1', lat: 40.7, lon: -74.0 },
|
||||
{ id: 'cam2', lat: 10.0, lon: 20.0 },
|
||||
];
|
||||
const inView = (lat: number, _lng: number) => lat > 30;
|
||||
const result = buildCctvGeoJSON(cameras, inView);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
});
|
||||
it('respects inView filter', () => {
|
||||
const cameras: CCTVCamera[] = [
|
||||
{ id: 'cam1', lat: 40.7, lon: -74.0 },
|
||||
{ id: 'cam2', lat: 10.0, lon: 20.0 },
|
||||
];
|
||||
const inView = (lat: number, _lng: number) => lat > 30;
|
||||
const result = buildCctvGeoJSON(cameras, inView);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── KiwiSDR ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildKiwisdrGeoJSON', () => {
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildKiwisdrGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildKiwisdrGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('builds features with SDR properties', () => {
|
||||
const receivers: KiwiSDR[] = [
|
||||
{ lat: 52.0, lon: 13.0, name: 'Berlin SDR', url: 'http://test.com', users: 3, users_max: 8, bands: 'HF', antenna: 'Long Wire', location: 'Berlin' },
|
||||
];
|
||||
const result = buildKiwisdrGeoJSON(receivers);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
expect(result!.features[0].properties?.name).toBe('Berlin SDR');
|
||||
expect(result!.features[0].properties?.users).toBe(3);
|
||||
});
|
||||
it('builds features with SDR properties', () => {
|
||||
const receivers: KiwiSDR[] = [
|
||||
{
|
||||
lat: 52.0,
|
||||
lon: 13.0,
|
||||
name: 'Berlin SDR',
|
||||
url: 'http://test.com',
|
||||
users: 3,
|
||||
users_max: 8,
|
||||
bands: 'HF',
|
||||
antenna: 'Long Wire',
|
||||
location: 'Berlin',
|
||||
},
|
||||
];
|
||||
const result = buildKiwisdrGeoJSON(receivers);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
expect(result!.features[0].properties?.name).toBe('Berlin SDR');
|
||||
expect(result!.features[0].properties?.users).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── FIRMS Fires ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildFirmsGeoJSON', () => {
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildFirmsGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildFirmsGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('classifies fires by FRP thresholds', () => {
|
||||
const fires: FireHotspot[] = [
|
||||
{ lat: 10, lng: 20, frp: 150, brightness: 400, confidence: 'high', daynight: 'D', acq_date: '2024-01-01', acq_time: '1200' },
|
||||
{ lat: 11, lng: 21, frp: 50, brightness: 350, confidence: 'medium', daynight: 'N', acq_date: '2024-01-01', acq_time: '0100' },
|
||||
{ lat: 12, lng: 22, frp: 10, brightness: 300, confidence: 'low', daynight: 'D', acq_date: '2024-01-01', acq_time: '1400' },
|
||||
{ lat: 13, lng: 23, frp: 2, brightness: 250, confidence: 'low', daynight: 'D', acq_date: '2024-01-01', acq_time: '1500' },
|
||||
];
|
||||
const result = buildFirmsGeoJSON(fires);
|
||||
expect(result!.features).toHaveLength(4);
|
||||
expect(result!.features[0].properties?.iconId).toBe('fire-darkred');
|
||||
expect(result!.features[1].properties?.iconId).toBe('fire-red');
|
||||
expect(result!.features[2].properties?.iconId).toBe('fire-orange');
|
||||
expect(result!.features[3].properties?.iconId).toBe('fire-yellow');
|
||||
});
|
||||
it('classifies fires by FRP thresholds', () => {
|
||||
const fires: FireHotspot[] = [
|
||||
{
|
||||
lat: 10,
|
||||
lng: 20,
|
||||
frp: 150,
|
||||
brightness: 400,
|
||||
confidence: 'high',
|
||||
daynight: 'D',
|
||||
acq_date: '2024-01-01',
|
||||
acq_time: '1200',
|
||||
},
|
||||
{
|
||||
lat: 11,
|
||||
lng: 21,
|
||||
frp: 50,
|
||||
brightness: 350,
|
||||
confidence: 'medium',
|
||||
daynight: 'N',
|
||||
acq_date: '2024-01-01',
|
||||
acq_time: '0100',
|
||||
},
|
||||
{
|
||||
lat: 12,
|
||||
lng: 22,
|
||||
frp: 10,
|
||||
brightness: 300,
|
||||
confidence: 'low',
|
||||
daynight: 'D',
|
||||
acq_date: '2024-01-01',
|
||||
acq_time: '1400',
|
||||
},
|
||||
{
|
||||
lat: 13,
|
||||
lng: 23,
|
||||
frp: 2,
|
||||
brightness: 250,
|
||||
confidence: 'low',
|
||||
daynight: 'D',
|
||||
acq_date: '2024-01-01',
|
||||
acq_time: '1500',
|
||||
},
|
||||
];
|
||||
const result = buildFirmsGeoJSON(fires);
|
||||
expect(result!.features).toHaveLength(4);
|
||||
expect(result!.features[0].properties?.iconId).toBe('fire-darkred');
|
||||
expect(result!.features[1].properties?.iconId).toBe('fire-red');
|
||||
expect(result!.features[2].properties?.iconId).toBe('fire-orange');
|
||||
expect(result!.features[3].properties?.iconId).toBe('fire-yellow');
|
||||
});
|
||||
|
||||
it('formats daynight correctly', () => {
|
||||
const fires: FireHotspot[] = [
|
||||
{ lat: 10, lng: 20, frp: 5, brightness: 300, confidence: 'low', daynight: 'D', acq_date: '2024-01-01', acq_time: '1200' },
|
||||
{ lat: 11, lng: 21, frp: 5, brightness: 300, confidence: 'low', daynight: 'N', acq_date: '2024-01-01', acq_time: '0100' },
|
||||
];
|
||||
const result = buildFirmsGeoJSON(fires);
|
||||
expect(result!.features[0].properties?.daynight).toBe('Day');
|
||||
expect(result!.features[1].properties?.daynight).toBe('Night');
|
||||
});
|
||||
it('formats daynight correctly', () => {
|
||||
const fires: FireHotspot[] = [
|
||||
{
|
||||
lat: 10,
|
||||
lng: 20,
|
||||
frp: 5,
|
||||
brightness: 300,
|
||||
confidence: 'low',
|
||||
daynight: 'D',
|
||||
acq_date: '2024-01-01',
|
||||
acq_time: '1200',
|
||||
},
|
||||
{
|
||||
lat: 11,
|
||||
lng: 21,
|
||||
frp: 5,
|
||||
brightness: 300,
|
||||
confidence: 'low',
|
||||
daynight: 'N',
|
||||
acq_date: '2024-01-01',
|
||||
acq_time: '0100',
|
||||
},
|
||||
];
|
||||
const result = buildFirmsGeoJSON(fires);
|
||||
expect(result!.features[0].properties?.daynight).toBe('Day');
|
||||
expect(result!.features[1].properties?.daynight).toBe('Night');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Internet Outages ───────────────────────────────────────────────────────
|
||||
|
||||
describe('buildInternetOutagesGeoJSON', () => {
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildInternetOutagesGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildInternetOutagesGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('builds features with detail string', () => {
|
||||
const outages: InternetOutage[] = [
|
||||
{ region_code: 'TX', region_name: 'Texas', country_code: 'US', country_name: 'United States', lat: 31.0, lng: -100.0, severity: 45, level: 'region', datasource: 'bgp' },
|
||||
];
|
||||
const result = buildInternetOutagesGeoJSON(outages);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
expect(result!.features[0].properties?.detail).toContain('Texas');
|
||||
expect(result!.features[0].properties?.detail).toContain('45% drop');
|
||||
});
|
||||
it('builds features with detail string', () => {
|
||||
const outages: InternetOutage[] = [
|
||||
{
|
||||
region_code: 'TX',
|
||||
region_name: 'Texas',
|
||||
country_code: 'US',
|
||||
country_name: 'United States',
|
||||
lat: 31.0,
|
||||
lng: -100.0,
|
||||
severity: 45,
|
||||
level: 'region',
|
||||
datasource: 'bgp',
|
||||
},
|
||||
];
|
||||
const result = buildInternetOutagesGeoJSON(outages);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
expect(result!.features[0].properties?.detail).toContain('Texas');
|
||||
expect(result!.features[0].properties?.detail).toContain('45% drop');
|
||||
});
|
||||
|
||||
it('filters out entries with null coordinates', () => {
|
||||
const outages: InternetOutage[] = [
|
||||
{ region_code: 'TX', region_name: 'Texas', country_code: 'US', country_name: 'United States', lat: null as any, lng: null as any, severity: 20, level: 'region', datasource: 'bgp' },
|
||||
{ region_code: 'CA', region_name: 'California', country_code: 'US', country_name: 'United States', lat: 37.0, lng: -122.0, severity: 30, level: 'region', datasource: 'bgp' },
|
||||
];
|
||||
const result = buildInternetOutagesGeoJSON(outages);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
});
|
||||
it('filters out entries with null coordinates', () => {
|
||||
const outages: InternetOutage[] = [
|
||||
{
|
||||
region_code: 'TX',
|
||||
region_name: 'Texas',
|
||||
country_code: 'US',
|
||||
country_name: 'United States',
|
||||
lat: null as any,
|
||||
lng: null as any,
|
||||
severity: 20,
|
||||
level: 'region',
|
||||
datasource: 'bgp',
|
||||
},
|
||||
{
|
||||
region_code: 'CA',
|
||||
region_name: 'California',
|
||||
country_code: 'US',
|
||||
country_name: 'United States',
|
||||
lat: 37.0,
|
||||
lng: -122.0,
|
||||
severity: 30,
|
||||
level: 'region',
|
||||
datasource: 'bgp',
|
||||
},
|
||||
];
|
||||
const result = buildInternetOutagesGeoJSON(outages);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Data Centers ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildDataCentersGeoJSON', () => {
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildDataCentersGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildDataCentersGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('builds features with datacenter properties', () => {
|
||||
const dcs: DataCenter[] = [
|
||||
{ lat: 40.0, lng: -74.0, name: 'NYC-DC1', company: 'Equinix', street: '123 Main', city: 'New York', country: 'US', zip: '10001' },
|
||||
];
|
||||
const result = buildDataCentersGeoJSON(dcs);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
expect(result!.features[0].properties?.id).toBe('dc-0');
|
||||
expect(result!.features[0].properties?.company).toBe('Equinix');
|
||||
});
|
||||
it('builds features with datacenter properties', () => {
|
||||
const dcs: DataCenter[] = [
|
||||
{
|
||||
lat: 40.0,
|
||||
lng: -74.0,
|
||||
name: 'NYC-DC1',
|
||||
company: 'Equinix',
|
||||
street: '123 Main',
|
||||
city: 'New York',
|
||||
country: 'US',
|
||||
zip: '10001',
|
||||
},
|
||||
];
|
||||
const result = buildDataCentersGeoJSON(dcs);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
expect(result!.features[0].properties?.id).toBe('dc-0');
|
||||
expect(result!.features[0].properties?.company).toBe('Equinix');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GDELT ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildGdeltGeoJSON', () => {
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildGdeltGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildGdeltGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('builds features from GDELT incidents', () => {
|
||||
const gdelt: GDELTIncident[] = [
|
||||
{ type: 'Feature', geometry: { type: 'Point', coordinates: [30, 50] }, properties: { name: 'Protest', count: 5, _urls_list: [], _headlines_list: [] } },
|
||||
];
|
||||
const result = buildGdeltGeoJSON(gdelt);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
expect(result!.features[0].properties?.type).toBe('gdelt');
|
||||
expect(result!.features[0].properties?.title).toBe('Protest');
|
||||
});
|
||||
it('builds features from GDELT incidents', () => {
|
||||
const gdelt: GDELTIncident[] = [
|
||||
{
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [30, 50] },
|
||||
properties: { name: 'Protest', count: 5, _urls_list: [], _headlines_list: [] },
|
||||
},
|
||||
];
|
||||
const result = buildGdeltGeoJSON(gdelt);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
expect(result!.features[0].properties?.type).toBe('gdelt');
|
||||
expect(result!.features[0].properties?.title).toBe('Protest');
|
||||
});
|
||||
|
||||
it('filters by inView when provided', () => {
|
||||
const gdelt: GDELTIncident[] = [
|
||||
{ type: 'Feature', geometry: { type: 'Point', coordinates: [30, 50] }, properties: { name: 'A', count: 1, _urls_list: [], _headlines_list: [] } },
|
||||
{ type: 'Feature', geometry: { type: 'Point', coordinates: [100, 10] }, properties: { name: 'B', count: 1, _urls_list: [], _headlines_list: [] } },
|
||||
];
|
||||
const inView = (lat: number, _lng: number) => lat > 30;
|
||||
const result = buildGdeltGeoJSON(gdelt, inView);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
});
|
||||
it('filters by inView when provided', () => {
|
||||
const gdelt: GDELTIncident[] = [
|
||||
{
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [30, 50] },
|
||||
properties: { name: 'A', count: 1, _urls_list: [], _headlines_list: [] },
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [100, 10] },
|
||||
properties: { name: 'B', count: 1, _urls_list: [], _headlines_list: [] },
|
||||
},
|
||||
];
|
||||
const inView = (lat: number, _lng: number) => lat > 30;
|
||||
const result = buildGdeltGeoJSON(gdelt, inView);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('filters out entries without geometry', () => {
|
||||
const gdelt: GDELTIncident[] = [
|
||||
{ type: 'Feature', geometry: { type: 'Point', coordinates: [30, 50] }, properties: { name: 'Good', count: 1, _urls_list: [], _headlines_list: [] } },
|
||||
{ type: 'Feature', geometry: null as any, properties: { name: 'Bad', count: 1, _urls_list: [], _headlines_list: [] } },
|
||||
];
|
||||
const result = buildGdeltGeoJSON(gdelt);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
});
|
||||
it('filters out entries without geometry', () => {
|
||||
const gdelt: GDELTIncident[] = [
|
||||
{
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [30, 50] },
|
||||
properties: { name: 'Good', count: 1, _urls_list: [], _headlines_list: [] },
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
geometry: null as any,
|
||||
properties: { name: 'Bad', count: 1, _urls_list: [], _headlines_list: [] },
|
||||
},
|
||||
];
|
||||
const result = buildGdeltGeoJSON(gdelt);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTrainsGeoJSON', () => {
|
||||
it('builds all trains when no inView filter is provided', () => {
|
||||
const trains: Train[] = [
|
||||
{
|
||||
id: 'amtrak-1',
|
||||
name: 'Empire Builder',
|
||||
number: '7',
|
||||
source: 'amtrak',
|
||||
source_label: 'Amtraker',
|
||||
operator: 'Amtrak',
|
||||
country: 'US',
|
||||
speed_kmh: 88,
|
||||
heading: 90,
|
||||
status: 'active',
|
||||
route: 'SEA-CHI',
|
||||
lat: 47.6,
|
||||
lng: -122.3,
|
||||
},
|
||||
{
|
||||
id: 'fin-1',
|
||||
name: 'Pendolino',
|
||||
number: 'S 94',
|
||||
source: 'digitraffic',
|
||||
source_label: 'Digitraffic',
|
||||
operator: 'VR',
|
||||
country: 'FI',
|
||||
speed_kmh: 120,
|
||||
heading: 180,
|
||||
status: 'active',
|
||||
route: 'HEL-TKU',
|
||||
lat: 60.17,
|
||||
lng: 24.94,
|
||||
},
|
||||
];
|
||||
|
||||
const result = buildTrainsGeoJSON(trains);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.features).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── LiveUAMap ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildLiveuaGeoJSON', () => {
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildLiveuaGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildLiveuaGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('classifies violent incidents with red icon', () => {
|
||||
const incidents: LiveUAmapIncident[] = [
|
||||
{ id: '1', lat: 48.0, lng: 35.0, title: 'Missile strike in Kharkiv', date: '2024-01-01' },
|
||||
{ id: '2', lat: 49.0, lng: 36.0, title: 'Humanitarian aid delivery', date: '2024-01-01' },
|
||||
];
|
||||
const result = buildLiveuaGeoJSON(incidents);
|
||||
expect(result!.features).toHaveLength(2);
|
||||
expect(result!.features[0].properties?.iconId).toBe('icon-liveua-red');
|
||||
expect(result!.features[1].properties?.iconId).toBe('icon-liveua-yellow');
|
||||
});
|
||||
it('classifies violent incidents with red icon', () => {
|
||||
const incidents: LiveUAmapIncident[] = [
|
||||
{ id: '1', lat: 48.0, lng: 35.0, title: 'Missile strike in Kharkiv', date: '2024-01-01' },
|
||||
{ id: '2', lat: 49.0, lng: 36.0, title: 'Humanitarian aid delivery', date: '2024-01-01' },
|
||||
];
|
||||
const result = buildLiveuaGeoJSON(incidents);
|
||||
expect(result!.features).toHaveLength(2);
|
||||
expect(result!.features[0].properties?.iconId).toBe('icon-liveua-red');
|
||||
expect(result!.features[1].properties?.iconId).toBe('icon-liveua-yellow');
|
||||
});
|
||||
|
||||
it('filters by inView when provided', () => {
|
||||
const incidents: LiveUAmapIncident[] = [
|
||||
{ id: '1', lat: 48.0, lng: 35.0, title: 'Test', date: '2024-01-01' },
|
||||
{ id: '2', lat: 10.0, lng: 20.0, title: 'Far away', date: '2024-01-01' },
|
||||
];
|
||||
const inView = (lat: number, _lng: number) => lat > 30;
|
||||
const result = buildLiveuaGeoJSON(incidents, inView);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
});
|
||||
it('filters by inView when provided', () => {
|
||||
const incidents: LiveUAmapIncident[] = [
|
||||
{ id: '1', lat: 48.0, lng: 35.0, title: 'Test', date: '2024-01-01' },
|
||||
{ id: '2', lat: 10.0, lng: 20.0, title: 'Far away', date: '2024-01-01' },
|
||||
];
|
||||
const inView = (lat: number, _lng: number) => lat > 30;
|
||||
const result = buildLiveuaGeoJSON(incidents, inView);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Frontline ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildFrontlineGeoJSON', () => {
|
||||
it('returns null for null/undefined input', () => {
|
||||
expect(buildFrontlineGeoJSON(null)).toBeNull();
|
||||
expect(buildFrontlineGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
it('returns null for null/undefined input', () => {
|
||||
expect(buildFrontlineGeoJSON(null)).toBeNull();
|
||||
expect(buildFrontlineGeoJSON(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the input unchanged when valid', () => {
|
||||
const fc = { type: 'FeatureCollection' as const, features: [{ type: 'Feature' as const, properties: { name: 'zone', zone_id: 1 }, geometry: { type: 'Polygon' as const, coordinates: [[[30, 48], [31, 49], [30, 49], [30, 48]]] as [number, number][][] } }] };
|
||||
const result = buildFrontlineGeoJSON(fc);
|
||||
expect(result).toBe(fc); // Same reference — passthrough
|
||||
});
|
||||
it('returns the input unchanged when valid', () => {
|
||||
const fc = {
|
||||
type: 'FeatureCollection' as const,
|
||||
features: [
|
||||
{
|
||||
type: 'Feature' as const,
|
||||
properties: { name: 'zone', zone_id: 1 },
|
||||
geometry: {
|
||||
type: 'Polygon' as const,
|
||||
coordinates: [
|
||||
[
|
||||
[30, 48],
|
||||
[31, 49],
|
||||
[30, 49],
|
||||
[30, 48],
|
||||
],
|
||||
] as [number, number][][],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = buildFrontlineGeoJSON(fc);
|
||||
expect(result).toBe(fc); // Same reference — passthrough
|
||||
});
|
||||
|
||||
it('returns null for empty features array', () => {
|
||||
const fc = { type: 'FeatureCollection' as const, features: [] };
|
||||
expect(buildFrontlineGeoJSON(fc)).toBeNull();
|
||||
});
|
||||
it('returns null for empty features array', () => {
|
||||
const fc = { type: 'FeatureCollection' as const, features: [] };
|
||||
expect(buildFrontlineGeoJSON(fc)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Scanners ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildScannerGeoJSON', () => {
|
||||
it('returns null for empty input', () => {
|
||||
expect(buildScannerGeoJSON(undefined)).toBeNull();
|
||||
expect(buildScannerGeoJSON([])).toBeNull();
|
||||
});
|
||||
|
||||
it('builds features with scanner properties', () => {
|
||||
const scanners: Scanner[] = [
|
||||
{
|
||||
shortName: 'TEST',
|
||||
name: 'Test System',
|
||||
lat: 39.0,
|
||||
lng: -104.0,
|
||||
city: 'Denver',
|
||||
state: 'CO',
|
||||
clientCount: 5,
|
||||
description: 'Demo',
|
||||
},
|
||||
];
|
||||
const result = buildScannerGeoJSON(scanners);
|
||||
expect(result!.features).toHaveLength(1);
|
||||
expect(result!.features[0].properties?.type).toBe('scanner');
|
||||
expect(result!.features[0].properties?.name).toBe('Test System');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
gateEnvelopeDisplayText,
|
||||
gateEnvelopeState,
|
||||
isEncryptedGateEnvelope,
|
||||
} from '@/mesh/gateEnvelope';
|
||||
import { normalizePayload } from '@/mesh/meshProtocol';
|
||||
import { validateEventPayload } from '@/mesh/meshSchema';
|
||||
|
||||
describe('gate envelope protocol', () => {
|
||||
it('normalizes encrypted gate-message payloads', () => {
|
||||
expect(
|
||||
normalizePayload('gate_message', {
|
||||
gate: 'Finance',
|
||||
epoch: '2',
|
||||
ciphertext: 'opaque',
|
||||
nonce: 'nonce-2',
|
||||
sender_ref: 'persona-fin-1',
|
||||
}),
|
||||
).toEqual({
|
||||
gate: 'finance',
|
||||
epoch: 2,
|
||||
ciphertext: 'opaque',
|
||||
nonce: 'nonce-2',
|
||||
sender_ref: 'persona-fin-1',
|
||||
format: 'g1',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts encrypted gate-message envelopes and rejects plaintext ones', () => {
|
||||
expect(
|
||||
validateEventPayload('gate_message', {
|
||||
gate: 'finance',
|
||||
epoch: 2,
|
||||
ciphertext: 'opaque',
|
||||
nonce: 'nonce-2',
|
||||
sender_ref: 'persona-fin-1',
|
||||
format: 'g1',
|
||||
}),
|
||||
).toEqual({ ok: true });
|
||||
|
||||
expect(
|
||||
validateEventPayload('gate_message', {
|
||||
gate: 'finance',
|
||||
message: 'plaintext',
|
||||
}),
|
||||
).toEqual({ ok: false, reason: 'Payload is not normalized' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('gate envelope display', () => {
|
||||
it('detects encrypted gate messages and shows placeholders honestly', () => {
|
||||
const encrypted = {
|
||||
event_type: 'gate_message',
|
||||
gate: 'finance',
|
||||
epoch: 2,
|
||||
ciphertext: 'opaque',
|
||||
nonce: 'nonce-2',
|
||||
sender_ref: 'persona-fin-1',
|
||||
};
|
||||
|
||||
expect(isEncryptedGateEnvelope(encrypted)).toBe(true);
|
||||
expect(gateEnvelopeState(encrypted)).toBe('locked');
|
||||
expect(gateEnvelopeDisplayText(encrypted)).toBe('ENCRYPTED GATE MESSAGE - KEY UNAVAILABLE');
|
||||
expect(
|
||||
gateEnvelopeState({
|
||||
...encrypted,
|
||||
decrypted_message: 'decoded text',
|
||||
}),
|
||||
).toBe('decrypted');
|
||||
expect(
|
||||
gateEnvelopeDisplayText({
|
||||
...encrypted,
|
||||
decrypted_message: 'decoded text',
|
||||
}),
|
||||
).toBe('decoded text');
|
||||
expect(
|
||||
gateEnvelopeState({
|
||||
event_type: 'gate_notice',
|
||||
message: 'legacy plaintext',
|
||||
}),
|
||||
).toBe('plaintext');
|
||||
expect(
|
||||
gateEnvelopeDisplayText({
|
||||
event_type: 'gate_notice',
|
||||
message: 'legacy plaintext',
|
||||
}),
|
||||
).toBe('legacy plaintext');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const deadDropTokensForContacts = vi.fn();
|
||||
const mailboxClaimToken = vi.fn();
|
||||
const mailboxDecoySharedToken = vi.fn();
|
||||
|
||||
vi.mock('@/mesh/meshDeadDrop', () => ({
|
||||
deadDropToken: vi.fn(),
|
||||
deadDropTokensForContacts,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshMailbox', () => ({
|
||||
mailboxClaimToken,
|
||||
mailboxDecoySharedToken,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshIdentity', () => ({
|
||||
deriveSenderSealKey: vi.fn(),
|
||||
ensureDhKeysFresh: vi.fn(),
|
||||
deriveSharedKey: vi.fn(),
|
||||
encryptDM: vi.fn(),
|
||||
getDHAlgo: vi.fn(() => 'X25519'),
|
||||
getNodeIdentity: vi.fn(() => ({ nodeId: '!self', publicKey: 'pub' })),
|
||||
getPublicKeyAlgo: vi.fn(() => 'Ed25519'),
|
||||
nextSequence: vi.fn(() => 1),
|
||||
verifyNodeIdBindingFromPublicKey: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/wormholeIdentityClient', () => ({
|
||||
buildWormholeSenderSeal: vi.fn(),
|
||||
getActiveSigningContext: vi.fn(async () => null),
|
||||
isWormholeSecureRequired: vi.fn(async () => false),
|
||||
issueWormholeDmSenderToken: vi.fn(),
|
||||
issueWormholeDmSenderTokens: vi.fn(),
|
||||
registerWormholeDmKey: vi.fn(),
|
||||
signRawMeshMessage: vi.fn(),
|
||||
signMeshEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshSchema', () => ({
|
||||
validateEventPayload: vi.fn(() => ({ ok: true, reason: 'ok' })),
|
||||
}));
|
||||
|
||||
describe('mailbox claim privacy padding', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.unstubAllEnvs();
|
||||
vi.stubEnv('NEXT_PUBLIC_ENABLE_RFC2A_CLAIM_SHAPE', '1');
|
||||
deadDropTokensForContacts.mockReset();
|
||||
mailboxClaimToken.mockReset();
|
||||
mailboxDecoySharedToken.mockReset();
|
||||
mailboxClaimToken.mockImplementation(async (type: string) => `${type}-token`);
|
||||
mailboxDecoySharedToken.mockImplementation(async (index: number) => `decoy-${index}`);
|
||||
});
|
||||
|
||||
function buildSharedTokens(count: number): string[] {
|
||||
return Array.from({ length: count }, (_, index) => `shared-${index + 1}`);
|
||||
}
|
||||
|
||||
it('uses bucketed shared-claim envelopes across multiple contact counts', async () => {
|
||||
const mod = await import('@/mesh/meshDmClient');
|
||||
|
||||
for (const testCase of [
|
||||
{ realSharedClaims: 0, expectedSharedClaims: 3, expectedTotalClaims: 5 },
|
||||
{ realSharedClaims: 1, expectedSharedClaims: 3, expectedTotalClaims: 5 },
|
||||
{ realSharedClaims: 3, expectedSharedClaims: 3, expectedTotalClaims: 5 },
|
||||
{ realSharedClaims: 4, expectedSharedClaims: 6, expectedTotalClaims: 8 },
|
||||
{ realSharedClaims: 7, expectedSharedClaims: 12, expectedTotalClaims: 14 },
|
||||
{ realSharedClaims: 25, expectedSharedClaims: 30, expectedTotalClaims: 32 },
|
||||
{ realSharedClaims: 30, expectedSharedClaims: 30, expectedTotalClaims: 32 },
|
||||
]) {
|
||||
deadDropTokensForContacts.mockResolvedValue(buildSharedTokens(testCase.realSharedClaims));
|
||||
|
||||
const claims = await mod.buildMailboxClaims({});
|
||||
expect(claims.slice(0, 2)).toEqual([
|
||||
{ type: 'self', token: 'self-token' },
|
||||
{ type: 'requests', token: 'requests-token' },
|
||||
]);
|
||||
expect(claims.filter((claim) => claim.type === 'shared')).toHaveLength(
|
||||
testCase.expectedSharedClaims,
|
||||
);
|
||||
expect(claims).toHaveLength(testCase.expectedTotalClaims);
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to the legacy shared-claim floor when the experiment is disabled', async () => {
|
||||
vi.resetModules();
|
||||
vi.stubEnv('NEXT_PUBLIC_ENABLE_RFC2A_CLAIM_SHAPE', '0');
|
||||
deadDropTokensForContacts.mockResolvedValue(['shared-1', 'shared-2', 'shared-3', 'shared-4']);
|
||||
|
||||
const mod = await import('@/mesh/meshDmClient');
|
||||
const claims = await mod.buildMailboxClaims({});
|
||||
const sharedClaims = claims.filter((claim) => claim.type === 'shared');
|
||||
|
||||
expect(mod.MAILBOX_SHARED_CLAIM_SHAPE_VERSION).toBe('legacy-floor-v1');
|
||||
expect(sharedClaims).toEqual([
|
||||
{ type: 'shared', token: 'shared-1' },
|
||||
{ type: 'shared', token: 'shared-2' },
|
||||
{ type: 'shared', token: 'shared-3' },
|
||||
{ type: 'shared', token: 'shared-4' },
|
||||
]);
|
||||
expect(claims).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('deduplicates real shared tokens before filling the bucketed envelope', async () => {
|
||||
deadDropTokensForContacts.mockResolvedValue(['shared-real', 'shared-real']);
|
||||
|
||||
const mod = await import('@/mesh/meshDmClient');
|
||||
const claims = await mod.buildMailboxClaims({
|
||||
alice: { blocked: false, dhPubKey: 'dh-a' },
|
||||
});
|
||||
|
||||
const sharedClaims = claims.filter((claim) => claim.type === 'shared');
|
||||
expect(sharedClaims).toEqual([
|
||||
{ type: 'shared', token: 'decoy-0' },
|
||||
{ type: 'shared', token: 'shared-real' },
|
||||
{ type: 'shared', token: 'decoy-1' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves every real shared token within the supported 30-claim shared range', async () => {
|
||||
const realSharedTokens = buildSharedTokens(30);
|
||||
deadDropTokensForContacts.mockResolvedValue(realSharedTokens);
|
||||
|
||||
const mod = await import('@/mesh/meshDmClient');
|
||||
const claims = await mod.buildMailboxClaims({});
|
||||
|
||||
const sharedTokens = claims
|
||||
.filter((claim) => claim.type === 'shared')
|
||||
.map((claim) => claim.token);
|
||||
expect(sharedTokens).toHaveLength(30);
|
||||
expect(new Set(sharedTokens)).toEqual(new Set(realSharedTokens));
|
||||
});
|
||||
|
||||
it('keeps decoy shared tokens distinct from real shared tokens', async () => {
|
||||
const realSharedTokens = ['shared-1', 'shared-2', 'shared-3', 'shared-4'];
|
||||
deadDropTokensForContacts.mockResolvedValue(realSharedTokens);
|
||||
|
||||
const mod = await import('@/mesh/meshDmClient');
|
||||
const claims = await mod.buildMailboxClaims({});
|
||||
|
||||
const sharedTokens = claims
|
||||
.filter((claim) => claim.type === 'shared')
|
||||
.map((claim) => String(claim.token || ''));
|
||||
const decoyTokens = sharedTokens.filter((token) => !realSharedTokens.includes(token));
|
||||
|
||||
expect(sharedTokens).toEqual([
|
||||
'shared-1',
|
||||
'decoy-0',
|
||||
'shared-2',
|
||||
'shared-3',
|
||||
'decoy-1',
|
||||
'shared-4',
|
||||
]);
|
||||
expect(decoyTokens).toEqual(['decoy-0', 'decoy-1']);
|
||||
expect(decoyTokens.every((token) => !realSharedTokens.includes(token))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { buildSignaturePayload, type JsonValue } from '@/mesh/meshProtocol';
|
||||
|
||||
type Fixture = {
|
||||
name: string;
|
||||
event_type: string;
|
||||
node_id: string;
|
||||
sequence: number;
|
||||
payload: Record<string, JsonValue>;
|
||||
expected: string;
|
||||
};
|
||||
|
||||
describe('mesh canonical signature payloads', () => {
|
||||
const cwd = process.cwd();
|
||||
const fixturePath = cwd.endsWith('frontend')
|
||||
? path.resolve(cwd, '..', 'docs', 'mesh', 'mesh-canonical-fixtures.json')
|
||||
: path.resolve(cwd, 'docs', 'mesh', 'mesh-canonical-fixtures.json');
|
||||
const fixtures = JSON.parse(readFileSync(fixturePath, 'utf-8')) as Fixture[];
|
||||
|
||||
for (const fixture of fixtures) {
|
||||
it(`matches fixture: ${fixture.name}`, () => {
|
||||
const result = buildSignaturePayload({
|
||||
eventType: fixture.event_type,
|
||||
nodeId: fixture.node_id,
|
||||
sequence: fixture.sequence,
|
||||
payload: fixture.payload,
|
||||
});
|
||||
expect(result).toBe(fixture.expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const controlPlaneJson = vi.fn();
|
||||
const idbStore = new Map<string, unknown>();
|
||||
|
||||
vi.mock('@/lib/controlPlane', () => ({
|
||||
controlPlaneJson,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshKeyStore', () => ({
|
||||
getKey: vi.fn(async (id: string) => idbStore.get(id) ?? null),
|
||||
setKey: vi.fn(async (id: string, key: unknown) => {
|
||||
idbStore.set(id, key);
|
||||
}),
|
||||
deleteKey: vi.fn(async (id: string) => {
|
||||
idbStore.delete(id);
|
||||
}),
|
||||
}));
|
||||
|
||||
async function flushStoragePersistence(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
async function waitForEncryptedContacts(): Promise<string | null> {
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
await flushStoragePersistence();
|
||||
const stored =
|
||||
sessionStorage.getItem('sb_mesh_contacts') || localStorage.getItem('sb_mesh_contacts');
|
||||
if (typeof stored === 'string' && stored.startsWith('enc:')) {
|
||||
return stored;
|
||||
}
|
||||
}
|
||||
return sessionStorage.getItem('sb_mesh_contacts') || localStorage.getItem('sb_mesh_contacts');
|
||||
}
|
||||
|
||||
function bufToBase64(buf: ArrayBuffer): string {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buf)));
|
||||
}
|
||||
|
||||
describe('meshIdentity contact storage hardening', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
controlPlaneJson.mockReset();
|
||||
idbStore.clear();
|
||||
const 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(),
|
||||
};
|
||||
};
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: makeStorage(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'sessionStorage', {
|
||||
value: makeStorage(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
async function provisionLocalIdentity(mod: typeof import('@/mesh/meshIdentity')) {
|
||||
localStorage.setItem('sb_mesh_pubkey', 'test-pub');
|
||||
localStorage.setItem('sb_mesh_node_id', '!sb_contacts123456');
|
||||
localStorage.setItem('sb_mesh_sovereignty_accepted', 'true');
|
||||
const keyPair = (await crypto.subtle.generateKey(
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
false,
|
||||
['deriveKey', 'deriveBits'],
|
||||
)) as CryptoKeyPair;
|
||||
const publicRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey);
|
||||
localStorage.setItem('sb_mesh_dh_pubkey', bufToBase64(publicRaw));
|
||||
localStorage.setItem('sb_mesh_dh_algo', 'ECDH');
|
||||
idbStore.set('sb_mesh_dh_priv', keyPair.privateKey);
|
||||
}
|
||||
|
||||
it('hydrates secure-mode contacts from Wormhole and avoids localStorage persistence', async () => {
|
||||
controlPlaneJson
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
contacts: {
|
||||
alice: { blocked: false, dhPubKey: 'dh_a', sharedAlias: 'alias_a' },
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
peer_id: 'alice',
|
||||
contact: { blocked: false, dhPubKey: 'dh_a2', sharedAlias: 'alias_a' },
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
mod.setSecureModeCached(true);
|
||||
|
||||
const contacts = await mod.hydrateWormholeContacts(true);
|
||||
expect(contacts.alice.dhPubKey).toBe('dh_a');
|
||||
|
||||
mod.addContact('alice', 'dh_a2');
|
||||
await Promise.resolve();
|
||||
|
||||
expect(localStorage.getItem('sb_mesh_contacts')).toBeNull();
|
||||
expect(mod.getContacts().alice.dhPubKey).toBe('dh_a2');
|
||||
expect(controlPlaneJson).toHaveBeenLastCalledWith('/api/wormhole/dm/contact', expect.any(Object));
|
||||
});
|
||||
|
||||
it('stores local contacts as encrypted ciphertext and hydrates them back', async () => {
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
await provisionLocalIdentity(mod);
|
||||
|
||||
mod.addContact('alice', 'dh_a', 'Alice', 'X25519');
|
||||
mod.updateContact('alice', {
|
||||
remotePrekeyFingerprint: 'fp-1',
|
||||
remotePrekeyObservedFingerprint: 'fp-1',
|
||||
remotePrekeyPinnedAt: 111,
|
||||
remotePrekeyLastSeenAt: 222,
|
||||
remotePrekeySequence: 3,
|
||||
remotePrekeySignedAt: 444,
|
||||
remotePrekeyMismatch: false,
|
||||
});
|
||||
const stored = await waitForEncryptedContacts();
|
||||
expect(String(stored ?? '')).toMatch(/^enc:/);
|
||||
expect(String(stored ?? '')).not.toContain('"alice"');
|
||||
expect(String(stored ?? '')).not.toContain('"dh_a"');
|
||||
|
||||
const hydrated = await mod.hydrateWormholeContacts(true);
|
||||
expect(hydrated.alice.dhPubKey).toBe('dh_a');
|
||||
expect(hydrated.alice.alias).toBe('Alice');
|
||||
expect(hydrated.alice.remotePrekeyFingerprint).toBe('fp-1');
|
||||
expect(hydrated.alice.remotePrekeyObservedFingerprint).toBe('fp-1');
|
||||
expect(hydrated.alice.remotePrekeyPinnedAt).toBe(111);
|
||||
expect(hydrated.alice.remotePrekeyLastSeenAt).toBe(222);
|
||||
expect(hydrated.alice.remotePrekeySequence).toBe(3);
|
||||
expect(hydrated.alice.remotePrekeySignedAt).toBe(444);
|
||||
expect(hydrated.alice.remotePrekeyMismatch).toBe(false);
|
||||
});
|
||||
|
||||
it('migrates legacy plaintext contacts to encrypted storage on first hydrate', async () => {
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
await provisionLocalIdentity(mod);
|
||||
|
||||
localStorage.setItem(
|
||||
'sb_mesh_contacts',
|
||||
JSON.stringify({ alice: { blocked: false, dhPubKey: 'legacy_dh', alias: 'Legacy Alice' } }),
|
||||
);
|
||||
|
||||
const hydrated = await mod.hydrateWormholeContacts(true);
|
||||
expect(hydrated.alice.dhPubKey).toBe('legacy_dh');
|
||||
expect(hydrated.alice.alias).toBe('Legacy Alice');
|
||||
|
||||
const stored = await waitForEncryptedContacts();
|
||||
expect(String(stored ?? '')).toMatch(/^enc:/);
|
||||
expect(String(stored ?? '')).not.toContain('legacy_dh');
|
||||
});
|
||||
|
||||
it('encrypts identity-bound browser payloads under distinct info domains', async () => {
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
await provisionLocalIdentity(mod);
|
||||
|
||||
const accessCipher = await mod.encryptIdentityBoundStoragePayload(
|
||||
[{ sender_id: 'alice', timestamp: 1 }],
|
||||
'SB-ACCESS-REQUESTS-STORAGE-V1',
|
||||
);
|
||||
expect(accessCipher).toMatch(/^enc:/);
|
||||
expect(accessCipher).not.toContain('alice');
|
||||
|
||||
const decrypted = await mod.decryptIdentityBoundStoragePayload(
|
||||
accessCipher,
|
||||
'SB-ACCESS-REQUESTS-STORAGE-V1',
|
||||
[],
|
||||
);
|
||||
expect(decrypted).toEqual([{ sender_id: 'alice', timestamp: 1 }]);
|
||||
|
||||
await expect(
|
||||
mod.decryptIdentityBoundStoragePayload(
|
||||
accessCipher,
|
||||
'SB-PENDING-CONTACTS-STORAGE-V1',
|
||||
[],
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('treats unreadable encrypted contacts as empty and warns instead of crashing', async () => {
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
await provisionLocalIdentity(mod);
|
||||
|
||||
localStorage.setItem('sb_mesh_contacts', 'enc:not-valid-ciphertext');
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const hydrated = await mod.hydrateWormholeContacts(true);
|
||||
|
||||
expect(hydrated).toEqual({});
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[mesh] contact storage unreadable — treating as empty contacts',
|
||||
expect.anything(),
|
||||
);
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('purges browser-persisted contact graph when secure mode boundary is applied', async () => {
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
localStorage.setItem(
|
||||
'sb_mesh_contacts',
|
||||
JSON.stringify({ bob: { blocked: false, sharedAlias: 'peer-b' } }),
|
||||
);
|
||||
|
||||
mod.purgeBrowserContactGraph();
|
||||
|
||||
expect(localStorage.getItem('sb_mesh_contacts')).toBeNull();
|
||||
expect(mod.getContacts()).toEqual({});
|
||||
});
|
||||
|
||||
it('rotates the mailbox-claim secret when identity state is cleared', async () => {
|
||||
const { mailboxClaimToken } = await import('@/mesh/meshMailbox');
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
await provisionLocalIdentity(mod);
|
||||
|
||||
const first = await mailboxClaimToken('requests', '!sb_contacts123456');
|
||||
const second = await mailboxClaimToken('requests', '!sb_contacts123456');
|
||||
expect(second).toBe(first);
|
||||
|
||||
await mod.clearBrowserIdentityState();
|
||||
|
||||
localStorage.setItem('sb_mesh_pubkey', 'test-pub');
|
||||
localStorage.setItem('sb_mesh_node_id', '!sb_contacts123456');
|
||||
localStorage.setItem('sb_mesh_sovereignty_accepted', 'true');
|
||||
const keyPair = (await crypto.subtle.generateKey(
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
false,
|
||||
['deriveKey', 'deriveBits'],
|
||||
)) as CryptoKeyPair;
|
||||
const publicRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey);
|
||||
localStorage.setItem('sb_mesh_dh_pubkey', bufToBase64(publicRaw));
|
||||
localStorage.setItem('sb_mesh_dh_algo', 'ECDH');
|
||||
idbStore.set('sb_mesh_dh_priv', keyPair.privateKey);
|
||||
|
||||
const rotated = await mailboxClaimToken('requests', '!sb_contacts123456');
|
||||
expect(rotated).not.toBe(first);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
allDmPeerIds,
|
||||
buildAliasRotateMessage,
|
||||
buildAccessGrantedMessage,
|
||||
buildContactAcceptMessage,
|
||||
buildContactDenyMessage,
|
||||
buildContactOfferMessage,
|
||||
mergeAliasHistory,
|
||||
parseAliasRotateMessage,
|
||||
parseAccessGrantedMessage,
|
||||
parseDmConsentMessage,
|
||||
preferredDmPeerId,
|
||||
} from '@/mesh/meshDmConsent';
|
||||
|
||||
describe('mesh DM consent helpers', () => {
|
||||
it('builds and parses access-granted payloads', () => {
|
||||
const message = buildAccessGrantedMessage('dmx_alpha');
|
||||
expect(parseAccessGrantedMessage(message)).toEqual({ shared_alias: 'dmx_alpha' });
|
||||
});
|
||||
|
||||
it('builds and parses off-ledger contact offer payloads', () => {
|
||||
const message = buildContactOfferMessage('dh_pub', 'X25519', '40.12,-105.27');
|
||||
expect(parseDmConsentMessage(message)).toEqual({
|
||||
kind: 'contact_offer',
|
||||
dh_pub_key: 'dh_pub',
|
||||
dh_algo: 'X25519',
|
||||
geo_hint: '40.12,-105.27',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds and parses off-ledger contact accept payloads', () => {
|
||||
const message = buildContactAcceptMessage('dmx_pairwise');
|
||||
expect(parseDmConsentMessage(message)).toEqual({
|
||||
kind: 'contact_accept',
|
||||
shared_alias: 'dmx_pairwise',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds and parses off-ledger contact deny payloads', () => {
|
||||
const message = buildContactDenyMessage('declined');
|
||||
expect(parseDmConsentMessage(message)).toEqual({
|
||||
kind: 'contact_deny',
|
||||
reason: 'declined',
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers the pairwise alias for shared DM routing', () => {
|
||||
expect(preferredDmPeerId('node_public', { sharedAlias: 'dmx_pairwise' })).toBe('dmx_pairwise');
|
||||
expect(preferredDmPeerId('node_public', { sharedAlias: '' })).toBe('node_public');
|
||||
});
|
||||
|
||||
it('keeps both alias and public ids during the transition window', () => {
|
||||
expect(allDmPeerIds('node_public', { sharedAlias: 'dmx_pairwise' })).toEqual([
|
||||
'dmx_pairwise',
|
||||
'node_public',
|
||||
]);
|
||||
expect(allDmPeerIds('node_public', { sharedAlias: 'node_public' })).toEqual(['node_public']);
|
||||
});
|
||||
|
||||
it('builds and parses alias rotation control payloads', () => {
|
||||
const message = buildAliasRotateMessage('dmx_next');
|
||||
expect(parseAliasRotateMessage(message)).toEqual({ shared_alias: 'dmx_next' });
|
||||
});
|
||||
|
||||
it('promotes pending alias after the grace window elapses', () => {
|
||||
const now = Date.now();
|
||||
expect(
|
||||
preferredDmPeerId('node_public', {
|
||||
sharedAlias: 'dmx_current',
|
||||
pendingSharedAlias: 'dmx_next',
|
||||
sharedAliasGraceUntil: now - 1,
|
||||
}),
|
||||
).toBe('dmx_next');
|
||||
});
|
||||
|
||||
it('keeps alias history compact and unique', () => {
|
||||
expect(mergeAliasHistory(['dmx_a', 'dmx_b', 'dmx_a', 'dmx_c', 'dmx_d'], 3)).toEqual([
|
||||
'dmx_a',
|
||||
'dmx_b',
|
||||
'dmx_c',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
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[] = [];
|
||||
const workerInstances: FakeWorker[] = [];
|
||||
|
||||
vi.mock('@/lib/controlPlane', () => ({
|
||||
controlPlaneJson: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/wormholeIdentityClient', () => ({
|
||||
ensureWormholeReadyForSecureAction: vi.fn(async () => undefined),
|
||||
isWormholeReady: vi.fn(async () => false),
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshIdentity', () => ({
|
||||
getDHAlgo: vi.fn(() => 'X25519'),
|
||||
}));
|
||||
|
||||
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(),
|
||||
get length() {
|
||||
return values.size;
|
||||
},
|
||||
key: (_i: number) => null as string | null,
|
||||
};
|
||||
}
|
||||
|
||||
class FakeWorker {
|
||||
onmessage: ((event: MessageEvent<{ id: string; ok: boolean; result?: string }>) => void) | null =
|
||||
null;
|
||||
terminated = false;
|
||||
|
||||
constructor() {
|
||||
workerInstances.push(this);
|
||||
}
|
||||
|
||||
postMessage(message: { id: string }) {
|
||||
queueMicrotask(() => {
|
||||
this.onmessage?.({
|
||||
data: { id: message.id, ok: true, result: '' },
|
||||
} as MessageEvent<{ id: string; ok: boolean; result?: string }>);
|
||||
});
|
||||
}
|
||||
|
||||
terminate() {
|
||||
this.terminated = true;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
},
|
||||
} 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('worker ratchet vault hardening', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
databases.clear();
|
||||
deletedDatabases.length = 0;
|
||||
workerInstances.length = 0;
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: makeStorage(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'sessionStorage', {
|
||||
value: makeStorage(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'Worker', {
|
||||
value: FakeWorker,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'indexedDB', {
|
||||
value: createFakeIndexedDb(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('persists worker ratchet state as an encrypted blob instead of raw state', async () => {
|
||||
const mod = await import('@/mesh/meshDmWorkerVault');
|
||||
const sample = {
|
||||
alice: {
|
||||
algo: 'X25519',
|
||||
rk: 'root-key',
|
||||
cks: 'send-chain',
|
||||
ckr: 'recv-chain',
|
||||
dhSelfPub: 'pub',
|
||||
dhSelfPriv: 'private-material',
|
||||
dhRemote: 'remote',
|
||||
ns: 1,
|
||||
nr: 2,
|
||||
pn: 3,
|
||||
skipped: { 'remote:1': 'mk' },
|
||||
updated: 123,
|
||||
},
|
||||
};
|
||||
|
||||
await mod.writeWorkerRatchetStates(sample);
|
||||
|
||||
const raw = getStoredValue(mod.WORKER_RATCHET_DB, 'ratchet', 'state');
|
||||
expect(typeof raw).toBe('string');
|
||||
expect(String(raw)).not.toContain('dhSelfPriv');
|
||||
expect(String(raw)).not.toContain('private-material');
|
||||
|
||||
const loaded = await mod.readWorkerRatchetStates();
|
||||
expect(loaded).toEqual(sample);
|
||||
});
|
||||
|
||||
it('migrates legacy plaintext worker state into encrypted storage on read', async () => {
|
||||
const legacyStore = ensureStore('sb_mesh_dm_worker', 1, 'ratchet');
|
||||
legacyStore.set('state', {
|
||||
bob: {
|
||||
algo: 'X25519',
|
||||
rk: 'legacy-rk',
|
||||
dhSelfPub: 'legacy-pub',
|
||||
dhSelfPriv: 'legacy-private',
|
||||
dhRemote: 'legacy-remote',
|
||||
ns: 0,
|
||||
nr: 0,
|
||||
pn: 0,
|
||||
updated: 999,
|
||||
},
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/meshDmWorkerVault');
|
||||
const loaded = await mod.readWorkerRatchetStates();
|
||||
const raw = getStoredValue(mod.WORKER_RATCHET_DB, 'ratchet', 'state');
|
||||
|
||||
expect(loaded.bob?.dhSelfPriv).toBe('legacy-private');
|
||||
expect(typeof raw).toBe('string');
|
||||
expect(String(raw)).not.toContain('legacy-private');
|
||||
});
|
||||
|
||||
it('purgeBrowserDmState clears worker persistence and legacy browser copies', async () => {
|
||||
localStorage.setItem('sb_mesh_dm_ratchet', 'legacy');
|
||||
sessionStorage.setItem('sb_mesh_ratchet_telemetry', '{"seen":1}');
|
||||
const mod = await import('@/mesh/meshDmWorkerClient');
|
||||
|
||||
await mod.purgeBrowserDmState();
|
||||
|
||||
expect(localStorage.getItem('sb_mesh_dm_ratchet')).toBeNull();
|
||||
expect(sessionStorage.getItem('sb_mesh_ratchet_telemetry')).toBeNull();
|
||||
expect(deletedDatabases).toContain('sb_mesh_dm_worker');
|
||||
expect(workerInstances[0]?.terminated).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/lib/controlPlane', () => ({
|
||||
controlPlaneJson: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshKeyStore', () => ({
|
||||
getKey: vi.fn().mockResolvedValue(null),
|
||||
setKey: vi.fn().mockResolvedValue(undefined),
|
||||
deleteKey: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
describe('mesh identity storage separation', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
const 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(),
|
||||
};
|
||||
};
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: makeStorage(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'sessionStorage', {
|
||||
value: makeStorage(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps public browser identity separate from Wormhole descriptor cache', async () => {
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
|
||||
mod.cachePublicIdentity({
|
||||
nodeId: '!sb_public',
|
||||
publicKey: 'public-key',
|
||||
publicKeyAlgo: 'Ed25519',
|
||||
});
|
||||
mod.cacheWormholeIdentityDescriptor({
|
||||
nodeId: '!sb_wormhole',
|
||||
publicKey: 'wormhole-key',
|
||||
publicKeyAlgo: 'Ed25519',
|
||||
});
|
||||
|
||||
expect(mod.getStoredNodeDescriptor()).toEqual({
|
||||
nodeId: '!sb_public',
|
||||
publicKey: 'public-key',
|
||||
publicKeyAlgo: 'Ed25519',
|
||||
});
|
||||
expect(mod.getWormholeIdentityDescriptor()).toEqual({
|
||||
nodeId: '!sb_wormhole',
|
||||
publicKey: 'wormhole-key',
|
||||
publicKeyAlgo: 'Ed25519',
|
||||
});
|
||||
});
|
||||
|
||||
it('clears browser public identity and Wormhole descriptor cache together on full reset', async () => {
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
|
||||
mod.cachePublicIdentity({
|
||||
nodeId: '!sb_public',
|
||||
publicKey: 'public-key',
|
||||
publicKeyAlgo: 'Ed25519',
|
||||
});
|
||||
mod.cacheWormholeIdentityDescriptor({
|
||||
nodeId: '!sb_wormhole',
|
||||
publicKey: 'wormhole-key',
|
||||
publicKeyAlgo: 'Ed25519',
|
||||
});
|
||||
|
||||
await mod.clearBrowserIdentityState();
|
||||
|
||||
expect(mod.getStoredNodeDescriptor()).toBeNull();
|
||||
expect(mod.getWormholeIdentityDescriptor()).toBeNull();
|
||||
});
|
||||
|
||||
it('migrates legacy browser and Wormhole node ids to the current format', async () => {
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
const publicKey = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=';
|
||||
const currentNodeId = await mod.deriveNodeIdFromPublicKey(publicKey);
|
||||
|
||||
mod.cachePublicIdentity({
|
||||
nodeId: '!sb_deadbeef',
|
||||
publicKey,
|
||||
publicKeyAlgo: 'Ed25519',
|
||||
});
|
||||
mod.cacheWormholeIdentityDescriptor({
|
||||
nodeId: '!sb_deadbeef',
|
||||
publicKey,
|
||||
publicKeyAlgo: 'Ed25519',
|
||||
});
|
||||
|
||||
await mod.migrateLegacyNodeIds();
|
||||
|
||||
expect(mod.getStoredNodeDescriptor()).toEqual({
|
||||
nodeId: currentNodeId,
|
||||
publicKey,
|
||||
publicKeyAlgo: 'Ed25519',
|
||||
});
|
||||
expect(mod.getWormholeIdentityDescriptor()).toEqual({
|
||||
nodeId: currentNodeId,
|
||||
publicKey,
|
||||
publicKeyAlgo: 'Ed25519',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { buildMerkleRoot, verifyMerkleProof } from '@/mesh/meshMerkle';
|
||||
|
||||
type Fixture = {
|
||||
leaves: string[];
|
||||
root: string;
|
||||
proofs: Record<string, { hash: string; side: string }[]>;
|
||||
};
|
||||
|
||||
describe('mesh merkle fixtures', () => {
|
||||
const cwd = process.cwd();
|
||||
const fixturePath = cwd.endsWith('frontend')
|
||||
? path.resolve(cwd, '..', 'docs', 'mesh', 'mesh-merkle-fixtures.json')
|
||||
: path.resolve(cwd, 'docs', 'mesh', 'mesh-merkle-fixtures.json');
|
||||
const fixtures = JSON.parse(readFileSync(fixturePath, 'utf-8')) as Fixture;
|
||||
|
||||
it('builds the expected root', async () => {
|
||||
const root = await buildMerkleRoot(fixtures.leaves);
|
||||
expect(root).toBe(fixtures.root);
|
||||
});
|
||||
|
||||
it('verifies provided proofs', async () => {
|
||||
const root = fixtures.root;
|
||||
for (const [idxStr, proof] of Object.entries(fixtures.proofs)) {
|
||||
const idx = Number(idxStr);
|
||||
const leaf = fixtures.leaves[idx];
|
||||
const ok = await verifyMerkleProof(leaf, idx, proof, root);
|
||||
expect(ok).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildDmTrustHint,
|
||||
buildPrivateLaneHint,
|
||||
dmTrustPrimaryActionLabel,
|
||||
isFirstContactTrustOnly,
|
||||
shortTrustFingerprint,
|
||||
shouldAutoRevealSasForTrust,
|
||||
} from '@/mesh/meshPrivacyHints';
|
||||
|
||||
describe('meshPrivacyHints', () => {
|
||||
it('flags recent private-lane fallback as a danger hint', () => {
|
||||
const hint = buildPrivateLaneHint({
|
||||
activeTab: 'dms',
|
||||
recentPrivateFallback: true,
|
||||
recentPrivateFallbackReason: 'Tor transport failed and clearnet relay was used.',
|
||||
dmTransportMode: 'relay',
|
||||
});
|
||||
|
||||
expect(hint).toEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'danger',
|
||||
title: 'RECENT PRIVACY DOWNGRADE',
|
||||
}),
|
||||
);
|
||||
expect(hint?.detail).toContain('clearnet relay');
|
||||
});
|
||||
|
||||
it('flags remote prekey mismatch as a danger trust hint', () => {
|
||||
const hint = buildDmTrustHint({
|
||||
remotePrekeyMismatch: true,
|
||||
});
|
||||
|
||||
expect(hint).toEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'danger',
|
||||
title: 'REMOTE PREKEY CHANGED',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('flags first-seen pinned contacts as TOFU until verified', () => {
|
||||
const contact = {
|
||||
remotePrekeyFingerprint: 'abc123',
|
||||
remotePrekeyPinnedAt: 123,
|
||||
verify_registry: false,
|
||||
verify_inband: false,
|
||||
verified: false,
|
||||
};
|
||||
|
||||
expect(isFirstContactTrustOnly(contact)).toBe(true);
|
||||
expect(buildDmTrustHint(contact)).toEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'warn',
|
||||
title: 'FIRST CONTACT (TOFU ONLY)',
|
||||
}),
|
||||
);
|
||||
expect(buildDmTrustHint(contact)?.detail).toContain('not proof of sender identity');
|
||||
expect(dmTrustPrimaryActionLabel(contact)).toBe('VERIFY SAS NOW');
|
||||
expect(shouldAutoRevealSasForTrust(contact)).toBe(true);
|
||||
});
|
||||
|
||||
it('auto-reveals SAS for trust hazards but keeps ordinary verified contacts quiet', () => {
|
||||
expect(
|
||||
shouldAutoRevealSasForTrust({
|
||||
remotePrekeyMismatch: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldAutoRevealSasForTrust({
|
||||
verify_mismatch: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldAutoRevealSasForTrust({
|
||||
verified: true,
|
||||
verify_inband: true,
|
||||
verify_registry: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
dmTrustPrimaryActionLabel({
|
||||
verified: true,
|
||||
verify_inband: true,
|
||||
verify_registry: true,
|
||||
}),
|
||||
).toBe('SHOW SAS');
|
||||
});
|
||||
|
||||
it('shortens long trust fingerprints for display', () => {
|
||||
expect(shortTrustFingerprint('abcdef0123456789fedcba9876543210')).toBe('abcdef01..543210');
|
||||
expect(shortTrustFingerprint('abcd1234')).toBe('abcd1234');
|
||||
expect(shortTrustFingerprint('')).toBe('unknown');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Track what gets stored in IndexedDB
|
||||
const idbStore = new Map<string, unknown>();
|
||||
const deletedDatabases: string[] = [];
|
||||
|
||||
vi.mock('@/lib/controlPlane', () => ({
|
||||
controlPlaneJson: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshKeyStore', () => ({
|
||||
getKey: vi.fn(async (id: string) => idbStore.get(id) ?? null),
|
||||
setKey: vi.fn(async (id: string, key: unknown) => {
|
||||
idbStore.set(id, key);
|
||||
}),
|
||||
deleteKey: vi.fn(async (id: string) => {
|
||||
idbStore.delete(id);
|
||||
}),
|
||||
}));
|
||||
|
||||
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(),
|
||||
get length() {
|
||||
return values.size;
|
||||
},
|
||||
key: (_i: number) => null as string | null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('signing key storage hardening', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
idbStore.clear();
|
||||
deletedDatabases.length = 0;
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: makeStorage(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'sessionStorage', {
|
||||
value: makeStorage(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'indexedDB', {
|
||||
value: {
|
||||
deleteDatabase: vi.fn((name: string) => {
|
||||
deletedDatabases.push(name);
|
||||
const request = {} as IDBOpenDBRequest;
|
||||
queueMicrotask(() => {
|
||||
request.onsuccess?.(new Event('success') as Event);
|
||||
});
|
||||
return request;
|
||||
}),
|
||||
},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('getNodeIdentity returns identity even when privateKey is empty (post-migration)', async () => {
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
|
||||
// Simulate a state where the signing key has already been migrated:
|
||||
// publicKey and nodeId exist, but privateKey does not.
|
||||
localStorage.setItem('sb_mesh_pubkey', 'test-pub');
|
||||
localStorage.setItem('sb_mesh_node_id', '!sb_abcd1234abcd1234');
|
||||
localStorage.setItem('sb_mesh_sovereignty_accepted', 'true');
|
||||
// No sb_mesh_privkey — simulates post-migration state
|
||||
|
||||
const identity = mod.getNodeIdentity();
|
||||
expect(identity).not.toBeNull();
|
||||
expect(identity!.publicKey).toBe('test-pub');
|
||||
expect(identity!.nodeId).toBe('!sb_abcd1234abcd1234');
|
||||
expect(identity!.privateKey).toBe('');
|
||||
});
|
||||
|
||||
it('getNodeIdentity triggers eager migration and does not expose legacy privateKey', async () => {
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
|
||||
localStorage.setItem('sb_mesh_pubkey', 'test-pub');
|
||||
localStorage.setItem('sb_mesh_node_id', '!sb_abcd1234abcd1234');
|
||||
localStorage.setItem('sb_mesh_privkey', '{"fake":"jwk"}');
|
||||
localStorage.setItem('sb_mesh_sovereignty_accepted', 'true');
|
||||
|
||||
const identity = mod.getNodeIdentity();
|
||||
expect(identity).not.toBeNull();
|
||||
expect(identity!.privateKey).toBe('');
|
||||
|
||||
// The eager migration fires asynchronously (void ensureSigningPrivateKey()).
|
||||
// In this test environment crypto.subtle.importKey will fail on the fake JWK,
|
||||
// but the extractable browser copy should still be scrubbed.
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(localStorage.getItem('sb_mesh_privkey')).toBeNull();
|
||||
expect(identity!.publicKey).toBe('test-pub');
|
||||
});
|
||||
|
||||
it('purgeBrowserSigningMaterial clears IndexedDB signing key', async () => {
|
||||
const { deleteKey } = await import('@/mesh/meshKeyStore');
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
|
||||
idbStore.set('sb_mesh_sign_priv', 'mock-crypto-key');
|
||||
localStorage.setItem('sb_mesh_privkey', '{"fake":"jwk"}');
|
||||
localStorage.setItem('sb_mesh_sequence', '42');
|
||||
|
||||
await mod.purgeBrowserSigningMaterial();
|
||||
|
||||
expect(deleteKey).toHaveBeenCalledWith('sb_mesh_sign_priv');
|
||||
expect(localStorage.getItem('sb_mesh_privkey')).toBeNull();
|
||||
expect(localStorage.getItem('sb_mesh_sequence')).toBeNull();
|
||||
});
|
||||
|
||||
it('clearBrowserIdentityState clears both DH and signing keys from IndexedDB', async () => {
|
||||
const { deleteKey } = await import('@/mesh/meshKeyStore');
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
|
||||
localStorage.setItem('sb_mesh_pubkey', 'test-pub');
|
||||
localStorage.setItem('sb_mesh_node_id', '!sb_test');
|
||||
localStorage.setItem('sb_mesh_privkey', '{"fake":"jwk"}');
|
||||
localStorage.setItem('sb_mesh_session_mode', 'true');
|
||||
localStorage.setItem('sb_mesh_sovereignty_accepted', 'true');
|
||||
localStorage.setItem('sb_dm_bundle_fingerprint', 'bundle-fp');
|
||||
sessionStorage.setItem('sb_wormhole_desc_node_id', '!sb_gate');
|
||||
sessionStorage.setItem('sb_mesh_dm_ratchet', 'encrypted');
|
||||
sessionStorage.setItem('sb_mesh_ratchet_telemetry', '{"seen":1}');
|
||||
|
||||
await mod.clearBrowserIdentityState();
|
||||
|
||||
expect(deleteKey).toHaveBeenCalledWith('sb_mesh_dh_priv');
|
||||
expect(deleteKey).toHaveBeenCalledWith('sb_mesh_sign_priv');
|
||||
expect(localStorage.getItem('sb_mesh_pubkey')).toBeNull();
|
||||
expect(localStorage.getItem('sb_mesh_privkey')).toBeNull();
|
||||
expect(localStorage.getItem('sb_dm_bundle_fingerprint')).toBeNull();
|
||||
expect(sessionStorage.getItem('sb_wormhole_desc_node_id')).toBeNull();
|
||||
expect(sessionStorage.getItem('sb_mesh_dm_ratchet')).toBeNull();
|
||||
expect(sessionStorage.getItem('sb_mesh_ratchet_telemetry')).toBeNull();
|
||||
expect(deletedDatabases).toContain('sb_mesh_ratchet_crypto');
|
||||
});
|
||||
|
||||
it('generateDHKeys fails closed when non-extractable DH key storage is unavailable', async () => {
|
||||
const { setKey } = await import('@/mesh/meshKeyStore');
|
||||
vi.mocked(setKey).mockRejectedValueOnce(new Error('idb unavailable'));
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
|
||||
await expect(mod.generateDHKeys()).rejects.toThrow('IndexedDB required for DH key storage');
|
||||
expect(localStorage.getItem('sb_mesh_dh_privkey')).toBeNull();
|
||||
expect(sessionStorage.getItem('sb_mesh_dh_privkey')).toBeNull();
|
||||
});
|
||||
|
||||
it('signWithStoredKey is exported and throws when no key available', async () => {
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
// No key in IndexedDB or localStorage
|
||||
await expect(mod.signWithStoredKey('test message')).rejects.toThrow(
|
||||
'No signing key available',
|
||||
);
|
||||
});
|
||||
|
||||
it('signEvent fails closed when only public identity metadata exists', async () => {
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
|
||||
sessionStorage.setItem('sb_mesh_pubkey', 'test-pub');
|
||||
sessionStorage.setItem('sb_mesh_node_id', '!sb_abcd1234abcd1234');
|
||||
sessionStorage.setItem('sb_mesh_sovereignty_accepted', 'true');
|
||||
sessionStorage.setItem('sb_mesh_algo', 'Ed25519');
|
||||
|
||||
await expect(
|
||||
mod.signEvent('message', '!sb_abcd1234abcd1234', 1, { message: 'hello' }),
|
||||
).rejects.toThrow('No signing key available');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
getMeshTerminalWriteLockReason,
|
||||
isMeshTerminalWriteCommand,
|
||||
} from '@/lib/meshTerminalPolicy';
|
||||
|
||||
describe('mesh terminal policy', () => {
|
||||
it('blocks sensitive terminal writes while anonymous mode is active', () => {
|
||||
const reason = getMeshTerminalWriteLockReason({
|
||||
wormholeRequired: true,
|
||||
wormholeReady: true,
|
||||
anonymousMode: true,
|
||||
anonymousModeReady: true,
|
||||
});
|
||||
|
||||
expect(reason).toContain('Anonymous Infonet mode');
|
||||
expect(isMeshTerminalWriteCommand('dm', ['add', '!sb_test'])).toBe(true);
|
||||
expect(isMeshTerminalWriteCommand('mesh', ['send', 'hello'])).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks sensitive terminal writes until Wormhole secure mode is ready', () => {
|
||||
const reason = getMeshTerminalWriteLockReason({
|
||||
wormholeRequired: true,
|
||||
wormholeReady: false,
|
||||
anonymousMode: false,
|
||||
anonymousModeReady: false,
|
||||
});
|
||||
|
||||
expect(reason).toContain('until Wormhole secure mode is ready');
|
||||
expect(isMeshTerminalWriteCommand('gate', ['create', 'newsroom'])).toBe(true);
|
||||
expect(isMeshTerminalWriteCommand('send', ['broadcast', 'hello'])).toBe(true);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import {
|
||||
getSenderRecoveryState,
|
||||
REQUEST_V2_REDUCED_VERSION,
|
||||
recoverSenderSealWithFallback,
|
||||
requiresSenderRecovery,
|
||||
shouldAllowRequestActions,
|
||||
shouldKeepUnresolvedRequestVisible,
|
||||
shouldPromoteRecoveredSenderForBootstrap,
|
||||
shouldPromoteRecoveredSenderForKnownContact,
|
||||
} from '@/mesh/requestSenderRecovery';
|
||||
|
||||
describe('requestSenderRecovery', () => {
|
||||
it('only promotes a known-contact sender when the seal verified and the sender matches', () => {
|
||||
expect(
|
||||
shouldPromoteRecoveredSenderForKnownContact(
|
||||
{ sender_id: 'alice', seal_verified: true },
|
||||
'alice',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldPromoteRecoveredSenderForKnownContact(
|
||||
{ sender_id: 'alice', seal_verified: false },
|
||||
'alice',
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldPromoteRecoveredSenderForKnownContact(
|
||||
{ sender_id: 'mallory', seal_verified: true },
|
||||
'alice',
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('only promotes a bootstrap-recovered sender when the seal verified', () => {
|
||||
expect(
|
||||
shouldPromoteRecoveredSenderForBootstrap({
|
||||
sender_id: 'alice',
|
||||
seal_verified: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldPromoteRecoveredSenderForBootstrap({
|
||||
sender_id: 'alice',
|
||||
seal_verified: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(shouldPromoteRecoveredSenderForBootstrap(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('prefers explicit request-v2 recovery markers over sealed-string inference', () => {
|
||||
expect(
|
||||
requiresSenderRecovery({
|
||||
sender_id: 'opaque',
|
||||
sender_seal: 'v3:test',
|
||||
request_contract_version: REQUEST_V2_REDUCED_VERSION,
|
||||
sender_recovery_required: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
getSenderRecoveryState({
|
||||
sender_id: 'opaque',
|
||||
sender_seal: 'v3:test',
|
||||
request_contract_version: REQUEST_V2_REDUCED_VERSION,
|
||||
sender_recovery_required: true,
|
||||
}),
|
||||
).toBe('pending');
|
||||
expect(
|
||||
requiresSenderRecovery({
|
||||
sender_id: 'sealed:abcd',
|
||||
sender_seal: 'v2:test',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('only allows request actions once canonical recovery reaches verified', () => {
|
||||
expect(
|
||||
shouldAllowRequestActions({
|
||||
request_contract_version: REQUEST_V2_REDUCED_VERSION,
|
||||
sender_recovery_required: true,
|
||||
sender_recovery_state: 'verified',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldAllowRequestActions({
|
||||
request_contract_version: REQUEST_V2_REDUCED_VERSION,
|
||||
sender_recovery_required: true,
|
||||
sender_recovery_state: 'pending',
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldAllowRequestActions({
|
||||
request_contract_version: REQUEST_V2_REDUCED_VERSION,
|
||||
sender_recovery_required: true,
|
||||
sender_recovery_state: 'failed',
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldAllowRequestActions({
|
||||
request_contract_version: undefined,
|
||||
sender_recovery_required: undefined,
|
||||
sender_recovery_state: undefined,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps only pending or failed canonical request-v2 mail visible in the unresolved inbox flow', () => {
|
||||
expect(
|
||||
shouldKeepUnresolvedRequestVisible({
|
||||
delivery_class: 'request',
|
||||
request_contract_version: REQUEST_V2_REDUCED_VERSION,
|
||||
sender_recovery_required: true,
|
||||
sender_recovery_state: 'pending',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldKeepUnresolvedRequestVisible({
|
||||
delivery_class: 'request',
|
||||
request_contract_version: REQUEST_V2_REDUCED_VERSION,
|
||||
sender_recovery_required: true,
|
||||
sender_recovery_state: 'failed',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldKeepUnresolvedRequestVisible({
|
||||
delivery_class: 'request',
|
||||
request_contract_version: REQUEST_V2_REDUCED_VERSION,
|
||||
sender_recovery_required: true,
|
||||
sender_recovery_state: 'verified',
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldKeepUnresolvedRequestVisible({
|
||||
delivery_class: 'request',
|
||||
request_contract_version: undefined,
|
||||
sender_recovery_required: undefined,
|
||||
sender_recovery_state: 'pending',
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldKeepUnresolvedRequestVisible({
|
||||
delivery_class: 'shared',
|
||||
request_contract_version: REQUEST_V2_REDUCED_VERSION,
|
||||
sender_recovery_required: true,
|
||||
sender_recovery_state: 'pending',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('prefers local recovery and only falls back to the helper on local failure', async () => {
|
||||
const openLocal = vi.fn().mockResolvedValue({
|
||||
sender_id: 'alice',
|
||||
seal_verified: true,
|
||||
});
|
||||
const openHelper = vi.fn().mockResolvedValue({
|
||||
sender_id: 'helper-alice',
|
||||
seal_verified: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
recoverSenderSealWithFallback({
|
||||
wormholeReady: true,
|
||||
openLocal,
|
||||
openHelper,
|
||||
}),
|
||||
).resolves.toEqual({ sender_id: 'alice', seal_verified: true });
|
||||
|
||||
expect(openLocal).toHaveBeenCalledTimes(1);
|
||||
expect(openHelper).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the helper only as fallback when local recovery cannot open the seal', async () => {
|
||||
const openLocal = vi.fn().mockResolvedValue(null);
|
||||
const openHelper = vi.fn().mockResolvedValue({
|
||||
sender_id: 'alice',
|
||||
seal_verified: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
recoverSenderSealWithFallback({
|
||||
wormholeReady: true,
|
||||
openLocal,
|
||||
openHelper,
|
||||
}),
|
||||
).resolves.toEqual({ sender_id: 'alice', seal_verified: true });
|
||||
|
||||
expect(openLocal).toHaveBeenCalledTimes(1);
|
||||
expect(openHelper).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not invoke the helper when Wormhole fallback is unavailable', async () => {
|
||||
const openLocal = vi.fn().mockResolvedValue(null);
|
||||
const openHelper = vi.fn().mockResolvedValue({
|
||||
sender_id: 'alice',
|
||||
seal_verified: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
recoverSenderSealWithFallback({
|
||||
wormholeReady: false,
|
||||
openLocal,
|
||||
openHelper,
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
|
||||
expect(openLocal).toHaveBeenCalledTimes(1);
|
||||
expect(openHelper).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats helper failure as unresolved instead of promoting helper authority', async () => {
|
||||
const openLocal = vi.fn().mockResolvedValue(null);
|
||||
const openHelper = vi.fn().mockRejectedValue(new Error('helper_failed'));
|
||||
|
||||
await expect(
|
||||
recoverSenderSealWithFallback({
|
||||
wormholeReady: true,
|
||||
openLocal,
|
||||
openHelper,
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
|
||||
expect(openLocal).toHaveBeenCalledTimes(1);
|
||||
expect(openHelper).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
ensureCanonicalRequestV2SenderSeal,
|
||||
REQUEST_V2_SENDER_SEAL_VERSION_ERROR,
|
||||
requiresCanonicalRequestV2SenderSeal,
|
||||
} from '@/mesh/requestSenderSealPolicy';
|
||||
|
||||
describe('requestSenderSealPolicy', () => {
|
||||
it('requires canonical v3 seals only for request-class sealed sender', () => {
|
||||
expect(
|
||||
requiresCanonicalRequestV2SenderSeal({
|
||||
deliveryClass: 'request',
|
||||
useSealedSender: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
requiresCanonicalRequestV2SenderSeal({
|
||||
deliveryClass: 'request',
|
||||
useSealedSender: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
requiresCanonicalRequestV2SenderSeal({
|
||||
deliveryClass: 'shared',
|
||||
useSealedSender: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts v3 seals and rejects non-v3 seals for canonical request-v2 sender sealing', () => {
|
||||
expect(ensureCanonicalRequestV2SenderSeal('v3:ephemeral:payload')).toBe(
|
||||
'v3:ephemeral:payload',
|
||||
);
|
||||
expect(() => ensureCanonicalRequestV2SenderSeal('v2:legacy-payload')).toThrow(
|
||||
REQUEST_V2_SENDER_SEAL_VERSION_ERROR,
|
||||
);
|
||||
expect(() => ensureCanonicalRequestV2SenderSeal('')).toThrow(
|
||||
REQUEST_V2_SENDER_SEAL_VERSION_ERROR,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const controlPlaneJson = vi.fn();
|
||||
const idbStore = new Map<string, unknown>();
|
||||
|
||||
vi.mock('@/lib/controlPlane', () => ({
|
||||
controlPlaneJson,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshKeyStore', () => ({
|
||||
getKey: vi.fn(async (id: string) => idbStore.get(id) ?? null),
|
||||
setKey: vi.fn(async (id: string, key: unknown) => {
|
||||
idbStore.set(id, key);
|
||||
}),
|
||||
deleteKey: vi.fn(async (id: string) => {
|
||||
idbStore.delete(id);
|
||||
}),
|
||||
}));
|
||||
|
||||
function bufToBase64(buf: ArrayBuffer): string {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buf)));
|
||||
}
|
||||
|
||||
async function buildV3SealForRecipient(params: {
|
||||
recipientPublicKey: CryptoKey;
|
||||
recipientId: string;
|
||||
msgId: string;
|
||||
plaintext: string;
|
||||
}) {
|
||||
const { encryptDM } = await import('@/mesh/meshIdentity');
|
||||
const { PROTOCOL_VERSION } = await import('@/mesh/meshProtocol');
|
||||
const ephemeral = (await crypto.subtle.generateKey(
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
true,
|
||||
['deriveBits', 'deriveKey'],
|
||||
)) as CryptoKeyPair;
|
||||
const ephemeralPubRaw = await crypto.subtle.exportKey('raw', ephemeral.publicKey);
|
||||
const ephemeralPub = bufToBase64(ephemeralPubRaw);
|
||||
const secret = await crypto.subtle.deriveBits(
|
||||
{ name: 'ECDH', public: params.recipientPublicKey },
|
||||
ephemeral.privateKey,
|
||||
256,
|
||||
);
|
||||
const salt = await crypto.subtle.digest(
|
||||
'SHA-256',
|
||||
new TextEncoder().encode(
|
||||
`SB-SEAL-SALT|${params.recipientId}|${params.msgId}|${PROTOCOL_VERSION}|${ephemeralPub}`,
|
||||
),
|
||||
);
|
||||
const hkdfKey = await crypto.subtle.importKey('raw', secret, 'HKDF', false, ['deriveKey']);
|
||||
const sealKey = await crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'HKDF',
|
||||
hash: 'SHA-256',
|
||||
salt,
|
||||
info: new TextEncoder().encode('SB-SENDER-SEAL-V3'),
|
||||
},
|
||||
hkdfKey,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
const ciphertext = await encryptDM(params.plaintext, sealKey);
|
||||
return `v3:${ephemeralPub}:${ciphertext}`;
|
||||
}
|
||||
|
||||
describe('request sender seal recovery window', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
controlPlaneJson.mockReset();
|
||||
idbStore.clear();
|
||||
const 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(),
|
||||
};
|
||||
};
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: makeStorage(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'sessionStorage', {
|
||||
value: makeStorage(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('opens a v3 sender seal with the immediately previous retained recipient key', async () => {
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
const previousRecipient = (await crypto.subtle.generateKey(
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
false,
|
||||
['deriveBits', 'deriveKey'],
|
||||
)) as CryptoKeyPair;
|
||||
const currentRecipient = (await crypto.subtle.generateKey(
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
false,
|
||||
['deriveBits', 'deriveKey'],
|
||||
)) as CryptoKeyPair;
|
||||
|
||||
idbStore.set('sb_mesh_dh_priv', currentRecipient.privateKey);
|
||||
idbStore.set('sb_mesh_dh_prev_priv', previousRecipient.privateKey);
|
||||
localStorage.setItem('sb_mesh_dh_algo', 'ECDH');
|
||||
|
||||
const plaintext = JSON.stringify({ sender_id: 'alice', msg_id: 'msg-rotation' });
|
||||
const senderSeal = await buildV3SealForRecipient({
|
||||
recipientPublicKey: previousRecipient.publicKey,
|
||||
recipientId: '!sb_recipient',
|
||||
msgId: 'msg-rotation',
|
||||
plaintext,
|
||||
});
|
||||
|
||||
await expect(
|
||||
mod.decryptSenderSealPayloadLocally(senderSeal, '', '!sb_recipient', 'msg-rotation'),
|
||||
).resolves.toBe(plaintext);
|
||||
});
|
||||
|
||||
it('returns null when the prior retained recipient key is unavailable', async () => {
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
const previousRecipient = (await crypto.subtle.generateKey(
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
false,
|
||||
['deriveBits', 'deriveKey'],
|
||||
)) as CryptoKeyPair;
|
||||
const currentRecipient = (await crypto.subtle.generateKey(
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
false,
|
||||
['deriveBits', 'deriveKey'],
|
||||
)) as CryptoKeyPair;
|
||||
|
||||
idbStore.set('sb_mesh_dh_priv', currentRecipient.privateKey);
|
||||
localStorage.setItem('sb_mesh_dh_algo', 'ECDH');
|
||||
|
||||
const senderSeal = await buildV3SealForRecipient({
|
||||
recipientPublicKey: previousRecipient.publicKey,
|
||||
recipientId: '!sb_recipient',
|
||||
msgId: 'msg-rotation-miss',
|
||||
plaintext: JSON.stringify({ sender_id: 'alice', msg_id: 'msg-rotation-miss' }),
|
||||
});
|
||||
|
||||
await expect(
|
||||
mod.decryptSenderSealPayloadLocally(senderSeal, '', '!sb_recipient', 'msg-rotation-miss'),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('retains the current DH private key in the previous-key slot when rotating', async () => {
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
const existing = (await crypto.subtle.generateKey(
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
false,
|
||||
['deriveBits', 'deriveKey'],
|
||||
)) as CryptoKeyPair;
|
||||
const originalGenerateKey = crypto.subtle.generateKey.bind(crypto.subtle);
|
||||
const generateKeySpy = vi
|
||||
.spyOn(crypto.subtle, 'generateKey')
|
||||
.mockImplementation(((algorithm: AlgorithmIdentifier, extractable: boolean, keyUsages: KeyUsage[]) => {
|
||||
if (algorithm === 'X25519') {
|
||||
return Promise.reject(new Error('x25519_unavailable_for_test'));
|
||||
}
|
||||
return originalGenerateKey(algorithm, extractable, keyUsages);
|
||||
}) as typeof crypto.subtle.generateKey);
|
||||
|
||||
idbStore.set('sb_mesh_dh_priv', existing.privateKey);
|
||||
try {
|
||||
await mod.generateDHKeys();
|
||||
|
||||
expect(idbStore.get('sb_mesh_dh_prev_priv')).toBe(existing.privateKey);
|
||||
expect(idbStore.get('sb_mesh_dh_priv')).not.toBe(existing.privateKey);
|
||||
} finally {
|
||||
generateKeySpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const controlPlaneJson = vi.fn();
|
||||
const getNodeIdentity = vi.fn<
|
||||
() => { nodeId: string; publicKey: string; privateKey: string } | null
|
||||
>(() => null);
|
||||
const signEvent = vi.fn();
|
||||
const signMessage = vi.fn();
|
||||
const signWithStoredKey = vi.fn();
|
||||
const isSecureModeCached = vi.fn(() => true);
|
||||
const fetchWormholeSettings = vi.fn(async () => ({ enabled: true }));
|
||||
const fetchWormholeState = vi.fn(async () => ({ ready: true }));
|
||||
|
||||
vi.mock('@/lib/controlPlane', () => ({
|
||||
controlPlaneJson,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshIdentity', () => ({
|
||||
cacheWormholeIdentityDescriptor: vi.fn(),
|
||||
getNodeIdentity,
|
||||
getPublicKeyAlgo: vi.fn(() => 'ed25519'),
|
||||
isSecureModeCached,
|
||||
purgeBrowserSigningMaterial: vi.fn(async () => {}),
|
||||
setSecureModeCached: vi.fn(),
|
||||
signEvent,
|
||||
signMessage,
|
||||
signWithStoredKey,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshProtocol', () => ({
|
||||
PROTOCOL_VERSION: 'sb-test',
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/wormholeClient', () => ({
|
||||
fetchWormholeSettings,
|
||||
fetchWormholeState,
|
||||
}));
|
||||
|
||||
describe('wormholeIdentityClient strict profile hints', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
controlPlaneJson.mockReset();
|
||||
controlPlaneJson.mockResolvedValue({ ok: true });
|
||||
getNodeIdentity.mockReset();
|
||||
getNodeIdentity.mockReturnValue(null);
|
||||
signEvent.mockReset();
|
||||
signMessage.mockReset();
|
||||
signWithStoredKey.mockReset();
|
||||
isSecureModeCached.mockReset();
|
||||
isSecureModeCached.mockReturnValue(true);
|
||||
fetchWormholeSettings.mockReset();
|
||||
fetchWormholeSettings.mockResolvedValue({ enabled: true });
|
||||
fetchWormholeState.mockReset();
|
||||
fetchWormholeState.mockResolvedValue({ ready: true });
|
||||
});
|
||||
|
||||
it('applies strict gate_operator enforcement to gate persona and compose operations', async () => {
|
||||
const mod = await import('@/mesh/wormholeIdentityClient');
|
||||
|
||||
await mod.listWormholeGatePersonas('infonet');
|
||||
await mod.createWormholeGatePersona('infonet', 'persona-1');
|
||||
await mod.activateWormholeGatePersona('infonet', 'persona-1');
|
||||
await mod.clearWormholeGatePersona('infonet');
|
||||
await mod.retireWormholeGatePersona('infonet', 'persona-1');
|
||||
await mod.composeWormholeGateMessage('infonet', 'hello');
|
||||
|
||||
expect(controlPlaneJson).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/wormhole/gate/infonet/personas',
|
||||
expect.objectContaining({
|
||||
capabilityIntent: 'wormhole_gate_persona',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
enforceProfileHint: true,
|
||||
}),
|
||||
);
|
||||
for (let i = 2; i <= 5; i += 1) {
|
||||
expect(controlPlaneJson).toHaveBeenNthCalledWith(
|
||||
i,
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
capabilityIntent: 'wormhole_gate_persona',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
enforceProfileHint: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
expect(controlPlaneJson).toHaveBeenNthCalledWith(
|
||||
6,
|
||||
'/api/wormhole/gate/message/compose',
|
||||
expect.objectContaining({
|
||||
capabilityIntent: 'wormhole_gate_content',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
enforceProfileHint: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('browser raw signing fails closed instead of falling back to legacy jwk signing', async () => {
|
||||
fetchWormholeSettings.mockResolvedValue({ enabled: false });
|
||||
fetchWormholeState.mockResolvedValue({ ready: false });
|
||||
getNodeIdentity.mockReturnValue({
|
||||
nodeId: '!sb_browser',
|
||||
publicKey: 'browser-pub',
|
||||
privateKey: '',
|
||||
});
|
||||
signWithStoredKey.mockRejectedValue(new Error('no key'));
|
||||
|
||||
const mod = await import('@/mesh/wormholeIdentityClient');
|
||||
|
||||
await expect(mod.signRawMeshMessage('payload')).rejects.toThrow(
|
||||
'browser_signing_key_unavailable',
|
||||
);
|
||||
expect(signWithStoredKey).toHaveBeenCalledWith('payload');
|
||||
expect(signMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the cached secure boundary when wormhole settings fetch fails', async () => {
|
||||
fetchWormholeSettings.mockRejectedValue(new Error('network down'));
|
||||
isSecureModeCached.mockReturnValue(true);
|
||||
|
||||
const mod = await import('@/mesh/wormholeIdentityClient');
|
||||
|
||||
await expect(mod.isWormholeSecureRequired()).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { classifyAircraft, HELI_TYPES, TURBOPROP_TYPES, BIZJET_TYPES } from '@/utils/aircraftClassification';
|
||||
import {
|
||||
classifyAircraft,
|
||||
HELI_TYPES,
|
||||
TURBOPROP_TYPES,
|
||||
BIZJET_TYPES,
|
||||
} from '@/utils/aircraftClassification';
|
||||
|
||||
describe('classifyAircraft', () => {
|
||||
// ─── Helicopter classification ────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const idbStore = new Map<string, unknown>();
|
||||
|
||||
vi.mock('@/mesh/meshKeyStore', () => ({
|
||||
getKey: vi.fn(async (id: string) => idbStore.get(id) ?? null),
|
||||
setKey: vi.fn(async (id: string, key: unknown) => {
|
||||
idbStore.set(id, key);
|
||||
}),
|
||||
deleteKey: vi.fn(async (id: string) => {
|
||||
idbStore.delete(id);
|
||||
}),
|
||||
}));
|
||||
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
||||
function bufToBase64(buf: ArrayBuffer): string {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buf)));
|
||||
}
|
||||
|
||||
async function provisionLocalIdentity(): Promise<void> {
|
||||
const meshIdentity = await import('@/mesh/meshIdentity');
|
||||
localStorage.setItem('sb_mesh_pubkey', 'test-pub');
|
||||
localStorage.setItem('sb_mesh_node_id', '!sb_sensitive123456');
|
||||
localStorage.setItem('sb_mesh_sovereignty_accepted', 'true');
|
||||
const keyPair = (await crypto.subtle.generateKey(
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
false,
|
||||
['deriveKey', 'deriveBits'],
|
||||
)) as CryptoKeyPair;
|
||||
const publicRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey);
|
||||
localStorage.setItem('sb_mesh_dh_pubkey', bufToBase64(publicRaw));
|
||||
localStorage.setItem('sb_mesh_dh_algo', 'ECDH');
|
||||
idbStore.set('sb_mesh_dh_priv', keyPair.privateKey);
|
||||
meshIdentity.getNodeIdentity();
|
||||
}
|
||||
|
||||
describe('identityBoundSensitiveStorage', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
idbStore.clear();
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: makeStorage(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'sessionStorage', {
|
||||
value: makeStorage(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('stores encrypted values in sensitive storage and keeps them out of localStorage', async () => {
|
||||
await provisionLocalIdentity();
|
||||
const storage = await import('@/lib/identityBoundSensitiveStorage');
|
||||
|
||||
await storage.persistIdentityBoundSensitiveValue(
|
||||
'sb_access_requests:test',
|
||||
'SB-ACCESS-REQUESTS-STORAGE-V1',
|
||||
[{ sender_id: 'alice', ts: 1 }],
|
||||
);
|
||||
|
||||
expect(String(sessionStorage.getItem('sb_access_requests:test') ?? '')).toMatch(/^enc:/);
|
||||
expect(localStorage.getItem('sb_access_requests:test')).toBeNull();
|
||||
|
||||
const hydrated = await storage.loadIdentityBoundSensitiveValue(
|
||||
'sb_access_requests:test',
|
||||
'SB-ACCESS-REQUESTS-STORAGE-V1',
|
||||
[],
|
||||
);
|
||||
expect(hydrated).toEqual([{ sender_id: 'alice', ts: 1 }]);
|
||||
});
|
||||
|
||||
it('migrates legacy plaintext sensitive values into encrypted session-backed storage', async () => {
|
||||
await provisionLocalIdentity();
|
||||
const storage = await import('@/lib/identityBoundSensitiveStorage');
|
||||
|
||||
localStorage.setItem('sb_mesh_muted', JSON.stringify(['alice', 'bob']));
|
||||
|
||||
const hydrated = await storage.loadIdentityBoundSensitiveValue(
|
||||
'sb_mesh_muted:!sb_sensitive123456',
|
||||
'SB-MUTED-LIST-V1',
|
||||
[],
|
||||
{ legacyKey: 'sb_mesh_muted' },
|
||||
);
|
||||
|
||||
expect(hydrated).toEqual(['alice', 'bob']);
|
||||
expect(String(sessionStorage.getItem('sb_mesh_muted:!sb_sensitive123456') ?? '')).toMatch(/^enc:/);
|
||||
expect(localStorage.getItem('sb_mesh_muted')).toBeNull();
|
||||
expect(sessionStorage.getItem('sb_mesh_muted')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
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('privacyBrowserStorage', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: makeStorage(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'sessionStorage', {
|
||||
value: makeStorage(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('stores sensitive items in sessionStorage by default', async () => {
|
||||
const mod = await import('@/lib/privacyBrowserStorage');
|
||||
|
||||
mod.setSensitiveBrowserItem('secret-key', 'alpha');
|
||||
|
||||
expect(mod.getSensitiveBrowserStorageMode()).toBe('session');
|
||||
expect(sessionStorage.getItem('secret-key')).toBe('alpha');
|
||||
expect(localStorage.getItem('secret-key')).toBeNull();
|
||||
expect(mod.getSensitiveBrowserItem('secret-key')).toBe('alpha');
|
||||
});
|
||||
|
||||
it('stores privacy preferences in session storage when session mode is enabled', async () => {
|
||||
const mod = await import('@/lib/privacyBrowserStorage');
|
||||
|
||||
mod.setSessionModePreference(true);
|
||||
mod.setPrivacyStrictPreference(true, { sessionMode: true });
|
||||
mod.setPrivacyProfilePreference('high', { sessionMode: true });
|
||||
|
||||
expect(mod.getSessionModePreference()).toBe(true);
|
||||
expect(mod.getPrivacyStrictPreference()).toBe(true);
|
||||
expect(mod.getPrivacyProfilePreference()).toBe('high');
|
||||
expect(sessionStorage.getItem('sb_mesh_session_mode')).toBe('true');
|
||||
expect(sessionStorage.getItem('sb_privacy_strict')).toBe('true');
|
||||
expect(sessionStorage.getItem('sb_privacy_profile')).toBe('high');
|
||||
expect(localStorage.getItem('sb_mesh_session_mode')).toBeNull();
|
||||
expect(localStorage.getItem('sb_privacy_strict')).toBeNull();
|
||||
expect(localStorage.getItem('sb_privacy_profile')).toBeNull();
|
||||
});
|
||||
|
||||
it('persists session mode locally only when the user explicitly disables it', async () => {
|
||||
const mod = await import('@/lib/privacyBrowserStorage');
|
||||
|
||||
mod.setSessionModePreference(false);
|
||||
|
||||
expect(mod.getSessionModePreference()).toBe(false);
|
||||
expect(localStorage.getItem('sb_mesh_session_mode')).toBe('false');
|
||||
expect(sessionStorage.getItem('sb_mesh_session_mode')).toBeNull();
|
||||
});
|
||||
|
||||
it('stores sensitive items in sessionStorage when privacy strict is enabled', async () => {
|
||||
localStorage.setItem('sb_privacy_strict', 'true');
|
||||
const mod = await import('@/lib/privacyBrowserStorage');
|
||||
|
||||
mod.setSensitiveBrowserItem('secret-key', 'bravo');
|
||||
|
||||
expect(mod.getSensitiveBrowserStorageMode()).toBe('session');
|
||||
expect(sessionStorage.getItem('secret-key')).toBe('bravo');
|
||||
expect(localStorage.getItem('secret-key')).toBeNull();
|
||||
});
|
||||
|
||||
it('migrates legacy localStorage values into sessionStorage in strict mode', async () => {
|
||||
localStorage.setItem('sb_privacy_strict', 'true');
|
||||
localStorage.setItem('secret-key', 'charlie');
|
||||
const mod = await import('@/lib/privacyBrowserStorage');
|
||||
|
||||
expect(mod.getSensitiveBrowserItem('secret-key')).toBe('charlie');
|
||||
expect(sessionStorage.getItem('secret-key')).toBe('charlie');
|
||||
expect(localStorage.getItem('secret-key')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -70,7 +70,8 @@ describe('computeNightPolygon', () => {
|
||||
.filter(([lng]: number[]) => lng >= -180 && lng <= 180)
|
||||
.slice(0, 361)
|
||||
.map(([, lat]: number[]) => lat);
|
||||
const avgLat = terminatorLats.reduce((a: number, b: number) => a + b, 0) / terminatorLats.length;
|
||||
const avgLat =
|
||||
terminatorLats.reduce((a: number, b: number) => a + b, 0) / terminatorLats.length;
|
||||
expect(avgLat).toBeLessThan(15);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildBoundsQuery,
|
||||
coarsenViewBounds,
|
||||
expandBoundsToRadius,
|
||||
} from '@/lib/viewportPrivacy';
|
||||
|
||||
describe('viewport privacy helper', () => {
|
||||
it('coarsens narrow bounds outward without clipping the original view', () => {
|
||||
const original = {
|
||||
south: 33.612,
|
||||
west: -84.452,
|
||||
north: 33.781,
|
||||
east: -84.211,
|
||||
};
|
||||
|
||||
const coarse = coarsenViewBounds(original);
|
||||
|
||||
expect(coarse.south).toBeLessThanOrEqual(original.south);
|
||||
expect(coarse.west).toBeLessThanOrEqual(original.west);
|
||||
expect(coarse.north).toBeGreaterThanOrEqual(original.north);
|
||||
expect(coarse.east).toBeGreaterThanOrEqual(original.east);
|
||||
expect(coarse.south).toBe(33.6);
|
||||
expect(coarse.west).toBe(-84.5);
|
||||
expect(coarse.north).toBe(33.8);
|
||||
expect(coarse.east).toBe(-84.2);
|
||||
});
|
||||
|
||||
it('canonicalizes the bounds query so nearby pans in the same coarse cell dedupe', () => {
|
||||
const a = buildBoundsQuery({
|
||||
south: 47.6011,
|
||||
west: -122.3484,
|
||||
north: 47.6902,
|
||||
east: -122.2012,
|
||||
});
|
||||
const b = buildBoundsQuery({
|
||||
south: 47.6039,
|
||||
west: -122.3441,
|
||||
north: 47.6883,
|
||||
east: -122.2051,
|
||||
});
|
||||
|
||||
expect(a).toBe('?s=47.60&w=-122.35&n=47.70&e=-122.20');
|
||||
expect(b).toBe(a);
|
||||
});
|
||||
|
||||
it('expands bounds to a fixed preload radius around the current view center', () => {
|
||||
const original = {
|
||||
south: 39.55,
|
||||
west: -105.25,
|
||||
north: 39.95,
|
||||
east: -104.75,
|
||||
};
|
||||
|
||||
const expanded = expandBoundsToRadius(original, 3000);
|
||||
|
||||
expect(expanded.south).toBeLessThanOrEqual(original.south);
|
||||
expect(expanded.west).toBeLessThanOrEqual(original.west);
|
||||
expect(expanded.north).toBeGreaterThanOrEqual(original.north);
|
||||
expect(expanded.east).toBeGreaterThanOrEqual(original.east);
|
||||
expect(expanded.north - expanded.south).toBeGreaterThan(80);
|
||||
expect(expanded.east - expanded.west).toBeGreaterThan(90);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user