mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-08-10 20:50:25 +02:00
release: prepare v0.9.7
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Phase 5F-A: CSP nonce plumbing tests.
|
||||
*
|
||||
* Validates:
|
||||
* 1. Nonce appears in document CSP header
|
||||
* 2. Nonce differs across repeated requests
|
||||
* 3. next.config.ts no longer owns a static CSP header
|
||||
* 4. Middleware does not break API/static routes (matcher exclusion)
|
||||
* 5. Google Fonts domains are preserved in CSP
|
||||
* 6. Production CSP preserves required directives
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
import { middleware, config as middlewareConfig } from '@/middleware';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Call middleware with a fake document request and return the response. */
|
||||
function callMiddleware(path = '/') {
|
||||
const req = new NextRequest(`http://localhost${path}`, { method: 'GET' });
|
||||
return middleware(req);
|
||||
}
|
||||
|
||||
/** Extract the CSP header string from a middleware response. */
|
||||
function getCsp(path = '/'): string {
|
||||
return callMiddleware(path).headers.get('Content-Security-Policy') ?? '';
|
||||
}
|
||||
|
||||
/** Check whether the middleware matcher regex excludes a given path. */
|
||||
function matcherExcludes(path: string): boolean {
|
||||
const pattern = middlewareConfig.matcher[0];
|
||||
// Next.js wraps the matcher in ^/<pattern>$ for path matching.
|
||||
// We replicate the essential check: the negative-lookahead prefix groups.
|
||||
const re = new RegExp(`^${pattern}$`);
|
||||
// Strip leading '/' because the matcher pattern starts with '/'.
|
||||
return !re.test(path);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Nonce appears in document CSP header
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('nonce in CSP header', () => {
|
||||
it('CSP header contains a nonce-<value> token in script-src', () => {
|
||||
const csp = getCsp();
|
||||
expect(csp).toMatch(/'nonce-[A-Za-z0-9+/=]+'/) ;
|
||||
});
|
||||
|
||||
it('nonce value is a base64-encoded UUID', () => {
|
||||
const csp = getCsp();
|
||||
const match = csp.match(/'nonce-([A-Za-z0-9+/=]+)'/);
|
||||
expect(match).not.toBeNull();
|
||||
const decoded = Buffer.from(match![1], 'base64').toString();
|
||||
// crypto.randomUUID() produces 8-4-4-4-12 hex with dashes
|
||||
expect(decoded).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-/);
|
||||
});
|
||||
|
||||
it('x-nonce request header is set on the response', () => {
|
||||
const res = callMiddleware();
|
||||
// NextResponse.next({ request: { headers } }) merges into request headers.
|
||||
// The CSP nonce in the header must match the one forwarded to server components.
|
||||
const csp = res.headers.get('Content-Security-Policy') ?? '';
|
||||
const nonceInCsp = csp.match(/'nonce-([A-Za-z0-9+/=]+)'/)?.[1];
|
||||
expect(nonceInCsp).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Nonce differs across repeated requests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('nonce uniqueness', () => {
|
||||
it('two sequential requests produce different nonces', () => {
|
||||
const csp1 = getCsp();
|
||||
const csp2 = getCsp();
|
||||
const nonce1 = csp1.match(/'nonce-([A-Za-z0-9+/=]+)'/)?.[1];
|
||||
const nonce2 = csp2.match(/'nonce-([A-Za-z0-9+/=]+)'/)?.[1];
|
||||
expect(nonce1).toBeTruthy();
|
||||
expect(nonce2).toBeTruthy();
|
||||
expect(nonce1).not.toBe(nonce2);
|
||||
});
|
||||
|
||||
it('ten requests produce ten distinct nonces', () => {
|
||||
const nonces = new Set<string>();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const csp = getCsp();
|
||||
const nonce = csp.match(/'nonce-([A-Za-z0-9+/=]+)'/)?.[1];
|
||||
expect(nonce).toBeTruthy();
|
||||
nonces.add(nonce!);
|
||||
}
|
||||
expect(nonces.size).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. next.config.ts no longer owns static CSP
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('next.config.ts CSP removal', () => {
|
||||
it('securityHeaders in next.config does not include Content-Security-Policy', async () => {
|
||||
// Import the built config and inspect the headers callback.
|
||||
const nextConfig = (await import('../../../next.config')).default;
|
||||
const headerEntries = await nextConfig.headers!();
|
||||
const allHeaders = headerEntries.flatMap(
|
||||
(entry: { headers: { key: string; value: string }[] }) => entry.headers,
|
||||
);
|
||||
const cspHeaders = allHeaders.filter(
|
||||
(h: { key: string }) => h.key.toLowerCase() === 'content-security-policy',
|
||||
);
|
||||
expect(cspHeaders).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('non-CSP security headers are still present', async () => {
|
||||
const nextConfig = (await import('../../../next.config')).default;
|
||||
const headerEntries = await nextConfig.headers!();
|
||||
const allKeys = headerEntries
|
||||
.flatMap(
|
||||
(entry: { headers: { key: string; value: string }[] }) => entry.headers,
|
||||
)
|
||||
.map((h: { key: string }) => h.key);
|
||||
expect(allKeys).toContain('Referrer-Policy');
|
||||
expect(allKeys).toContain('X-Content-Type-Options');
|
||||
expect(allKeys).toContain('X-Frame-Options');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Middleware does not break API/static routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('middleware matcher exclusions', () => {
|
||||
it('excludes /api paths', () => {
|
||||
expect(matcherExcludes('/api/mesh/events')).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes /_next/static paths', () => {
|
||||
expect(matcherExcludes('/_next/static/chunks/main.js')).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes /_next/image paths', () => {
|
||||
expect(matcherExcludes('/_next/image?url=foo')).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes /favicon.ico', () => {
|
||||
expect(matcherExcludes('/favicon.ico')).toBe(true);
|
||||
});
|
||||
|
||||
it('includes document paths like /', () => {
|
||||
expect(matcherExcludes('/')).toBe(false);
|
||||
});
|
||||
|
||||
it('includes document paths like /dashboard', () => {
|
||||
expect(matcherExcludes('/dashboard')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Google Fonts domains are preserved in CSP
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Google Fonts domains in CSP', () => {
|
||||
it('style-src includes https://fonts.googleapis.com', () => {
|
||||
const csp = getCsp();
|
||||
expect(csp).toContain('https://fonts.googleapis.com');
|
||||
});
|
||||
|
||||
it('font-src includes https://fonts.gstatic.com', () => {
|
||||
const csp = getCsp();
|
||||
expect(csp).toContain('https://fonts.gstatic.com');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Production CSP directive completeness
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('production CSP directive completeness', () => {
|
||||
const csp = getCsp();
|
||||
|
||||
it('has default-src self', () => {
|
||||
expect(csp).toContain("default-src 'self'");
|
||||
});
|
||||
|
||||
it('has script-src with nonce', () => {
|
||||
expect(csp).toMatch(/script-src [^;]*'nonce-/);
|
||||
});
|
||||
|
||||
it('has style-src with unsafe-inline and fonts.googleapis.com', () => {
|
||||
expect(csp).toMatch(/style-src [^;]*'unsafe-inline'/);
|
||||
expect(csp).toMatch(/style-src [^;]*https:\/\/fonts\.googleapis\.com/);
|
||||
});
|
||||
|
||||
it('has worker-src self blob:', () => {
|
||||
expect(csp).toContain("worker-src 'self' blob:");
|
||||
});
|
||||
|
||||
it('has child-src self blob:', () => {
|
||||
expect(csp).toContain("child-src 'self' blob:");
|
||||
});
|
||||
|
||||
it('has img-src with self data: blob: https:', () => {
|
||||
expect(csp).toContain("img-src 'self' data: blob: https:");
|
||||
});
|
||||
|
||||
it('has connect-src with self', () => {
|
||||
expect(csp).toMatch(/connect-src 'self'/);
|
||||
});
|
||||
|
||||
it('has object-src none', () => {
|
||||
expect(csp).toContain("object-src 'none'");
|
||||
});
|
||||
|
||||
it('has frame-ancestors none', () => {
|
||||
expect(csp).toContain("frame-ancestors 'none'");
|
||||
});
|
||||
|
||||
it('has base-uri self', () => {
|
||||
expect(csp).toContain("base-uri 'self'");
|
||||
});
|
||||
|
||||
it('has form-action self', () => {
|
||||
expect(csp).toContain("form-action 'self'");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Phase 5F-B: Production script-src unsafe-inline removal tests.
|
||||
*
|
||||
* Validates:
|
||||
* 1. Production CSP omits script-src 'unsafe-inline'
|
||||
* 2. Dev CSP retains 'unsafe-inline' and 'unsafe-eval'
|
||||
* 3. Unchanged directives (style-src, font-src, worker-src, etc.) intact
|
||||
* 4. API/static route exclusions remain intact
|
||||
* 5. isDev is evaluated per-request (not cached at module load)
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
import { middleware, config as middlewareConfig } from '@/middleware';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function callMiddleware(path = '/') {
|
||||
const req = new NextRequest(`http://localhost${path}`, { method: 'GET' });
|
||||
return middleware(req);
|
||||
}
|
||||
|
||||
function getCsp(path = '/'): string {
|
||||
return callMiddleware(path).headers.get('Content-Security-Policy') ?? '';
|
||||
}
|
||||
|
||||
/** Extract a single CSP directive by name. */
|
||||
function getDirective(name: string, csp?: string): string {
|
||||
const full = csp ?? getCsp();
|
||||
const re = new RegExp(`${name}\\s+([^;]+)`);
|
||||
return re.exec(full)?.[1]?.trim() ?? '';
|
||||
}
|
||||
|
||||
function matcherExcludes(path: string): boolean {
|
||||
const pattern = middlewareConfig.matcher[0];
|
||||
const re = new RegExp(`^${pattern}$`);
|
||||
return !re.test(path);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Production CSP omits script-src 'unsafe-inline'
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('production script-src hardening', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('production script-src does NOT contain unsafe-inline', () => {
|
||||
const scriptSrc = getDirective('script-src');
|
||||
expect(scriptSrc).not.toContain("'unsafe-inline'");
|
||||
});
|
||||
|
||||
it('production script-src does NOT contain unsafe-eval', () => {
|
||||
const scriptSrc = getDirective('script-src');
|
||||
expect(scriptSrc).not.toContain("'unsafe-eval'");
|
||||
});
|
||||
|
||||
it('production script-src contains nonce', () => {
|
||||
const scriptSrc = getDirective('script-src');
|
||||
expect(scriptSrc).toMatch(/'nonce-[A-Za-z0-9+/=]+'/);
|
||||
});
|
||||
|
||||
it('production script-src contains self and blob:', () => {
|
||||
const scriptSrc = getDirective('script-src');
|
||||
expect(scriptSrc).toContain("'self'");
|
||||
expect(scriptSrc).toContain('blob:');
|
||||
});
|
||||
|
||||
it('production connect-src uses restricted set', () => {
|
||||
const connectSrc = getDirective('connect-src');
|
||||
expect(connectSrc).not.toContain('http://127.0.0.1:8000');
|
||||
expect(connectSrc).not.toContain('http://127.0.0.1:8787');
|
||||
expect(connectSrc).toContain("'self'");
|
||||
expect(connectSrc).toContain('wss:');
|
||||
expect(connectSrc).toContain('https:');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Dev CSP retains required dev allowances
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('dev script-src allowances', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('NODE_ENV', 'development');
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('dev script-src contains unsafe-inline', () => {
|
||||
const scriptSrc = getDirective('script-src');
|
||||
expect(scriptSrc).toContain("'unsafe-inline'");
|
||||
});
|
||||
|
||||
it('dev script-src contains unsafe-eval', () => {
|
||||
const scriptSrc = getDirective('script-src');
|
||||
expect(scriptSrc).toContain("'unsafe-eval'");
|
||||
});
|
||||
|
||||
it('dev script-src still contains nonce', () => {
|
||||
const scriptSrc = getDirective('script-src');
|
||||
expect(scriptSrc).toMatch(/'nonce-[A-Za-z0-9+/=]+'/);
|
||||
});
|
||||
|
||||
it('dev connect-src includes localhost backends', () => {
|
||||
const connectSrc = getDirective('connect-src');
|
||||
expect(connectSrc).toContain('http://127.0.0.1:8000');
|
||||
expect(connectSrc).toContain('http://127.0.0.1:8787');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Unchanged directives remain intact across both modes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('unchanged directives in production', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('style-src preserves unsafe-inline and Google Fonts', () => {
|
||||
const styleSrc = getDirective('style-src');
|
||||
expect(styleSrc).toContain("'unsafe-inline'");
|
||||
expect(styleSrc).toContain('https://fonts.googleapis.com');
|
||||
});
|
||||
|
||||
it('font-src preserves data: and fonts.gstatic.com', () => {
|
||||
const fontSrc = getDirective('font-src');
|
||||
expect(fontSrc).toContain('data:');
|
||||
expect(fontSrc).toContain('https://fonts.gstatic.com');
|
||||
});
|
||||
|
||||
it('worker-src self blob:', () => {
|
||||
expect(getCsp()).toContain("worker-src 'self' blob:");
|
||||
});
|
||||
|
||||
it('child-src self blob:', () => {
|
||||
expect(getCsp()).toContain("child-src 'self' blob:");
|
||||
});
|
||||
|
||||
it('img-src self data: blob: https:', () => {
|
||||
expect(getCsp()).toContain("img-src 'self' data: blob: https:");
|
||||
});
|
||||
|
||||
it('object-src none', () => {
|
||||
expect(getCsp()).toContain("object-src 'none'");
|
||||
});
|
||||
|
||||
it('frame-ancestors none', () => {
|
||||
expect(getCsp()).toContain("frame-ancestors 'none'");
|
||||
});
|
||||
|
||||
it('base-uri self', () => {
|
||||
expect(getCsp()).toContain("base-uri 'self'");
|
||||
});
|
||||
|
||||
it('form-action self', () => {
|
||||
expect(getCsp()).toContain("form-action 'self'");
|
||||
});
|
||||
|
||||
it('default-src self', () => {
|
||||
expect(getCsp()).toContain("default-src 'self'");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. API/static route exclusions remain intact
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('matcher exclusions unchanged', () => {
|
||||
it('excludes /api paths', () => {
|
||||
expect(matcherExcludes('/api/mesh/events')).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes /_next/static paths', () => {
|
||||
expect(matcherExcludes('/_next/static/chunks/main.js')).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes /_next/image paths', () => {
|
||||
expect(matcherExcludes('/_next/image?url=foo')).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes /favicon.ico', () => {
|
||||
expect(matcherExcludes('/favicon.ico')).toBe(true);
|
||||
});
|
||||
|
||||
it('includes document paths', () => {
|
||||
expect(matcherExcludes('/')).toBe(false);
|
||||
expect(matcherExcludes('/dashboard')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. isDev evaluated per-request (not cached at module load)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('per-request environment evaluation', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('switching NODE_ENV between calls changes script-src', () => {
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
const prodScriptSrc = getDirective('script-src');
|
||||
expect(prodScriptSrc).not.toContain("'unsafe-inline'");
|
||||
|
||||
vi.stubEnv('NODE_ENV', 'development');
|
||||
const devScriptSrc = getDirective('script-src');
|
||||
expect(devScriptSrc).toContain("'unsafe-inline'");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Tests for getBackendEndpoint() — the runtime-resolved API endpoint
|
||||
* displayed in "Connect" modals for external tool configuration.
|
||||
*
|
||||
* Verifies:
|
||||
* - Returns window.location.origin when window is available
|
||||
* - Returns fallback when window is undefined (SSR)
|
||||
* - Does NOT hardcode :8000
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
describe('getBackendEndpoint', () => {
|
||||
const originalWindow = globalThis.window;
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
// Restore window if we deleted it
|
||||
if (!globalThis.window && originalWindow) {
|
||||
globalThis.window = originalWindow;
|
||||
}
|
||||
});
|
||||
|
||||
it('returns window.location.origin in browser context', async () => {
|
||||
// Default test environment (jsdom) has window defined
|
||||
const { getBackendEndpoint } = await import('@/lib/backendEndpoint');
|
||||
const result = getBackendEndpoint();
|
||||
expect(result).toBe(window.location.origin);
|
||||
expect(result).not.toContain(':8000');
|
||||
});
|
||||
|
||||
it('does not hardcode port 8000', async () => {
|
||||
const { getBackendEndpoint } = await import('@/lib/backendEndpoint');
|
||||
const result = getBackendEndpoint();
|
||||
// The result should be derived from window.location, not a hardcoded backend port
|
||||
expect(result).not.toMatch(/:8000$/);
|
||||
});
|
||||
|
||||
it('returns http://localhost:8000 fallback when window is undefined (SSR)', async () => {
|
||||
// Temporarily remove window to simulate SSR
|
||||
// @ts-expect-error — intentionally removing window for SSR simulation
|
||||
delete globalThis.window;
|
||||
const { getBackendEndpoint } = await import('@/lib/backendEndpoint');
|
||||
const result = getBackendEndpoint();
|
||||
expect(result).toBe('http://localhost:8000');
|
||||
// Restore
|
||||
globalThis.window = originalWindow;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Tests for companion section failure visibility in SettingsPanel.
|
||||
*
|
||||
* Verifies the contract from P6E:
|
||||
* - When fetchCompanionStatus rejects, the companion section still renders
|
||||
* (with an unavailable/error state) rather than silently disappearing
|
||||
* - When fetchCompanionStatus succeeds, normal controls are shown
|
||||
*
|
||||
* These are logic-level tests for the state transitions — they do NOT
|
||||
* render SettingsPanel (which has deep dependency chains). They verify
|
||||
* the decision logic: companionLoadFailed drives visibility.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('companion section visibility contract', () => {
|
||||
it('companionAvailable && companionLoadFailed shows the section (failure path)', () => {
|
||||
// Simulates the render guard: {companionAvailable && (companion || companionLoadFailed) && (...)}
|
||||
const companionAvailable = true;
|
||||
const companion = null; // fetch failed, no status loaded
|
||||
const companionLoadFailed = true;
|
||||
|
||||
const shouldRender = companionAvailable && (companion || companionLoadFailed);
|
||||
expect(shouldRender).toBeTruthy();
|
||||
});
|
||||
|
||||
it('companionAvailable && companion shows the section (success path)', () => {
|
||||
const companionAvailable = true;
|
||||
const companion = { enabled: false, url: null, warning: 'Reduced trust.' };
|
||||
const companionLoadFailed = false;
|
||||
|
||||
const shouldRender = companionAvailable && (companion || companionLoadFailed);
|
||||
expect(shouldRender).toBeTruthy();
|
||||
});
|
||||
|
||||
it('section is hidden when not on desktop (companionAvailable=false)', () => {
|
||||
const companionAvailable = false;
|
||||
const companion = null;
|
||||
const companionLoadFailed = true;
|
||||
|
||||
const shouldRender = companionAvailable && (companion || companionLoadFailed);
|
||||
expect(shouldRender).toBeFalsy();
|
||||
});
|
||||
|
||||
it('section is hidden before first load attempt (no status, no failure)', () => {
|
||||
const companionAvailable = true;
|
||||
const companion = null;
|
||||
const companionLoadFailed = false;
|
||||
|
||||
const shouldRender = companionAvailable && (companion || companionLoadFailed);
|
||||
expect(shouldRender).toBeFalsy();
|
||||
});
|
||||
|
||||
it('controls are hidden when companion is null (failure mode shows only error)', () => {
|
||||
// In the rendered UI: {companion && (<buttons>)} — buttons hidden when companion is null
|
||||
const companion = null;
|
||||
const companionLoadFailed = true;
|
||||
|
||||
const showControls = !!companion;
|
||||
expect(showControls).toBe(false);
|
||||
// But the section itself should still render
|
||||
expect(companionLoadFailed).toBe(true);
|
||||
});
|
||||
|
||||
it('warning box only renders when companion has a warning string', () => {
|
||||
// {companion?.warning && (<warning>)}
|
||||
const companionNull = null;
|
||||
const companionWithWarning = { enabled: true, url: 'http://127.0.0.1:9876', warning: 'Reduced trust.' };
|
||||
const companionNoWarning = { enabled: false, url: null, warning: '' };
|
||||
|
||||
expect(companionNull?.warning).toBeFalsy();
|
||||
expect(companionWithWarning?.warning).toBeTruthy();
|
||||
expect(companionNoWarning?.warning).toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,387 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ShadowbrokerDesktopRuntime } from '@/lib/desktopBridge';
|
||||
|
||||
describe('desktopBridgeBootstrapPreference', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
// Clean window globals before each test
|
||||
delete (window as Record<string, unknown>).__SHADOWBROKER_DESKTOP__;
|
||||
delete (window as Record<string, unknown>).__SHADOWBROKER_LOCAL_CONTROL__;
|
||||
});
|
||||
|
||||
it('prefers a pre-installed native runtime over the HTTP shim', async () => {
|
||||
const nativeInvoke = vi.fn().mockResolvedValue({ ok: true });
|
||||
const nativeRuntime: ShadowbrokerDesktopRuntime = {
|
||||
invokeLocalControl: nativeInvoke,
|
||||
getNativeControlAuditReport: () => ({
|
||||
totalEvents: 0,
|
||||
totalRecorded: 0,
|
||||
recent: [],
|
||||
byOutcome: {},
|
||||
}),
|
||||
clearNativeControlAuditReport: vi.fn(),
|
||||
};
|
||||
|
||||
// Simulate Tauri injection: set __SHADOWBROKER_DESKTOP__ before bootstrap
|
||||
window.__SHADOWBROKER_DESKTOP__ = nativeRuntime;
|
||||
|
||||
const { bootstrapDesktopControlBridge } = await import('@/lib/desktopBridge');
|
||||
const installed = bootstrapDesktopControlBridge();
|
||||
|
||||
expect(installed).toBe(true);
|
||||
// The bridge should have been derived from the native runtime
|
||||
expect(window.__SHADOWBROKER_LOCAL_CONTROL__).toBeDefined();
|
||||
expect(window.__SHADOWBROKER_LOCAL_CONTROL__!.invoke).toBeDefined();
|
||||
|
||||
// Invoke through the bridge — should delegate to the native runtime
|
||||
await window.__SHADOWBROKER_LOCAL_CONTROL__!.invoke!({
|
||||
command: 'wormhole.status',
|
||||
payload: undefined,
|
||||
});
|
||||
expect(nativeInvoke).toHaveBeenCalledTimes(1);
|
||||
expect(nativeInvoke.mock.calls[0][0]).toBe('wormhole.status');
|
||||
});
|
||||
|
||||
it('does not install bridge when no native runtime and shim env is off', async () => {
|
||||
// No __SHADOWBROKER_DESKTOP__ and NEXT_PUBLIC_ENABLE_DESKTOP_BRIDGE_SHIM != '1'
|
||||
const originalEnv = process.env.NEXT_PUBLIC_ENABLE_DESKTOP_BRIDGE_SHIM;
|
||||
process.env.NEXT_PUBLIC_ENABLE_DESKTOP_BRIDGE_SHIM = '0';
|
||||
|
||||
const { bootstrapDesktopControlBridge } = await import('@/lib/desktopBridge');
|
||||
const installed = bootstrapDesktopControlBridge();
|
||||
|
||||
expect(installed).toBe(false);
|
||||
expect(window.__SHADOWBROKER_LOCAL_CONTROL__).toBeUndefined();
|
||||
|
||||
process.env.NEXT_PUBLIC_ENABLE_DESKTOP_BRIDGE_SHIM = originalEnv;
|
||||
});
|
||||
|
||||
it('falls back to HTTP shim when no native runtime and shim env is on', async () => {
|
||||
const originalEnv = process.env.NEXT_PUBLIC_ENABLE_DESKTOP_BRIDGE_SHIM;
|
||||
process.env.NEXT_PUBLIC_ENABLE_DESKTOP_BRIDGE_SHIM = '1';
|
||||
|
||||
const { bootstrapDesktopControlBridge } = await import('@/lib/desktopBridge');
|
||||
const installed = bootstrapDesktopControlBridge();
|
||||
|
||||
expect(installed).toBe(true);
|
||||
// Bridge installed via shim
|
||||
expect(window.__SHADOWBROKER_LOCAL_CONTROL__).toBeDefined();
|
||||
// __SHADOWBROKER_DESKTOP__ is the HTTP-backed shim
|
||||
expect(window.__SHADOWBROKER_DESKTOP__).toBeDefined();
|
||||
expect(window.__SHADOWBROKER_DESKTOP__!.invokeLocalControl).toBeDefined();
|
||||
expect(window.__SHADOWBROKER_DESKTOP__!.getNativeControlAuditReport).toBeDefined();
|
||||
expect(window.__SHADOWBROKER_DESKTOP__!.clearNativeControlAuditReport).toBeDefined();
|
||||
|
||||
process.env.NEXT_PUBLIC_ENABLE_DESKTOP_BRIDGE_SHIM = originalEnv;
|
||||
});
|
||||
|
||||
it('native runtime audit report is accessible through getDesktopNativeControlAuditReport', async () => {
|
||||
const auditReport = {
|
||||
totalEvents: 5,
|
||||
totalRecorded: 5,
|
||||
recent: [],
|
||||
byOutcome: { allowed: 5 },
|
||||
};
|
||||
const nativeRuntime: ShadowbrokerDesktopRuntime = {
|
||||
invokeLocalControl: vi.fn().mockResolvedValue({}),
|
||||
getNativeControlAuditReport: () => auditReport,
|
||||
clearNativeControlAuditReport: vi.fn(),
|
||||
};
|
||||
window.__SHADOWBROKER_DESKTOP__ = nativeRuntime;
|
||||
|
||||
const { bootstrapDesktopControlBridge, getDesktopNativeControlAuditReport } =
|
||||
await import('@/lib/desktopBridge');
|
||||
bootstrapDesktopControlBridge();
|
||||
|
||||
const report = getDesktopNativeControlAuditReport();
|
||||
expect(report).toEqual(auditReport);
|
||||
});
|
||||
|
||||
it('localControlFetch routes through native bridge when available', async () => {
|
||||
const nativeInvoke = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, status: 'connected' });
|
||||
const nativeRuntime: ShadowbrokerDesktopRuntime = {
|
||||
invokeLocalControl: nativeInvoke,
|
||||
};
|
||||
window.__SHADOWBROKER_DESKTOP__ = nativeRuntime;
|
||||
|
||||
const { installDesktopControlBridge } = await import('@/lib/desktopBridge');
|
||||
installDesktopControlBridge(nativeRuntime);
|
||||
|
||||
const { localControlFetch } = await import('@/lib/localControlTransport');
|
||||
const response = await localControlFetch('/api/wormhole/status');
|
||||
const data = await response.json();
|
||||
|
||||
expect(nativeInvoke).toHaveBeenCalledTimes(1);
|
||||
expect(nativeInvoke.mock.calls[0][0]).toBe('wormhole.status');
|
||||
expect(data).toEqual({ ok: true, status: 'connected' });
|
||||
});
|
||||
|
||||
it('localControlFetch falls back to fetch when no bridge is present', async () => {
|
||||
// No bridge installed — localControlFetch should use regular fetch
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
|
||||
const { localControlFetch } = await import('@/lib/localControlTransport');
|
||||
await localControlFetch('/api/wormhole/status');
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
const callUrl = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(callUrl).toContain('/api/wormhole/status');
|
||||
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('Rust handler coverage matches the full contract command set', async () => {
|
||||
const { DESKTOP_CONTROL_COMMANDS } = await import(
|
||||
'@/lib/desktopControlContract'
|
||||
);
|
||||
// This test documents the expected contract size.
|
||||
// If the contract grows, the Rust handlers.rs must be updated to match.
|
||||
expect(DESKTOP_CONTROL_COMMANDS.length).toBe(27);
|
||||
|
||||
// Verify every command has a corresponding HTTP route
|
||||
const { commandToHttpRequest } = await import(
|
||||
'@/lib/desktopControlRouting'
|
||||
);
|
||||
for (const command of DESKTOP_CONTROL_COMMANDS) {
|
||||
// Gate commands need a payload with gate_id
|
||||
const payload = command.includes('gate')
|
||||
? { gate_id: 'test-gate', plaintext: 'x', reason: 'test', label: 'l', persona_id: 'p', epoch: 0, ciphertext: '', nonce: '', sender_ref: '', messages: [] }
|
||||
: undefined;
|
||||
expect(() => commandToHttpRequest(command, payload)).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('native runtime forwards meta to invokeLocalControl', async () => {
|
||||
const nativeInvoke = vi.fn().mockResolvedValue({ ok: true });
|
||||
const nativeRuntime: ShadowbrokerDesktopRuntime = {
|
||||
invokeLocalControl: nativeInvoke,
|
||||
};
|
||||
window.__SHADOWBROKER_DESKTOP__ = nativeRuntime;
|
||||
|
||||
const { installDesktopControlBridge } = await import('@/lib/desktopBridge');
|
||||
installDesktopControlBridge(nativeRuntime);
|
||||
|
||||
await window.__SHADOWBROKER_LOCAL_CONTROL__!.invoke!({
|
||||
command: 'wormhole.gate.key.rotate',
|
||||
payload: { gate_id: 'infonet', reason: 'operator_reset' },
|
||||
meta: {
|
||||
capability: 'wormhole_gate_key',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
enforceProfileHint: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(nativeInvoke).toHaveBeenCalledTimes(1);
|
||||
expect(nativeInvoke.mock.calls[0][0]).toBe('wormhole.gate.key.rotate');
|
||||
expect(nativeInvoke.mock.calls[0][1]).toEqual({ gate_id: 'infonet', reason: 'operator_reset' });
|
||||
// meta must not be dropped
|
||||
const receivedMeta = nativeInvoke.mock.calls[0][2];
|
||||
expect(receivedMeta).toBeDefined();
|
||||
expect(receivedMeta).toEqual(expect.objectContaining({
|
||||
capability: 'wormhole_gate_key',
|
||||
sessionProfileHint: 'gate_operator',
|
||||
enforceProfileHint: true,
|
||||
}));
|
||||
});
|
||||
|
||||
it('native runtime rejects on capability mismatch and records audit', async () => {
|
||||
const { controlCommandCapability } = await import('@/lib/desktopControlContract');
|
||||
const nativeInvoke = vi.fn().mockResolvedValue({ ok: true });
|
||||
const auditEntries: unknown[] = [];
|
||||
|
||||
// Simulate a Tauri-like runtime that checks capability mismatch
|
||||
const nativeRuntime: ShadowbrokerDesktopRuntime = {
|
||||
invokeLocalControl: async (command, payload, meta) => {
|
||||
const expectedCap = controlCommandCapability(command!);
|
||||
if (meta?.capability && meta.capability !== expectedCap) {
|
||||
auditEntries.push({
|
||||
command,
|
||||
expectedCapability: expectedCap,
|
||||
declaredCapability: meta.capability,
|
||||
outcome: 'capability_mismatch',
|
||||
});
|
||||
throw new Error(
|
||||
`native_control_capability_mismatch:${meta.capability}:${expectedCap}`,
|
||||
);
|
||||
}
|
||||
return nativeInvoke(command, payload, meta);
|
||||
},
|
||||
getNativeControlAuditReport: () => ({
|
||||
totalEvents: auditEntries.length,
|
||||
totalRecorded: auditEntries.length,
|
||||
recent: [],
|
||||
byOutcome: { capability_mismatch: auditEntries.length },
|
||||
}),
|
||||
clearNativeControlAuditReport: vi.fn(),
|
||||
};
|
||||
window.__SHADOWBROKER_DESKTOP__ = nativeRuntime;
|
||||
|
||||
const { installDesktopControlBridge } = await import('@/lib/desktopBridge');
|
||||
installDesktopControlBridge(nativeRuntime);
|
||||
|
||||
// Declare wrong capability: 'settings' for a wormhole_gate_key command
|
||||
await expect(
|
||||
window.__SHADOWBROKER_LOCAL_CONTROL__!.invoke!({
|
||||
command: 'wormhole.gate.key.rotate',
|
||||
payload: { gate_id: 'infonet', reason: 'test' },
|
||||
meta: { capability: 'settings' },
|
||||
}),
|
||||
).rejects.toThrow('native_control_capability_mismatch');
|
||||
|
||||
expect(nativeInvoke).not.toHaveBeenCalled();
|
||||
expect(auditEntries).toHaveLength(1);
|
||||
expect(auditEntries[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
command: 'wormhole.gate.key.rotate',
|
||||
declaredCapability: 'settings',
|
||||
expectedCapability: 'wormhole_gate_key',
|
||||
outcome: 'capability_mismatch',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('native runtime denies on profile enforcement and records audit', async () => {
|
||||
const { controlCommandCapability, sessionProfileCapabilities } = await import(
|
||||
'@/lib/desktopControlContract'
|
||||
);
|
||||
const nativeInvoke = vi.fn().mockResolvedValue({ ok: true });
|
||||
const auditEntries: unknown[] = [];
|
||||
|
||||
// Simulate a Tauri-like runtime that enforces session profiles
|
||||
const nativeRuntime: ShadowbrokerDesktopRuntime = {
|
||||
invokeLocalControl: async (command, payload, meta) => {
|
||||
const expectedCap = controlCommandCapability(command!);
|
||||
const profile = meta?.sessionProfileHint;
|
||||
const profileCaps = profile ? sessionProfileCapabilities(profile) : [];
|
||||
const profileAllows =
|
||||
!profile || profileCaps.length === 0 || profileCaps.includes(expectedCap);
|
||||
const enforced = Boolean(meta?.enforceProfileHint && profile);
|
||||
if (!profileAllows && enforced) {
|
||||
auditEntries.push({
|
||||
command,
|
||||
expectedCapability: expectedCap,
|
||||
sessionProfile: profile,
|
||||
outcome: 'profile_denied',
|
||||
});
|
||||
throw new Error(
|
||||
`native_control_profile_mismatch:${profile}:${expectedCap}`,
|
||||
);
|
||||
}
|
||||
return nativeInvoke(command, payload, meta);
|
||||
},
|
||||
getNativeControlAuditReport: () => ({
|
||||
totalEvents: auditEntries.length,
|
||||
totalRecorded: auditEntries.length,
|
||||
recent: [],
|
||||
byOutcome: { profile_denied: auditEntries.length },
|
||||
}),
|
||||
clearNativeControlAuditReport: vi.fn(),
|
||||
};
|
||||
window.__SHADOWBROKER_DESKTOP__ = nativeRuntime;
|
||||
|
||||
const { installDesktopControlBridge } = await import('@/lib/desktopBridge');
|
||||
installDesktopControlBridge(nativeRuntime);
|
||||
|
||||
// settings_only profile cannot access wormhole_gate_key commands
|
||||
await expect(
|
||||
window.__SHADOWBROKER_LOCAL_CONTROL__!.invoke!({
|
||||
command: 'wormhole.gate.key.rotate',
|
||||
payload: { gate_id: 'infonet', reason: 'test' },
|
||||
meta: {
|
||||
capability: 'wormhole_gate_key',
|
||||
sessionProfileHint: 'settings_only',
|
||||
enforceProfileHint: true,
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow('native_control_profile_mismatch');
|
||||
|
||||
expect(nativeInvoke).not.toHaveBeenCalled();
|
||||
expect(auditEntries).toHaveLength(1);
|
||||
expect(auditEntries[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
command: 'wormhole.gate.key.rotate',
|
||||
expectedCapability: 'wormhole_gate_key',
|
||||
sessionProfile: 'settings_only',
|
||||
outcome: 'profile_denied',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('native runtime audit report populates on allowed invocations', async () => {
|
||||
let auditCallCount = 0;
|
||||
const nativeInvoke = vi.fn().mockResolvedValue({ ok: true });
|
||||
const nativeRuntime: ShadowbrokerDesktopRuntime = {
|
||||
invokeLocalControl: async (command, payload, meta) => {
|
||||
auditCallCount++;
|
||||
return nativeInvoke(command, payload, meta);
|
||||
},
|
||||
getNativeControlAuditReport: () => ({
|
||||
totalEvents: auditCallCount,
|
||||
totalRecorded: auditCallCount,
|
||||
recent: [],
|
||||
byOutcome: { allowed: auditCallCount },
|
||||
}),
|
||||
clearNativeControlAuditReport: () => { auditCallCount = 0; },
|
||||
};
|
||||
window.__SHADOWBROKER_DESKTOP__ = nativeRuntime;
|
||||
|
||||
const { installDesktopControlBridge, getDesktopNativeControlAuditReport } =
|
||||
await import('@/lib/desktopBridge');
|
||||
installDesktopControlBridge(nativeRuntime);
|
||||
|
||||
await window.__SHADOWBROKER_LOCAL_CONTROL__!.invoke!({
|
||||
command: 'wormhole.status',
|
||||
payload: undefined,
|
||||
});
|
||||
await window.__SHADOWBROKER_LOCAL_CONTROL__!.invoke!({
|
||||
command: 'settings.privacy.get',
|
||||
payload: undefined,
|
||||
});
|
||||
|
||||
const report = getDesktopNativeControlAuditReport();
|
||||
expect(report).toBeDefined();
|
||||
expect(report!.totalEvents).toBe(2);
|
||||
expect(report!.totalRecorded).toBe(2);
|
||||
expect(report!.byOutcome).toEqual(expect.objectContaining({ allowed: 2 }));
|
||||
});
|
||||
|
||||
it('injected JS capability map covers every contract command', async () => {
|
||||
const { DESKTOP_CONTROL_COMMANDS, controlCommandCapability } = await import(
|
||||
'@/lib/desktopControlContract'
|
||||
);
|
||||
// The capability map embedded in the Tauri injected JS (main.rs) must
|
||||
// cover every command. This test verifies the TypeScript contract source
|
||||
// which the JS map mirrors — if the contract grows, this catches drift.
|
||||
for (const command of DESKTOP_CONTROL_COMMANDS) {
|
||||
const cap = controlCommandCapability(command);
|
||||
expect(cap).toBeDefined();
|
||||
expect(typeof cap).toBe('string');
|
||||
}
|
||||
});
|
||||
|
||||
it('profile capability resolution matches between TS and expected Tauri JS tables', async () => {
|
||||
const { sessionProfileCapabilities } = await import(
|
||||
'@/lib/desktopControlContract'
|
||||
);
|
||||
// Verify the profile→capabilities mapping that the Tauri JS mirrors
|
||||
const profiles = [
|
||||
'full_app', 'gate_observe', 'gate_operator', 'wormhole_runtime', 'settings_only',
|
||||
] as const;
|
||||
for (const profile of profiles) {
|
||||
const caps = sessionProfileCapabilities(profile);
|
||||
expect(Array.isArray(caps)).toBe(true);
|
||||
expect(caps.length).toBeGreaterThan(0);
|
||||
}
|
||||
// Specific assertions matching the Tauri JS table
|
||||
expect(sessionProfileCapabilities('settings_only')).toEqual(['settings']);
|
||||
expect(sessionProfileCapabilities('gate_observe')).toEqual(['wormhole_gate_content']);
|
||||
expect(sessionProfileCapabilities('full_app')).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Tests for the desktop companion mode helper (desktopCompanion.ts).
|
||||
*
|
||||
* Validates runtime detection, Tauri invoke delegation, and browser-mode
|
||||
* fallback behavior without requiring a live Tauri runtime.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
isNativeDesktop,
|
||||
companionStatus,
|
||||
companionEnable,
|
||||
companionDisable,
|
||||
companionOpenBrowser,
|
||||
} from '@/lib/desktopCompanion';
|
||||
|
||||
const MOCK_STATUS = {
|
||||
enabled: false,
|
||||
url: null,
|
||||
warning: 'Browser companion mode is less secure than the native desktop window.',
|
||||
};
|
||||
|
||||
const MOCK_ENABLED = {
|
||||
enabled: true,
|
||||
url: 'http://127.0.0.1:3000',
|
||||
warning: 'Browser companion mode is less secure than the native desktop window.',
|
||||
};
|
||||
|
||||
describe('desktopCompanion', () => {
|
||||
afterEach(() => {
|
||||
// Clean up __TAURI__ mock
|
||||
delete (window as Record<string, unknown>).__TAURI__;
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Runtime detection
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('isNativeDesktop', () => {
|
||||
it('returns false when __TAURI__ is not present', () => {
|
||||
expect(isNativeDesktop()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when __TAURI__.core.invoke is missing', () => {
|
||||
(window as Record<string, unknown>).__TAURI__ = { core: {} };
|
||||
expect(isNativeDesktop()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when __TAURI__.core.invoke is available', () => {
|
||||
(window as Record<string, unknown>).__TAURI__ = { core: { invoke: vi.fn() } };
|
||||
expect(isNativeDesktop()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Browser-mode fallback (all commands return null)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('browser mode (no Tauri)', () => {
|
||||
it('companionStatus returns null', async () => {
|
||||
expect(await companionStatus()).toBeNull();
|
||||
});
|
||||
|
||||
it('companionEnable returns null', async () => {
|
||||
expect(await companionEnable()).toBeNull();
|
||||
});
|
||||
|
||||
it('companionDisable returns null', async () => {
|
||||
expect(await companionDisable()).toBeNull();
|
||||
});
|
||||
|
||||
it('companionOpenBrowser returns null', async () => {
|
||||
expect(await companionOpenBrowser()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Desktop mode (mocked Tauri invoke)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('desktop mode (Tauri present)', () => {
|
||||
let mockInvoke: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockInvoke = vi.fn();
|
||||
(window as Record<string, unknown>).__TAURI__ = { core: { invoke: mockInvoke } };
|
||||
});
|
||||
|
||||
it('companionStatus invokes companion_status', async () => {
|
||||
mockInvoke.mockResolvedValue(MOCK_STATUS);
|
||||
const result = await companionStatus();
|
||||
expect(mockInvoke).toHaveBeenCalledWith('companion_status');
|
||||
expect(result).toEqual(MOCK_STATUS);
|
||||
});
|
||||
|
||||
it('companionEnable invokes companion_enable', async () => {
|
||||
mockInvoke.mockResolvedValue(MOCK_ENABLED);
|
||||
const result = await companionEnable();
|
||||
expect(mockInvoke).toHaveBeenCalledWith('companion_enable');
|
||||
expect(result).toEqual(MOCK_ENABLED);
|
||||
});
|
||||
|
||||
it('companionDisable invokes companion_disable', async () => {
|
||||
mockInvoke.mockResolvedValue(MOCK_STATUS);
|
||||
const result = await companionDisable();
|
||||
expect(mockInvoke).toHaveBeenCalledWith('companion_disable');
|
||||
expect(result).toEqual(MOCK_STATUS);
|
||||
});
|
||||
|
||||
it('companionOpenBrowser invokes companion_open_browser', async () => {
|
||||
mockInvoke.mockResolvedValue(MOCK_ENABLED);
|
||||
const result = await companionOpenBrowser();
|
||||
expect(mockInvoke).toHaveBeenCalledWith('companion_open_browser');
|
||||
expect(result).toEqual(MOCK_ENABLED);
|
||||
});
|
||||
|
||||
it('propagates Tauri invoke errors', async () => {
|
||||
mockInvoke.mockRejectedValue(new Error('companion_not_enabled'));
|
||||
await expect(companionOpenBrowser()).rejects.toThrow('companion_not_enabled');
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Status shape
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('CompanionStatus shape', () => {
|
||||
it('disabled status has null url', () => {
|
||||
expect(MOCK_STATUS.enabled).toBe(false);
|
||||
expect(MOCK_STATUS.url).toBeNull();
|
||||
expect(MOCK_STATUS.warning).toBeTruthy();
|
||||
});
|
||||
|
||||
it('enabled status has a url', () => {
|
||||
expect(MOCK_ENABLED.enabled).toBe(true);
|
||||
expect(MOCK_ENABLED.url).toBe('http://127.0.0.1:3000');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
describeNativeControlError,
|
||||
extractNativeGateResyncTarget,
|
||||
extractGateTargetRef,
|
||||
} from '../../lib/desktopControlContract';
|
||||
|
||||
@@ -22,6 +23,12 @@ describe('extractGateTargetRef', () => {
|
||||
expect(extractGateTargetRef('wormhole.gate.proof', { gate_id: 'alpha' })).toBe('alpha');
|
||||
});
|
||||
|
||||
it('extracts gate_id from gate state resync payload', () => {
|
||||
expect(extractGateTargetRef('wormhole.gate.state.resync', { 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' }),
|
||||
@@ -86,9 +93,30 @@ describe('describeNativeControlError', () => {
|
||||
expect(describeNativeControlError(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('describes native gate resync requirement errors', () => {
|
||||
expect(
|
||||
describeNativeControlError('native_gate_state_resync_required:ops'),
|
||||
).toContain('gate resync');
|
||||
});
|
||||
|
||||
it('handles plain string errors', () => {
|
||||
expect(
|
||||
describeNativeControlError('native_control_profile_mismatch:foo'),
|
||||
).toContain('Denied');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractNativeGateResyncTarget', () => {
|
||||
it('extracts the gate id from native resync-required errors', () => {
|
||||
expect(extractNativeGateResyncTarget('native_gate_state_resync_required:ops')).toBe('ops');
|
||||
expect(extractNativeGateResyncTarget(new Error('native_gate_state_resync_required:infonet'))).toBe(
|
||||
'infonet',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null for unrelated errors', () => {
|
||||
expect(extractNativeGateResyncTarget(new Error('network_error'))).toBeNull();
|
||||
expect(extractNativeGateResyncTarget('native_control_profile_mismatch:foo')).toBeNull();
|
||||
expect(extractNativeGateResyncTarget(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,11 @@ describe('desktopControlRouting', () => {
|
||||
path: '/api/wormhole/gate/infonet/key',
|
||||
method: 'GET',
|
||||
});
|
||||
expect(commandToHttpRequest('wormhole.gate.state.resync', { gate_id: 'infonet' })).toEqual({
|
||||
path: '/api/wormhole/gate/state/export',
|
||||
method: 'POST',
|
||||
payload: { gate_id: 'infonet' },
|
||||
});
|
||||
expect(commandToHttpRequest('settings.news.reset')).toEqual({
|
||||
path: '/api/settings/news-feeds/reset',
|
||||
method: 'POST',
|
||||
@@ -27,11 +32,12 @@ describe('desktopControlRouting', () => {
|
||||
commandToHttpRequest('wormhole.gate.message.post', {
|
||||
gate_id: 'ops',
|
||||
plaintext: 'hello',
|
||||
reply_to: 'evt-parent-1',
|
||||
}),
|
||||
).toEqual({
|
||||
path: '/api/wormhole/gate/message/post',
|
||||
method: 'POST',
|
||||
payload: { gate_id: 'ops', plaintext: 'hello' },
|
||||
payload: { gate_id: 'ops', plaintext: 'hello', reply_to: 'evt-parent-1' },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,8 +59,18 @@ describe('desktopControlRouting', () => {
|
||||
JSON.stringify({ gate_id: 'infonet', reason: 'operator_reset' }),
|
||||
),
|
||||
).toEqual({
|
||||
command: 'wormhole.gate.key.rotate',
|
||||
payload: { gate_id: 'infonet', reason: 'operator_reset' },
|
||||
command: 'wormhole.gate.key.rotate',
|
||||
payload: { gate_id: 'infonet', reason: 'operator_reset' },
|
||||
});
|
||||
expect(
|
||||
httpRequestToInvokeRequest(
|
||||
'/api/wormhole/gate/state/export',
|
||||
'POST',
|
||||
JSON.stringify({ gate_id: 'infonet' }),
|
||||
),
|
||||
).toEqual({
|
||||
command: 'wormhole.gate.state.resync',
|
||||
payload: { gate_id: 'infonet' },
|
||||
});
|
||||
expect(
|
||||
httpRequestToInvokeRequest(
|
||||
@@ -96,6 +112,16 @@ describe('desktopControlRouting', () => {
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(
|
||||
httpRequestToInvokeRequest(
|
||||
'/api/wormhole/gate/message/post',
|
||||
'POST',
|
||||
JSON.stringify({ gate_id: 'ops', plaintext: 'hello', reply_to: 'evt-parent-2' }),
|
||||
),
|
||||
).toEqual({
|
||||
command: 'wormhole.gate.message.post',
|
||||
payload: { gate_id: 'ops', plaintext: 'hello', reply_to: 'evt-parent-2' },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for unsupported paths', () => {
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Tests for native desktop protected-settings readiness bypass.
|
||||
*
|
||||
* Verifies that:
|
||||
* - isNativeProtectedSettingsReady() correctly reflects native bridge presence
|
||||
* - When the native bridge is present, admin-session browser flow is bypassed
|
||||
* - When no native bridge, existing admin-session gating is preserved
|
||||
*
|
||||
* These are unit tests for the extracted readiness logic. They do NOT render
|
||||
* SettingsPanel — they test the decision layer that SettingsPanel depends on.
|
||||
* Full component render coverage is not claimed.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock the bridge detection used by nativeProtectedSettings
|
||||
const mockHasLocalControlBridge = vi.fn();
|
||||
|
||||
vi.mock('@/lib/localControlTransport', () => ({
|
||||
hasLocalControlBridge: () => mockHasLocalControlBridge(),
|
||||
canInvokeLocalControl: vi.fn(),
|
||||
localControlFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock adminSession to verify it's bypassed or called as expected
|
||||
const mockHasAdminSession = vi.fn();
|
||||
const mockPrimeAdminSession = vi.fn();
|
||||
|
||||
vi.mock('@/lib/adminSession', () => ({
|
||||
hasAdminSession: () => mockHasAdminSession(),
|
||||
primeAdminSession: (...args: unknown[]) => mockPrimeAdminSession(...args),
|
||||
clearAdminSession: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('isNativeProtectedSettingsReady', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
mockHasLocalControlBridge.mockReset();
|
||||
});
|
||||
|
||||
it('returns true when native local-control bridge is present', async () => {
|
||||
mockHasLocalControlBridge.mockReturnValue(true);
|
||||
const mod = await import('@/lib/nativeProtectedSettings');
|
||||
expect(mod.isNativeProtectedSettingsReady()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when no native bridge (browser mode)', async () => {
|
||||
mockHasLocalControlBridge.mockReturnValue(false);
|
||||
const mod = await import('@/lib/nativeProtectedSettings');
|
||||
expect(mod.isNativeProtectedSettingsReady()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('controlPlaneFetch admin-session bypass with native bridge', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
mockHasLocalControlBridge.mockReset();
|
||||
mockHasAdminSession.mockReset();
|
||||
mockPrimeAdminSession.mockReset();
|
||||
});
|
||||
|
||||
it('skips primeAdminSession when native bridge handles the request', async () => {
|
||||
mockHasLocalControlBridge.mockReturnValue(true);
|
||||
// canInvokeLocalControl is mocked to be truthy via the mock setup
|
||||
const { canInvokeLocalControl, localControlFetch } = await import(
|
||||
'@/lib/localControlTransport'
|
||||
);
|
||||
(canInvokeLocalControl as ReturnType<typeof vi.fn>).mockReturnValue(true);
|
||||
(localControlFetch as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
|
||||
const mod = await import('@/lib/controlPlane');
|
||||
await mod.controlPlaneFetch('/api/settings/api-keys', {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
expect(mockPrimeAdminSession).not.toHaveBeenCalled();
|
||||
expect(localControlFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('still primes admin session in browser mode (no native bridge)', async () => {
|
||||
mockHasLocalControlBridge.mockReturnValue(false);
|
||||
const { canInvokeLocalControl, localControlFetch } = await import(
|
||||
'@/lib/localControlTransport'
|
||||
);
|
||||
(canInvokeLocalControl as ReturnType<typeof vi.fn>).mockReturnValue(false);
|
||||
(localControlFetch as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
mockPrimeAdminSession.mockResolvedValue(undefined);
|
||||
|
||||
const mod = await import('@/lib/controlPlane');
|
||||
await mod.controlPlaneFetch('/api/settings/api-keys', {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
expect(mockPrimeAdminSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('native protected-settings readiness in SettingsPanel context', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
mockHasLocalControlBridge.mockReset();
|
||||
mockHasAdminSession.mockReset();
|
||||
});
|
||||
|
||||
it('native bridge present: hasAdminSession is NOT called by refreshAdminSession logic', async () => {
|
||||
mockHasLocalControlBridge.mockReturnValue(true);
|
||||
// The helper returns true — SettingsPanel's refreshAdminSession should
|
||||
// short-circuit and never call hasAdminSession()
|
||||
const mod = await import('@/lib/nativeProtectedSettings');
|
||||
expect(mod.isNativeProtectedSettingsReady()).toBe(true);
|
||||
// hasAdminSession should not have been called
|
||||
expect(mockHasAdminSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('no native bridge: hasAdminSession is the readiness source', async () => {
|
||||
mockHasLocalControlBridge.mockReturnValue(false);
|
||||
const mod = await import('@/lib/nativeProtectedSettings');
|
||||
expect(mod.isNativeProtectedSettingsReady()).toBe(false);
|
||||
// In this scenario, SettingsPanel would call hasAdminSession() — we
|
||||
// verify the helper returns false so the browser flow is used.
|
||||
mockHasAdminSession.mockResolvedValue(true);
|
||||
const adminMod = await import('@/lib/adminSession');
|
||||
const ready = await adminMod.hasAdminSession();
|
||||
expect(ready).toBe(true);
|
||||
expect(mockHasAdminSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
classifyUpdateRuntime,
|
||||
getDesktopUpdateContext,
|
||||
getPreferredManualUpdateUrl,
|
||||
getUpdateAction,
|
||||
pickDesktopInstallerUrl,
|
||||
type GitHubLatestRelease,
|
||||
} from '@/lib/updateRuntime';
|
||||
|
||||
const RELEASE: GitHubLatestRelease = {
|
||||
html_url: 'https://github.com/BigBodyCobain/Shadowbroker/releases/tag/v0.9.7',
|
||||
assets: [
|
||||
{ name: 'ShadowBroker_0.9.7_x64_en-US.msi', browser_download_url: 'https://example.test/windows.msi' },
|
||||
{ name: 'ShadowBroker_0.9.7_x64-setup.exe', browser_download_url: 'https://example.test/windows-setup.exe' },
|
||||
{ name: 'ShadowBroker_0.9.7_aarch64.dmg', browser_download_url: 'https://example.test/macos.dmg' },
|
||||
{ name: 'ShadowBroker_0.9.7_amd64.AppImage', browser_download_url: 'https://example.test/linux.AppImage' },
|
||||
],
|
||||
};
|
||||
|
||||
describe('updateRuntime', () => {
|
||||
afterEach(() => {
|
||||
delete (window as Record<string, unknown>).__TAURI__;
|
||||
});
|
||||
|
||||
describe('getDesktopUpdateContext', () => {
|
||||
it('returns null when Tauri is not present', async () => {
|
||||
expect(await getDesktopUpdateContext()).toBeNull();
|
||||
});
|
||||
|
||||
it('invokes desktop_update_context when Tauri is present', async () => {
|
||||
const invoke = vi.fn().mockResolvedValue({
|
||||
mode: 'packaged',
|
||||
platform: 'windows',
|
||||
is_packaged_build: true,
|
||||
backend_mode: 'managed',
|
||||
owns_local_backend: true,
|
||||
});
|
||||
(window as Record<string, unknown>).__TAURI__ = { core: { invoke } };
|
||||
|
||||
const result = await getDesktopUpdateContext();
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith('desktop_update_context');
|
||||
expect(result).toEqual({
|
||||
mode: 'packaged',
|
||||
platform: 'windows',
|
||||
is_packaged_build: true,
|
||||
backend_mode: 'managed',
|
||||
owns_local_backend: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('runtime classification', () => {
|
||||
it('classifies browser mode when no desktop context exists', () => {
|
||||
expect(classifyUpdateRuntime(null)).toBe('browser');
|
||||
expect(getUpdateAction('browser')).toBe('auto_apply');
|
||||
});
|
||||
|
||||
it('classifies desktop dev mode as auto-apply', () => {
|
||||
expect(
|
||||
classifyUpdateRuntime({
|
||||
mode: 'dev',
|
||||
platform: 'windows',
|
||||
is_packaged_build: false,
|
||||
}),
|
||||
).toBe('desktop_dev');
|
||||
expect(getUpdateAction('desktop_dev')).toBe('auto_apply');
|
||||
});
|
||||
|
||||
it('classifies packaged desktop mode as manual-download', () => {
|
||||
expect(
|
||||
classifyUpdateRuntime({
|
||||
mode: 'packaged',
|
||||
platform: 'windows',
|
||||
is_packaged_build: true,
|
||||
}),
|
||||
).toBe('desktop_packaged');
|
||||
expect(getUpdateAction('desktop_packaged')).toBe('manual_download');
|
||||
});
|
||||
});
|
||||
|
||||
describe('installer asset selection', () => {
|
||||
it('prefers msi installers on windows', () => {
|
||||
expect(pickDesktopInstallerUrl(RELEASE, 'windows')).toBe('https://example.test/windows.msi');
|
||||
});
|
||||
|
||||
it('prefers dmg installers on macos', () => {
|
||||
expect(pickDesktopInstallerUrl(RELEASE, 'macos')).toBe('https://example.test/macos.dmg');
|
||||
});
|
||||
|
||||
it('prefers appimage installers on linux', () => {
|
||||
expect(pickDesktopInstallerUrl(RELEASE, 'linux')).toBe('https://example.test/linux.AppImage');
|
||||
});
|
||||
|
||||
it('falls back to the release page when no platform asset matches', () => {
|
||||
expect(getPreferredManualUpdateUrl(RELEASE, 'desktop_packaged', 'unknown')).toBe(
|
||||
RELEASE.html_url,
|
||||
);
|
||||
});
|
||||
|
||||
it('uses release page for non-packaged runtimes', () => {
|
||||
expect(getPreferredManualUpdateUrl(RELEASE, 'browser', 'windows')).toBe(RELEASE.html_url);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Sprint 4D behavioral tests — MaplibreViewer CCTV proxy, subscription isolation,
|
||||
* and parent-owned interpolation.
|
||||
*
|
||||
* These tests exercise actual runtime logic:
|
||||
* 1. buildCctvProxyUrl — proxy construction with various URL inputs
|
||||
* 2. Popup components do not own keyed subscriptions or map lifecycle
|
||||
* 3. Parent-owned interpolation: ShipPopup receives pre-interpolated coords
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildCctvProxyUrl } from '@/lib/cctvProxy';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const POPUP_DIR = path.resolve(__dirname, '../../components/MaplibreViewer/popups');
|
||||
const COMP_DIR = path.resolve(__dirname, '../../components');
|
||||
|
||||
function readPopup(name: string): string {
|
||||
return fs.readFileSync(path.join(POPUP_DIR, name), 'utf-8');
|
||||
}
|
||||
|
||||
// ─── buildCctvProxyUrl runtime behavior ───────────────────────────────────
|
||||
|
||||
describe('MaplibreViewer behavior — buildCctvProxyUrl', () => {
|
||||
it('proxies http:// URLs through /api/cctv/media', () => {
|
||||
const result = buildCctvProxyUrl('http://example.com/stream.mjpg');
|
||||
expect(result).toBe('/api/cctv/media?url=http%3A%2F%2Fexample.com%2Fstream.mjpg');
|
||||
});
|
||||
|
||||
it('proxies https:// URLs through /api/cctv/media', () => {
|
||||
const result = buildCctvProxyUrl('https://cdn.dot.gov/cam/42.m3u8');
|
||||
expect(result).toBe('/api/cctv/media?url=https%3A%2F%2Fcdn.dot.gov%2Fcam%2F42.m3u8');
|
||||
});
|
||||
|
||||
it('passes through relative URLs unchanged', () => {
|
||||
expect(buildCctvProxyUrl('/local/stream.mp4')).toBe('/local/stream.mp4');
|
||||
});
|
||||
|
||||
it('passes through empty string unchanged', () => {
|
||||
expect(buildCctvProxyUrl('')).toBe('');
|
||||
});
|
||||
|
||||
it('passes through data: URIs unchanged', () => {
|
||||
expect(buildCctvProxyUrl('data:image/png;base64,abc')).toBe('data:image/png;base64,abc');
|
||||
});
|
||||
|
||||
it('correctly encodes special characters in URLs', () => {
|
||||
const url = 'http://cam.example.com/view?id=42&token=a b';
|
||||
const result = buildCctvProxyUrl(url);
|
||||
expect(result).toContain('/api/cctv/media?url=');
|
||||
// Decode and verify roundtrip
|
||||
const encoded = result.replace('/api/cctv/media?url=', '');
|
||||
expect(decodeURIComponent(encoded)).toBe(url);
|
||||
});
|
||||
|
||||
it('handles URLs with fragments and query params', () => {
|
||||
const url = 'https://cam.example.com/stream#t=0?quality=hd';
|
||||
const result = buildCctvProxyUrl(url);
|
||||
expect(result).toContain('/api/cctv/media?url=');
|
||||
const encoded = result.replace('/api/cctv/media?url=', '');
|
||||
expect(decodeURIComponent(encoded)).toBe(url);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── MaplibreViewer wiring — uses buildCctvProxyUrl ───────────────────────
|
||||
|
||||
describe('MaplibreViewer behavior — CCTV proxy wiring', () => {
|
||||
const viewer = fs.readFileSync(path.join(COMP_DIR, 'MaplibreViewer.tsx'), 'utf-8');
|
||||
|
||||
it('MaplibreViewer calls buildCctvProxyUrl(rawUrl) in CCTV section', () => {
|
||||
expect(viewer).toContain('buildCctvProxyUrl(rawUrl)');
|
||||
});
|
||||
|
||||
it('MaplibreViewer imports buildCctvProxyUrl from @/lib/cctvProxy', () => {
|
||||
expect(viewer).toMatch(
|
||||
/import\s*\{[^}]*buildCctvProxyUrl[^}]*\}\s*from\s+['"]@\/lib\/cctvProxy['"]/,
|
||||
);
|
||||
});
|
||||
|
||||
it('CCTV proxy URL is assigned to `url` and passed to CctvFullscreenModal', () => {
|
||||
const cctvSection = viewer.slice(
|
||||
viewer.indexOf("selectedEntity?.type === 'cctv'"),
|
||||
viewer.indexOf("selectedEntity?.type === 'cctv'") + 1600,
|
||||
);
|
||||
expect(cctvSection).toContain('const url = buildCctvProxyUrl(rawUrl)');
|
||||
expect(cctvSection).toContain('url={url}');
|
||||
expect(cctvSection).toContain('<CctvFullscreenModal');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Popup subscription isolation ─────────────────────────────────────────
|
||||
|
||||
describe('MaplibreViewer behavior — popup components have no keyed subscriptions', () => {
|
||||
const popupFiles = [
|
||||
'SatellitePopup.tsx',
|
||||
'ShipPopup.tsx',
|
||||
'SigintPopup.tsx',
|
||||
'MilitaryBasePopup.tsx',
|
||||
'RegionDossierPanel.tsx',
|
||||
];
|
||||
|
||||
const FORBIDDEN_HOOKS = [
|
||||
'useDataKeys',
|
||||
'useDataSnapshot',
|
||||
'useDataStore',
|
||||
'useImperativeSource',
|
||||
'useViewportBounds',
|
||||
'useInterpolation',
|
||||
];
|
||||
|
||||
for (const file of popupFiles) {
|
||||
const name = path.basename(file, '.tsx');
|
||||
it(`${name} does not import any data-store or map-lifecycle hooks`, () => {
|
||||
const content = readPopup(file);
|
||||
for (const hook of FORBIDDEN_HOOKS) {
|
||||
expect(content).not.toContain(hook);
|
||||
}
|
||||
});
|
||||
|
||||
it(`${name} does not reference mapRef or mapInitRef`, () => {
|
||||
const content = readPopup(file);
|
||||
expect(content).not.toContain('mapRef');
|
||||
expect(content).not.toContain('mapInitRef');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Parent-owned interpolation for popup positions ───────────────────────
|
||||
|
||||
describe('MaplibreViewer behavior — parent-owned interpolation feeds popup coords', () => {
|
||||
const viewer = fs.readFileSync(path.join(COMP_DIR, 'MaplibreViewer.tsx'), 'utf-8');
|
||||
|
||||
it('MaplibreViewer calls interpShip before passing coords to ShipPopup', () => {
|
||||
// Find the ship popup section
|
||||
const shipSection = viewer.slice(
|
||||
viewer.indexOf('{/* Ship / carrier click popup */}'),
|
||||
viewer.indexOf('{/* Ship / carrier click popup */}') + 800,
|
||||
);
|
||||
// interpShip must be called, and its result fed into ShipPopup props
|
||||
expect(shipSection).toContain('interpShip(ship)');
|
||||
expect(shipSection).toContain('longitude={iLng}');
|
||||
expect(shipSection).toContain('latitude={iLat}');
|
||||
});
|
||||
|
||||
it('ShipPopup receives longitude and latitude as props (not computing them)', () => {
|
||||
const shipPopup = readPopup('ShipPopup.tsx');
|
||||
expect(shipPopup).toContain('longitude: number');
|
||||
expect(shipPopup).toContain('latitude: number');
|
||||
// Must NOT contain interpolation logic
|
||||
expect(shipPopup).not.toContain('interpolatePosition');
|
||||
expect(shipPopup).not.toContain('interpShip');
|
||||
expect(shipPopup).not.toContain('useInterpolation');
|
||||
});
|
||||
|
||||
it('MaplibreViewer owns useInterpolation hook', () => {
|
||||
expect(viewer).toContain('useInterpolation');
|
||||
expect(viewer).toMatch(/interpShip/);
|
||||
expect(viewer).toMatch(/interpFlight/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* Sprint 4C regression tests — MaplibreViewer decomposition boundary checks.
|
||||
*
|
||||
* These tests validate the frozen contract for MaplibreViewer decomposition:
|
||||
* 1. CctvFullscreenModal extracted to MaplibreViewer-local module
|
||||
* 2. Popup components extracted to MaplibreViewer/popups/
|
||||
* 3. CCTV proxy URL construction stays in MaplibreViewer (not in CctvFullscreenModal)
|
||||
* 4. Popup components receive explicit props (not parent-scope captures)
|
||||
* 5. Selection/dismissal: entity click → onClose dispatches onEntityClick(null)
|
||||
* 6. MaplibreViewer retains <Map>, mapRef, useImperativeSource, Source/Layer, useViewportBounds
|
||||
* 7. No keyed subscription regression (useDataKeys, not useDataSnapshot)
|
||||
* 8. No mega-hook extraction (no useMapController)
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const COMP_DIR = path.resolve(__dirname, '../../components');
|
||||
|
||||
function readComp(name: string): string {
|
||||
return fs.readFileSync(path.join(COMP_DIR, name), 'utf-8');
|
||||
}
|
||||
|
||||
// ─── CctvFullscreenModal extraction ────────────────────────────────────────
|
||||
|
||||
describe('MaplibreViewer decomposition — CctvFullscreenModal extraction', () => {
|
||||
it('CctvFullscreenModal is defined in its own MaplibreViewer-local module', () => {
|
||||
const modal = readComp('MaplibreViewer/CctvFullscreenModal.tsx');
|
||||
expect(modal).toMatch(/export\s+function\s+CctvFullscreenModal/);
|
||||
expect(modal).toContain('onClose');
|
||||
});
|
||||
|
||||
it('CctvFullscreenModal exports CctvFullscreenModalProps interface', () => {
|
||||
const modal = readComp('MaplibreViewer/CctvFullscreenModal.tsx');
|
||||
expect(modal).toMatch(/export\s+interface\s+CctvFullscreenModalProps/);
|
||||
expect(modal).toContain('url: string');
|
||||
expect(modal).toContain('mediaType: string');
|
||||
expect(modal).toContain('isVideo: boolean');
|
||||
expect(modal).toContain('cameraName: string');
|
||||
expect(modal).toContain('sourceAgency: string');
|
||||
expect(modal).toContain('cameraId: string');
|
||||
});
|
||||
|
||||
it('MaplibreViewer imports CctvFullscreenModal from extracted module', () => {
|
||||
const viewer = readComp('MaplibreViewer.tsx');
|
||||
expect(viewer).toMatch(
|
||||
/import\s*\{.*CctvFullscreenModal.*\}\s*from\s+['"]@\/components\/MaplibreViewer\/CctvFullscreenModal['"]/,
|
||||
);
|
||||
});
|
||||
|
||||
it('MaplibreViewer no longer defines CctvFullscreenModal inline', () => {
|
||||
const viewer = readComp('MaplibreViewer.tsx');
|
||||
expect(viewer).not.toMatch(/^function\s+CctvFullscreenModal\s*\(/m);
|
||||
});
|
||||
|
||||
it('CctvFullscreenModal does NOT contain proxy URL logic (stays in MaplibreViewer)', () => {
|
||||
const modal = readComp('MaplibreViewer/CctvFullscreenModal.tsx');
|
||||
// Proxy construction (/api/cctv/media?url=) must stay in MaplibreViewer
|
||||
expect(modal).not.toContain('/api/cctv/media');
|
||||
expect(modal).not.toContain('encodeURIComponent');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── CCTV proxy URL behavior ───────────────────────────────────────────────
|
||||
|
||||
describe('MaplibreViewer decomposition — CCTV proxy URL behavior', () => {
|
||||
const viewer = readComp('MaplibreViewer.tsx');
|
||||
|
||||
it('CCTV section delegates proxy URL construction to buildCctvProxyUrl', () => {
|
||||
expect(viewer).toContain('buildCctvProxyUrl(rawUrl)');
|
||||
expect(viewer).toMatch(
|
||||
/import\s*\{[^}]*buildCctvProxyUrl[^}]*\}\s*from\s+['"]@\/lib\/cctvProxy['"]/,
|
||||
);
|
||||
});
|
||||
|
||||
it('CCTV section passes proxied URL to CctvFullscreenModal', () => {
|
||||
// The pattern: url={url} where url is the proxied URL
|
||||
const cctvSection = viewer.slice(
|
||||
viewer.indexOf("selectedEntity?.type === 'cctv'"),
|
||||
viewer.indexOf('</CctvFullscreenModal>') !== -1
|
||||
? viewer.indexOf('</CctvFullscreenModal>')
|
||||
: viewer.indexOf('/>', viewer.indexOf('<CctvFullscreenModal')) + 2,
|
||||
);
|
||||
expect(cctvSection).toContain('<CctvFullscreenModal');
|
||||
expect(cctvSection).toContain('url={url}');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Popup explicit props ──────────────────────────────────────────────────
|
||||
|
||||
describe('MaplibreViewer decomposition — popup explicit props', () => {
|
||||
it('SatellitePopup receives sat and onClose props', () => {
|
||||
const popup = readComp('MaplibreViewer/popups/SatellitePopup.tsx');
|
||||
expect(popup).toMatch(/export\s+interface\s+SatellitePopupProps/);
|
||||
expect(popup).toContain('sat: Satellite');
|
||||
expect(popup).toContain('onClose: () => void');
|
||||
});
|
||||
|
||||
it('ShipPopup receives ship, longitude, latitude, onClose props', () => {
|
||||
const popup = readComp('MaplibreViewer/popups/ShipPopup.tsx');
|
||||
expect(popup).toMatch(/export\s+interface\s+ShipPopupProps/);
|
||||
expect(popup).toContain('ship: Ship');
|
||||
expect(popup).toContain('longitude: number');
|
||||
expect(popup).toContain('latitude: number');
|
||||
expect(popup).toContain('onClose: () => void');
|
||||
});
|
||||
|
||||
it('SigintPopup receives data, lat, lng, kiwisdrs, setTrackedSdr, onClose props', () => {
|
||||
const popup = readComp('MaplibreViewer/popups/SigintPopup.tsx');
|
||||
expect(popup).toMatch(/export\s+interface\s+SigintPopupProps/);
|
||||
expect(popup).toContain('data: SigintData');
|
||||
expect(popup).toContain('lat: number');
|
||||
expect(popup).toContain('lng: number');
|
||||
expect(popup).toContain('kiwisdrs: KiwiSDR[]');
|
||||
expect(popup).toContain('setTrackedSdr');
|
||||
expect(popup).toContain('onClose: () => void');
|
||||
});
|
||||
|
||||
it('MilitaryBasePopup receives base, oracleIntel, onClose props', () => {
|
||||
const popup = readComp('MaplibreViewer/popups/MilitaryBasePopup.tsx');
|
||||
expect(popup).toMatch(/export\s+interface\s+MilitaryBasePopupProps/);
|
||||
expect(popup).toContain('base: MilitaryBase');
|
||||
expect(popup).toContain('oracleIntel');
|
||||
expect(popup).toContain('onClose: () => void');
|
||||
});
|
||||
|
||||
it('RegionDossierPanel receives sentinel2, lat, lng, onClose props', () => {
|
||||
const popup = readComp('MaplibreViewer/popups/RegionDossierPanel.tsx');
|
||||
expect(popup).toMatch(/export\s+interface\s+RegionDossierPanelProps/);
|
||||
expect(popup).toContain('sentinel2: Sentinel2Data');
|
||||
expect(popup).toContain('lat: number');
|
||||
expect(popup).toContain('lng: number');
|
||||
expect(popup).toContain('onClose: () => void');
|
||||
});
|
||||
|
||||
it('SigintPopup imports SigintSendForm and MeshtasticChannelFeed from SigintPanels', () => {
|
||||
const popup = readComp('MaplibreViewer/popups/SigintPopup.tsx');
|
||||
expect(popup).toMatch(
|
||||
/import\s*\{[^}]*SigintSendForm[^}]*\}\s*from\s+['"]@\/components\/map\/panels\/SigintPanels['"]/,
|
||||
);
|
||||
expect(popup).toMatch(
|
||||
/import\s*\{[^}]*MeshtasticChannelFeed[^}]*\}\s*from\s+['"]@\/components\/map\/panels\/SigintPanels['"]/,
|
||||
);
|
||||
});
|
||||
|
||||
it('SigintPopup computes nearestSdr internally (not passed from parent)', () => {
|
||||
const popup = readComp('MaplibreViewer/popups/SigintPopup.tsx');
|
||||
expect(popup).toContain('findNearestSdr');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Selection / dismissal behavior ────────────────────────────────────────
|
||||
|
||||
describe('MaplibreViewer decomposition — selection and dismissal', () => {
|
||||
const viewer = readComp('MaplibreViewer.tsx');
|
||||
|
||||
it('satellite popup calls onEntityClick(null) on close', () => {
|
||||
const satSection = viewer.slice(
|
||||
viewer.indexOf("selectedEntity?.type === 'satellite'"),
|
||||
viewer.indexOf("selectedEntity?.type === 'satellite'") + 500,
|
||||
);
|
||||
expect(satSection).toContain('<SatellitePopup');
|
||||
expect(satSection).toContain('onClose={() => onEntityClick?.(null)}');
|
||||
});
|
||||
|
||||
it('ship popup calls onEntityClick(null) on close', () => {
|
||||
const shipSection = viewer.slice(
|
||||
viewer.indexOf('{/* Ship / carrier click popup */}'),
|
||||
viewer.indexOf('{/* Ship / carrier click popup */}') + 800,
|
||||
);
|
||||
expect(shipSection).toContain('<ShipPopup');
|
||||
expect(shipSection).toContain('onClose={() => onEntityClick?.(null)}');
|
||||
});
|
||||
|
||||
it('sigint popup calls onEntityClick(null) on close', () => {
|
||||
const sigintSection = viewer.slice(
|
||||
viewer.indexOf('{/* SIGINT signal click popup */}'),
|
||||
viewer.indexOf('{/* SIGINT signal click popup */}') + 1200,
|
||||
);
|
||||
expect(sigintSection).toContain('<SigintPopup');
|
||||
expect(sigintSection).toContain('onClose={() => onEntityClick?.(null)}');
|
||||
});
|
||||
|
||||
it('military base popup calls onEntityClick(null) on close', () => {
|
||||
const milSection = viewer.slice(
|
||||
viewer.indexOf("selectedEntity?.type === 'military_base'"),
|
||||
viewer.indexOf("selectedEntity?.type === 'military_base'") + 600,
|
||||
);
|
||||
expect(milSection).toContain('<MilitaryBasePopup');
|
||||
expect(milSection).toContain('onClose={() => onEntityClick?.(null)}');
|
||||
});
|
||||
|
||||
it('region dossier panel calls onEntityClick(null) on close', () => {
|
||||
const rdSection = viewer.slice(
|
||||
viewer.indexOf('{/* SENTINEL-2 IMAGERY'),
|
||||
viewer.indexOf('{/* SENTINEL-2 IMAGERY') + 500,
|
||||
);
|
||||
expect(rdSection).toContain('<RegionDossierPanel');
|
||||
expect(rdSection).toContain('onClose={() => onEntityClick(null)}');
|
||||
});
|
||||
|
||||
it('CCTV fullscreen modal calls onEntityClick(null) on close', () => {
|
||||
const cctvSection = viewer.slice(
|
||||
viewer.indexOf("selectedEntity?.type === 'cctv'"),
|
||||
viewer.indexOf("selectedEntity?.type === 'cctv'") + 1600,
|
||||
);
|
||||
expect(cctvSection).toContain('<CctvFullscreenModal');
|
||||
expect(cctvSection).toContain('onClose={() => onEntityClick(null)}');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── MaplibreViewer retains core responsibilities ──────────────────────────
|
||||
|
||||
describe('MaplibreViewer decomposition — retained core', () => {
|
||||
const viewer = readComp('MaplibreViewer.tsx');
|
||||
|
||||
it('MaplibreViewer retains <Map> component', () => {
|
||||
expect(viewer).toContain('<Map');
|
||||
expect(viewer).toContain('</Map>');
|
||||
});
|
||||
|
||||
it('MaplibreViewer retains mapRef', () => {
|
||||
expect(viewer).toMatch(/mapRef\s*=\s*useRef/);
|
||||
});
|
||||
|
||||
it('MaplibreViewer retains mapInitRef', () => {
|
||||
expect(viewer).toMatch(/mapInitRef\s*=\s*useRef/);
|
||||
});
|
||||
|
||||
it('MaplibreViewer retains initializeMap', () => {
|
||||
expect(viewer).toContain('initializeMap');
|
||||
});
|
||||
|
||||
it('MaplibreViewer retains useImperativeSource calls', () => {
|
||||
expect(viewer).toContain('useImperativeSource');
|
||||
});
|
||||
|
||||
it('MaplibreViewer retains Source and Layer declarations', () => {
|
||||
expect(viewer).toContain('<Source');
|
||||
expect(viewer).toContain('<Layer');
|
||||
});
|
||||
|
||||
it('MaplibreViewer retains useViewportBounds', () => {
|
||||
expect(viewer).toContain('useViewportBounds');
|
||||
});
|
||||
|
||||
it('MaplibreViewer retains activeInteractiveLayerIds', () => {
|
||||
expect(viewer).toContain('activeInteractiveLayerIds');
|
||||
});
|
||||
|
||||
it('MaplibreViewer retains worker hooks', () => {
|
||||
expect(viewer).toContain('useDynamicMapLayersWorker');
|
||||
expect(viewer).toContain('useStaticMapLayersWorker');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── No keyed subscription regression ──────────────────────────────────────
|
||||
|
||||
describe('MaplibreViewer decomposition — no keyed subscription regression', () => {
|
||||
const viewer = readComp('MaplibreViewer.tsx');
|
||||
|
||||
it('MaplibreViewer uses useDataKeys (keyed subscription model)', () => {
|
||||
expect(viewer).toContain('useDataKeys');
|
||||
});
|
||||
|
||||
it('MaplibreViewer does NOT use useDataSnapshot', () => {
|
||||
expect(viewer).not.toContain('useDataSnapshot');
|
||||
});
|
||||
|
||||
it('MaplibreViewer imports useDataKeys from @/hooks/useDataStore', () => {
|
||||
expect(viewer).toMatch(
|
||||
/import\s*\{[^}]*useDataKeys[^}]*\}\s*from\s+['"]@\/hooks\/useDataStore['"]/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── No mega-hook extraction ───────────────────────────────────────────────
|
||||
|
||||
describe('MaplibreViewer decomposition — no mega-hook', () => {
|
||||
it('no useMapController hook exists', () => {
|
||||
const viewerDir = path.join(COMP_DIR, 'MaplibreViewer');
|
||||
const files = fs.readdirSync(viewerDir, { recursive: true }) as string[];
|
||||
const hookFiles = files.filter(
|
||||
(f: string) => f.includes('useMapController') || f.includes('use-map-controller'),
|
||||
);
|
||||
expect(hookFiles).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('MaplibreViewer does not import useMapController', () => {
|
||||
const viewer = readComp('MaplibreViewer.tsx');
|
||||
expect(viewer).not.toContain('useMapController');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Popup components use Popup from react-map-gl ──────────────────────────
|
||||
|
||||
describe('MaplibreViewer decomposition — popup components own their Popup wrapper', () => {
|
||||
const popupFiles = [
|
||||
'MaplibreViewer/popups/SatellitePopup.tsx',
|
||||
'MaplibreViewer/popups/ShipPopup.tsx',
|
||||
'MaplibreViewer/popups/SigintPopup.tsx',
|
||||
'MaplibreViewer/popups/MilitaryBasePopup.tsx',
|
||||
];
|
||||
|
||||
for (const file of popupFiles) {
|
||||
const name = path.basename(file, '.tsx');
|
||||
it(`${name} imports Popup from react-map-gl/maplibre`, () => {
|
||||
const content = readComp(file);
|
||||
expect(content).toMatch(
|
||||
/import\s*\{[^}]*Popup[^}]*\}\s*from\s+['"]react-map-gl\/maplibre['"]/,
|
||||
);
|
||||
});
|
||||
|
||||
it(`${name} renders a <Popup> component`, () => {
|
||||
const content = readComp(file);
|
||||
expect(content).toContain('<Popup');
|
||||
});
|
||||
}
|
||||
|
||||
it('RegionDossierPanel renders a fixed overlay (not a map Popup)', () => {
|
||||
const content = readComp('MaplibreViewer/popups/RegionDossierPanel.tsx');
|
||||
expect(content).not.toContain('<Popup');
|
||||
expect(content).toContain("position: 'fixed'");
|
||||
});
|
||||
|
||||
it('CctvFullscreenModal renders a fixed overlay (not a map Popup)', () => {
|
||||
const content = readComp('MaplibreViewer/CctvFullscreenModal.tsx');
|
||||
expect(content).not.toContain('<Popup');
|
||||
expect(content).toContain("position: 'fixed'");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Data lookups stay in MaplibreViewer ────────────────────────────────────
|
||||
|
||||
describe('MaplibreViewer decomposition — data lookups in parent', () => {
|
||||
it('satellite lookup stays in MaplibreViewer', () => {
|
||||
const viewer = readComp('MaplibreViewer.tsx');
|
||||
expect(viewer).toContain("data?.satellites?.find");
|
||||
});
|
||||
|
||||
it('ship lookup stays in MaplibreViewer', () => {
|
||||
const viewer = readComp('MaplibreViewer.tsx');
|
||||
expect(viewer).toContain("data?.ships?.find");
|
||||
});
|
||||
|
||||
it('sigint lookup stays in MaplibreViewer', () => {
|
||||
const viewer = readComp('MaplibreViewer.tsx');
|
||||
expect(viewer).toContain("data?.sigint?.find");
|
||||
});
|
||||
|
||||
it('military_bases lookup stays in MaplibreViewer', () => {
|
||||
const viewer = readComp('MaplibreViewer.tsx');
|
||||
expect(viewer).toContain("data?.military_bases?.find");
|
||||
});
|
||||
|
||||
it('popup components do NOT access data store directly', () => {
|
||||
const popupFiles = [
|
||||
'MaplibreViewer/popups/SatellitePopup.tsx',
|
||||
'MaplibreViewer/popups/ShipPopup.tsx',
|
||||
'MaplibreViewer/popups/SigintPopup.tsx',
|
||||
'MaplibreViewer/popups/MilitaryBasePopup.tsx',
|
||||
'MaplibreViewer/popups/RegionDossierPanel.tsx',
|
||||
];
|
||||
for (const file of popupFiles) {
|
||||
const content = readComp(file);
|
||||
expect(content).not.toContain('useDataKeys');
|
||||
expect(content).not.toContain('useDataSnapshot');
|
||||
expect(content).not.toContain('useDataStore');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function readSource(relativePath: string): string {
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
return fs.readFileSync(path.resolve(here, relativePath), 'utf-8');
|
||||
}
|
||||
|
||||
describe('Sprint 6 DM compatibility sunset policy', () => {
|
||||
it('keeps receive-side MeshChat request parsing off ambient legacy agent-id lookup', () => {
|
||||
const controller = readSource('../../components/MeshChat/useMeshChatController.ts');
|
||||
|
||||
expect(controller).toMatch(
|
||||
/fetchDmPublicKey\(\s*API_BASE,\s*m\.sender_id,\s*senderContact\?\.invitePinnedPrekeyLookupHandle/s,
|
||||
);
|
||||
expect(controller).not.toMatch(
|
||||
/fetchDmPublicKey\(\s*API_BASE,\s*m\.sender_id,[\s\S]{0,200}allowLegacyAgentId:\s*true/s,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps MessagesView receive-side contact parsing off ambient legacy agent-id lookup', () => {
|
||||
const messagesView = readSource('../../components/InfonetTerminal/MessagesView.tsx');
|
||||
|
||||
expect(messagesView).toMatch(
|
||||
/fetchDmPublicKey\(\s*API_BASE,\s*senderId,\s*existingContact\?\.invitePinnedPrekeyLookupHandle/s,
|
||||
);
|
||||
expect(messagesView).not.toMatch(
|
||||
/fetchDmPublicKey\(\s*API_BASE,\s*senderId,[\s\S]{0,200}allowLegacyAgentId:\s*true/s,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps MeshTerminal legacy lookup limited to explicit migration commands', () => {
|
||||
const terminal = readSource('../../components/MeshTerminal.tsx');
|
||||
|
||||
expect(terminal).not.toMatch(
|
||||
/fetchDmPublicKey\(\s*API,\s*message\.sender_id,[\s\S]{0,200}allowLegacyAgentId:/s,
|
||||
);
|
||||
expect(terminal).not.toMatch(
|
||||
/fetchDmPublicKey\(\s*API,\s*m\.sender_id,[\s\S]{0,200}allowLegacyAgentId:/s,
|
||||
);
|
||||
|
||||
const legacyLookupMatches = terminal.match(/allowLegacyAgentId:\s*true/g) || [];
|
||||
expect(legacyLookupMatches).toHaveLength(1);
|
||||
expect(terminal).toContain("only for legacy migration");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
function makeStorage() {
|
||||
const values = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => void values.set(key, value),
|
||||
removeItem: (key: string) => void values.delete(key),
|
||||
clear: () => void values.clear(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('dmPollScheduler', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: makeStorage(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'sessionStorage', {
|
||||
value: makeStorage(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe('jitteredPollDelay', () => {
|
||||
it('returns a value within the default jitter band', async () => {
|
||||
const { jitteredPollDelay } = await import('@/lib/dmPollScheduler');
|
||||
const base = 12_000;
|
||||
// r=0 → factor=0.8 → 9600; r=1 → factor=1.4 → 16800
|
||||
expect(jitteredPollDelay(base, { profile: 'default', random: 0 })).toBe(9600);
|
||||
expect(jitteredPollDelay(base, { profile: 'default', random: 1 })).toBe(16800);
|
||||
});
|
||||
|
||||
it('high-privacy band is wider than default', async () => {
|
||||
const { jitteredPollDelay } = await import('@/lib/dmPollScheduler');
|
||||
const base = 12_000;
|
||||
const defaultMin = jitteredPollDelay(base, { profile: 'default', random: 0 });
|
||||
const defaultMax = jitteredPollDelay(base, { profile: 'default', random: 1 });
|
||||
const highMin = jitteredPollDelay(base, { profile: 'high', random: 0 });
|
||||
const highMax = jitteredPollDelay(base, { profile: 'high', random: 1 });
|
||||
|
||||
const defaultRange = defaultMax - defaultMin;
|
||||
const highRange = highMax - highMin;
|
||||
expect(highRange).toBeGreaterThan(defaultRange);
|
||||
});
|
||||
|
||||
it('never returns the exact base interval across random inputs', async () => {
|
||||
const { jitteredPollDelay } = await import('@/lib/dmPollScheduler');
|
||||
const base = 12_000;
|
||||
const samples = Array.from({ length: 100 }, (_, i) =>
|
||||
jitteredPollDelay(base, { profile: 'default', random: i / 100 }),
|
||||
);
|
||||
// At most one value could accidentally equal base; the set should be diverse
|
||||
const unique = new Set(samples);
|
||||
expect(unique.size).toBeGreaterThan(50);
|
||||
// The exact base value corresponds to r ≈ 0.333...; verify it's the only one
|
||||
const exactBaseCount = samples.filter((v) => v === base).length;
|
||||
expect(exactBaseCount).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('reads privacy profile from browser storage when no override', async () => {
|
||||
sessionStorage.setItem('sb_privacy_profile', 'high');
|
||||
const { jitteredPollDelay } = await import('@/lib/dmPollScheduler');
|
||||
const base = 10_000;
|
||||
// r=0 with high profile → factor=0.5 → 5000
|
||||
expect(jitteredPollDelay(base, { random: 0 })).toBe(5000);
|
||||
});
|
||||
|
||||
it('returns positive value for any base and profile', async () => {
|
||||
const { jitteredPollDelay } = await import('@/lib/dmPollScheduler');
|
||||
for (const profile of ['default', 'high', 'unknown']) {
|
||||
for (const r of [0, 0.25, 0.5, 0.75, 1]) {
|
||||
const delay = jitteredPollDelay(15_000, { profile, random: r });
|
||||
expect(delay).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('catchUpDelay', () => {
|
||||
it('returns a value within the default catch-up band', async () => {
|
||||
const { catchUpDelay } = await import('@/lib/dmPollScheduler');
|
||||
// default: min=2000, max=5000
|
||||
expect(catchUpDelay({ profile: 'default', random: 0 })).toBe(2000);
|
||||
expect(catchUpDelay({ profile: 'default', random: 1 })).toBe(5000);
|
||||
});
|
||||
|
||||
it('high-privacy catch-up delay is longer than default', async () => {
|
||||
const { catchUpDelay } = await import('@/lib/dmPollScheduler');
|
||||
const defaultMid = catchUpDelay({ profile: 'default', random: 0.5 });
|
||||
const highMid = catchUpDelay({ profile: 'high', random: 0.5 });
|
||||
expect(highMid).toBeGreaterThan(defaultMid);
|
||||
});
|
||||
|
||||
it('catch-up delay is always shorter than normal poll delay', async () => {
|
||||
const { jitteredPollDelay, catchUpDelay } = await import('@/lib/dmPollScheduler');
|
||||
// Worst-case catch-up (r=1, high) vs best-case normal poll (r=0, default, base=12000)
|
||||
const maxCatchUp = catchUpDelay({ profile: 'high', random: 1 });
|
||||
const minNormal = jitteredPollDelay(12_000, { profile: 'default', random: 0 });
|
||||
expect(maxCatchUp).toBeLessThan(minNormal);
|
||||
});
|
||||
|
||||
it('catch-up delay is never zero', async () => {
|
||||
const { catchUpDelay } = await import('@/lib/dmPollScheduler');
|
||||
for (const profile of ['default', 'high']) {
|
||||
const delay = catchUpDelay({ profile, random: 0 });
|
||||
expect(delay).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('MAX_CATCHUP_POLLS', () => {
|
||||
it('is a small positive integer bounding catch-up bursts', async () => {
|
||||
const { MAX_CATCHUP_POLLS } = await import('@/lib/dmPollScheduler');
|
||||
expect(MAX_CATCHUP_POLLS).toBeGreaterThanOrEqual(1);
|
||||
expect(MAX_CATCHUP_POLLS).toBeLessThanOrEqual(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyTick', () => {
|
||||
it('catch-up tick skips count refresh', async () => {
|
||||
const { classifyTick } = await import('@/lib/dmPollScheduler');
|
||||
const result = classifyTick(true, 3, 12_000, { profile: 'default', random: 0.5 });
|
||||
expect(result.refreshCount).toBe(false);
|
||||
expect(result.newBudget).toBe(2);
|
||||
});
|
||||
|
||||
it('normal tick includes count refresh', async () => {
|
||||
const { classifyTick } = await import('@/lib/dmPollScheduler');
|
||||
const result = classifyTick(false, 3, 12_000, { profile: 'default', random: 0.5 });
|
||||
expect(result.refreshCount).toBe(true);
|
||||
});
|
||||
|
||||
it('budget exhaustion falls back to normal with count', async () => {
|
||||
const { classifyTick } = await import('@/lib/dmPollScheduler');
|
||||
// has_more=true but budget=0 → normal tick
|
||||
const result = classifyTick(true, 0, 12_000, { profile: 'default', random: 0.5 });
|
||||
expect(result.refreshCount).toBe(true);
|
||||
});
|
||||
|
||||
it('budget resets after fallback to normal', async () => {
|
||||
const { classifyTick, MAX_CATCHUP_POLLS } = await import('@/lib/dmPollScheduler');
|
||||
const result = classifyTick(false, 1, 12_000, { profile: 'default', random: 0.5 });
|
||||
expect(result.newBudget).toBe(MAX_CATCHUP_POLLS);
|
||||
});
|
||||
|
||||
it('catch-up delay is used during catch-up ticks', async () => {
|
||||
const { classifyTick, catchUpDelay } = await import('@/lib/dmPollScheduler');
|
||||
const opts = { profile: 'default' as const, random: 0.5 };
|
||||
const result = classifyTick(true, 2, 12_000, opts);
|
||||
expect(result.delay).toBe(catchUpDelay(opts));
|
||||
});
|
||||
|
||||
it('normal delay is used during normal ticks', async () => {
|
||||
const { classifyTick, jitteredPollDelay } = await import('@/lib/dmPollScheduler');
|
||||
const opts = { profile: 'default' as const, random: 0.5 };
|
||||
const result = classifyTick(false, 3, 12_000, opts);
|
||||
expect(result.delay).toBe(jitteredPollDelay(12_000, opts));
|
||||
});
|
||||
});
|
||||
|
||||
describe('scheduling contract', () => {
|
||||
it('simulated poll loop uses classifyTick for cadence and count decisions', async () => {
|
||||
const { classifyTick, catchUpDelay, jitteredPollDelay, MAX_CATCHUP_POLLS } =
|
||||
await import('@/lib/dmPollScheduler');
|
||||
|
||||
const ticks: Array<{ delay: number; refreshCount: boolean }> = [];
|
||||
let budget = MAX_CATCHUP_POLLS;
|
||||
const hasMoreSequence = [true, true, true, true, false, false, true, false];
|
||||
const opts = { profile: 'default' as const, random: 0.5 };
|
||||
|
||||
for (const hasMore of hasMoreSequence) {
|
||||
const result = classifyTick(hasMore, budget, 12_000, opts);
|
||||
budget = result.newBudget;
|
||||
ticks.push({ delay: result.delay, refreshCount: result.refreshCount });
|
||||
}
|
||||
|
||||
const catchUpValue = catchUpDelay(opts);
|
||||
const normalValue = jitteredPollDelay(12_000, opts);
|
||||
|
||||
// First MAX_CATCHUP_POLLS catch-up ticks: short delay, no count
|
||||
for (let i = 0; i < MAX_CATCHUP_POLLS; i++) {
|
||||
expect(ticks[i].delay).toBe(catchUpValue);
|
||||
expect(ticks[i].refreshCount).toBe(false);
|
||||
}
|
||||
// 4th has_more exceeds budget → normal with count
|
||||
expect(ticks[MAX_CATCHUP_POLLS].delay).toBe(normalValue);
|
||||
expect(ticks[MAX_CATCHUP_POLLS].refreshCount).toBe(true);
|
||||
// Non-has_more ticks: normal with count
|
||||
expect(ticks[4].delay).toBe(normalValue);
|
||||
expect(ticks[4].refreshCount).toBe(true);
|
||||
expect(ticks[5].delay).toBe(normalValue);
|
||||
expect(ticks[5].refreshCount).toBe(true);
|
||||
});
|
||||
|
||||
it('count is never refreshed during catch-up across a full backlog drain', async () => {
|
||||
const { classifyTick, MAX_CATCHUP_POLLS } = await import('@/lib/dmPollScheduler');
|
||||
|
||||
let budget = MAX_CATCHUP_POLLS;
|
||||
const countRefreshes: boolean[] = [];
|
||||
|
||||
// Simulate: has_more for exactly budget ticks, then two normal ticks
|
||||
const hasMoreSequence = [
|
||||
...Array(MAX_CATCHUP_POLLS).fill(true),
|
||||
false,
|
||||
false,
|
||||
];
|
||||
for (const hasMore of hasMoreSequence) {
|
||||
const result = classifyTick(hasMore, budget, 12_000, { profile: 'default', random: 0.5 });
|
||||
budget = result.newBudget;
|
||||
countRefreshes.push(result.refreshCount);
|
||||
}
|
||||
|
||||
// Catch-up ticks should not refresh count
|
||||
for (let i = 0; i < MAX_CATCHUP_POLLS; i++) {
|
||||
expect(countRefreshes[i]).toBe(false);
|
||||
}
|
||||
// Normal ticks after catch-up do refresh count
|
||||
expect(countRefreshes[MAX_CATCHUP_POLLS]).toBe(true);
|
||||
expect(countRefreshes[MAX_CATCHUP_POLLS + 1]).toBe(true);
|
||||
});
|
||||
|
||||
it('no fixed cadence is reintroduced by classifyTick', async () => {
|
||||
const { classifyTick } = await import('@/lib/dmPollScheduler');
|
||||
const delays = new Set<number>();
|
||||
for (let r = 0; r < 20; r++) {
|
||||
const result = classifyTick(false, 3, 12_000, { profile: 'default', random: r / 20 });
|
||||
delays.add(result.delay);
|
||||
}
|
||||
// All 20 random inputs should produce diverse delays, not a fixed value
|
||||
expect(delays.size).toBeGreaterThan(10);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const controlPlaneJson = vi.fn();
|
||||
|
||||
vi.mock('@/lib/controlPlane', () => ({
|
||||
controlPlaneJson,
|
||||
}));
|
||||
|
||||
describe('DM selftest client', () => {
|
||||
beforeEach(() => {
|
||||
controlPlaneJson.mockReset();
|
||||
});
|
||||
|
||||
it('runs the local DM selftest without requiring an admin browser session', async () => {
|
||||
controlPlaneJson.mockResolvedValue({ ok: true });
|
||||
|
||||
const { runWormholeDmSelftest } = await import('@/mesh/wormholeIdentityClient');
|
||||
|
||||
await runWormholeDmSelftest('probe');
|
||||
|
||||
expect(controlPlaneJson).toHaveBeenCalledWith(
|
||||
'/api/wormhole/dm/selftest',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
requireAdminSession: false,
|
||||
body: JSON.stringify({ message: 'probe' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const controlPlaneJson = vi.fn();
|
||||
const hasLocalControlBridge = vi.fn(() => false);
|
||||
const getGateSessionStreamAccessHeaders = vi.fn();
|
||||
|
||||
vi.mock('@/lib/controlPlane', () => ({
|
||||
controlPlaneJson,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/localControlTransport', () => ({
|
||||
hasLocalControlBridge,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/gateSessionStream', () => ({
|
||||
getGateSessionStreamAccessHeaders,
|
||||
}));
|
||||
|
||||
describe('gateAccessProof cache', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
controlPlaneJson.mockReset();
|
||||
hasLocalControlBridge.mockReset();
|
||||
hasLocalControlBridge.mockReturnValue(false);
|
||||
getGateSessionStreamAccessHeaders.mockReset();
|
||||
getGateSessionStreamAccessHeaders.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
it('caches browser/web gate proofs just under the backend validity window', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-05T22:40:00.000Z'));
|
||||
try {
|
||||
controlPlaneJson.mockResolvedValue({
|
||||
node_id: '!sb_gate',
|
||||
ts: 1712345678,
|
||||
proof: 'proof-a',
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gateAccessProof');
|
||||
|
||||
await expect(mod.buildGateAccessHeaders('finance')).resolves.toEqual({
|
||||
'X-Wormhole-Node-Id': '!sb_gate',
|
||||
'X-Wormhole-Gate-Proof': 'proof-a',
|
||||
'X-Wormhole-Gate-Ts': '1712345678',
|
||||
});
|
||||
await expect(mod.buildGateAccessHeaders('finance')).resolves.toEqual({
|
||||
'X-Wormhole-Node-Id': '!sb_gate',
|
||||
'X-Wormhole-Gate-Proof': 'proof-a',
|
||||
'X-Wormhole-Gate-Ts': '1712345678',
|
||||
});
|
||||
|
||||
expect(controlPlaneJson).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(52_001);
|
||||
|
||||
await mod.buildGateAccessHeaders('finance');
|
||||
expect(controlPlaneJson).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses a shorter proof cache window on native runtimes', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-05T22:40:00.000Z'));
|
||||
try {
|
||||
hasLocalControlBridge.mockReturnValue(true);
|
||||
controlPlaneJson.mockResolvedValue({
|
||||
node_id: '!sb_gate',
|
||||
ts: 1712345678,
|
||||
proof: 'proof-native',
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gateAccessProof');
|
||||
|
||||
await mod.buildGateAccessHeaders('finance');
|
||||
vi.advanceTimersByTime(35_001);
|
||||
await mod.buildGateAccessHeaders('finance');
|
||||
|
||||
expect(controlPlaneJson).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('coalesces concurrent proof requests for the same gate into one control-plane call', async () => {
|
||||
let release: ((value: { node_id: string; ts: number; proof: string }) => void) | null = null;
|
||||
controlPlaneJson.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
release = resolve as typeof release;
|
||||
}),
|
||||
);
|
||||
|
||||
const mod = await import('@/mesh/gateAccessProof');
|
||||
|
||||
const first = mod.buildGateAccessHeaders('finance');
|
||||
const second = mod.buildGateAccessHeaders('finance');
|
||||
|
||||
expect(controlPlaneJson).toHaveBeenCalledTimes(1);
|
||||
|
||||
release?.({
|
||||
node_id: '!sb_gate',
|
||||
ts: 1712345678,
|
||||
proof: 'proof-a',
|
||||
});
|
||||
|
||||
await expect(first).resolves.toEqual({
|
||||
'X-Wormhole-Node-Id': '!sb_gate',
|
||||
'X-Wormhole-Gate-Proof': 'proof-a',
|
||||
'X-Wormhole-Gate-Ts': '1712345678',
|
||||
});
|
||||
await expect(second).resolves.toEqual({
|
||||
'X-Wormhole-Node-Id': '!sb_gate',
|
||||
'X-Wormhole-Gate-Proof': 'proof-a',
|
||||
'X-Wormhole-Gate-Ts': '1712345678',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses stream bootstrap access headers before falling back to the gate proof endpoint', async () => {
|
||||
getGateSessionStreamAccessHeaders.mockReturnValue({
|
||||
'X-Wormhole-Node-Id': '!sb_stream',
|
||||
'X-Wormhole-Gate-Proof': 'proof-stream',
|
||||
'X-Wormhole-Gate-Ts': '1712345678',
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gateAccessProof');
|
||||
|
||||
await expect(mod.buildGateAccessHeaders('finance', { mode: 'session_stream' })).resolves.toEqual({
|
||||
'X-Wormhole-Node-Id': '!sb_stream',
|
||||
'X-Wormhole-Gate-Proof': 'proof-stream',
|
||||
'X-Wormhole-Gate-Ts': '1712345678',
|
||||
});
|
||||
expect(getGateSessionStreamAccessHeaders).toHaveBeenCalledWith('finance');
|
||||
expect(controlPlaneJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reuses a fresh-enough proof longer for held wait requests than for ordinary reads', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-05T22:40:00.000Z'));
|
||||
try {
|
||||
const firstTs = Math.floor(Date.now() / 1000);
|
||||
const secondTs = Math.floor((Date.now() + 55_000) / 1000);
|
||||
controlPlaneJson
|
||||
.mockResolvedValueOnce({
|
||||
node_id: '!sb_gate',
|
||||
ts: firstTs,
|
||||
proof: 'proof-a',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
node_id: '!sb_gate',
|
||||
ts: secondTs,
|
||||
proof: 'proof-b',
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gateAccessProof');
|
||||
|
||||
await expect(mod.buildGateAccessHeaders('finance')).resolves.toEqual({
|
||||
'X-Wormhole-Node-Id': '!sb_gate',
|
||||
'X-Wormhole-Gate-Proof': 'proof-a',
|
||||
'X-Wormhole-Gate-Ts': String(firstTs),
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(55_000);
|
||||
|
||||
await expect(mod.buildGateAccessHeaders('finance', { mode: 'wait' })).resolves.toEqual({
|
||||
'X-Wormhole-Node-Id': '!sb_gate',
|
||||
'X-Wormhole-Gate-Proof': 'proof-a',
|
||||
'X-Wormhole-Gate-Ts': String(firstTs),
|
||||
});
|
||||
expect(controlPlaneJson).toHaveBeenCalledTimes(1);
|
||||
|
||||
await expect(mod.buildGateAccessHeaders('finance')).resolves.toEqual({
|
||||
'X-Wormhole-Node-Id': '!sb_gate',
|
||||
'X-Wormhole-Gate-Proof': 'proof-b',
|
||||
'X-Wormhole-Gate-Ts': String(secondTs),
|
||||
});
|
||||
expect(controlPlaneJson).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('reuses a fresh-enough proof longer for session-stream refreshes than for ordinary reads', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-05T22:40:00.000Z'));
|
||||
try {
|
||||
const firstTs = Math.floor(Date.now() / 1000);
|
||||
const secondTs = Math.floor((Date.now() + 55_000) / 1000);
|
||||
controlPlaneJson
|
||||
.mockResolvedValueOnce({
|
||||
node_id: '!sb_gate',
|
||||
ts: firstTs,
|
||||
proof: 'proof-a',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
node_id: '!sb_gate',
|
||||
ts: secondTs,
|
||||
proof: 'proof-b',
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gateAccessProof');
|
||||
|
||||
await expect(mod.buildGateAccessHeaders('finance')).resolves.toEqual({
|
||||
'X-Wormhole-Node-Id': '!sb_gate',
|
||||
'X-Wormhole-Gate-Proof': 'proof-a',
|
||||
'X-Wormhole-Gate-Ts': String(firstTs),
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(55_000);
|
||||
|
||||
await expect(mod.buildGateAccessHeaders('finance', { mode: 'session_stream' })).resolves.toEqual({
|
||||
'X-Wormhole-Node-Id': '!sb_gate',
|
||||
'X-Wormhole-Gate-Proof': 'proof-a',
|
||||
'X-Wormhole-Gate-Ts': String(firstTs),
|
||||
});
|
||||
expect(controlPlaneJson).toHaveBeenCalledTimes(1);
|
||||
|
||||
await expect(mod.buildGateAccessHeaders('finance')).resolves.toEqual({
|
||||
'X-Wormhole-Node-Id': '!sb_gate',
|
||||
'X-Wormhole-Gate-Proof': 'proof-b',
|
||||
'X-Wormhole-Gate-Ts': String(secondTs),
|
||||
});
|
||||
expect(controlPlaneJson).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const hasLocalControlBridge = vi.fn(() => false);
|
||||
|
||||
vi.mock('@/lib/localControlTransport', () => ({
|
||||
hasLocalControlBridge,
|
||||
}));
|
||||
|
||||
describe('gateCatalogSnapshot cache', () => {
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
fetchMock.mockReset();
|
||||
hasLocalControlBridge.mockReset();
|
||||
hasLocalControlBridge.mockReturnValue(false);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
it('coarsens browser/web gate catalog reads through a short shared cache window', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-05T23:10:00.000Z'));
|
||||
try {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
gates: [{ gate_id: 'infonet', display_name: 'Infonet Commons' }],
|
||||
}),
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gateCatalogSnapshot');
|
||||
|
||||
await expect(mod.fetchGateCatalogSnapshot()).resolves.toEqual([
|
||||
{ gate_id: 'infonet', display_name: 'Infonet Commons' },
|
||||
]);
|
||||
await expect(mod.fetchGateCatalogSnapshot()).resolves.toEqual([
|
||||
{ gate_id: 'infonet', display_name: 'Infonet Commons' },
|
||||
]);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(18_001);
|
||||
|
||||
await mod.fetchGateCatalogSnapshot();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses a shorter cache window for native gate catalog/detail snapshots', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-05T23:10:00.000Z'));
|
||||
try {
|
||||
hasLocalControlBridge.mockReturnValue(true);
|
||||
fetchMock
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
gates: [{ gate_id: 'finance', display_name: 'Finance' }],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
gates: [{ gate_id: 'finance', display_name: 'Finance' }],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
gate_id: 'finance',
|
||||
display_name: 'Finance',
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
gate_id: 'finance',
|
||||
display_name: 'Finance',
|
||||
}),
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gateCatalogSnapshot');
|
||||
|
||||
await mod.fetchGateCatalogSnapshot();
|
||||
vi.advanceTimersByTime(6_001);
|
||||
await mod.fetchGateCatalogSnapshot();
|
||||
|
||||
await mod.fetchGateDetailSnapshot('finance');
|
||||
vi.advanceTimersByTime(5_001);
|
||||
await mod.fetchGateDetailSnapshot('finance');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('invalidates cached gate detail snapshots explicitly', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
gate_id: 'infonet',
|
||||
display_name: 'Infonet Commons',
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
gate_id: 'infonet',
|
||||
display_name: 'Infonet Commons v2',
|
||||
}),
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gateCatalogSnapshot');
|
||||
|
||||
await expect(mod.fetchGateDetailSnapshot('infonet')).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
gate_id: 'infonet',
|
||||
display_name: 'Infonet Commons',
|
||||
}),
|
||||
);
|
||||
mod.invalidateGateDetailSnapshot('infonet');
|
||||
await expect(mod.fetchGateDetailSnapshot('infonet')).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
gate_id: 'infonet',
|
||||
display_name: 'Infonet Commons v2',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,737 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
|
||||
import React from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
controlPlaneJson: vi.fn(),
|
||||
approveGateCompatFallback: vi.fn(),
|
||||
decryptWormholeGateMessages: vi.fn(),
|
||||
fetchWormholeGateKeyStatus: vi.fn(),
|
||||
hasGateCompatFallbackApproval: vi.fn(() => false),
|
||||
postWormholeGateMessage: vi.fn(),
|
||||
prepareWormholeInteractiveLane: vi.fn(async () => ({
|
||||
ready: true,
|
||||
settingsEnabled: true,
|
||||
transportTier: 'private_transitional',
|
||||
identity: null,
|
||||
})),
|
||||
revokeGateCompatFallback: vi.fn(),
|
||||
syncBrowserWormholeGateState: vi.fn(async () => true),
|
||||
getGateSessionStreamStatus: vi.fn(() => ({
|
||||
enabled: false,
|
||||
phase: 'idle',
|
||||
transport: 'sse',
|
||||
sessionId: '',
|
||||
subscriptions: [],
|
||||
heartbeatS: 0,
|
||||
batchMs: 0,
|
||||
lastEventType: '',
|
||||
lastEventAt: 0,
|
||||
detail: '',
|
||||
})),
|
||||
retainGateSessionStreamGate: vi.fn(() => vi.fn()),
|
||||
subscribeGateSessionStreamEvents: vi.fn(() => vi.fn()),
|
||||
subscribeGateSessionStreamStatus: vi.fn((listener: (status: unknown) => void) => {
|
||||
listener(mocks.getGateSessionStreamStatus());
|
||||
return vi.fn();
|
||||
}),
|
||||
getGateSessionStreamAccessHeaders: vi.fn(() => undefined),
|
||||
getGateSessionStreamKeyStatus: vi.fn(() => null),
|
||||
invalidateGateSessionStreamGateContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
API_BASE: 'http://test.local',
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/controlPlane', () => ({
|
||||
controlPlaneJson: mocks.controlPlaneJson,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshIdentity', () => ({
|
||||
nextSequence: vi.fn(() => 1),
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/wormholeIdentityClient', () => ({
|
||||
approveGateCompatFallback: mocks.approveGateCompatFallback,
|
||||
decryptWormholeGateMessages: mocks.decryptWormholeGateMessages,
|
||||
fetchWormholeGateKeyStatus: mocks.fetchWormholeGateKeyStatus,
|
||||
hasGateCompatFallbackApproval: mocks.hasGateCompatFallbackApproval,
|
||||
postWormholeGateMessage: mocks.postWormholeGateMessage,
|
||||
prepareWormholeInteractiveLane: mocks.prepareWormholeInteractiveLane,
|
||||
revokeGateCompatFallback: mocks.revokeGateCompatFallback,
|
||||
signMeshEvent: vi.fn(),
|
||||
syncBrowserWormholeGateState: mocks.syncBrowserWormholeGateState,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/gateEnvelope', () => ({
|
||||
gateEnvelopeDisplayText: vi.fn(() => 'sealed'),
|
||||
gateEnvelopeState: vi.fn(() => 'sealed'),
|
||||
isEncryptedGateEnvelope: vi.fn((message: { ciphertext?: string }) => Boolean(message?.ciphertext)),
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshSchema', () => ({
|
||||
validateEventPayload: vi.fn(() => ({ ok: true })),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useGateSSE', () => ({
|
||||
useGateSSE: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/gateSessionStream', () => ({
|
||||
getGateSessionStreamAccessHeaders: mocks.getGateSessionStreamAccessHeaders,
|
||||
getGateSessionStreamKeyStatus: mocks.getGateSessionStreamKeyStatus,
|
||||
getGateSessionStreamStatus: mocks.getGateSessionStreamStatus,
|
||||
invalidateGateSessionStreamGateContext: mocks.invalidateGateSessionStreamGateContext,
|
||||
retainGateSessionStreamGate: mocks.retainGateSessionStreamGate,
|
||||
subscribeGateSessionStreamEvents: mocks.subscribeGateSessionStreamEvents,
|
||||
subscribeGateSessionStreamStatus: mocks.subscribeGateSessionStreamStatus,
|
||||
}));
|
||||
|
||||
describe('GateView compat-decrypt UX', () => {
|
||||
let streamStatusListeners: Array<(status: unknown) => void> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
streamStatusListeners = [];
|
||||
mocks.controlPlaneJson.mockReset();
|
||||
mocks.approveGateCompatFallback.mockReset();
|
||||
mocks.decryptWormholeGateMessages.mockReset();
|
||||
mocks.fetchWormholeGateKeyStatus.mockReset();
|
||||
mocks.hasGateCompatFallbackApproval.mockReset();
|
||||
mocks.hasGateCompatFallbackApproval.mockReturnValue(false);
|
||||
mocks.postWormholeGateMessage.mockReset();
|
||||
mocks.prepareWormholeInteractiveLane.mockReset();
|
||||
mocks.prepareWormholeInteractiveLane.mockResolvedValue({
|
||||
ready: true,
|
||||
settingsEnabled: true,
|
||||
transportTier: 'private_transitional',
|
||||
identity: null,
|
||||
});
|
||||
mocks.revokeGateCompatFallback.mockReset();
|
||||
mocks.syncBrowserWormholeGateState.mockReset();
|
||||
mocks.getGateSessionStreamStatus.mockReset();
|
||||
mocks.retainGateSessionStreamGate.mockReset();
|
||||
mocks.subscribeGateSessionStreamEvents.mockReset();
|
||||
mocks.subscribeGateSessionStreamStatus.mockReset();
|
||||
mocks.getGateSessionStreamAccessHeaders.mockReset();
|
||||
mocks.getGateSessionStreamAccessHeaders.mockReturnValue(undefined);
|
||||
mocks.getGateSessionStreamKeyStatus.mockReset();
|
||||
mocks.getGateSessionStreamKeyStatus.mockReturnValue(null);
|
||||
mocks.invalidateGateSessionStreamGateContext.mockReset();
|
||||
mocks.syncBrowserWormholeGateState.mockResolvedValue(true);
|
||||
mocks.getGateSessionStreamStatus.mockReturnValue({
|
||||
enabled: false,
|
||||
phase: 'idle',
|
||||
transport: 'sse',
|
||||
sessionId: '',
|
||||
subscriptions: [],
|
||||
heartbeatS: 0,
|
||||
batchMs: 0,
|
||||
lastEventType: '',
|
||||
lastEventAt: 0,
|
||||
detail: '',
|
||||
});
|
||||
mocks.retainGateSessionStreamGate.mockReturnValue(vi.fn());
|
||||
mocks.subscribeGateSessionStreamEvents.mockReturnValue(vi.fn());
|
||||
mocks.subscribeGateSessionStreamStatus.mockImplementation((listener: (status: unknown) => void) => {
|
||||
streamStatusListeners.push(listener);
|
||||
listener(mocks.getGateSessionStreamStatus());
|
||||
return vi.fn();
|
||||
});
|
||||
|
||||
mocks.fetchWormholeGateKeyStatus.mockResolvedValue({
|
||||
ok: true,
|
||||
has_local_access: true,
|
||||
identity_scope: 'gate',
|
||||
});
|
||||
mocks.controlPlaneJson.mockResolvedValue({
|
||||
node_id: '!sb_local',
|
||||
proof: 'proof-token',
|
||||
ts: 1712345678,
|
||||
});
|
||||
mocks.decryptWormholeGateMessages.mockResolvedValue({
|
||||
ok: true,
|
||||
results: [
|
||||
{
|
||||
ok: true,
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
plaintext: 'sealed',
|
||||
identity_scope: 'browser_privacy_core',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (input: string | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes('/api/mesh/infonet/messages')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: [
|
||||
{
|
||||
event_id: 'evt-1',
|
||||
timestamp: 1712345678,
|
||||
payload: {
|
||||
gate: 'infonet',
|
||||
ciphertext: 'ciphertext-1',
|
||||
nonce: 'nonce-1',
|
||||
sender_ref: 'sender-ref-1',
|
||||
format: 'mls1',
|
||||
gate_envelope: 'gate-envelope-1',
|
||||
envelope_hash: 'hash-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (url.includes('/api/mesh/reputation/batch')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ reputations: {} }),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected fetch url: ${url}`);
|
||||
}),
|
||||
);
|
||||
|
||||
Object.defineProperty(Element.prototype, 'scrollIntoView', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const emitStreamStatus = (status: {
|
||||
enabled: boolean;
|
||||
phase: 'idle' | 'connecting' | 'open' | 'closed' | 'disabled' | 'error';
|
||||
transport: 'sse';
|
||||
sessionId: string;
|
||||
subscriptions: string[];
|
||||
heartbeatS: number;
|
||||
batchMs: number;
|
||||
lastEventType: string;
|
||||
lastEventAt: number;
|
||||
detail: string;
|
||||
}) => {
|
||||
mocks.getGateSessionStreamStatus.mockReturnValue(status);
|
||||
streamStatusListeners.forEach((listener) => listener(status));
|
||||
};
|
||||
|
||||
it('shows a clear room error when browser-local gate runtime is required', async () => {
|
||||
mocks.decryptWormholeGateMessages.mockRejectedValue(
|
||||
new Error('gate_local_runtime_required:browser_gate_state_resync_required:infonet'),
|
||||
);
|
||||
|
||||
const { default: GateView } = await import('@/components/InfonetTerminal/GateView');
|
||||
|
||||
render(
|
||||
<GateView
|
||||
gateName="infonet"
|
||||
persona="!sb_local"
|
||||
onBack={() => {}}
|
||||
onNavigateGate={() => {}}
|
||||
availableGates={['infonet']}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'Local infonet state needs a resync on this device. Use native desktop or resync local gate state.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'ENABLE FOR ROOM' })).not.toBeInTheDocument();
|
||||
expect(mocks.syncBrowserWormholeGateState).toHaveBeenCalledWith('infonet');
|
||||
expect(mocks.decryptWormholeGateMessages).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
gate_id: 'infonet',
|
||||
ciphertext: 'ciphertext-1',
|
||||
envelope_hash: 'hash-1',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps recovery-only decrypt failures out of the red room-error path', async () => {
|
||||
mocks.decryptWormholeGateMessages.mockResolvedValue({
|
||||
ok: true,
|
||||
results: [
|
||||
{
|
||||
ok: false,
|
||||
detail: 'gate_backend_decrypt_recovery_only',
|
||||
gate_id: 'infonet',
|
||||
compat_requested: true,
|
||||
compat_effective: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { default: GateView } = await import('@/components/InfonetTerminal/GateView');
|
||||
|
||||
render(
|
||||
<GateView
|
||||
gateName="infonet"
|
||||
persona="!sb_local"
|
||||
onBack={() => {}}
|
||||
onNavigateGate={() => {}}
|
||||
availableGates={['infonet']}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(mocks.decryptWormholeGateMessages).toHaveBeenCalled());
|
||||
expect(screen.queryByText('COMPAT MODE')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(
|
||||
'Service-side gate decrypt is disabled on this runtime. Use native desktop or an explicit recovery path.',
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText('sealed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a friendly room message instead of a raw transport-tier gate post failure', async () => {
|
||||
mocks.postWormholeGateMessage.mockRejectedValue(new Error('transport tier insufficient'));
|
||||
|
||||
const { default: GateView } = await import('@/components/InfonetTerminal/GateView');
|
||||
|
||||
render(
|
||||
<GateView
|
||||
gateName="infonet"
|
||||
persona="!sb_local"
|
||||
onBack={() => {}}
|
||||
onNavigateGate={() => {}}
|
||||
availableGates={['infonet']}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('sealed')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(mocks.prepareWormholeInteractiveLane).toHaveBeenCalledWith({
|
||||
minimumTransportTier: 'private_control_only',
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Post into this gate...'), {
|
||||
target: { value: 'hello' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /post/i }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'The obfuscated lane is still warming up in the background. Stay in the room and posting should unlock shortly.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a friendly room message instead of a raw gate-envelope post failure', async () => {
|
||||
mocks.postWormholeGateMessage.mockRejectedValue(new Error('gate_envelope_required'));
|
||||
|
||||
const { default: GateView } = await import('@/components/InfonetTerminal/GateView');
|
||||
|
||||
render(
|
||||
<GateView
|
||||
gateName="infonet"
|
||||
persona="!sb_local"
|
||||
onBack={() => {}}
|
||||
onNavigateGate={() => {}}
|
||||
availableGates={['infonet']}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('sealed')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(mocks.prepareWormholeInteractiveLane).toHaveBeenCalledWith({
|
||||
minimumTransportTier: 'private_control_only',
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Post into this gate...'), {
|
||||
target: { value: 'hello' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /post/i }));
|
||||
|
||||
expect(
|
||||
await screen.findByText('Local gate sealing is warming up. Your draft is still here.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does one initial gate fetch and then switches to wait-for-change reads', async () => {
|
||||
const fetchMock = vi.fn(async (input: string | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes('/api/mesh/infonet/messages/wait?')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
gate: 'infonet',
|
||||
changed: false,
|
||||
cursor: 1,
|
||||
messages: [
|
||||
{
|
||||
event_id: 'evt-1',
|
||||
timestamp: 1712345678,
|
||||
payload: {
|
||||
gate: 'infonet',
|
||||
ciphertext: 'ciphertext-1',
|
||||
nonce: 'nonce-1',
|
||||
sender_ref: 'sender-ref-1',
|
||||
format: 'mls1',
|
||||
gate_envelope: 'gate-envelope-1',
|
||||
envelope_hash: 'hash-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (url.includes('/api/mesh/infonet/messages?gate=')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
cursor: 1,
|
||||
messages: [
|
||||
{
|
||||
event_id: 'evt-1',
|
||||
timestamp: 1712345678,
|
||||
payload: {
|
||||
gate: 'infonet',
|
||||
ciphertext: 'ciphertext-1',
|
||||
nonce: 'nonce-1',
|
||||
sender_ref: 'sender-ref-1',
|
||||
format: 'mls1',
|
||||
gate_envelope: 'gate-envelope-1',
|
||||
envelope_hash: 'hash-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (url.includes('/api/mesh/reputation/batch')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ reputations: {} }),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected fetch url: ${url}`);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const gateSnapshotModule = await import('@/mesh/gateMessageSnapshot');
|
||||
const fetchSnapshotSpy = vi.spyOn(gateSnapshotModule, 'fetchGateMessageSnapshotState');
|
||||
const waitSnapshotSpy = vi.spyOn(gateSnapshotModule, 'waitForGateMessageSnapshot');
|
||||
gateSnapshotModule.invalidateGateMessageSnapshot('infonet');
|
||||
const { default: GateView } = await import('@/components/InfonetTerminal/GateView');
|
||||
|
||||
render(
|
||||
<GateView
|
||||
gateName="infonet"
|
||||
persona="!sb_local"
|
||||
onBack={() => {}}
|
||||
onNavigateGate={() => {}}
|
||||
availableGates={['infonet']}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('sealed')).toBeInTheDocument();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
fetchMock.mock.calls.some(([input]) =>
|
||||
String(input).includes('/api/mesh/infonet/messages/wait?gate=infonet&after=1'),
|
||||
),
|
||||
).toBe(true),
|
||||
);
|
||||
expect(fetchSnapshotSpy).toHaveBeenCalledWith('infonet', 40, expect.any(Object));
|
||||
expect(waitSnapshotSpy).toHaveBeenCalledWith(
|
||||
'infonet',
|
||||
1,
|
||||
40,
|
||||
expect.objectContaining({ timeoutMs: expect.any(Number), signal: expect.any(Object) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses stream-driven room updates as the steady-state path when the gate session stream is open', async () => {
|
||||
const streamEventListeners: Array<(event: { event: string; data: unknown }) => void> = [];
|
||||
mocks.getGateSessionStreamAccessHeaders.mockReturnValue({
|
||||
'X-Wormhole-Node-Id': '!sb_stream',
|
||||
'X-Wormhole-Gate-Proof': 'proof-stream',
|
||||
'X-Wormhole-Gate-Ts': '1712360000',
|
||||
});
|
||||
emitStreamStatus({
|
||||
enabled: true,
|
||||
phase: 'open',
|
||||
transport: 'sse',
|
||||
sessionId: 'sess-1',
|
||||
subscriptions: ['infonet'],
|
||||
heartbeatS: 20,
|
||||
batchMs: 1500,
|
||||
lastEventType: 'hello',
|
||||
lastEventAt: 1712360000,
|
||||
detail: '',
|
||||
});
|
||||
mocks.subscribeGateSessionStreamStatus.mockImplementation((listener: (status: unknown) => void) => {
|
||||
streamStatusListeners.push(listener);
|
||||
listener(mocks.getGateSessionStreamStatus());
|
||||
return vi.fn();
|
||||
});
|
||||
mocks.subscribeGateSessionStreamEvents.mockImplementation((listener: (event: { event: string; data: unknown }) => void) => {
|
||||
streamEventListeners.push(listener);
|
||||
return vi.fn();
|
||||
});
|
||||
|
||||
const fetchMock = vi.fn(async (input: string | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes('/api/mesh/infonet/messages?gate=')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
cursor: url.includes('force') ? 2 : 1,
|
||||
messages: [
|
||||
{
|
||||
event_id: url.includes('force') ? 'evt-2' : 'evt-1',
|
||||
timestamp: 1712345678,
|
||||
payload: {
|
||||
gate: 'infonet',
|
||||
ciphertext: 'ciphertext-1',
|
||||
nonce: 'nonce-1',
|
||||
sender_ref: 'sender-ref-1',
|
||||
format: 'mls1',
|
||||
gate_envelope: 'gate-envelope-1',
|
||||
envelope_hash: 'hash-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (url.includes('/api/mesh/reputation/batch')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ reputations: {} }),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected fetch url: ${url}`);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const gateSnapshotModule = await import('@/mesh/gateMessageSnapshot');
|
||||
const fetchSnapshotSpy = vi.spyOn(gateSnapshotModule, 'fetchGateMessageSnapshotState');
|
||||
const waitSnapshotSpy = vi.spyOn(gateSnapshotModule, 'waitForGateMessageSnapshot');
|
||||
gateSnapshotModule.invalidateGateMessageSnapshot('infonet');
|
||||
|
||||
const { default: GateView } = await import('@/components/InfonetTerminal/GateView');
|
||||
|
||||
render(
|
||||
<GateView
|
||||
gateName="infonet"
|
||||
persona="!sb_local"
|
||||
onBack={() => {}}
|
||||
onNavigateGate={() => {}}
|
||||
availableGates={['infonet']}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('sealed')).toBeInTheDocument();
|
||||
expect(mocks.subscribeGateSessionStreamEvents).toHaveBeenCalled();
|
||||
expect(mocks.fetchWormholeGateKeyStatus).toHaveBeenCalledWith(
|
||||
'infonet',
|
||||
expect.objectContaining({ mode: 'session_stream' }),
|
||||
);
|
||||
expect(mocks.controlPlaneJson).not.toHaveBeenCalled();
|
||||
waitSnapshotSpy.mockClear();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(waitSnapshotSpy).not.toHaveBeenCalled();
|
||||
|
||||
streamEventListeners.forEach((listener) =>
|
||||
listener({
|
||||
event: 'gate_update',
|
||||
data: {
|
||||
session_id: 'sess-1',
|
||||
updates: [{ gate_id: 'infonet', cursor: 2 }],
|
||||
ts: 1712360001,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(fetchSnapshotSpy).toHaveBeenCalledWith(
|
||||
'infonet',
|
||||
40,
|
||||
expect.objectContaining({ force: true, proofMode: 'session_stream' }),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
fetchMock.mock.calls.some(([input]) =>
|
||||
String(input).includes('/api/mesh/infonet/messages/wait?'),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(mocks.controlPlaneJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to wait-for-change on stream loss and hands control back after reconnect', async () => {
|
||||
const streamEventListeners: Array<(event: { event: string; data: unknown }) => void> = [];
|
||||
emitStreamStatus({
|
||||
enabled: true,
|
||||
phase: 'open',
|
||||
transport: 'sse',
|
||||
sessionId: 'sess-2',
|
||||
subscriptions: ['infonet'],
|
||||
heartbeatS: 20,
|
||||
batchMs: 1500,
|
||||
lastEventType: 'hello',
|
||||
lastEventAt: 1712360100,
|
||||
detail: '',
|
||||
});
|
||||
mocks.subscribeGateSessionStreamEvents.mockImplementation((listener: (event: { event: string; data: unknown }) => void) => {
|
||||
streamEventListeners.push(listener);
|
||||
return vi.fn();
|
||||
});
|
||||
|
||||
const fetchMock = vi.fn(async (input: string | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes('/api/mesh/infonet/messages/wait?')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
gate: 'infonet',
|
||||
changed: false,
|
||||
cursor: 1,
|
||||
messages: [
|
||||
{
|
||||
event_id: 'evt-1',
|
||||
timestamp: 1712345678,
|
||||
payload: {
|
||||
gate: 'infonet',
|
||||
ciphertext: 'ciphertext-1',
|
||||
nonce: 'nonce-1',
|
||||
sender_ref: 'sender-ref-1',
|
||||
format: 'mls1',
|
||||
gate_envelope: 'gate-envelope-1',
|
||||
envelope_hash: 'hash-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (url.includes('/api/mesh/infonet/messages?gate=')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
cursor: 1,
|
||||
messages: [
|
||||
{
|
||||
event_id: 'evt-1',
|
||||
timestamp: 1712345678,
|
||||
payload: {
|
||||
gate: 'infonet',
|
||||
ciphertext: 'ciphertext-1',
|
||||
nonce: 'nonce-1',
|
||||
sender_ref: 'sender-ref-1',
|
||||
format: 'mls1',
|
||||
gate_envelope: 'gate-envelope-1',
|
||||
envelope_hash: 'hash-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (url.includes('/api/mesh/reputation/batch')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ reputations: {} }),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected fetch url: ${url}`);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const gateSnapshotModule = await import('@/mesh/gateMessageSnapshot');
|
||||
const fetchSnapshotSpy = vi.spyOn(gateSnapshotModule, 'fetchGateMessageSnapshotState');
|
||||
const waitSnapshotSpy = vi.spyOn(gateSnapshotModule, 'waitForGateMessageSnapshot');
|
||||
gateSnapshotModule.invalidateGateMessageSnapshot('infonet');
|
||||
|
||||
const { default: GateView } = await import('@/components/InfonetTerminal/GateView');
|
||||
|
||||
render(
|
||||
<GateView
|
||||
gateName="infonet"
|
||||
persona="!sb_local"
|
||||
onBack={() => {}}
|
||||
onNavigateGate={() => {}}
|
||||
availableGates={['infonet']}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('sealed')).toBeInTheDocument();
|
||||
waitSnapshotSpy.mockClear();
|
||||
|
||||
emitStreamStatus({
|
||||
enabled: false,
|
||||
phase: 'closed',
|
||||
transport: 'sse',
|
||||
sessionId: 'sess-2',
|
||||
subscriptions: ['infonet'],
|
||||
heartbeatS: 20,
|
||||
batchMs: 1500,
|
||||
lastEventType: 'heartbeat',
|
||||
lastEventAt: 1712360200,
|
||||
detail: 'gate_session_stream_closed',
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(waitSnapshotSpy).toHaveBeenCalledWith(
|
||||
'infonet',
|
||||
1,
|
||||
40,
|
||||
expect.objectContaining({ timeoutMs: expect.any(Number), signal: expect.any(Object) }),
|
||||
),
|
||||
);
|
||||
|
||||
waitSnapshotSpy.mockClear();
|
||||
fetchSnapshotSpy.mockClear();
|
||||
|
||||
emitStreamStatus({
|
||||
enabled: true,
|
||||
phase: 'open',
|
||||
transport: 'sse',
|
||||
sessionId: 'sess-3',
|
||||
subscriptions: ['infonet'],
|
||||
heartbeatS: 20,
|
||||
batchMs: 1500,
|
||||
lastEventType: 'hello',
|
||||
lastEventAt: 1712360300,
|
||||
detail: '',
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(waitSnapshotSpy).not.toHaveBeenCalled();
|
||||
|
||||
streamEventListeners.forEach((listener) =>
|
||||
listener({
|
||||
event: 'gate_update',
|
||||
data: {
|
||||
session_id: 'sess-3',
|
||||
updates: [{ gate_id: 'infonet', cursor: 2 }],
|
||||
ts: 1712360301,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(fetchSnapshotSpy).toHaveBeenCalledWith(
|
||||
'infonet',
|
||||
40,
|
||||
expect.objectContaining({ force: true }),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const getNodeIdentity = vi.fn(() => null);
|
||||
const getWormholeIdentityDescriptor = vi.fn(() => ({ nodeId: '!sb_scope_a' }));
|
||||
|
||||
vi.mock('@/mesh/meshIdentity', () => ({
|
||||
getNodeIdentity,
|
||||
getWormholeIdentityDescriptor,
|
||||
}));
|
||||
|
||||
describe('gateCompatTelemetry', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
window.localStorage.clear();
|
||||
window.sessionStorage.clear();
|
||||
getNodeIdentity.mockReset();
|
||||
getNodeIdentity.mockReturnValue(null);
|
||||
getWormholeIdentityDescriptor.mockReset();
|
||||
getWormholeIdentityDescriptor.mockReturnValue({ nodeId: '!sb_scope_a' });
|
||||
});
|
||||
|
||||
it('records required and used compat events with reason summaries', async () => {
|
||||
const mod = await import('@/mesh/gateCompatTelemetry');
|
||||
|
||||
mod.recordGateCompatTelemetry({
|
||||
gateId: 'infonet',
|
||||
action: 'decrypt',
|
||||
reason: 'browser_gate_state_resync_required:infonet',
|
||||
kind: 'required',
|
||||
at: 1712500000000,
|
||||
});
|
||||
mod.recordGateCompatTelemetry({
|
||||
gateId: 'infonet',
|
||||
action: 'decrypt',
|
||||
reason: 'browser_gate_state_resync_required:infonet',
|
||||
kind: 'used',
|
||||
at: 1712500005000,
|
||||
});
|
||||
|
||||
const snapshot = mod.getGateCompatTelemetrySnapshot();
|
||||
|
||||
expect(snapshot.totalRequired).toBe(1);
|
||||
expect(snapshot.totalUsed).toBe(1);
|
||||
expect(snapshot.reasons[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
reason: 'browser_gate_state_resync_required:infonet',
|
||||
requiredCount: 1,
|
||||
usedCount: 1,
|
||||
recentGates: ['infonet'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps telemetry scoped to the current browser profile across reloads', async () => {
|
||||
const mod = await import('@/mesh/gateCompatTelemetry');
|
||||
|
||||
mod.recordGateCompatTelemetry({
|
||||
gateId: 'infonet',
|
||||
action: 'compose',
|
||||
reason: 'browser_gate_worker_unavailable',
|
||||
kind: 'required',
|
||||
at: 1712501000000,
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
getWormholeIdentityDescriptor.mockReturnValue({ nodeId: '!sb_scope_a' });
|
||||
|
||||
const reloaded = await import('@/mesh/gateCompatTelemetry');
|
||||
expect(reloaded.getGateCompatTelemetrySnapshot().totalRequired).toBe(1);
|
||||
|
||||
vi.resetModules();
|
||||
getWormholeIdentityDescriptor.mockReturnValue({ nodeId: '!sb_scope_b' });
|
||||
|
||||
const otherScope = await import('@/mesh/gateCompatTelemetry');
|
||||
expect(otherScope.getGateCompatTelemetrySnapshot().totalRequired).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -62,7 +62,13 @@ describe('gate envelope display', () => {
|
||||
|
||||
expect(isEncryptedGateEnvelope(encrypted)).toBe(true);
|
||||
expect(gateEnvelopeState(encrypted)).toBe('locked');
|
||||
expect(gateEnvelopeDisplayText(encrypted)).toBe('ENCRYPTED GATE MESSAGE - KEY UNAVAILABLE');
|
||||
expect(gateEnvelopeDisplayText(encrypted)).toBe('Sealed message - durable gate envelope was not stored.');
|
||||
expect(
|
||||
gateEnvelopeDisplayText({
|
||||
...encrypted,
|
||||
gate_envelope: 'opaque-envelope',
|
||||
}),
|
||||
).toBe('Sealed message - waiting for local gate decrypt.');
|
||||
expect(
|
||||
gateEnvelopeState({
|
||||
...encrypted,
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* P5A: End-to-end gate envelope hash binding on the live decrypt path.
|
||||
*
|
||||
* Tests prove:
|
||||
* - normalizeInfoNetMessage preserves envelope_hash from payload
|
||||
* - normalizeInfoNetMessage preserves top-level envelope_hash
|
||||
* - legacy messages without envelope_hash are not broken
|
||||
* - WormholeGateDecryptPayload shape includes envelope_hash
|
||||
* - decryptWormholeGateMessage single-message helper accepts integrity fields
|
||||
* - MeshTerminal normalizer pattern preserves gate_envelope and envelope_hash
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { InfoNetMessage } from '@/components/MeshChat/types';
|
||||
import { normalizeInfoNetMessage } from '@/components/MeshChat/utils';
|
||||
import {
|
||||
decryptWormholeGateMessage,
|
||||
type WormholeGateDecryptPayload,
|
||||
} from '@/mesh/wormholeIdentityClient';
|
||||
|
||||
describe('normalizeInfoNetMessage preserves envelope_hash', () => {
|
||||
it('extracts envelope_hash from nested payload', () => {
|
||||
const raw: InfoNetMessage = {
|
||||
event_id: 'e1',
|
||||
timestamp: 1000,
|
||||
payload: {
|
||||
gate: 'finance',
|
||||
ciphertext: 'ct',
|
||||
nonce: 'n1',
|
||||
sender_ref: 'sr1',
|
||||
format: 'mls1',
|
||||
envelope_hash: 'abc123hash',
|
||||
},
|
||||
};
|
||||
const normalized = normalizeInfoNetMessage(raw);
|
||||
expect(normalized.envelope_hash).toBe('abc123hash');
|
||||
});
|
||||
|
||||
it('preserves top-level envelope_hash over payload', () => {
|
||||
const raw: InfoNetMessage = {
|
||||
event_id: 'e2',
|
||||
timestamp: 2000,
|
||||
envelope_hash: 'top-level-hash',
|
||||
payload: {
|
||||
gate: 'finance',
|
||||
ciphertext: 'ct',
|
||||
nonce: 'n2',
|
||||
sender_ref: 'sr2',
|
||||
format: 'mls1',
|
||||
envelope_hash: 'payload-hash',
|
||||
},
|
||||
};
|
||||
const normalized = normalizeInfoNetMessage(raw);
|
||||
expect(normalized.envelope_hash).toBe('top-level-hash');
|
||||
});
|
||||
|
||||
it('returns empty string when no envelope_hash present', () => {
|
||||
const raw: InfoNetMessage = {
|
||||
event_id: 'e3',
|
||||
timestamp: 3000,
|
||||
payload: {
|
||||
gate: 'finance',
|
||||
ciphertext: 'ct',
|
||||
nonce: 'n3',
|
||||
sender_ref: 'sr3',
|
||||
format: 'mls1',
|
||||
},
|
||||
};
|
||||
const normalized = normalizeInfoNetMessage(raw);
|
||||
expect(normalized.envelope_hash).toBe('');
|
||||
});
|
||||
|
||||
it('does not break messages without payload', () => {
|
||||
const raw: InfoNetMessage = {
|
||||
event_id: 'e4',
|
||||
timestamp: 4000,
|
||||
ciphertext: 'ct',
|
||||
gate_envelope: 'env',
|
||||
envelope_hash: 'hash4',
|
||||
};
|
||||
const normalized = normalizeInfoNetMessage(raw);
|
||||
// No payload → returns message as-is, envelope_hash untouched
|
||||
expect(normalized.envelope_hash).toBe('hash4');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WormholeGateDecryptPayload supports envelope_hash', () => {
|
||||
it('accepts envelope_hash in the payload type', () => {
|
||||
const payload: WormholeGateDecryptPayload = {
|
||||
gate_id: 'gate1',
|
||||
ciphertext: 'ct',
|
||||
nonce: 'n1',
|
||||
sender_ref: 'sr1',
|
||||
format: 'mls1',
|
||||
gate_envelope: 'env',
|
||||
envelope_hash: 'abc123hash',
|
||||
};
|
||||
expect(payload.envelope_hash).toBe('abc123hash');
|
||||
});
|
||||
|
||||
it('allows omitting envelope_hash for legacy compatibility', () => {
|
||||
const payload: WormholeGateDecryptPayload = {
|
||||
gate_id: 'gate1',
|
||||
ciphertext: 'ct',
|
||||
};
|
||||
expect(payload.envelope_hash).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('decrypt caller payload construction includes envelope_hash', () => {
|
||||
it('builds decrypt payload with envelope_hash when present on message', () => {
|
||||
// Simulates the payload construction pattern used in GateView and useMeshChatController
|
||||
const message = {
|
||||
gate: 'finance',
|
||||
epoch: 2,
|
||||
ciphertext: 'ct',
|
||||
nonce: 'n1',
|
||||
sender_ref: 'sr1',
|
||||
format: 'mls1',
|
||||
gate_envelope: 'env-data',
|
||||
envelope_hash: 'sha256-hex-hash',
|
||||
};
|
||||
|
||||
const decryptPayload: WormholeGateDecryptPayload = {
|
||||
gate_id: String(message.gate || ''),
|
||||
epoch: Number(message.epoch || 0),
|
||||
ciphertext: String(message.ciphertext || ''),
|
||||
nonce: String(message.nonce || ''),
|
||||
sender_ref: String(message.sender_ref || ''),
|
||||
format: String(message.format || 'mls1'),
|
||||
gate_envelope: String(message.gate_envelope || ''),
|
||||
envelope_hash: String(message.envelope_hash || ''),
|
||||
};
|
||||
|
||||
expect(decryptPayload.envelope_hash).toBe('sha256-hex-hash');
|
||||
expect(decryptPayload.gate_envelope).toBe('env-data');
|
||||
});
|
||||
|
||||
it('builds decrypt payload with empty envelope_hash for legacy messages', () => {
|
||||
const message = {
|
||||
gate: 'finance',
|
||||
ciphertext: 'ct',
|
||||
nonce: 'n1',
|
||||
sender_ref: 'sr1',
|
||||
format: 'mls1',
|
||||
gate_envelope: 'env-data',
|
||||
};
|
||||
|
||||
const decryptPayload: WormholeGateDecryptPayload = {
|
||||
gate_id: String(message.gate || ''),
|
||||
epoch: 0,
|
||||
ciphertext: String(message.ciphertext || ''),
|
||||
nonce: String(message.nonce || ''),
|
||||
sender_ref: String(message.sender_ref || ''),
|
||||
format: String(message.format || 'mls1'),
|
||||
gate_envelope: String(message.gate_envelope || ''),
|
||||
envelope_hash: String((message as Record<string, unknown>).envelope_hash || ''),
|
||||
};
|
||||
|
||||
expect(decryptPayload.envelope_hash).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('single-message decryptWormholeGateMessage accepts integrity fields', () => {
|
||||
it('function signature accepts gate_envelope and envelope_hash', async () => {
|
||||
// Verify the function exists and accepts the extended signature.
|
||||
// We cannot call it without a running backend, but we can verify
|
||||
// the function shape by checking it is callable with 7 args.
|
||||
expect(typeof decryptWormholeGateMessage).toBe('function');
|
||||
expect(decryptWormholeGateMessage.length).toBeLessThanOrEqual(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MeshTerminal normalizeInfonetMessageRecord equivalent pattern', () => {
|
||||
it('preserves gate_envelope and envelope_hash from nested payload', () => {
|
||||
// Simulate the normalizeInfonetMessageRecord pattern from MeshTerminal
|
||||
const message: Record<string, unknown> = {
|
||||
event_id: 'e1',
|
||||
timestamp: 1000,
|
||||
payload: {
|
||||
gate: 'finance',
|
||||
ciphertext: 'ct',
|
||||
nonce: 'n1',
|
||||
sender_ref: 'sr1',
|
||||
format: 'mls1',
|
||||
gate_envelope: 'env-payload',
|
||||
envelope_hash: 'hash-payload',
|
||||
},
|
||||
};
|
||||
const payload = message.payload as Record<string, string> | undefined;
|
||||
const normalized = {
|
||||
...message,
|
||||
gate: String(message.gate ?? payload?.gate ?? ''),
|
||||
ciphertext: String(message.ciphertext ?? payload?.ciphertext ?? ''),
|
||||
nonce: String(message.nonce ?? payload?.nonce ?? ''),
|
||||
sender_ref: String(message.sender_ref ?? payload?.sender_ref ?? ''),
|
||||
format: String(message.format ?? payload?.format ?? ''),
|
||||
gate_envelope: String(message.gate_envelope ?? payload?.gate_envelope ?? ''),
|
||||
envelope_hash: String(message.envelope_hash ?? payload?.envelope_hash ?? ''),
|
||||
};
|
||||
expect(normalized.gate_envelope).toBe('env-payload');
|
||||
expect(normalized.envelope_hash).toBe('hash-payload');
|
||||
});
|
||||
|
||||
it('single decrypt call site passes integrity fields through', () => {
|
||||
const normalized = {
|
||||
gate: 'finance',
|
||||
epoch: 2,
|
||||
ciphertext: 'ct',
|
||||
nonce: 'n1',
|
||||
sender_ref: 'sr1',
|
||||
gate_envelope: 'env-data',
|
||||
envelope_hash: 'sha256-hex',
|
||||
};
|
||||
// Matches the call pattern in describeGateMessage
|
||||
const args = [
|
||||
String(normalized.gate || ''),
|
||||
Number(normalized.epoch || 0),
|
||||
String(normalized.ciphertext || ''),
|
||||
String(normalized.nonce || ''),
|
||||
String(normalized.sender_ref || ''),
|
||||
String(normalized.gate_envelope || ''),
|
||||
String(normalized.envelope_hash || ''),
|
||||
];
|
||||
expect(args[5]).toBe('env-data');
|
||||
expect(args[6]).toBe('sha256-hex');
|
||||
});
|
||||
|
||||
it('legacy message without integrity fields produces empty strings', () => {
|
||||
const normalized = {
|
||||
gate: 'finance',
|
||||
epoch: 1,
|
||||
ciphertext: 'ct',
|
||||
nonce: 'n1',
|
||||
sender_ref: 'sr1',
|
||||
};
|
||||
const args = [
|
||||
String(normalized.gate || ''),
|
||||
Number(normalized.epoch || 0),
|
||||
String(normalized.ciphertext || ''),
|
||||
String(normalized.nonce || ''),
|
||||
String(normalized.sender_ref || ''),
|
||||
String((normalized as any).gate_envelope || ''),
|
||||
String((normalized as any).envelope_hash || ''),
|
||||
];
|
||||
expect(args[5]).toBe('');
|
||||
expect(args[6]).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const hasLocalControlBridge = vi.fn(() => false);
|
||||
const buildGateAccessHeaders = vi.fn();
|
||||
|
||||
vi.mock('@/lib/localControlTransport', () => ({
|
||||
hasLocalControlBridge,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/gateAccessProof', () => ({
|
||||
buildGateAccessHeaders,
|
||||
}));
|
||||
|
||||
describe('gateMessageSnapshot cache', () => {
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
fetchMock.mockReset();
|
||||
buildGateAccessHeaders.mockReset();
|
||||
hasLocalControlBridge.mockReset();
|
||||
hasLocalControlBridge.mockReturnValue(false);
|
||||
buildGateAccessHeaders.mockResolvedValue({
|
||||
'X-Wormhole-Node-Id': '!sb_gate',
|
||||
'X-Wormhole-Gate-Proof': 'proof',
|
||||
'X-Wormhole-Gate-Ts': '1712345678',
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
it('coarsens browser/web gate message reads through a short shared cache window', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-05T23:45:00.000Z'));
|
||||
try {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: [{ event_id: 'evt-1', gate: 'infonet', timestamp: 1712360000 }],
|
||||
}),
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gateMessageSnapshot');
|
||||
|
||||
await expect(mod.fetchGateMessageSnapshot('infonet', 20)).resolves.toEqual([
|
||||
expect.objectContaining({ event_id: 'evt-1', gate: 'infonet' }),
|
||||
]);
|
||||
await mod.fetchGateMessageSnapshot('infonet', 20);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(10_001);
|
||||
|
||||
await mod.fetchGateMessageSnapshot('infonet', 20);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('reuses a larger cached limit for smaller reads without another fetch', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: Array.from({ length: 8 }, (_, index) => ({
|
||||
event_id: `evt-${index + 1}`,
|
||||
gate: 'finance',
|
||||
timestamp: 1712360000 + index,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gateMessageSnapshot');
|
||||
|
||||
await expect(mod.fetchGateMessageSnapshot('finance', 8)).resolves.toHaveLength(8);
|
||||
await expect(mod.fetchGateMessageSnapshot('finance', 4)).resolves.toHaveLength(4);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uses session-stream proof reuse for stream-owned snapshot refreshes', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: [{ event_id: 'evt-1', gate: 'finance', timestamp: 1712360000 }],
|
||||
cursor: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gateMessageSnapshot');
|
||||
|
||||
await expect(
|
||||
mod.fetchGateMessageSnapshotState('finance', 20, { proofMode: 'session_stream' }),
|
||||
).resolves.toEqual({
|
||||
messages: [expect.objectContaining({ event_id: 'evt-1', gate: 'finance' })],
|
||||
cursor: 1,
|
||||
});
|
||||
|
||||
expect(buildGateAccessHeaders).toHaveBeenCalledWith('finance', { mode: 'session_stream' });
|
||||
});
|
||||
|
||||
it('reuses a larger in-flight snapshot fetch for a smaller concurrent read', async () => {
|
||||
let releaseFetch:
|
||||
| ((value: {
|
||||
ok: true;
|
||||
json: () => Promise<{
|
||||
messages: Array<{ event_id: string; gate: string; timestamp: number }>;
|
||||
cursor: number;
|
||||
}>;
|
||||
}) => void)
|
||||
| null = null;
|
||||
fetchMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
releaseFetch = resolve as typeof releaseFetch;
|
||||
}),
|
||||
);
|
||||
|
||||
const mod = await import('@/mesh/gateMessageSnapshot');
|
||||
|
||||
const larger = mod.fetchGateMessageSnapshotState('infonet', 40);
|
||||
const smaller = mod.fetchGateMessageSnapshotState('infonet', 20);
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(String(fetchMock.mock.calls[0]?.[0] || '')).toContain('/api/mesh/infonet/messages?gate=infonet&limit=40');
|
||||
|
||||
releaseFetch?.({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: Array.from({ length: 3 }, (_, index) => ({
|
||||
event_id: `evt-${index + 1}`,
|
||||
gate: 'infonet',
|
||||
timestamp: 1712360000 + index,
|
||||
})),
|
||||
cursor: 3,
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(larger).resolves.toEqual({
|
||||
messages: [
|
||||
expect.objectContaining({ event_id: 'evt-1', gate: 'infonet' }),
|
||||
expect.objectContaining({ event_id: 'evt-2', gate: 'infonet' }),
|
||||
expect.objectContaining({ event_id: 'evt-3', gate: 'infonet' }),
|
||||
],
|
||||
cursor: 3,
|
||||
});
|
||||
await expect(smaller).resolves.toEqual({
|
||||
messages: [
|
||||
expect.objectContaining({ event_id: 'evt-1', gate: 'infonet' }),
|
||||
expect.objectContaining({ event_id: 'evt-2', gate: 'infonet' }),
|
||||
expect.objectContaining({ event_id: 'evt-3', gate: 'infonet' }),
|
||||
],
|
||||
cursor: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses a shorter native cache window and supports explicit invalidation', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-05T23:45:00.000Z'));
|
||||
try {
|
||||
hasLocalControlBridge.mockReturnValue(true);
|
||||
fetchMock
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: [{ event_id: 'evt-1', gate: 'ops', timestamp: 1712360000 }],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: [{ event_id: 'evt-2', gate: 'ops', timestamp: 1712360010 }],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: [{ event_id: 'evt-3', gate: 'ops', timestamp: 1712360020 }],
|
||||
}),
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gateMessageSnapshot');
|
||||
|
||||
await expect(mod.fetchGateMessageSnapshot('ops', 6)).resolves.toEqual([
|
||||
expect.objectContaining({ event_id: 'evt-1' }),
|
||||
]);
|
||||
vi.advanceTimersByTime(3_001);
|
||||
await expect(mod.fetchGateMessageSnapshot('ops', 6)).resolves.toEqual([
|
||||
expect.objectContaining({ event_id: 'evt-2' }),
|
||||
]);
|
||||
|
||||
mod.invalidateGateMessageSnapshot('ops');
|
||||
await expect(mod.fetchGateMessageSnapshot('ops', 6)).resolves.toEqual([
|
||||
expect.objectContaining({ event_id: 'evt-3' }),
|
||||
]);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('tracks cursors and waits for gate changes without re-reading the ordinary route', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: [{ event_id: 'evt-1', gate: 'infonet', timestamp: 1712360000 }],
|
||||
cursor: 1,
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: [
|
||||
{ event_id: 'evt-2', gate: 'infonet', timestamp: 1712360010 },
|
||||
{ event_id: 'evt-1', gate: 'infonet', timestamp: 1712360000 },
|
||||
],
|
||||
cursor: 2,
|
||||
changed: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gateMessageSnapshot');
|
||||
|
||||
await expect(mod.fetchGateMessageSnapshotState('infonet', 20)).resolves.toEqual({
|
||||
messages: [expect.objectContaining({ event_id: 'evt-1', gate: 'infonet' })],
|
||||
cursor: 1,
|
||||
});
|
||||
await expect(mod.waitForGateMessageSnapshot('infonet', 1, 20, { timeoutMs: 18_000 })).resolves.toEqual({
|
||||
messages: [
|
||||
expect.objectContaining({ event_id: 'evt-2', gate: 'infonet' }),
|
||||
expect.objectContaining({ event_id: 'evt-1', gate: 'infonet' }),
|
||||
],
|
||||
cursor: 2,
|
||||
changed: true,
|
||||
});
|
||||
expect(mod.getGateMessageSnapshotCursor('infonet')).toBe(2);
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toContain('/api/mesh/infonet/messages/wait?gate=infonet&after=1');
|
||||
});
|
||||
|
||||
it('coalesces concurrent gate wait requests for the same gate cursor', async () => {
|
||||
let releaseWait:
|
||||
| ((value: { ok: true; json: () => Promise<{ messages: Array<{ event_id: string; gate: string; timestamp: number }>; cursor: number; changed: boolean }> }) => void)
|
||||
| null = null;
|
||||
fetchMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
releaseWait = resolve as typeof releaseWait;
|
||||
}),
|
||||
);
|
||||
|
||||
const mod = await import('@/mesh/gateMessageSnapshot');
|
||||
|
||||
const first = mod.waitForGateMessageSnapshot('infonet', 4, 20, { timeoutMs: 18_000 });
|
||||
const second = mod.waitForGateMessageSnapshot('infonet', 4, 20, { timeoutMs: 24_000 });
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(String(fetchMock.mock.calls[0]?.[0] || '')).toContain('/api/mesh/infonet/messages/wait?gate=infonet&after=4');
|
||||
|
||||
releaseWait?.({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: [{ event_id: 'evt-5', gate: 'infonet', timestamp: 1712360050 }],
|
||||
cursor: 5,
|
||||
changed: true,
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(first).resolves.toEqual({
|
||||
messages: [expect.objectContaining({ event_id: 'evt-5', gate: 'infonet' })],
|
||||
cursor: 5,
|
||||
changed: true,
|
||||
});
|
||||
await expect(second).resolves.toEqual({
|
||||
messages: [expect.objectContaining({ event_id: 'evt-5', gate: 'infonet' })],
|
||||
cursor: 5,
|
||||
changed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('reuses a larger in-flight gate wait for a smaller concurrent consumer', async () => {
|
||||
let releaseWait:
|
||||
| ((value: {
|
||||
ok: true;
|
||||
json: () => Promise<{
|
||||
messages: Array<{ event_id: string; gate: string; timestamp: number }>;
|
||||
cursor: number;
|
||||
changed: boolean;
|
||||
}>;
|
||||
}) => void)
|
||||
| null = null;
|
||||
fetchMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
releaseWait = resolve as typeof releaseWait;
|
||||
}),
|
||||
);
|
||||
|
||||
const mod = await import('@/mesh/gateMessageSnapshot');
|
||||
|
||||
const larger = mod.waitForGateMessageSnapshot('infonet', 4, 40, { timeoutMs: 18_000 });
|
||||
const smaller = mod.waitForGateMessageSnapshot('infonet', 4, 20, { timeoutMs: 24_000 });
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(String(fetchMock.mock.calls[0]?.[0] || '')).toContain('/api/mesh/infonet/messages/wait?gate=infonet&after=4&limit=40');
|
||||
|
||||
releaseWait?.({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: [
|
||||
{ event_id: 'evt-8', gate: 'infonet', timestamp: 1712360080 },
|
||||
{ event_id: 'evt-7', gate: 'infonet', timestamp: 1712360070 },
|
||||
],
|
||||
cursor: 8,
|
||||
changed: true,
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(larger).resolves.toEqual({
|
||||
messages: [
|
||||
expect.objectContaining({ event_id: 'evt-8', gate: 'infonet' }),
|
||||
expect.objectContaining({ event_id: 'evt-7', gate: 'infonet' }),
|
||||
],
|
||||
cursor: 8,
|
||||
changed: true,
|
||||
});
|
||||
await expect(smaller).resolves.toEqual({
|
||||
messages: [
|
||||
expect.objectContaining({ event_id: 'evt-8', gate: 'infonet' }),
|
||||
expect.objectContaining({ event_id: 'evt-7', gate: 'infonet' }),
|
||||
],
|
||||
cursor: 8,
|
||||
changed: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const hasLocalControlBridge = vi.fn(() => false);
|
||||
|
||||
vi.mock('@/lib/localControlTransport', () => ({
|
||||
hasLocalControlBridge,
|
||||
}));
|
||||
|
||||
describe('gate metadata timing policy', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
hasLocalControlBridge.mockReset();
|
||||
});
|
||||
|
||||
it('jittered browser/web polling avoids an exact cadence', async () => {
|
||||
hasLocalControlBridge.mockReturnValue(false);
|
||||
const mod = await import('@/mesh/gateMetadataTiming');
|
||||
const pollDelays = Array.from({ length: 12 }, () => mod.nextGateMessagesPollDelayMs());
|
||||
expect(pollDelays.every((delay) => delay >= 24_000 && delay <= 36_000)).toBe(true);
|
||||
expect(new Set(pollDelays).size).toBeGreaterThan(1);
|
||||
const waitTimeouts = Array.from({ length: 12 }, () => mod.nextGateMessagesWaitTimeoutMs());
|
||||
expect(waitTimeouts.every((delay) => delay >= 26_000 && delay <= 38_000)).toBe(true);
|
||||
expect(new Set(waitTimeouts).size).toBeGreaterThan(1);
|
||||
const rearmDelays = Array.from({ length: 12 }, () => mod.nextGateMessagesWaitRearmDelayMs());
|
||||
expect(rearmDelays.every((delay) => delay >= 3_000 && delay <= 4_200)).toBe(true);
|
||||
expect(new Set(rearmDelays).size).toBeGreaterThan(1);
|
||||
const refreshDelays = Array.from({ length: 12 }, () => mod.nextGateActivityRefreshDelayMs());
|
||||
expect(refreshDelays.every((delay) => delay >= 4_500 && delay <= 9_500)).toBe(true);
|
||||
expect(new Set(refreshDelays).size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('native desktop keeps the tighter poll/send timing path', async () => {
|
||||
hasLocalControlBridge.mockReturnValue(true);
|
||||
const mod = await import('@/mesh/gateMetadataTiming');
|
||||
expect(mod.shouldJitterGateMetadataTiming()).toBe(false);
|
||||
expect(mod.nextGateMessagesPollDelayMs()).toBe(30_000);
|
||||
expect(mod.nextGateMessagesWaitTimeoutMs()).toBe(20_000);
|
||||
expect(mod.nextGateMessagesWaitRearmDelayMs()).toBe(750);
|
||||
expect(mod.nextGateActivityRefreshDelayMs()).toBe(0);
|
||||
});
|
||||
|
||||
it('coarsens hidden browser tab gate polling further', async () => {
|
||||
hasLocalControlBridge.mockReturnValue(false);
|
||||
const originalVisibility = Object.getOwnPropertyDescriptor(document, 'visibilityState');
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
configurable: true,
|
||||
value: 'hidden',
|
||||
});
|
||||
try {
|
||||
const mod = await import('@/mesh/gateMetadataTiming');
|
||||
const pollDelays = Array.from({ length: 12 }, () => mod.nextGateMessagesPollDelayMs());
|
||||
expect(pollDelays.every((delay) => delay >= 48_000 && delay <= 72_000)).toBe(true);
|
||||
const waitTimeouts = Array.from({ length: 12 }, () => mod.nextGateMessagesWaitTimeoutMs());
|
||||
expect(waitTimeouts.every((delay) => delay >= 60_000 && delay <= 84_000)).toBe(true);
|
||||
const rearmDelays = Array.from({ length: 12 }, () => mod.nextGateMessagesWaitRearmDelayMs());
|
||||
expect(rearmDelays.every((delay) => delay >= 6_000 && delay <= 12_000)).toBe(true);
|
||||
const refreshDelays = Array.from({ length: 12 }, () => mod.nextGateActivityRefreshDelayMs());
|
||||
expect(refreshDelays.every((delay) => delay >= 14_000 && delay <= 22_000)).toBe(true);
|
||||
} finally {
|
||||
if (originalVisibility) {
|
||||
Object.defineProperty(document, 'visibilityState', originalVisibility);
|
||||
} else {
|
||||
delete (document as Document & { visibilityState?: string }).visibilityState;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const hasLocalControlBridge = vi.fn(() => false);
|
||||
const buildGateAccessHeaders = vi.fn();
|
||||
const decryptWormholeGateMessage = vi.fn();
|
||||
|
||||
vi.mock('@/lib/localControlTransport', () => ({
|
||||
hasLocalControlBridge,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/gateAccessProof', () => ({
|
||||
buildGateAccessHeaders,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/wormholeIdentityClient', () => ({
|
||||
decryptWormholeGateMessage,
|
||||
}));
|
||||
|
||||
describe('gatePreviewSnapshot cache', () => {
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
fetchMock.mockReset();
|
||||
buildGateAccessHeaders.mockReset();
|
||||
decryptWormholeGateMessage.mockReset();
|
||||
hasLocalControlBridge.mockReset();
|
||||
hasLocalControlBridge.mockReturnValue(false);
|
||||
buildGateAccessHeaders.mockResolvedValue({
|
||||
'X-Wormhole-Node-Id': '!sb_gate',
|
||||
'X-Wormhole-Gate-Proof': 'proof',
|
||||
'X-Wormhole-Gate-Ts': '1712345678',
|
||||
});
|
||||
decryptWormholeGateMessage.mockResolvedValue({
|
||||
ok: true,
|
||||
plaintext: 'sealed preview',
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
it('coarsens browser/web gate preview fetches through a short cache window', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-05T23:30:00.000Z'));
|
||||
try {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: [
|
||||
{
|
||||
event_id: 'evt-1',
|
||||
event_type: 'gate_message',
|
||||
node_id: '!sb_sender',
|
||||
gate: 'infonet',
|
||||
epoch: 7,
|
||||
ciphertext: 'ct',
|
||||
nonce: 'nonce',
|
||||
sender_ref: 'sender-ref',
|
||||
format: 'mls1',
|
||||
gate_envelope: 'env',
|
||||
envelope_hash: 'hash',
|
||||
timestamp: Math.floor(Date.now() / 1000) - 60,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gatePreviewSnapshot');
|
||||
|
||||
await expect(mod.fetchGateThreadPreviewSnapshot('infonet')).resolves.toEqual([
|
||||
{
|
||||
nodeId: '!sb_sender',
|
||||
age: '1m ago',
|
||||
text: 'sealed preview',
|
||||
encrypted: true,
|
||||
},
|
||||
]);
|
||||
await mod.fetchGateThreadPreviewSnapshot('infonet');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(decryptWormholeGateMessage).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(12_001);
|
||||
|
||||
await mod.fetchGateThreadPreviewSnapshot('infonet');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses a shorter preview cache window on native runtimes', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-05T23:30:00.000Z'));
|
||||
try {
|
||||
hasLocalControlBridge.mockReturnValue(true);
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: [
|
||||
{
|
||||
event_id: 'evt-1',
|
||||
node_id: '!sb_sender',
|
||||
message: 'plain preview',
|
||||
timestamp: Math.floor(Date.now() / 1000) - 60,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gatePreviewSnapshot');
|
||||
|
||||
await mod.fetchGateThreadPreviewSnapshot('infonet');
|
||||
vi.advanceTimersByTime(4_001);
|
||||
await mod.fetchGateThreadPreviewSnapshot('infonet');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('invalidates cached gate previews explicitly', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: [
|
||||
{
|
||||
event_id: 'evt-1',
|
||||
node_id: '!sb_sender',
|
||||
message: 'plain preview',
|
||||
timestamp: 1712360000,
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
messages: [
|
||||
{
|
||||
event_id: 'evt-2',
|
||||
node_id: '!sb_sender',
|
||||
message: 'updated preview',
|
||||
timestamp: 1712360100,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gatePreviewSnapshot');
|
||||
|
||||
await expect(mod.fetchGateThreadPreviewSnapshot('infonet')).resolves.toEqual([
|
||||
expect.objectContaining({ text: 'plain preview' }),
|
||||
]);
|
||||
mod.invalidateGateThreadPreviewSnapshot('infonet');
|
||||
await expect(mod.fetchGateThreadPreviewSnapshot('infonet')).resolves.toEqual([
|
||||
expect.objectContaining({ text: 'updated preview' }),
|
||||
]);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,292 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const controlPlaneFetch = vi.fn();
|
||||
|
||||
vi.mock('@/lib/controlPlane', () => ({
|
||||
controlPlaneFetch,
|
||||
}));
|
||||
|
||||
describe('gateSessionStream manager', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
controlPlaneFetch.mockReset();
|
||||
});
|
||||
|
||||
it('marks the stream disabled when the backend feature flag is off', async () => {
|
||||
controlPlaneFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: false, detail: 'gate_session_stream_disabled' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
|
||||
const mod = await import('@/mesh/gateSessionStream');
|
||||
|
||||
mod.connectGateSessionStream();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(mod.getGateSessionStreamStatus()).toMatchObject({
|
||||
enabled: false,
|
||||
phase: 'disabled',
|
||||
detail: 'gate_session_stream_disabled',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses hello and heartbeat events from the session stream skeleton', async () => {
|
||||
const encoder = new TextEncoder();
|
||||
controlPlaneFetch.mockResolvedValue(
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
[
|
||||
'event: hello',
|
||||
'data: {"session_id":"sess-1","subscriptions":["alpha","beta"],"heartbeat_s":20,"batch_ms":1500,"transport":"sse","gate_access":{"alpha":{"node_id":"!node_alpha","proof":"proof-alpha","ts":"1712360000"}},"gate_key_status":{"alpha":{"ok":true,"gate_id":"alpha","current_epoch":7,"has_local_access":true}}}',
|
||||
'',
|
||||
'event: heartbeat',
|
||||
'data: {"session_id":"sess-1","ts":1712360000}',
|
||||
'',
|
||||
].join('\n'),
|
||||
),
|
||||
);
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const mod = await import('@/mesh/gateSessionStream');
|
||||
|
||||
mod.setGateSessionStreamSubscriptions(['Alpha', 'beta']);
|
||||
mod.connectGateSessionStream();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(controlPlaneFetch).toHaveBeenCalledWith(
|
||||
'/api/mesh/infonet/session-stream?gates=alpha%2Cbeta',
|
||||
expect.objectContaining({
|
||||
requireAdminSession: true,
|
||||
cache: 'no-store',
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
}),
|
||||
);
|
||||
expect(mod.getGateSessionStreamStatus()).toMatchObject({
|
||||
enabled: false,
|
||||
phase: 'closed',
|
||||
sessionId: 'sess-1',
|
||||
subscriptions: ['alpha', 'beta'],
|
||||
heartbeatS: 20,
|
||||
batchMs: 1500,
|
||||
lastEventType: 'heartbeat',
|
||||
});
|
||||
expect(mod.getGateSessionStreamAccessHeaders('alpha')).toEqual({
|
||||
'X-Wormhole-Node-Id': '!node_alpha',
|
||||
'X-Wormhole-Gate-Proof': 'proof-alpha',
|
||||
'X-Wormhole-Gate-Ts': '1712360000',
|
||||
});
|
||||
expect(mod.getGateSessionStreamKeyStatus('alpha')).toEqual({
|
||||
ok: true,
|
||||
gate_id: 'alpha',
|
||||
current_epoch: 7,
|
||||
has_local_access: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('retains one shared subscription set across multiple same-gate consumers', async () => {
|
||||
controlPlaneFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: false, detail: 'gate_session_stream_disabled' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
|
||||
const mod = await import('@/mesh/gateSessionStream');
|
||||
|
||||
const releaseA = mod.retainGateSessionStreamGate('Alpha');
|
||||
const releaseB = mod.retainGateSessionStreamGate('alpha');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(controlPlaneFetch).toHaveBeenCalledTimes(1);
|
||||
expect(mod.getGateSessionStreamStatus().subscriptions).toEqual(['alpha']);
|
||||
|
||||
releaseA();
|
||||
expect(mod.getGateSessionStreamStatus().subscriptions).toEqual(['alpha']);
|
||||
|
||||
releaseB();
|
||||
expect(mod.getGateSessionStreamStatus()).toMatchObject({
|
||||
phase: 'idle',
|
||||
subscriptions: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('can invalidate cached per-gate stream bootstrap context without dropping the stream status', async () => {
|
||||
const encoder = new TextEncoder();
|
||||
controlPlaneFetch.mockResolvedValue(
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
[
|
||||
'event: hello',
|
||||
'data: {"session_id":"sess-ctx","subscriptions":["alpha"],"heartbeat_s":20,"batch_ms":1500,"transport":"sse","gate_access":{"alpha":{"node_id":"!node_alpha","proof":"proof-alpha","ts":"1712360000"}},"gate_key_status":{"alpha":{"ok":true,"gate_id":"alpha","current_epoch":7,"has_local_access":true}}}',
|
||||
'',
|
||||
].join('\n'),
|
||||
),
|
||||
);
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const mod = await import('@/mesh/gateSessionStream');
|
||||
|
||||
mod.retainGateSessionStreamGate('alpha');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(mod.getGateSessionStreamAccessHeaders('alpha')).toBeDefined();
|
||||
expect(mod.getGateSessionStreamKeyStatus('alpha')).toBeTruthy();
|
||||
|
||||
mod.invalidateGateSessionStreamGateContext('alpha');
|
||||
|
||||
expect(mod.getGateSessionStreamAccessHeaders('alpha')).toBeUndefined();
|
||||
expect(mod.getGateSessionStreamKeyStatus('alpha')).toBeNull();
|
||||
expect(mod.getGateSessionStreamStatus().sessionId).toBe('sess-ctx');
|
||||
});
|
||||
|
||||
it('emits parsed gate_update events to stream event listeners', async () => {
|
||||
const encoder = new TextEncoder();
|
||||
controlPlaneFetch.mockResolvedValue(
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
[
|
||||
'event: hello',
|
||||
'data: {"session_id":"sess-2","subscriptions":["alpha"],"heartbeat_s":20,"batch_ms":1500,"transport":"sse"}',
|
||||
'',
|
||||
'event: gate_update',
|
||||
'data: {"session_id":"sess-2","updates":[{"gate_id":"alpha","cursor":3}],"ts":1712360001}',
|
||||
'',
|
||||
].join('\n'),
|
||||
),
|
||||
);
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const mod = await import('@/mesh/gateSessionStream');
|
||||
const events: Array<{ event: string; data: unknown }> = [];
|
||||
const unsubscribe = mod.subscribeGateSessionStreamEvents((event) => {
|
||||
events.push({ event: event.event, data: event.data });
|
||||
});
|
||||
|
||||
mod.retainGateSessionStreamGate('alpha');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
unsubscribe();
|
||||
|
||||
expect(events.some((event) => event.event === 'hello')).toBe(true);
|
||||
expect(events).toContainEqual({
|
||||
event: 'gate_update',
|
||||
data: {
|
||||
session_id: 'sess-2',
|
||||
updates: [{ gate_id: 'alpha', cursor: 3 }],
|
||||
ts: 1712360001,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('reconnects with retained subscriptions after the stream closes', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const encoder = new TextEncoder();
|
||||
let callCount = 0;
|
||||
controlPlaneFetch.mockImplementation(async () => {
|
||||
callCount += 1;
|
||||
if (callCount === 1) {
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
[
|
||||
'event: hello',
|
||||
'data: {"session_id":"sess-3","subscriptions":["alpha"],"heartbeat_s":20,"batch_ms":1500,"transport":"sse"}',
|
||||
'',
|
||||
].join('\n'),
|
||||
),
|
||||
);
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
},
|
||||
);
|
||||
}
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
[
|
||||
'event: hello',
|
||||
'data: {"session_id":"sess-4","subscriptions":["alpha"],"heartbeat_s":20,"batch_ms":1500,"transport":"sse"}',
|
||||
'',
|
||||
].join('\n'),
|
||||
),
|
||||
);
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/gateSessionStream');
|
||||
const release = mod.retainGateSessionStreamGate('alpha');
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(controlPlaneFetch).toHaveBeenCalledTimes(2);
|
||||
expect(mod.getGateSessionStreamStatus()).toMatchObject({
|
||||
enabled: true,
|
||||
subscriptions: ['alpha'],
|
||||
});
|
||||
expect(['connecting', 'open']).toContain(mod.getGateSessionStreamStatus().phase);
|
||||
|
||||
release();
|
||||
mod.disconnectGateSessionStream();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -155,4 +155,14 @@ describe('mailbox claim privacy padding', () => {
|
||||
expect(decoyTokens).toEqual(['decoy-0', 'decoy-1']);
|
||||
expect(decoyTokens.every((token) => !realSharedTokens.includes(token))).toBe(true);
|
||||
});
|
||||
|
||||
it('can build mailbox claims from a prepared Wormhole identity override', async () => {
|
||||
deadDropTokensForContacts.mockResolvedValue([]);
|
||||
|
||||
const mod = await import('@/mesh/meshDmClient');
|
||||
await mod.buildMailboxClaims({}, { nodeId: '!sb_wormhole_dm' });
|
||||
|
||||
expect(mailboxClaimToken).toHaveBeenCalledWith('self', '!sb_wormhole_dm');
|
||||
expect(mailboxClaimToken).toHaveBeenCalledWith('requests', '!sb_wormhole_dm');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isDmPollBlocked, isGateSendBlocked, shouldQueueDmSend } from '@/lib/meshChatPolicies';
|
||||
|
||||
function readSource(relativePath: string): string {
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
return fs.readFileSync(path.resolve(here, relativePath), 'utf-8');
|
||||
}
|
||||
|
||||
describe('MeshChat behavior - shouldQueueDmSend', () => {
|
||||
it('returns false for default privacy profile', () => {
|
||||
expect(shouldQueueDmSend('default')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for high privacy profile', () => {
|
||||
expect(shouldQueueDmSend('high')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MeshChat behavior - isGateSendBlocked', () => {
|
||||
it('blocks when on infonet tab with gate selected but access not ready', () => {
|
||||
expect(isGateSendBlocked('infonet', true, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not block when gate access is ready', () => {
|
||||
expect(isGateSendBlocked('infonet', true, true)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not block when no gate is selected', () => {
|
||||
expect(isGateSendBlocked('infonet', false, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not block on non-infonet tabs', () => {
|
||||
expect(isGateSendBlocked('dms', true, false)).toBe(false);
|
||||
expect(isGateSendBlocked('meshtastic', true, false)).toBe(false);
|
||||
expect(isGateSendBlocked('mesh', true, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not block when all conditions are false', () => {
|
||||
expect(isGateSendBlocked('dms', false, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MeshChat behavior - isDmPollBlocked', () => {
|
||||
it('blocks when wormhole is enabled but not ready', () => {
|
||||
expect(isDmPollBlocked(true, false, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks when anonymous DM is blocked', () => {
|
||||
expect(isDmPollBlocked(false, false, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks when both wormhole not ready and anonymous blocked', () => {
|
||||
expect(isDmPollBlocked(true, false, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not block when wormhole is ready and anonymous is not blocked', () => {
|
||||
expect(isDmPollBlocked(true, true, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not block when wormhole is disabled and anonymous is not blocked', () => {
|
||||
expect(isDmPollBlocked(false, false, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not block when wormhole is disabled and ready', () => {
|
||||
expect(isDmPollBlocked(false, true, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MeshChat behavior - policy wiring', () => {
|
||||
it('controller imports all three policy functions from meshChatPolicies', () => {
|
||||
const controller = readSource('../../components/MeshChat/useMeshChatController.ts');
|
||||
expect(controller).toMatch(
|
||||
/import\s*\{[^}]*shouldQueueDmSend[^}]*\}\s*from\s+['"]@\/lib\/meshChatPolicies['"]/,
|
||||
);
|
||||
expect(controller).toMatch(
|
||||
/import\s*\{[^}]*isGateSendBlocked[^}]*\}\s*from\s+['"]@\/lib\/meshChatPolicies['"]/,
|
||||
);
|
||||
expect(controller).toMatch(
|
||||
/import\s*\{[^}]*isDmPollBlocked[^}]*\}\s*from\s+['"]@\/lib\/meshChatPolicies['"]/,
|
||||
);
|
||||
});
|
||||
|
||||
it('controller calls shouldQueueDmSend in enqueueDmSend', () => {
|
||||
const controller = readSource('../../components/MeshChat/useMeshChatController.ts');
|
||||
expect(controller).toContain('shouldQueueDmSend(privacyProfile)');
|
||||
});
|
||||
|
||||
it('controller calls isGateSendBlocked in handleSend', () => {
|
||||
const controller = readSource('../../components/MeshChat/useMeshChatController.ts');
|
||||
expect(controller).toContain('isGateSendBlocked(');
|
||||
});
|
||||
|
||||
it('controller calls isDmPollBlocked in DM poll effects', () => {
|
||||
const controller = readSource('../../components/MeshChat/useMeshChatController.ts');
|
||||
expect(controller).toContain(
|
||||
'isDmPollBlocked(wormholeEnabled, wormholeReadyState, anonymousDmBlocked)',
|
||||
);
|
||||
});
|
||||
|
||||
it('controller suppresses unread-count polling while the DMS tab owns mailbox refresh', () => {
|
||||
const controller = readSource('../../components/MeshChat/useMeshChatController.ts');
|
||||
expect(controller).toContain("if (!hasId || !getDMNotify() || (expanded && activeTab === 'dms')) return;");
|
||||
expect(controller).toContain("jitteredPollDelay(baseDelay, { profile: privacyProfile })");
|
||||
});
|
||||
|
||||
it('controller uses the shared DM poll scheduler for live mailbox refresh cadence', () => {
|
||||
const controller = readSource('../../components/MeshChat/useMeshChatController.ts');
|
||||
expect(controller).toContain('classifyTick(hasMore, catchUpBudget, DM_MESSAGES_POLL_MS');
|
||||
expect(controller).toContain('timer = setTimeout(() => void poll(classification.refreshCount), classification.delay);');
|
||||
});
|
||||
|
||||
it('dead-drop UI distinguishes invite-pinned trust from TOFU-only', () => {
|
||||
const index = readSource('../../components/MeshChat/index.tsx');
|
||||
expect(index).toContain('getContactTrustSummary');
|
||||
expect(index).toContain('INVITE PINNED');
|
||||
expect(index).toContain('TOFU ONLY');
|
||||
expect(index).toContain('anchored by an imported signed invite');
|
||||
expect(index).toContain('rootWitnessContinuityLabel');
|
||||
expect(index).toContain('RECOVER ROOT');
|
||||
expect(index).toContain('!selectedContactTrustSummary?.rootMismatch');
|
||||
});
|
||||
|
||||
it('request UI does not route ordinary request flow through legacy add-contact lookup', () => {
|
||||
const index = readSource('../../components/MeshChat/index.tsx');
|
||||
expect(index).toContain('handleRequestComposerAction');
|
||||
expect(index).not.toContain('handleAddContact().catch(() =>');
|
||||
expect(index).toContain('dm add');
|
||||
expect(index).toContain('legacy migration');
|
||||
});
|
||||
|
||||
it('controller blocks trust-new-key when the stable root changed', () => {
|
||||
const controller = readSource('../../components/MeshChat/useMeshChatController.ts');
|
||||
expect(controller).toContain('contactInfo?.remotePrekeyRootMismatch');
|
||||
expect(controller).toContain('stable root changed; use RECOVER ROOT or replace the signed invite');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Sprint 4A regression tests — MeshChat decomposition boundary checks.
|
||||
*
|
||||
* These tests validate the frozen contract:
|
||||
* 1. High-privacy DM queueing lives in the controller
|
||||
* 2. selectedGateAccessReady gating lives in the controller
|
||||
* 3. DM polling trust-mutation code lives in the controller
|
||||
* 4. Gate refresh is controller-owned via authenticated poll (SSE removed in S3A)
|
||||
* 5. Identity persistence stays in meshIdentity.ts (not in presentational code)
|
||||
* 6. No direct trust-mutating imports in presentational components
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const MESH_CHAT_DIR = path.resolve(__dirname, '../../components/MeshChat');
|
||||
|
||||
function readFile(name: string): string {
|
||||
return fs.readFileSync(path.join(MESH_CHAT_DIR, name), 'utf-8');
|
||||
}
|
||||
|
||||
// ─── Trust-mutation isolation ───────────────────────────────────────────────
|
||||
|
||||
const TRUST_MUTATING_IMPORTS = [
|
||||
'addContact',
|
||||
'updateContact',
|
||||
'blockContact',
|
||||
'purgeBrowserSigningMaterial',
|
||||
'purgeBrowserContactGraph',
|
||||
'purgeBrowserDmState',
|
||||
];
|
||||
|
||||
describe('MeshChat decomposition — trust mutation isolation', () => {
|
||||
it('controller imports all trust-mutating functions', () => {
|
||||
const controller = readFile('useMeshChatController.ts');
|
||||
for (const fn of TRUST_MUTATING_IMPORTS) {
|
||||
expect(controller).toContain(fn);
|
||||
}
|
||||
});
|
||||
|
||||
it('presentational index.tsx does NOT import trust-mutating functions directly', () => {
|
||||
const index = readFile('index.tsx');
|
||||
for (const fn of TRUST_MUTATING_IMPORTS) {
|
||||
// Check that none of these appear in import statements
|
||||
const importPattern = new RegExp(
|
||||
`import\\s*\\{[^}]*\\b${fn}\\b[^}]*\\}\\s*from`,
|
||||
);
|
||||
expect(index).not.toMatch(importPattern);
|
||||
}
|
||||
});
|
||||
|
||||
it('presentational index.tsx does not import from meshIdentity', () => {
|
||||
const index = readFile('index.tsx');
|
||||
expect(index).not.toMatch(/from\s+['"]@\/mesh\/meshIdentity['"]/);
|
||||
});
|
||||
|
||||
it('presentational index.tsx does not import from meshDmWorkerClient', () => {
|
||||
const index = readFile('index.tsx');
|
||||
expect(index).not.toMatch(/from\s+['"]@\/mesh\/meshDmWorkerClient['"]/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Controller owns required-cohesion items ────────────────────────────────
|
||||
|
||||
describe('MeshChat decomposition — controller required-cohesion', () => {
|
||||
const controller = readFile('useMeshChatController.ts');
|
||||
|
||||
it('controller exports enqueueDmSend (high-privacy DM queueing)', () => {
|
||||
expect(controller).toMatch(/enqueueDmSend/);
|
||||
// Also in the return block
|
||||
expect(controller).toMatch(/return\s*\{[\s\S]*enqueueDmSend[\s\S]*\}/);
|
||||
});
|
||||
|
||||
it('controller exports flushDmQueue (high-privacy DM queueing)', () => {
|
||||
expect(controller).toMatch(/flushDmQueue/);
|
||||
expect(controller).toMatch(/return\s*\{[\s\S]*flushDmQueue[\s\S]*\}/);
|
||||
});
|
||||
|
||||
it('controller exports selectedGateAccessReady', () => {
|
||||
expect(controller).toMatch(/selectedGateAccessReady/);
|
||||
expect(controller).toMatch(/return\s*\{[\s\S]*selectedGateAccessReady[\s\S]*\}/);
|
||||
});
|
||||
|
||||
it('controller exports selectedGateKeyStatus', () => {
|
||||
expect(controller).toMatch(/selectedGateKeyStatus/);
|
||||
expect(controller).toMatch(/return\s*\{[\s\S]*selectedGateKeyStatus[\s\S]*\}/);
|
||||
});
|
||||
|
||||
it('controller exports native gate resync state and handler', () => {
|
||||
expect(controller).toMatch(/gateResyncTarget/);
|
||||
expect(controller).toMatch(/gateResyncBusy/);
|
||||
expect(controller).toMatch(/handleResyncGateState/);
|
||||
expect(controller).toMatch(/return\s*\{[\s\S]*gateResyncTarget[\s\S]*\}/);
|
||||
expect(controller).toMatch(/return\s*\{[\s\S]*gateResyncBusy[\s\S]*\}/);
|
||||
expect(controller).toMatch(/return\s*\{[\s\S]*handleResyncGateState[\s\S]*\}/);
|
||||
});
|
||||
|
||||
it('controller exports secureDmBlocked', () => {
|
||||
expect(controller).toMatch(/secureDmBlocked/);
|
||||
expect(controller).toMatch(/return\s*\{[\s\S]*secureDmBlocked[\s\S]*\}/);
|
||||
});
|
||||
|
||||
it('controller exports privacyProfile', () => {
|
||||
expect(controller).toMatch(/privacyProfile/);
|
||||
expect(controller).toMatch(/return\s*\{[\s\S]*privacyProfile[\s\S]*\}/);
|
||||
});
|
||||
|
||||
it('controller exports hasId and hasPublicLaneIdentity', () => {
|
||||
expect(controller).toMatch(/return\s*\{[\s\S]*hasId[\s\S]*\}/);
|
||||
expect(controller).toMatch(/return\s*\{[\s\S]*hasPublicLaneIdentity[\s\S]*\}/);
|
||||
});
|
||||
|
||||
it('controller exports publicMeshBlockedByWormhole', () => {
|
||||
expect(controller).toMatch(/return\s*\{[\s\S]*publicMeshBlockedByWormhole[\s\S]*\}/);
|
||||
});
|
||||
|
||||
it('controller exports anonymousPublicBlocked and anonymousDmBlocked', () => {
|
||||
expect(controller).toMatch(/return\s*\{[\s\S]*anonymousPublicBlocked[\s\S]*\}/);
|
||||
expect(controller).toMatch(/return\s*\{[\s\S]*anonymousDmBlocked[\s\S]*\}/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Gate refresh is controller-owned (SSE removed in S3A) ────────────────
|
||||
|
||||
describe('MeshChat decomposition — gate refresh ownership', () => {
|
||||
it('controller does NOT import useGateSSE (removed in S3A)', () => {
|
||||
const controller = readFile('useMeshChatController.ts');
|
||||
expect(controller).not.toMatch(/import.*useGateSSE.*from/);
|
||||
expect(controller).not.toMatch(/useGateSSE\(/);
|
||||
});
|
||||
|
||||
it('controller owns gate message polling via authenticated fetch', () => {
|
||||
const controller = readFile('useMeshChatController.ts');
|
||||
// The controller polls /api/mesh/infonet/messages for gate refresh
|
||||
expect(controller).toMatch(/\/api\/mesh\/infonet\/messages/);
|
||||
expect(controller).toMatch(/setInterval\(poll/);
|
||||
});
|
||||
|
||||
it('useGateSSE is NOT imported in the presentational shell', () => {
|
||||
const index = readFile('index.tsx');
|
||||
expect(index).not.toMatch(/useGateSSE/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── DM polling trust unit controller-owned ─────────────────────────────────
|
||||
|
||||
describe('MeshChat decomposition — DM poll sequence in controller', () => {
|
||||
const controller = readFile('useMeshChatController.ts');
|
||||
|
||||
it('DM polling (pollDmMailboxes) is in the controller', () => {
|
||||
expect(controller).toMatch(/pollDmMailboxes/);
|
||||
});
|
||||
|
||||
it('decryptDM is called in the controller (DM decrypt)', () => {
|
||||
expect(controller).toMatch(/decryptDM/);
|
||||
});
|
||||
|
||||
it('ratchetDecryptDM is in the controller', () => {
|
||||
expect(controller).toMatch(/ratchetDecryptDM/);
|
||||
});
|
||||
|
||||
it('sender seal decryption is in the controller via storage import', () => {
|
||||
expect(controller).toMatch(/decryptSenderSealForContact/);
|
||||
});
|
||||
|
||||
it('contact mutation (addContact/updateContact) happens only in controller', () => {
|
||||
const index = readFile('index.tsx');
|
||||
// These should not appear as direct function calls in the view
|
||||
expect(index).not.toMatch(/\baddContact\s*\(/);
|
||||
expect(index).not.toMatch(/\bupdateContact\s*\(/);
|
||||
expect(index).not.toMatch(/\bblockContact\s*\(/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Identity persistence through meshIdentity.ts ───────────────────────────
|
||||
|
||||
describe('MeshChat decomposition — identity persistence', () => {
|
||||
it('controller imports identity functions from meshIdentity', () => {
|
||||
const controller = readFile('useMeshChatController.ts');
|
||||
expect(controller).toMatch(/from\s+['"]@\/mesh\/meshIdentity['"]/);
|
||||
expect(controller).toMatch(/getNodeIdentity/);
|
||||
expect(controller).toMatch(/generateNodeKeys/);
|
||||
expect(controller).toMatch(/signEvent/);
|
||||
});
|
||||
|
||||
it('storage module imports from meshIdentity for seal operations', () => {
|
||||
const storage = readFile('storage.ts');
|
||||
expect(storage).toMatch(/from\s+['"]@\/mesh\/meshIdentity['"]/);
|
||||
});
|
||||
|
||||
it('types module re-exports Contact and NodeIdentity from meshIdentity', () => {
|
||||
const types = readFile('types.ts');
|
||||
expect(types).toMatch(/Contact/);
|
||||
expect(types).toMatch(/NodeIdentity/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Re-export stability ────────────────────────────────────────────────────
|
||||
|
||||
describe('MeshChat decomposition — export stability', () => {
|
||||
it('MeshChat.tsx re-exports default from MeshChat/index', () => {
|
||||
const reExport = fs.readFileSync(
|
||||
path.resolve(MESH_CHAT_DIR, '../MeshChat.tsx'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(reExport).toMatch(/export\s*\{\s*default\s*\}\s*from\s+['"]\.\/MeshChat\/index['"]/);
|
||||
});
|
||||
|
||||
it('MeshChat.tsx re-exports MeshChatProps type', () => {
|
||||
const reExport = fs.readFileSync(
|
||||
path.resolve(MESH_CHAT_DIR, '../MeshChat.tsx'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(reExport).toMatch(/export\s+type\s*\{\s*MeshChatProps\s*\}/);
|
||||
});
|
||||
|
||||
it('index.tsx exports default MeshChat component', () => {
|
||||
const index = readFile('index.tsx');
|
||||
expect(index).toMatch(/export\s+default\s+MeshChat/);
|
||||
});
|
||||
|
||||
it('presentational shell exposes the gate resync affordance', () => {
|
||||
const index = readFile('index.tsx');
|
||||
expect(index).toContain('RESYNC GATE STATE');
|
||||
expect(index).toContain('handleResyncGateState(selectedGate)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Phase 6A: Residual Backlog & Hygiene Closeout tests.
|
||||
*
|
||||
* Validates:
|
||||
* 1. DECOY_KEY removal from types.ts causes no import/runtime regression
|
||||
* 2. build_controller.py and build_index.py are deleted
|
||||
* 3. promotePendingAlias no longer calls updateContact from storage.ts
|
||||
* 4. Alias-promotion behavior unchanged after controller applies returned delta
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const MESH_CHAT_DIR = path.resolve(__dirname, '../../components/MeshChat');
|
||||
|
||||
function readFile(name: string): string {
|
||||
return fs.readFileSync(path.join(MESH_CHAT_DIR, name), 'utf-8');
|
||||
}
|
||||
|
||||
function fileExists(name: string): boolean {
|
||||
return fs.existsSync(path.join(MESH_CHAT_DIR, name));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. DECOY_KEY removal causes no import/runtime regression
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('DECOY_KEY deduplication', () => {
|
||||
it('types.ts does NOT export DECOY_KEY', () => {
|
||||
const types = readFile('types.ts');
|
||||
expect(types).not.toMatch(/export\s+(const|let|var)\s+DECOY_KEY/);
|
||||
});
|
||||
|
||||
it('storage.ts still exports DECOY_KEY as canonical location', () => {
|
||||
const storage = readFile('storage.ts');
|
||||
expect(storage).toMatch(/export\s+const\s+DECOY_KEY/);
|
||||
});
|
||||
|
||||
it('DECOY_KEY is importable from storage at runtime', async () => {
|
||||
const { DECOY_KEY } = await import('../../components/MeshChat/storage');
|
||||
expect(DECOY_KEY).toBe('sb_dm_decoy');
|
||||
});
|
||||
|
||||
it('no file imports DECOY_KEY from types', () => {
|
||||
const files = fs.readdirSync(MESH_CHAT_DIR).filter((f) => f.endsWith('.ts') || f.endsWith('.tsx'));
|
||||
for (const file of files) {
|
||||
const content = readFile(file);
|
||||
const importFromTypes = content.match(/import\s*\{[^}]*DECOY_KEY[^}]*\}\s*from\s*['"]\.\/types['"]/);
|
||||
expect(importFromTypes, `${file} should not import DECOY_KEY from types`).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. build_controller.py and build_index.py are deleted
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('stale generator scripts removed', () => {
|
||||
it('build_controller.py does not exist', () => {
|
||||
expect(fileExists('build_controller.py')).toBe(false);
|
||||
});
|
||||
|
||||
it('build_index.py does not exist', () => {
|
||||
expect(fileExists('build_index.py')).toBe(false);
|
||||
});
|
||||
|
||||
it('no build/test config references build_controller.py or build_index.py', () => {
|
||||
const packageJson = fs.readFileSync(
|
||||
path.resolve(MESH_CHAT_DIR, '../../../package.json'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(packageJson).not.toContain('build_controller.py');
|
||||
expect(packageJson).not.toContain('build_index.py');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. promotePendingAlias no longer calls updateContact from storage.ts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('promotePendingAlias decoupled from updateContact', () => {
|
||||
it('storage.ts does not import updateContact', () => {
|
||||
const storage = readFile('storage.ts');
|
||||
expect(storage).not.toMatch(/import\s*\{[^}]*updateContact[^}]*\}\s*from/);
|
||||
});
|
||||
|
||||
it('storage.ts does not import getContacts', () => {
|
||||
const storage = readFile('storage.ts');
|
||||
expect(storage).not.toMatch(/import\s*\{[^}]*getContacts[^}]*\}\s*from/);
|
||||
});
|
||||
|
||||
it('promotePendingAlias does not call updateContact', () => {
|
||||
const storage = readFile('storage.ts');
|
||||
// Extract the promotePendingAlias function body
|
||||
const fnStart = storage.indexOf('export function promotePendingAlias');
|
||||
expect(fnStart).toBeGreaterThan(-1);
|
||||
const fnBody = storage.slice(fnStart, storage.indexOf('\n}', fnStart) + 2);
|
||||
expect(fnBody).not.toContain('updateContact(');
|
||||
});
|
||||
|
||||
it('promotePendingAlias does not call getContacts', () => {
|
||||
const storage = readFile('storage.ts');
|
||||
const fnStart = storage.indexOf('export function promotePendingAlias');
|
||||
const fnBody = storage.slice(fnStart, storage.indexOf('\n}', fnStart) + 2);
|
||||
expect(fnBody).not.toContain('getContacts(');
|
||||
});
|
||||
|
||||
it('controller call sites apply updateContact after promotePendingAlias', () => {
|
||||
const controller = readFile('useMeshChatController.ts');
|
||||
// Both call sites should follow pattern: promotePendingAlias → updateContact
|
||||
const promotionCalls = controller.match(/const promotion = promotePendingAlias\(/g);
|
||||
expect(promotionCalls?.length).toBeGreaterThanOrEqual(2);
|
||||
const updateAfterPromotion = controller.match(
|
||||
/if \(promotion\) updateContact\([^,]+, promotion\.delta\.updates\)/g,
|
||||
);
|
||||
expect(updateAfterPromotion?.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Alias-promotion behavior unchanged (delta structure)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('alias-promotion delta correctness', () => {
|
||||
it('returns null when contact has no pendingSharedAlias', async () => {
|
||||
const { promotePendingAlias } = await import('../../components/MeshChat/storage');
|
||||
const contact = { sharedAlias: 'abc' } as any;
|
||||
const result = promotePendingAlias('test-id', contact);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when grace period has not expired', async () => {
|
||||
const { promotePendingAlias } = await import('../../components/MeshChat/storage');
|
||||
const contact = {
|
||||
pendingSharedAlias: 'new-alias',
|
||||
sharedAlias: 'old-alias',
|
||||
sharedAliasGraceUntil: Date.now() + 60_000,
|
||||
} as any;
|
||||
const result = promotePendingAlias('test-id', contact);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns delta with promoted contact when grace period expired', async () => {
|
||||
const { promotePendingAlias } = await import('../../components/MeshChat/storage');
|
||||
const contact = {
|
||||
pendingSharedAlias: 'new-alias',
|
||||
sharedAlias: 'old-alias',
|
||||
sharedAliasGraceUntil: Date.now() - 1000,
|
||||
previousSharedAliases: [],
|
||||
} as any;
|
||||
const result = promotePendingAlias('test-id', contact);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.delta.updates.sharedAlias).toBe('new-alias');
|
||||
expect(result!.delta.updates.pendingSharedAlias).toBeUndefined();
|
||||
expect(result!.delta.updates.sharedAliasGraceUntil).toBeUndefined();
|
||||
expect(result!.delta.updates.sharedAliasRotatedAt).toBeGreaterThan(0);
|
||||
expect(result!.delta.updates.previousSharedAliases).toContain('old-alias');
|
||||
expect(result!.promoted.sharedAlias).toBe('new-alias');
|
||||
expect(result!.promoted.pendingSharedAlias).toBeUndefined();
|
||||
});
|
||||
|
||||
it('promoted contact merges updates onto original contact', async () => {
|
||||
const { promotePendingAlias } = await import('../../components/MeshChat/storage');
|
||||
const contact = {
|
||||
dhPubKey: 'some-key',
|
||||
pendingSharedAlias: 'next',
|
||||
sharedAlias: 'current',
|
||||
sharedAliasGraceUntil: 0,
|
||||
previousSharedAliases: ['older'],
|
||||
} as any;
|
||||
const result = promotePendingAlias('test-id', contact);
|
||||
expect(result).not.toBeNull();
|
||||
// Original fields preserved
|
||||
expect(result!.promoted.dhPubKey).toBe('some-key');
|
||||
// Alias history includes both old aliases
|
||||
expect(result!.promoted.previousSharedAliases).toContain('current');
|
||||
expect(result!.promoted.previousSharedAliases).toContain('older');
|
||||
});
|
||||
});
|
||||
@@ -121,6 +121,11 @@ describe('meshIdentity contact storage hardening', () => {
|
||||
remotePrekeySequence: 3,
|
||||
remotePrekeySignedAt: 444,
|
||||
remotePrekeyMismatch: false,
|
||||
remotePrekeyTransparencyHead: 'head-1',
|
||||
remotePrekeyTransparencySize: 2,
|
||||
remotePrekeyTransparencySeenAt: 555,
|
||||
remotePrekeyTransparencyConflict: false,
|
||||
remotePrekeyLookupMode: 'legacy_agent_id',
|
||||
});
|
||||
const stored = await waitForEncryptedContacts();
|
||||
expect(String(stored ?? '')).toMatch(/^enc:/);
|
||||
@@ -137,6 +142,11 @@ describe('meshIdentity contact storage hardening', () => {
|
||||
expect(hydrated.alice.remotePrekeySequence).toBe(3);
|
||||
expect(hydrated.alice.remotePrekeySignedAt).toBe(444);
|
||||
expect(hydrated.alice.remotePrekeyMismatch).toBe(false);
|
||||
expect(hydrated.alice.remotePrekeyTransparencyHead).toBe('head-1');
|
||||
expect(hydrated.alice.remotePrekeyTransparencySize).toBe(2);
|
||||
expect(hydrated.alice.remotePrekeyTransparencySeenAt).toBe(555);
|
||||
expect(hydrated.alice.remotePrekeyTransparencyConflict).toBe(false);
|
||||
expect(hydrated.alice.remotePrekeyLookupMode).toBe('legacy_agent_id');
|
||||
});
|
||||
|
||||
it('migrates legacy plaintext contacts to encrypted storage on first hydrate', async () => {
|
||||
@@ -241,4 +251,17 @@ describe('meshIdentity contact storage hardening', () => {
|
||||
const rotated = await mailboxClaimToken('requests', '!sb_contacts123456');
|
||||
expect(rotated).not.toBe(first);
|
||||
});
|
||||
|
||||
it('rotates mailbox claim tokens across mailbox epochs', async () => {
|
||||
const { mailboxClaimToken } = await import('@/mesh/meshMailbox');
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
await provisionLocalIdentity(mod);
|
||||
|
||||
const first = await mailboxClaimToken('requests', '!sb_contacts123456', 100);
|
||||
const second = await mailboxClaimToken('requests', '!sb_contacts123456', 100);
|
||||
const rotated = await mailboxClaimToken('requests', '!sb_contacts123456', 21_700);
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(rotated).not.toBe(first);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const deriveWormholeDeadDropTokens = vi.fn();
|
||||
const deriveWormholeDeadDropTokenPair = vi.fn();
|
||||
const isWormholeReady = vi.fn();
|
||||
|
||||
vi.mock('@/mesh/wormholeIdentityClient', () => ({
|
||||
deriveWormholeDeadDropTokens,
|
||||
deriveWormholeDeadDropTokenPair,
|
||||
isWormholeReady,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshIdentity', () => ({
|
||||
deriveSharedSecret: vi.fn(),
|
||||
getStoredNodeDescriptor: vi.fn(() => ({ nodeId: 'local-node' })),
|
||||
}));
|
||||
|
||||
describe('mesh dead-drop alias hygiene', () => {
|
||||
beforeEach(() => {
|
||||
deriveWormholeDeadDropTokens.mockReset();
|
||||
deriveWormholeDeadDropTokenPair.mockReset();
|
||||
isWormholeReady.mockReset();
|
||||
});
|
||||
|
||||
it('sends alias refs instead of the stable peer id when mailbox aliases exist', async () => {
|
||||
isWormholeReady.mockResolvedValue(true);
|
||||
deriveWormholeDeadDropTokens.mockResolvedValue({
|
||||
ok: true,
|
||||
tokens: [
|
||||
{ peer_id: 'peer_alpha', peer_ref: 'dmx_alpha', current: 'tok1', previous: 'tok0', epoch: 7 },
|
||||
],
|
||||
});
|
||||
|
||||
const { deadDropTokensForContacts } = await import('@/mesh/meshDeadDrop');
|
||||
const tokens = await deadDropTokensForContacts(
|
||||
{
|
||||
peer_alpha: {
|
||||
blocked: false,
|
||||
dhPubKey: 'dhpub_alpha',
|
||||
sharedAlias: 'dmx_alpha',
|
||||
previousSharedAliases: ['dmx_prev_alpha'],
|
||||
} as any,
|
||||
},
|
||||
24,
|
||||
);
|
||||
|
||||
expect(tokens).toEqual(['tok1', 'tok0']);
|
||||
expect(deriveWormholeDeadDropTokens).toHaveBeenCalledWith(
|
||||
[
|
||||
{
|
||||
peer_id: 'peer_alpha',
|
||||
peer_dh_pub: 'dhpub_alpha',
|
||||
peer_refs: ['dmx_alpha', 'dmx_prev_alpha'],
|
||||
},
|
||||
],
|
||||
24,
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the stable peer id only when no alias history exists', async () => {
|
||||
isWormholeReady.mockResolvedValue(true);
|
||||
deriveWormholeDeadDropTokens.mockResolvedValue({
|
||||
ok: true,
|
||||
tokens: [
|
||||
{ peer_id: 'peer_bravo', peer_ref: 'peer_bravo', current: 'tok2', previous: 'tok1', epoch: 8 },
|
||||
],
|
||||
});
|
||||
|
||||
const { deadDropTokensForContacts } = await import('@/mesh/meshDeadDrop');
|
||||
await deadDropTokensForContacts(
|
||||
{
|
||||
peer_bravo: {
|
||||
blocked: false,
|
||||
dhPubKey: 'dhpub_bravo',
|
||||
} as any,
|
||||
},
|
||||
24,
|
||||
);
|
||||
|
||||
expect(deriveWormholeDeadDropTokens).toHaveBeenCalledWith(
|
||||
[
|
||||
{
|
||||
peer_id: 'peer_bravo',
|
||||
peer_dh_pub: 'dhpub_bravo',
|
||||
peer_refs: ['peer_bravo'],
|
||||
},
|
||||
],
|
||||
24,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('fetchDmPublicKey lookup posture', () => {
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('does not use legacy agent-id lookup unless explicitly allowed', async () => {
|
||||
const mod = await import('@/mesh/meshDmClient');
|
||||
|
||||
const result = await mod.fetchDmPublicKey('http://localhost:8000', '!sb_legacy');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses invite lookup handles without enabling legacy agent-id lookup', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
json: async () => ({ ok: true, dh_pub_key: 'peer-dh', lookup_mode: 'invite_lookup_handle' }),
|
||||
});
|
||||
const mod = await import('@/mesh/meshDmClient');
|
||||
|
||||
const result = await mod.fetchDmPublicKey(
|
||||
'http://localhost:8000',
|
||||
'!sb_peer',
|
||||
'invite-handle-123',
|
||||
);
|
||||
|
||||
expect(result?.dh_pub_key).toBe('peer-dh');
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8000/api/mesh/dm/pubkey?lookup_token=invite-handle-123',
|
||||
);
|
||||
});
|
||||
|
||||
it('still supports explicit legacy agent-id lookup for migration-only paths', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
json: async () => ({ ok: true, dh_pub_key: 'peer-dh', lookup_mode: 'legacy_agent_id' }),
|
||||
});
|
||||
const mod = await import('@/mesh/meshDmClient');
|
||||
|
||||
const result = await mod.fetchDmPublicKey('http://localhost:8000', '!sb_legacy', undefined, {
|
||||
allowLegacyAgentId: true,
|
||||
});
|
||||
|
||||
expect(result?.dh_pub_key).toBe('peer-dh');
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8000/api/mesh/dm/pubkey?agent_id=%21sb_legacy',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
allDmPeerIds,
|
||||
mailboxPeerRefs,
|
||||
buildAliasRotateMessage,
|
||||
buildAccessGrantedMessage,
|
||||
buildContactAcceptMessage,
|
||||
@@ -59,6 +60,17 @@ describe('mesh DM consent helpers', () => {
|
||||
expect(allDmPeerIds('node_public', { sharedAlias: 'node_public' })).toEqual(['node_public']);
|
||||
});
|
||||
|
||||
it('prefers alias history for mailbox refs and drops stable public id once aliasing exists', () => {
|
||||
expect(
|
||||
mailboxPeerRefs('node_public', {
|
||||
sharedAlias: 'dmx_current',
|
||||
pendingSharedAlias: 'dmx_next',
|
||||
previousSharedAliases: ['dmx_prev'],
|
||||
}),
|
||||
).toEqual(['dmx_current', 'dmx_next', 'dmx_prev']);
|
||||
expect(mailboxPeerRefs('node_public', { sharedAlias: '' })).toEqual(['node_public']);
|
||||
});
|
||||
|
||||
it('builds and parses alias rotation control payloads', () => {
|
||||
const message = buildAliasRotateMessage('dmx_next');
|
||||
expect(parseAliasRotateMessage(message)).toEqual({ shared_alias: 'dmx_next' });
|
||||
@@ -76,10 +88,38 @@ describe('mesh DM consent helpers', () => {
|
||||
});
|
||||
|
||||
it('keeps alias history compact and unique', () => {
|
||||
expect(mergeAliasHistory(['dmx_a', 'dmx_b', 'dmx_a', 'dmx_c', 'dmx_d'])).toEqual([
|
||||
'dmx_a',
|
||||
'dmx_b',
|
||||
]);
|
||||
expect(mergeAliasHistory(['dmx_a', 'dmx_b', 'dmx_a', 'dmx_c', 'dmx_d'], 3)).toEqual([
|
||||
'dmx_a',
|
||||
'dmx_b',
|
||||
'dmx_c',
|
||||
]);
|
||||
});
|
||||
|
||||
it('bounds mailbox peer refs to 4 and excludes long tail', () => {
|
||||
expect(
|
||||
mailboxPeerRefs('node_public', {
|
||||
sharedAlias: 'dmx_current',
|
||||
pendingSharedAlias: 'dmx_next',
|
||||
previousSharedAliases: ['dmx_prev1', 'dmx_prev2', 'dmx_prev3'],
|
||||
}),
|
||||
).toEqual(['dmx_current', 'dmx_next', 'dmx_prev1', 'dmx_prev2']);
|
||||
});
|
||||
|
||||
it('bounds allDmPeerIds previous alias enumeration to 2', () => {
|
||||
const ids = allDmPeerIds('node_public', {
|
||||
sharedAlias: 'dmx_current',
|
||||
pendingSharedAlias: 'dmx_next',
|
||||
previousSharedAliases: ['dmx_prev1', 'dmx_prev2', 'dmx_prev3'],
|
||||
});
|
||||
// current + pending + at most 2 previous + peerId
|
||||
expect(ids).toContain('dmx_current');
|
||||
expect(ids).toContain('dmx_next');
|
||||
expect(ids).toContain('dmx_prev1');
|
||||
expect(ids).toContain('dmx_prev2');
|
||||
expect(ids).not.toContain('dmx_prev3');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const signMeshEvent = vi.fn();
|
||||
const issueWormholeDmSenderToken = vi.fn();
|
||||
const issueWormholeDmSenderTokens = vi.fn();
|
||||
const registerWormholeDmKey = vi.fn();
|
||||
const validateEventPayload = vi.fn(() => ({ ok: true, reason: 'ok' }));
|
||||
const nextSequence = vi.fn(() => 42);
|
||||
|
||||
vi.mock('@/mesh/meshDeadDrop', () => ({
|
||||
deadDropToken: vi.fn(async () => 'shared-token'),
|
||||
deadDropTokensForContacts: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshMailbox', () => ({
|
||||
mailboxClaimToken: vi.fn(async (type: string) => `${type}-token`),
|
||||
mailboxDecoySharedToken: vi.fn(async (index: number) => `decoy-${index}`),
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshIdentity', () => ({
|
||||
deriveSenderSealKey: vi.fn(),
|
||||
ensureDhKeysFresh: vi.fn(),
|
||||
deriveSharedKey: vi.fn(),
|
||||
encryptDM: vi.fn(),
|
||||
getDHAlgo: vi.fn(() => 'X25519'),
|
||||
getNodeIdentity: vi.fn(() => ({ nodeId: '!sb_self', publicKey: 'pub' })),
|
||||
getPublicKeyAlgo: vi.fn(() => 'Ed25519'),
|
||||
nextSequence,
|
||||
verifyNodeIdBindingFromPublicKey: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/wormholeIdentityClient', () => ({
|
||||
buildWormholeSenderSeal: vi.fn(),
|
||||
getActiveSigningContext: vi.fn(async () => null),
|
||||
isWormholeSecureRequired: vi.fn(async () => false),
|
||||
issueWormholeDmSenderToken,
|
||||
issueWormholeDmSenderTokens,
|
||||
registerWormholeDmKey,
|
||||
signRawMeshMessage: vi.fn(),
|
||||
signMeshEvent,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshSchema', () => ({
|
||||
validateEventPayload,
|
||||
}));
|
||||
|
||||
describe('DM transport lock signing', () => {
|
||||
const fetchMock = vi.fn();
|
||||
const identity = {
|
||||
nodeId: '!sb_self',
|
||||
publicKey: 'pub',
|
||||
privateKey: 'priv',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
validateEventPayload.mockClear();
|
||||
nextSequence.mockClear();
|
||||
signMeshEvent.mockReset();
|
||||
issueWormholeDmSenderToken.mockReset();
|
||||
issueWormholeDmSenderTokens.mockReset();
|
||||
registerWormholeDmKey.mockReset();
|
||||
signMeshEvent.mockResolvedValue({
|
||||
context: {
|
||||
nodeId: '!sb_self',
|
||||
publicKey: 'pub',
|
||||
publicKeyAlgo: 'Ed25519',
|
||||
},
|
||||
signature: 'sig',
|
||||
sequence: 42,
|
||||
protocolVersion: 'infonet/2',
|
||||
});
|
||||
issueWormholeDmSenderTokens.mockResolvedValue({ tokens: [] });
|
||||
issueWormholeDmSenderToken.mockResolvedValue({ sender_token: 'sender-token' });
|
||||
registerWormholeDmKey.mockResolvedValue({ ok: true });
|
||||
fetchMock.mockResolvedValue({ json: async () => ({ ok: true }) });
|
||||
});
|
||||
|
||||
it('signs and sends private_strong on DM sends', async () => {
|
||||
const mod = await import('@/mesh/meshDmClient');
|
||||
|
||||
await mod.sendDmMessage({
|
||||
apiBase: 'http://localhost:8000',
|
||||
identity,
|
||||
recipientId: '!sb_peer',
|
||||
ciphertext: 'sealed',
|
||||
msgId: 'dm-test-1',
|
||||
timestamp: 123,
|
||||
deliveryClass: 'request',
|
||||
});
|
||||
|
||||
expect(signMeshEvent).toHaveBeenCalledWith(
|
||||
'dm_message',
|
||||
expect.objectContaining({ transport_lock: 'private_strong' }),
|
||||
42,
|
||||
);
|
||||
const body = JSON.parse(fetchMock.mock.calls.at(-1)?.[1]?.body as string);
|
||||
expect(body.transport_lock).toBe('private_strong');
|
||||
});
|
||||
|
||||
it('signs and sends private_strong on DM poll/count', async () => {
|
||||
const mod = await import('@/mesh/meshDmClient');
|
||||
const claims = [{ type: 'requests' as const, token: 'request-token' }];
|
||||
|
||||
await mod.pollDmMailboxes('http://localhost:8000', identity, claims);
|
||||
await mod.countDmMailboxes('http://localhost:8000', identity, claims);
|
||||
|
||||
expect(signMeshEvent).toHaveBeenCalledWith(
|
||||
'dm_poll',
|
||||
expect.objectContaining({ transport_lock: 'private_strong' }),
|
||||
42,
|
||||
);
|
||||
expect(signMeshEvent).toHaveBeenCalledWith(
|
||||
'dm_count',
|
||||
expect.objectContaining({ transport_lock: 'private_strong' }),
|
||||
42,
|
||||
);
|
||||
const pollBody = JSON.parse(fetchMock.mock.calls[0][1].body as string);
|
||||
const countBody = JSON.parse(fetchMock.mock.calls[1][1].body as string);
|
||||
expect(pollBody.transport_lock).toBe('private_strong');
|
||||
expect(countBody.transport_lock).toBe('private_strong');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,328 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const controlPlaneJson = vi.fn();
|
||||
const probeInlineGateCryptoSupport = vi.fn(async () => ({ supported: true, reason: '' }));
|
||||
const adoptInlineGateState = vi.fn(async (snapshot) => snapshot);
|
||||
const composeInlineGateMessage = vi.fn(async () => ({
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
ciphertext: 'inline-ciphertext',
|
||||
nonce: 'inline-nonce',
|
||||
}));
|
||||
const decryptInlineGateMessages = vi.fn(async () => [
|
||||
{
|
||||
ok: true,
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
plaintext: 'sealed',
|
||||
reply_to: '',
|
||||
identity_scope: 'browser_privacy_core',
|
||||
},
|
||||
]);
|
||||
const forgetInlineGateState = vi.fn(async () => {});
|
||||
|
||||
vi.mock('@/lib/controlPlane', () => ({
|
||||
controlPlaneJson,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshGateLocalRuntime', () => ({
|
||||
probeInlineGateCryptoSupport,
|
||||
adoptInlineGateState,
|
||||
composeInlineGateMessage,
|
||||
decryptInlineGateMessages,
|
||||
forgetInlineGateState,
|
||||
}));
|
||||
|
||||
describe('meshGateWorkerClient inline fallback', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
controlPlaneJson.mockReset();
|
||||
probeInlineGateCryptoSupport.mockReset();
|
||||
adoptInlineGateState.mockReset();
|
||||
composeInlineGateMessage.mockReset();
|
||||
decryptInlineGateMessages.mockReset();
|
||||
forgetInlineGateState.mockReset();
|
||||
|
||||
probeInlineGateCryptoSupport.mockResolvedValue({ supported: true, reason: '' });
|
||||
adoptInlineGateState.mockImplementation(async (snapshot) => snapshot);
|
||||
composeInlineGateMessage.mockResolvedValue({
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
ciphertext: 'inline-ciphertext',
|
||||
nonce: 'inline-nonce',
|
||||
});
|
||||
decryptInlineGateMessages.mockResolvedValue([
|
||||
{
|
||||
ok: true,
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
plaintext: 'sealed',
|
||||
reply_to: '',
|
||||
identity_scope: 'browser_privacy_core',
|
||||
},
|
||||
]);
|
||||
forgetInlineGateState.mockResolvedValue(undefined);
|
||||
|
||||
Object.defineProperty(globalThis, 'Worker', {
|
||||
value: undefined,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the inline runtime when the Worker transport is unavailable', async () => {
|
||||
controlPlaneJson
|
||||
.mockResolvedValueOnce({
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
rust_state_blob_b64: 'blob',
|
||||
members: [],
|
||||
active_identity_scope: 'anonymous',
|
||||
active_persona_id: '',
|
||||
active_node_id: '!sb_local',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
gate_id: 'infonet',
|
||||
sender_id: '!sb_gate',
|
||||
public_key: 'pub',
|
||||
public_key_algo: 'ed25519',
|
||||
protocol_version: 'sb-test',
|
||||
sequence: 3,
|
||||
signature: 'sig',
|
||||
epoch: 7,
|
||||
ciphertext: 'inline-ciphertext',
|
||||
nonce: 'inline-nonce',
|
||||
sender_ref: 'sender-ref',
|
||||
format: 'mls1',
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/meshGateWorkerClient');
|
||||
|
||||
await expect(mod.syncBrowserGateState('infonet', { force: true })).resolves.toBe(true);
|
||||
await expect(mod.composeBrowserGateMessage('infonet', 'hello')).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
ok: true,
|
||||
gate_id: 'infonet',
|
||||
ciphertext: 'inline-ciphertext',
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
mod.decryptBrowserGateMessages([
|
||||
{
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
ciphertext: 'inline-ciphertext',
|
||||
},
|
||||
]),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
results: [
|
||||
expect.objectContaining({
|
||||
ok: true,
|
||||
gate_id: 'infonet',
|
||||
plaintext: 'sealed',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(probeInlineGateCryptoSupport).toHaveBeenCalled();
|
||||
expect(adoptInlineGateState).toHaveBeenCalled();
|
||||
expect(composeInlineGateMessage).toHaveBeenCalledWith('infonet', 'hello', '');
|
||||
expect(decryptInlineGateMessages).toHaveBeenCalledWith([
|
||||
{
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
ciphertext: 'inline-ciphertext',
|
||||
},
|
||||
]);
|
||||
expect(mod.getBrowserGateLocalRuntimeStatus()).toEqual(
|
||||
expect.objectContaining({
|
||||
mode: 'inline',
|
||||
health: 'active',
|
||||
reason: 'browser_gate_worker_unavailable',
|
||||
}),
|
||||
);
|
||||
expect(mod.describeBrowserGateLocalRuntimeStatus(mod.getBrowserGateLocalRuntimeStatus())).toBe(
|
||||
'INLINE local gate runtime active (worker unavailable)',
|
||||
);
|
||||
expect(controlPlaneJson).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/wormhole/gate/state/export',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(controlPlaneJson).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/wormhole/gate/message/sign-encrypted',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
ciphertext: 'inline-ciphertext',
|
||||
nonce: 'inline-nonce',
|
||||
format: 'mls1',
|
||||
reply_to: '',
|
||||
compat_reply_to: false,
|
||||
recovery_plaintext: 'hello',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to backend sealing when browser signing cannot return a durable gate envelope', async () => {
|
||||
controlPlaneJson
|
||||
.mockResolvedValueOnce({
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
rust_state_blob_b64: 'blob',
|
||||
members: [],
|
||||
active_identity_scope: 'anonymous',
|
||||
active_persona_id: '',
|
||||
active_node_id: '!sb_local',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
gate_id: 'infonet',
|
||||
sender_id: '!sb_gate',
|
||||
public_key: 'pub',
|
||||
public_key_algo: 'ed25519',
|
||||
protocol_version: 'sb-test',
|
||||
sequence: 3,
|
||||
signature: 'sig',
|
||||
epoch: 7,
|
||||
ciphertext: 'inline-ciphertext',
|
||||
nonce: 'inline-nonce',
|
||||
sender_ref: 'sender-ref',
|
||||
format: 'mls1',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
event_id: 'evt-backend-sealed',
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/meshGateWorkerClient');
|
||||
|
||||
await expect(mod.syncBrowserGateState('infonet', { force: true })).resolves.toBe(true);
|
||||
await expect(mod.postBrowserGateMessage('infonet', 'hello durable', 'evt-parent-1')).resolves.toEqual({
|
||||
ok: true,
|
||||
event_id: 'evt-backend-sealed',
|
||||
});
|
||||
|
||||
expect(controlPlaneJson).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'/api/wormhole/gate/message/post',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
gate_id: 'infonet',
|
||||
plaintext: 'hello durable',
|
||||
reply_to: 'evt-parent-1',
|
||||
compat_plaintext: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('marks the selected inline runtime as degraded when a later local compose fails', async () => {
|
||||
controlPlaneJson.mockResolvedValueOnce({
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
rust_state_blob_b64: 'blob',
|
||||
members: [],
|
||||
active_identity_scope: 'anonymous',
|
||||
active_persona_id: '',
|
||||
active_node_id: '!sb_local',
|
||||
});
|
||||
composeInlineGateMessage.mockRejectedValueOnce(new Error('worker_gate_wrap_key_missing'));
|
||||
|
||||
const mod = await import('@/mesh/meshGateWorkerClient');
|
||||
|
||||
await expect(mod.syncBrowserGateState('infonet', { force: true })).resolves.toBe(true);
|
||||
await expect(mod.composeBrowserGateMessage('infonet', 'hello')).resolves.toBeNull();
|
||||
|
||||
expect(mod.getBrowserGateCryptoFailureReason('infonet', 'compose')).toBe('worker_gate_wrap_key_missing');
|
||||
expect(mod.getBrowserGateLocalRuntimeStatus()).toEqual(
|
||||
expect.objectContaining({
|
||||
mode: 'inline',
|
||||
health: 'degraded',
|
||||
reason: 'worker_gate_wrap_key_missing',
|
||||
}),
|
||||
);
|
||||
expect(mod.describeBrowserGateLocalRuntimeStatus(mod.getBrowserGateLocalRuntimeStatus())).toBe(
|
||||
'INLINE local gate runtime degraded (secure storage unavailable)',
|
||||
);
|
||||
});
|
||||
|
||||
it('reuses self-authored plaintext when local gate decrypt cannot reopen the just-posted ciphertext', async () => {
|
||||
controlPlaneJson
|
||||
.mockResolvedValueOnce({
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
rust_state_blob_b64: 'blob',
|
||||
members: [],
|
||||
active_identity_scope: 'anonymous',
|
||||
active_persona_id: '',
|
||||
active_node_id: '!sb_local',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
gate_id: 'infonet',
|
||||
sender_id: '!sb_gate',
|
||||
public_key: 'pub',
|
||||
public_key_algo: 'ed25519',
|
||||
protocol_version: 'sb-test',
|
||||
sequence: 3,
|
||||
signature: 'sig',
|
||||
epoch: 7,
|
||||
ciphertext: 'inline-ciphertext',
|
||||
nonce: 'inline-nonce',
|
||||
sender_ref: 'sender-ref',
|
||||
format: 'mls1',
|
||||
});
|
||||
decryptInlineGateMessages.mockResolvedValueOnce([
|
||||
{
|
||||
ok: false,
|
||||
gate_id: 'infonet',
|
||||
detail: 'gate_mls_decrypt_failed',
|
||||
},
|
||||
]);
|
||||
|
||||
const mod = await import('@/mesh/meshGateWorkerClient');
|
||||
|
||||
await expect(mod.syncBrowserGateState('infonet', { force: true })).resolves.toBe(true);
|
||||
await expect(mod.composeBrowserGateMessage('infonet', 'hello self', 'evt-parent-7')).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
ok: true,
|
||||
gate_id: 'infonet',
|
||||
ciphertext: 'inline-ciphertext',
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
mod.decryptBrowserGateMessages([
|
||||
{
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
ciphertext: 'inline-ciphertext',
|
||||
},
|
||||
]),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
results: [
|
||||
expect.objectContaining({
|
||||
ok: true,
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
plaintext: 'hello self',
|
||||
reply_to: 'evt-parent-7',
|
||||
identity_scope: 'browser_self_echo',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(mod.getBrowserGateLocalRuntimeStatus()).toEqual(
|
||||
expect.objectContaining({
|
||||
mode: 'inline',
|
||||
health: 'active',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
type StoreRecord = Map<string, unknown>;
|
||||
type DbRecord = {
|
||||
version: number;
|
||||
stores: Map<string, StoreRecord>;
|
||||
};
|
||||
|
||||
const databases = new Map<string, DbRecord>();
|
||||
const deletedDatabases: string[] = [];
|
||||
|
||||
function domStringList(record: DbRecord): DOMStringList {
|
||||
return {
|
||||
contains: (name: string) => record.stores.has(name),
|
||||
item: (index: number) => Array.from(record.stores.keys())[index] ?? null,
|
||||
get length() {
|
||||
return record.stores.size;
|
||||
},
|
||||
} as DOMStringList;
|
||||
}
|
||||
|
||||
function makeRequest<T>(
|
||||
executor: (request: IDBRequest<T>) => void,
|
||||
tx?: IDBTransaction,
|
||||
): IDBRequest<T> {
|
||||
const request = {} as IDBRequest<T>;
|
||||
queueMicrotask(() => {
|
||||
executor(request);
|
||||
tx?.oncomplete?.(new Event('complete') as Event);
|
||||
});
|
||||
return request;
|
||||
}
|
||||
|
||||
function makeObjectStore(record: DbRecord, name: string, tx: IDBTransaction): IDBObjectStore {
|
||||
const store = record.stores.get(name);
|
||||
if (!store) throw new Error(`missing object store ${name}`);
|
||||
return {
|
||||
get(key: IDBValidKey) {
|
||||
return makeRequest((request) => {
|
||||
(request as { result?: unknown }).result = store.get(String(key));
|
||||
request.onsuccess?.(new Event('success') as Event);
|
||||
}, tx);
|
||||
},
|
||||
put(value: unknown, key?: IDBValidKey) {
|
||||
return makeRequest((request) => {
|
||||
store.set(String(key ?? ''), value);
|
||||
(request as { result?: unknown }).result = key;
|
||||
request.onsuccess?.(new Event('success') as Event);
|
||||
}, tx);
|
||||
},
|
||||
delete(key: IDBValidKey) {
|
||||
return makeRequest((request) => {
|
||||
store.delete(String(key));
|
||||
request.onsuccess?.(new Event('success') as Event);
|
||||
}, tx);
|
||||
},
|
||||
clear() {
|
||||
return makeRequest((request) => {
|
||||
store.clear();
|
||||
request.onsuccess?.(new Event('success') as Event);
|
||||
}, tx);
|
||||
},
|
||||
} as unknown as IDBObjectStore;
|
||||
}
|
||||
|
||||
function makeTransaction(record: DbRecord): IDBTransaction {
|
||||
const tx = {
|
||||
oncomplete: null,
|
||||
onerror: null,
|
||||
onabort: null,
|
||||
objectStore: (name: string) => makeObjectStore(record, name, tx as unknown as IDBTransaction),
|
||||
} as unknown as IDBTransaction;
|
||||
return tx;
|
||||
}
|
||||
|
||||
function makeDb(name: string, record: DbRecord): IDBDatabase {
|
||||
return {
|
||||
name,
|
||||
version: record.version,
|
||||
objectStoreNames: domStringList(record),
|
||||
createObjectStore(storeName: string) {
|
||||
if (!record.stores.has(storeName)) {
|
||||
record.stores.set(storeName, new Map());
|
||||
}
|
||||
return {} as IDBObjectStore;
|
||||
},
|
||||
transaction(_storeName: string | string[]) {
|
||||
return makeTransaction(record);
|
||||
},
|
||||
close() {
|
||||
/* noop */
|
||||
},
|
||||
} as unknown as IDBDatabase;
|
||||
}
|
||||
|
||||
function createFakeIndexedDb() {
|
||||
return {
|
||||
open(name: string, version?: number) {
|
||||
const request = {} as IDBOpenDBRequest;
|
||||
queueMicrotask(() => {
|
||||
const resolvedVersion = Number(version || 1);
|
||||
let record = databases.get(name);
|
||||
const upgrading = !record || resolvedVersion > record.version;
|
||||
if (!record) {
|
||||
record = { version: resolvedVersion, stores: new Map() };
|
||||
databases.set(name, record);
|
||||
}
|
||||
if (upgrading) {
|
||||
record.version = resolvedVersion;
|
||||
(request as { result?: IDBDatabase }).result = makeDb(name, record);
|
||||
request.onupgradeneeded?.(new Event('upgradeneeded') as IDBVersionChangeEvent);
|
||||
}
|
||||
(request as { result?: IDBDatabase }).result = makeDb(name, record);
|
||||
request.onsuccess?.(new Event('success') as Event);
|
||||
});
|
||||
return request;
|
||||
},
|
||||
deleteDatabase(name: string) {
|
||||
const request = {} as IDBOpenDBRequest;
|
||||
queueMicrotask(() => {
|
||||
deletedDatabases.push(name);
|
||||
databases.delete(name);
|
||||
request.onsuccess?.(new Event('success') as Event);
|
||||
});
|
||||
return request;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function ensureStore(name: string, version: number, storeName: string): StoreRecord {
|
||||
let record = databases.get(name);
|
||||
if (!record) {
|
||||
record = { version, stores: new Map() };
|
||||
databases.set(name, record);
|
||||
}
|
||||
record.version = Math.max(record.version, version);
|
||||
if (!record.stores.has(storeName)) {
|
||||
record.stores.set(storeName, new Map());
|
||||
}
|
||||
return record.stores.get(storeName)!;
|
||||
}
|
||||
|
||||
function getStoredValue(name: string, storeName: string, key: string): unknown {
|
||||
return databases.get(name)?.stores.get(storeName)?.get(key);
|
||||
}
|
||||
|
||||
describe('gate worker vault hardening', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
databases.clear();
|
||||
deletedDatabases.length = 0;
|
||||
Object.defineProperty(globalThis, 'indexedDB', {
|
||||
value: createFakeIndexedDb(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('persists worker gate state as an encrypted blob instead of raw state', async () => {
|
||||
const mod = await import('@/mesh/meshGateWorkerVault');
|
||||
const sample = {
|
||||
gate_id: 'infonet',
|
||||
epoch: 7,
|
||||
rust_state_blob_b64: 'blob-private',
|
||||
members: [
|
||||
{
|
||||
persona_id: 'persona-a',
|
||||
node_id: '!sb_gate',
|
||||
identity_scope: 'persona',
|
||||
group_handle: 11,
|
||||
},
|
||||
],
|
||||
active_identity_scope: 'persona',
|
||||
active_persona_id: 'persona-a',
|
||||
active_node_id: '!sb_gate',
|
||||
};
|
||||
|
||||
await mod.writeWorkerGateState(sample);
|
||||
|
||||
const raw = getStoredValue(mod.WORKER_GATE_DB, 'gate_state', 'infonet');
|
||||
expect(typeof raw).toBe('string');
|
||||
expect(String(raw)).not.toContain('blob-private');
|
||||
expect(String(raw)).not.toContain('persona-a');
|
||||
|
||||
const loaded = await mod.readWorkerGateState('infonet');
|
||||
expect(loaded).toEqual(sample);
|
||||
});
|
||||
|
||||
it('migrates legacy plaintext gate state into encrypted storage on read', async () => {
|
||||
const legacyStore = ensureStore('sb_mesh_gate_worker', 1, 'gate_state');
|
||||
ensureStore('sb_mesh_gate_worker', 1, 'meta');
|
||||
legacyStore.set('infonet', {
|
||||
gate_id: 'infonet',
|
||||
epoch: 4,
|
||||
rust_state_blob_b64: 'legacy-blob',
|
||||
members: [],
|
||||
active_identity_scope: 'anonymous',
|
||||
active_persona_id: '',
|
||||
active_node_id: '!sb_legacy',
|
||||
});
|
||||
|
||||
const mod = await import('@/mesh/meshGateWorkerVault');
|
||||
const loaded = await mod.readWorkerGateState('infonet');
|
||||
const raw = getStoredValue(mod.WORKER_GATE_DB, 'gate_state', 'infonet');
|
||||
|
||||
expect(loaded?.rust_state_blob_b64).toBe('legacy-blob');
|
||||
expect(typeof raw).toBe('string');
|
||||
expect(String(raw)).not.toContain('legacy-blob');
|
||||
});
|
||||
|
||||
it('drops stale encrypted gate state when the wrap key is missing so the room can resync cleanly', async () => {
|
||||
const gateStore = ensureStore('sb_mesh_gate_worker', 1, 'gate_state');
|
||||
gateStore.set('infonet', 'encrypted-state-that-cannot-be-opened');
|
||||
ensureStore('sb_mesh_gate_worker', 1, 'meta');
|
||||
|
||||
const mod = await import('@/mesh/meshGateWorkerVault');
|
||||
await expect(mod.readWorkerGateState('infonet')).resolves.toBeNull();
|
||||
expect(getStoredValue(mod.WORKER_GATE_DB, 'gate_state', 'infonet')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('deleteWorkerGateDatabase removes the persisted gate vault', async () => {
|
||||
const mod = await import('@/mesh/meshGateWorkerVault');
|
||||
await mod.deleteWorkerGateDatabase();
|
||||
expect(deletedDatabases).toContain('sb_mesh_gate_worker');
|
||||
});
|
||||
});
|
||||
@@ -80,10 +80,11 @@ describe('mesh identity storage separation', () => {
|
||||
expect(mod.getWormholeIdentityDescriptor()).toBeNull();
|
||||
});
|
||||
|
||||
it('migrates legacy browser and Wormhole node ids to the current format', async () => {
|
||||
it('migrates stored browser and Wormhole node ids from 8-hex and 16-hex forms', async () => {
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
const publicKey = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=';
|
||||
const currentNodeId = await mod.deriveNodeIdFromPublicKey(publicKey);
|
||||
const compatNodeId = currentNodeId.slice(0, '!sb_'.length + 16);
|
||||
|
||||
mod.cachePublicIdentity({
|
||||
nodeId: '!sb_deadbeef',
|
||||
@@ -108,5 +109,42 @@ describe('mesh identity storage separation', () => {
|
||||
publicKey,
|
||||
publicKeyAlgo: 'Ed25519',
|
||||
});
|
||||
|
||||
mod.cachePublicIdentity({
|
||||
nodeId: compatNodeId,
|
||||
publicKey,
|
||||
publicKeyAlgo: 'Ed25519',
|
||||
});
|
||||
mod.cacheWormholeIdentityDescriptor({
|
||||
nodeId: compatNodeId,
|
||||
publicKey,
|
||||
publicKeyAlgo: 'Ed25519',
|
||||
});
|
||||
|
||||
await mod.migrateLegacyNodeIds();
|
||||
|
||||
expect(mod.getStoredNodeDescriptor()).toEqual({
|
||||
nodeId: currentNodeId,
|
||||
publicKey,
|
||||
publicKeyAlgo: 'Ed25519',
|
||||
});
|
||||
expect(mod.getWormholeIdentityDescriptor()).toEqual({
|
||||
nodeId: currentNodeId,
|
||||
publicKey,
|
||||
publicKeyAlgo: 'Ed25519',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts 32-hex current node ids and 16-hex compatibility ids, but not 8-hex ids', async () => {
|
||||
const mod = await import('@/mesh/meshIdentity');
|
||||
const publicKey = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=';
|
||||
const currentNodeId = await mod.deriveNodeIdFromPublicKey(publicKey);
|
||||
const compatNodeId = currentNodeId.slice(0, '!sb_'.length + 16);
|
||||
|
||||
await expect(mod.verifyNodeIdBindingFromPublicKey(publicKey, currentNodeId)).resolves.toBe(true);
|
||||
await expect(mod.verifyNodeIdBindingFromPublicKey(publicKey, compatNodeId)).resolves.toBe(true);
|
||||
await expect(mod.verifyNodeIdBindingFromPublicKey(publicKey, '!sb_deadbeef')).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,11 @@ import {
|
||||
buildDmTrustHint,
|
||||
buildPrivateLaneHint,
|
||||
dmTrustPrimaryActionLabel,
|
||||
hasKnownFirstContactAnchor,
|
||||
hasVerifiedFirstContactAnchor,
|
||||
isInvitePinnedFirstContact,
|
||||
isFirstContactTrustOnly,
|
||||
requiresVerifiedFirstContact,
|
||||
shortTrustFingerprint,
|
||||
shouldAutoRevealSasForTrust,
|
||||
} from '@/mesh/meshPrivacyHints';
|
||||
@@ -61,6 +65,149 @@ describe('meshPrivacyHints', () => {
|
||||
expect(shouldAutoRevealSasForTrust(contact)).toBe(true);
|
||||
});
|
||||
|
||||
it('treats invite-pinned first contact as stronger than TOFU', () => {
|
||||
const contact = {
|
||||
trustSummary: {
|
||||
state: 'invite_pinned',
|
||||
label: 'INVITE PINNED',
|
||||
severity: 'warn',
|
||||
detail: 'anchored by signed invite',
|
||||
verifiedFirstContact: true,
|
||||
recommendedAction: 'show_sas',
|
||||
legacyLookup: false,
|
||||
inviteAttested: true,
|
||||
rootAttested: true,
|
||||
rootWitnessed: true,
|
||||
rootDistributionState: 'quorum_witnessed',
|
||||
rootWitnessThreshold: 2,
|
||||
rootWitnessCount: 2,
|
||||
rootWitnessDomainCount: 1,
|
||||
rootWitnessProvenanceState: 'local_quorum',
|
||||
rootWitnessIndependentQuorumMet: false,
|
||||
rootMismatch: false,
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
};
|
||||
|
||||
expect(isInvitePinnedFirstContact(contact)).toBe(true);
|
||||
expect(isFirstContactTrustOnly(contact)).toBe(false);
|
||||
expect(buildDmTrustHint(contact)).toEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'warn',
|
||||
title: 'ROOT LOCAL QUORUM',
|
||||
}),
|
||||
);
|
||||
expect(buildDmTrustHint(contact)?.detail).toContain('co-resident in one trust domain');
|
||||
expect(dmTrustPrimaryActionLabel(contact)).toBe('SHOW SAS');
|
||||
expect(shouldAutoRevealSasForTrust(contact)).toBe(false);
|
||||
});
|
||||
|
||||
it('distinguishes independent quorum provenance from local quorum', () => {
|
||||
const contact = {
|
||||
trustSummary: {
|
||||
state: 'invite_pinned',
|
||||
label: 'INVITE PINNED',
|
||||
severity: 'warn',
|
||||
detail: 'anchored by signed invite on independent quorum root',
|
||||
verifiedFirstContact: true,
|
||||
recommendedAction: 'show_sas',
|
||||
legacyLookup: false,
|
||||
inviteAttested: true,
|
||||
rootAttested: true,
|
||||
rootWitnessed: true,
|
||||
rootDistributionState: 'quorum_witnessed',
|
||||
rootWitnessThreshold: 2,
|
||||
rootWitnessCount: 2,
|
||||
rootWitnessDomainCount: 2,
|
||||
rootWitnessProvenanceState: 'independent_quorum',
|
||||
rootWitnessIndependentQuorumMet: true,
|
||||
rootMismatch: false,
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildDmTrustHint(contact)).toEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'warn',
|
||||
title: 'ROOT INDEPENDENT QUORUM',
|
||||
}),
|
||||
);
|
||||
expect(buildDmTrustHint(contact)?.detail).toContain('independently quorum-witnessed');
|
||||
});
|
||||
|
||||
it('requires verified first-contact anchors before secure bootstrap', () => {
|
||||
expect(requiresVerifiedFirstContact(undefined)).toBe(true);
|
||||
expect(hasKnownFirstContactAnchor(undefined)).toBe(false);
|
||||
expect(hasVerifiedFirstContactAnchor(undefined)).toBe(false);
|
||||
|
||||
expect(
|
||||
requiresVerifiedFirstContact({
|
||||
trustSummary: {
|
||||
state: 'invite_pinned',
|
||||
label: 'INVITE PINNED',
|
||||
severity: 'warn',
|
||||
detail: 'anchored by signed invite',
|
||||
verifiedFirstContact: true,
|
||||
recommendedAction: 'show_sas',
|
||||
legacyLookup: false,
|
||||
inviteAttested: true,
|
||||
rootWitnessed: true,
|
||||
rootDistributionState: 'quorum_witnessed',
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
hasVerifiedFirstContactAnchor({
|
||||
trustSummary: {
|
||||
state: 'invite_pinned',
|
||||
label: 'INVITE PINNED',
|
||||
severity: 'warn',
|
||||
detail: 'anchored by signed invite',
|
||||
verifiedFirstContact: true,
|
||||
recommendedAction: 'show_sas',
|
||||
legacyLookup: false,
|
||||
inviteAttested: true,
|
||||
rootWitnessed: true,
|
||||
rootDistributionState: 'quorum_witnessed',
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
requiresVerifiedFirstContact({
|
||||
remotePrekeyFingerprint: 'abc123',
|
||||
remotePrekeyPinnedAt: 123,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasVerifiedFirstContactAnchor({
|
||||
remotePrekeyFingerprint: 'abc123',
|
||||
remotePrekeyPinnedAt: 123,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
requiresVerifiedFirstContact({
|
||||
verified: true,
|
||||
verify_inband: true,
|
||||
verify_registry: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasVerifiedFirstContactAnchor({
|
||||
verified: true,
|
||||
verify_inband: true,
|
||||
verify_registry: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('auto-reveals SAS for trust hazards but keeps ordinary verified contacts quiet', () => {
|
||||
expect(
|
||||
shouldAutoRevealSasForTrust({
|
||||
@@ -74,20 +221,295 @@ describe('meshPrivacyHints', () => {
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldAutoRevealSasForTrust({
|
||||
verified: true,
|
||||
verify_inband: true,
|
||||
verify_registry: true,
|
||||
trustSummary: {
|
||||
state: 'sas_verified',
|
||||
label: 'SAS VERIFIED',
|
||||
severity: 'good',
|
||||
detail: 'sas verified',
|
||||
verifiedFirstContact: true,
|
||||
recommendedAction: 'show_sas',
|
||||
legacyLookup: false,
|
||||
inviteAttested: false,
|
||||
rootDistributionState: 'none',
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
dmTrustPrimaryActionLabel({
|
||||
verified: true,
|
||||
verify_inband: true,
|
||||
verify_registry: true,
|
||||
trustSummary: {
|
||||
state: 'sas_verified',
|
||||
label: 'SAS VERIFIED',
|
||||
severity: 'good',
|
||||
detail: 'sas verified',
|
||||
verifiedFirstContact: true,
|
||||
recommendedAction: 'show_sas',
|
||||
legacyLookup: false,
|
||||
inviteAttested: false,
|
||||
rootDistributionState: 'none',
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
}),
|
||||
).toBe('SHOW SAS');
|
||||
});
|
||||
|
||||
it('maps import-invite and reverify actions to distinct labels', () => {
|
||||
expect(
|
||||
dmTrustPrimaryActionLabel({
|
||||
trustSummary: {
|
||||
state: 'unpinned',
|
||||
label: 'UNVERIFIED',
|
||||
severity: 'warn',
|
||||
detail: 'invite required',
|
||||
verifiedFirstContact: false,
|
||||
recommendedAction: 'import_invite',
|
||||
legacyLookup: false,
|
||||
inviteAttested: false,
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
}),
|
||||
).toBe('IMPORT INVITE');
|
||||
expect(
|
||||
dmTrustPrimaryActionLabel({
|
||||
trustSummary: {
|
||||
state: 'continuity_broken',
|
||||
label: 'CONTINUITY BROKEN',
|
||||
severity: 'danger',
|
||||
detail: 'reverify',
|
||||
verifiedFirstContact: false,
|
||||
recommendedAction: 'reverify',
|
||||
legacyLookup: false,
|
||||
inviteAttested: true,
|
||||
registryMismatch: true,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
}),
|
||||
).toBe('REVERIFY NOW');
|
||||
});
|
||||
|
||||
it('surfaces stable root mismatch as a continuity hazard', () => {
|
||||
const contact = {
|
||||
trustSummary: {
|
||||
state: 'continuity_broken',
|
||||
label: 'CONTINUITY BROKEN',
|
||||
severity: 'danger',
|
||||
detail: 'root changed',
|
||||
verifiedFirstContact: false,
|
||||
recommendedAction: 'reverify',
|
||||
legacyLookup: false,
|
||||
inviteAttested: true,
|
||||
rootAttested: true,
|
||||
rootWitnessed: true,
|
||||
rootDistributionState: 'quorum_witnessed',
|
||||
rootMismatch: true,
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildDmTrustHint(contact)).toEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'danger',
|
||||
title: 'CONTINUITY BROKEN',
|
||||
}),
|
||||
);
|
||||
expect(buildDmTrustHint(contact)?.detail).toContain('stable root identity');
|
||||
});
|
||||
|
||||
it('treats legacy lookup on an otherwise verified contact as an invite-import migration state', () => {
|
||||
const contact = {
|
||||
trustSummary: {
|
||||
state: 'sas_verified',
|
||||
label: 'SAS VERIFIED',
|
||||
severity: 'good',
|
||||
detail: 'sas verified but still legacy lookup',
|
||||
verifiedFirstContact: true,
|
||||
recommendedAction: 'import_invite',
|
||||
legacyLookup: true,
|
||||
inviteAttested: false,
|
||||
rootDistributionState: 'none',
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
};
|
||||
|
||||
expect(dmTrustPrimaryActionLabel(contact)).toBe('IMPORT INVITE');
|
||||
expect(buildDmTrustHint(contact)).toEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'warn',
|
||||
title: 'LEGACY LOOKUP',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces internal-only root continuity as an invite refresh state', () => {
|
||||
const contact = {
|
||||
trustSummary: {
|
||||
state: 'invite_pinned',
|
||||
label: 'INVITE PINNED',
|
||||
severity: 'warn',
|
||||
detail: 'invite pinned on internal root only',
|
||||
verifiedFirstContact: true,
|
||||
recommendedAction: 'import_invite',
|
||||
legacyLookup: false,
|
||||
inviteAttested: true,
|
||||
rootAttested: true,
|
||||
rootWitnessed: false,
|
||||
rootDistributionState: 'internal_only',
|
||||
rootMismatch: false,
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
};
|
||||
|
||||
expect(dmTrustPrimaryActionLabel(contact)).toBe('IMPORT INVITE');
|
||||
expect(buildDmTrustHint(contact)).toEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'warn',
|
||||
title: 'ROOT INTERNAL ONLY',
|
||||
}),
|
||||
);
|
||||
expect(buildDmTrustHint(contact)?.detail).toContain('witnessed root');
|
||||
});
|
||||
|
||||
it('surfaces single-witness root continuity as a weaker witnessed state', () => {
|
||||
const contact = {
|
||||
trustSummary: {
|
||||
state: 'invite_pinned',
|
||||
label: 'INVITE PINNED',
|
||||
severity: 'warn',
|
||||
detail: 'invite pinned on single witness root',
|
||||
verifiedFirstContact: true,
|
||||
recommendedAction: 'import_invite',
|
||||
legacyLookup: false,
|
||||
inviteAttested: true,
|
||||
rootAttested: true,
|
||||
rootWitnessed: true,
|
||||
rootDistributionState: 'single_witness',
|
||||
rootWitnessCount: 1,
|
||||
rootWitnessThreshold: 1,
|
||||
rootWitnessQuorumMet: true,
|
||||
rootMismatch: false,
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
};
|
||||
|
||||
expect(dmTrustPrimaryActionLabel(contact)).toBe('IMPORT INVITE');
|
||||
expect(buildDmTrustHint(contact)).toEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'warn',
|
||||
title: 'ROOT SINGLE WITNESS',
|
||||
}),
|
||||
);
|
||||
expect(buildDmTrustHint(contact)?.detail).toContain('quorum witness provenance');
|
||||
});
|
||||
|
||||
it('surfaces unproven witnessed root rotation as a hard invite refresh state', () => {
|
||||
const contact = {
|
||||
trustSummary: {
|
||||
state: 'invite_pinned',
|
||||
label: 'INVITE PINNED',
|
||||
severity: 'warn',
|
||||
detail: 'invite pinned on witnessed root without rotation proof',
|
||||
verifiedFirstContact: false,
|
||||
recommendedAction: 'import_invite',
|
||||
legacyLookup: false,
|
||||
inviteAttested: true,
|
||||
rootAttested: true,
|
||||
rootWitnessed: true,
|
||||
rootDistributionState: 'quorum_witnessed',
|
||||
rootManifestGeneration: 2,
|
||||
rootRotationProven: false,
|
||||
rootMismatch: false,
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
};
|
||||
|
||||
expect(dmTrustPrimaryActionLabel(contact)).toBe('IMPORT INVITE');
|
||||
expect(buildDmTrustHint(contact)).toEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'danger',
|
||||
title: 'ROOT ROTATION UNPROVEN',
|
||||
}),
|
||||
);
|
||||
expect(buildDmTrustHint(contact)?.detail).toContain('previous-root proof');
|
||||
});
|
||||
|
||||
it('surfaces unsatisfied witness policy as a hard invite refresh state', () => {
|
||||
const contact = {
|
||||
trustSummary: {
|
||||
state: 'invite_pinned',
|
||||
label: 'INVITE PINNED',
|
||||
severity: 'warn',
|
||||
detail: 'invite pinned on root missing witness quorum',
|
||||
verifiedFirstContact: false,
|
||||
recommendedAction: 'import_invite',
|
||||
legacyLookup: false,
|
||||
inviteAttested: true,
|
||||
rootAttested: true,
|
||||
rootWitnessed: true,
|
||||
rootDistributionState: 'witness_policy_not_met',
|
||||
rootWitnessCount: 1,
|
||||
rootWitnessThreshold: 2,
|
||||
rootWitnessQuorumMet: false,
|
||||
rootMismatch: false,
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
};
|
||||
|
||||
expect(dmTrustPrimaryActionLabel(contact)).toBe('IMPORT INVITE');
|
||||
expect(buildDmTrustHint(contact)).toEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'danger',
|
||||
title: 'ROOT WITNESS POLICY NOT MET',
|
||||
}),
|
||||
);
|
||||
expect(buildDmTrustHint(contact)?.detail).toContain('witness policy');
|
||||
});
|
||||
|
||||
it('transitional lane hint separates gate posture from DM posture', () => {
|
||||
const hint = buildPrivateLaneHint({
|
||||
activeTab: 'infonet',
|
||||
privateInfonetReady: true,
|
||||
privateInfonetTransportReady: false,
|
||||
});
|
||||
|
||||
expect(hint).toEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'warn',
|
||||
title: 'TRANSITIONAL PRIVATE LANE',
|
||||
}),
|
||||
);
|
||||
// Must explicitly mention gate is on a transitional lane
|
||||
expect(hint?.detail).toContain('transitional');
|
||||
// Must explicitly mention DM requires a stronger tier
|
||||
expect(hint?.detail).toContain('Dead Drop');
|
||||
expect(hint?.detail).toMatch(/PRIVATE \/ STRONG/i);
|
||||
// Must not imply gate and DM share the same posture
|
||||
expect(hint?.detail).toContain('weaker than DM');
|
||||
});
|
||||
|
||||
it('relay delivery hint is specific to Dead Drop, not gate', () => {
|
||||
const hint = buildPrivateLaneHint({
|
||||
activeTab: 'dms',
|
||||
dmTransportMode: 'relay',
|
||||
});
|
||||
|
||||
expect(hint).toEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'warn',
|
||||
title: 'RELAY DELIVERY ACTIVE',
|
||||
}),
|
||||
);
|
||||
expect(hint?.detail).toContain('Dead Drop');
|
||||
});
|
||||
|
||||
it('shortens long trust fingerprints for display', () => {
|
||||
expect(shortTrustFingerprint('abcdef0123456789fedcba9876543210')).toBe('abcdef01..543210');
|
||||
expect(shortTrustFingerprint('abcd1234')).toBe('abcd1234');
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
@@ -32,10 +35,59 @@ describe('mesh terminal policy', () => {
|
||||
expect(isMeshTerminalWriteCommand('send', ['broadcast', 'hello'])).toBe(true);
|
||||
});
|
||||
|
||||
it('wormhole active lock reason distinguishes gate and DM posture', () => {
|
||||
const reason = getMeshTerminalWriteLockReason({
|
||||
wormholeRequired: true,
|
||||
wormholeReady: true,
|
||||
anonymousMode: false,
|
||||
anonymousModeReady: false,
|
||||
});
|
||||
|
||||
// Must mention gate as transitional lane
|
||||
expect(reason).toContain('gate chat (transitional lane)');
|
||||
// Must mention Dead Drop as the stronger lane
|
||||
expect(reason).toContain('Dead Drop (stronger private lane)');
|
||||
// Must NOT use "hardened private actions" which flattens both
|
||||
expect(reason).not.toContain('hardened private actions');
|
||||
});
|
||||
|
||||
it('anonymous mode lock reason distinguishes gate and DM posture', () => {
|
||||
const reason = getMeshTerminalWriteLockReason({
|
||||
wormholeRequired: true,
|
||||
wormholeReady: true,
|
||||
anonymousMode: true,
|
||||
anonymousModeReady: true,
|
||||
});
|
||||
|
||||
expect(reason).toContain('gate chat (transitional lane)');
|
||||
expect(reason).toContain('Dead Drop (stronger private lane)');
|
||||
expect(reason).not.toContain('hardened');
|
||||
});
|
||||
|
||||
it('keeps read-only terminal commands available', () => {
|
||||
expect(isMeshTerminalWriteCommand('status', [])).toBe(false);
|
||||
expect(isMeshTerminalWriteCommand('signals', ['10'])).toBe(false);
|
||||
expect(isMeshTerminalWriteCommand('mesh', ['listen', '20'])).toBe(false);
|
||||
expect(isMeshTerminalWriteCommand('messages', [])).toBe(false);
|
||||
});
|
||||
|
||||
it('MeshTerminal does not use raw agent-id fetch as the ordinary DM send path', () => {
|
||||
const terminal = fs.readFileSync(
|
||||
path.resolve(__dirname, '../../components/MeshTerminal.tsx'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(terminal).toContain('fetchDmPublicKey');
|
||||
expect(terminal).toContain("only for legacy migration");
|
||||
expect(terminal).not.toContain('/api/mesh/dm/pubkey?agent_id=');
|
||||
});
|
||||
|
||||
it('MeshTerminal inbox surface owns mailbox refresh instead of racing the unread poll loop', () => {
|
||||
const terminal = fs.readFileSync(
|
||||
path.resolve(__dirname, '../../components/MeshTerminal.tsx'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(terminal).toContain("if (!isOpen || !nodeIdentity || !hasSovereignty() || !getDMNotify() || surfacePanel === 'inbox') return;");
|
||||
expect(terminal).toContain('classifyTick(hasMore, catchUpBudget, 15_000)');
|
||||
expect(terminal).toContain('() => void loadInboxSurface(classification.refreshCount)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,569 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
|
||||
import React from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
let contactsState: Record<string, any> = {};
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
buildMailboxClaims: vi.fn(async () => []),
|
||||
countDmMailboxes: vi.fn(async () => ({ ok: true, count: 0 })),
|
||||
ensureRegisteredDmKey: vi.fn(async () => ({ dhPubKey: 'local-dh', dhAlgo: 'X25519' })),
|
||||
fetchDmPublicKey: vi.fn(async () => ({ dh_pub_key: 'peer-dh', dh_algo: 'X25519' })),
|
||||
pollDmMailboxes: vi.fn(async () => ({ ok: true, messages: [] })),
|
||||
sendDmMessage: vi.fn(async () => ({ ok: true, transport: 'relay' })),
|
||||
sendOffLedgerConsentMessage: vi.fn(async () => ({ ok: true, transport: 'relay' })),
|
||||
sharedMailboxToken: vi.fn(async () => 'shared-token'),
|
||||
buildContactAcceptMessage: vi.fn(() => 'accept'),
|
||||
buildContactDenyMessage: vi.fn(() => 'deny'),
|
||||
buildContactOfferMessage: vi.fn(() => 'offer'),
|
||||
generateSharedAlias: vi.fn(() => 'alias-123'),
|
||||
mergeAliasHistory: vi.fn((history?: string[]) => history || []),
|
||||
parseAliasRotateMessage: vi.fn(() => null),
|
||||
parseDmConsentMessage: vi.fn(() => null),
|
||||
preferredDmPeerId: vi.fn((peerId: string) => peerId),
|
||||
allDmPeerIds: vi.fn(() => []),
|
||||
purgeBrowserDmState: vi.fn(async () => {}),
|
||||
ratchetDecryptDM: vi.fn(async () => {
|
||||
throw new Error('no_ratchet_state');
|
||||
}),
|
||||
ratchetEncryptDM: vi.fn(async () => 'ratchet-ciphertext'),
|
||||
addContact: vi.fn(),
|
||||
blockContact: vi.fn(),
|
||||
decryptDM: vi.fn(async () => 'plaintext'),
|
||||
decryptSenderSealPayloadLocally: vi.fn(async () => ''),
|
||||
deriveSharedKey: vi.fn(async () => ({})),
|
||||
encryptDM: vi.fn(async () => 'ciphertext'),
|
||||
getContacts: vi.fn(() => contactsState),
|
||||
getDHAlgo: vi.fn(() => 'X25519'),
|
||||
getNodeIdentity: vi.fn(() => ({
|
||||
nodeId: '!sb_local',
|
||||
publicKey: 'local-pub',
|
||||
privateKey: 'local-priv',
|
||||
})),
|
||||
hasSovereignty: vi.fn(() => true),
|
||||
hydrateWormholeContacts: vi.fn(async () => contactsState),
|
||||
purgeBrowserContactGraph: vi.fn(),
|
||||
purgeBrowserSigningMaterial: vi.fn(),
|
||||
removeContact: vi.fn(),
|
||||
unblockContact: vi.fn(),
|
||||
unwrapSenderSealPayload: vi.fn(() => ({ version: 'v2', ephemeralPub: '' })),
|
||||
updateContact: vi.fn(),
|
||||
verifyNodeIdBindingFromPublicKey: vi.fn(async () => true),
|
||||
verifyRawSignature: vi.fn(async () => true),
|
||||
getSenderRecoveryState: vi.fn(() => 'verified'),
|
||||
recoverSenderSealWithFallback: vi.fn(async () => null),
|
||||
requiresSenderRecovery: vi.fn(() => false),
|
||||
shouldKeepUnresolvedRequestVisible: vi.fn(() => false),
|
||||
shouldPromoteRecoveredSenderForBootstrap: vi.fn(() => false),
|
||||
shouldPromoteRecoveredSenderForKnownContact: vi.fn(() => false),
|
||||
bootstrapDecryptAccessRequest: vi.fn(async () => 'offer'),
|
||||
bootstrapEncryptAccessRequest: vi.fn(async () => 'x3dh1:bootstrap'),
|
||||
canUseWormholeBootstrap: vi.fn(async () => false),
|
||||
fetchWormholeStatus: vi.fn(async () => ({ ready: true, transport_tier: 'private_strong' })),
|
||||
fetchWormholeIdentity: vi.fn(async () => ({ node_id: '!sb_local', public_key: 'local-pub' })),
|
||||
prepareWormholeInteractiveLane: vi.fn(async () => ({
|
||||
ready: true,
|
||||
settingsEnabled: true,
|
||||
transportTier: 'private_transitional',
|
||||
identity: { node_id: '!sb_local', public_key: 'local-pub' },
|
||||
})),
|
||||
importWormholeDmInvite: vi.fn(async () => ({
|
||||
ok: true,
|
||||
peer_id: '!sb_imported',
|
||||
trust_fingerprint: 'invitefp',
|
||||
trust_level: 'invite_pinned',
|
||||
})),
|
||||
isWormholeReady: vi.fn(async () => true),
|
||||
isWormholeSecureRequired: vi.fn(async () => false),
|
||||
issueWormholePairwiseAlias: vi.fn(async () => ({ ok: true, shared_alias: 'alias-123' })),
|
||||
openWormholeSenderSeal: vi.fn(async () => ({ sender_id: '!sb_peer', seal_verified: true })),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
API_BASE: 'http://localhost:8000',
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshDmClient', () => ({
|
||||
buildMailboxClaims: mocks.buildMailboxClaims,
|
||||
countDmMailboxes: mocks.countDmMailboxes,
|
||||
ensureRegisteredDmKey: mocks.ensureRegisteredDmKey,
|
||||
fetchDmPublicKey: mocks.fetchDmPublicKey,
|
||||
pollDmMailboxes: mocks.pollDmMailboxes,
|
||||
sendDmMessage: mocks.sendDmMessage,
|
||||
sendOffLedgerConsentMessage: mocks.sendOffLedgerConsentMessage,
|
||||
sharedMailboxToken: mocks.sharedMailboxToken,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshDmConsent', () => ({
|
||||
allDmPeerIds: mocks.allDmPeerIds,
|
||||
buildContactAcceptMessage: mocks.buildContactAcceptMessage,
|
||||
buildContactDenyMessage: mocks.buildContactDenyMessage,
|
||||
buildContactOfferMessage: mocks.buildContactOfferMessage,
|
||||
generateSharedAlias: mocks.generateSharedAlias,
|
||||
mergeAliasHistory: mocks.mergeAliasHistory,
|
||||
parseAliasRotateMessage: mocks.parseAliasRotateMessage,
|
||||
parseDmConsentMessage: mocks.parseDmConsentMessage,
|
||||
preferredDmPeerId: mocks.preferredDmPeerId,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshDmWorkerClient', () => ({
|
||||
purgeBrowserDmState: mocks.purgeBrowserDmState,
|
||||
ratchetDecryptDM: mocks.ratchetDecryptDM,
|
||||
ratchetEncryptDM: mocks.ratchetEncryptDM,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshIdentity', () => ({
|
||||
addContact: mocks.addContact,
|
||||
blockContact: mocks.blockContact,
|
||||
decryptDM: mocks.decryptDM,
|
||||
decryptSenderSealPayloadLocally: mocks.decryptSenderSealPayloadLocally,
|
||||
deriveSharedKey: mocks.deriveSharedKey,
|
||||
encryptDM: mocks.encryptDM,
|
||||
getContacts: mocks.getContacts,
|
||||
getDHAlgo: mocks.getDHAlgo,
|
||||
getNodeIdentity: mocks.getNodeIdentity,
|
||||
hasSovereignty: mocks.hasSovereignty,
|
||||
hydrateWormholeContacts: mocks.hydrateWormholeContacts,
|
||||
purgeBrowserContactGraph: mocks.purgeBrowserContactGraph,
|
||||
purgeBrowserSigningMaterial: mocks.purgeBrowserSigningMaterial,
|
||||
removeContact: mocks.removeContact,
|
||||
unblockContact: mocks.unblockContact,
|
||||
unwrapSenderSealPayload: mocks.unwrapSenderSealPayload,
|
||||
updateContact: mocks.updateContact,
|
||||
verifyNodeIdBindingFromPublicKey: mocks.verifyNodeIdBindingFromPublicKey,
|
||||
verifyRawSignature: mocks.verifyRawSignature,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/requestSenderRecovery', () => ({
|
||||
getSenderRecoveryState: mocks.getSenderRecoveryState,
|
||||
recoverSenderSealWithFallback: mocks.recoverSenderSealWithFallback,
|
||||
requiresSenderRecovery: mocks.requiresSenderRecovery,
|
||||
shouldKeepUnresolvedRequestVisible: mocks.shouldKeepUnresolvedRequestVisible,
|
||||
shouldPromoteRecoveredSenderForBootstrap: mocks.shouldPromoteRecoveredSenderForBootstrap,
|
||||
shouldPromoteRecoveredSenderForKnownContact: mocks.shouldPromoteRecoveredSenderForKnownContact,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/wormholeDmBootstrapClient', () => ({
|
||||
bootstrapDecryptAccessRequest: mocks.bootstrapDecryptAccessRequest,
|
||||
bootstrapEncryptAccessRequest: mocks.bootstrapEncryptAccessRequest,
|
||||
canUseWormholeBootstrap: mocks.canUseWormholeBootstrap,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/wormholeIdentityClient', () => ({
|
||||
fetchWormholeStatus: mocks.fetchWormholeStatus,
|
||||
fetchWormholeIdentity: mocks.fetchWormholeIdentity,
|
||||
prepareWormholeInteractiveLane: mocks.prepareWormholeInteractiveLane,
|
||||
getWormholeDmInviteImportErrorResult: (error: unknown) =>
|
||||
error && typeof error === 'object' && 'result' in (error as Record<string, unknown>)
|
||||
? (((error as Record<string, unknown>).result as Record<string, unknown>) || null)
|
||||
: null,
|
||||
importWormholeDmInvite: mocks.importWormholeDmInvite,
|
||||
isWormholeReady: mocks.isWormholeReady,
|
||||
isWormholeSecureRequired: mocks.isWormholeSecureRequired,
|
||||
issueWormholePairwiseAlias: mocks.issueWormholePairwiseAlias,
|
||||
openWormholeSenderSeal: mocks.openWormholeSenderSeal,
|
||||
}));
|
||||
|
||||
import MessagesView from '@/components/InfonetTerminal/MessagesView';
|
||||
|
||||
function renderMessagesView(options?: {
|
||||
onOpenDeadDrop?: (peerId: string, opts?: { showSas?: boolean }) => void;
|
||||
}) {
|
||||
return render(<MessagesView onBack={() => {}} onOpenDeadDrop={options?.onOpenDeadDrop} />);
|
||||
}
|
||||
|
||||
async function openComposeForRecipient(recipient: string, body: string) {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'COMPOSE' }));
|
||||
fireEvent.change(screen.getByLabelText(/Recipient agent ID/i), {
|
||||
target: { value: recipient },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/Message/i), {
|
||||
target: { value: body },
|
||||
});
|
||||
await screen.findByLabelText(/Recipient agent ID/i);
|
||||
}
|
||||
|
||||
describe('MessagesView first-contact trust UX', () => {
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
contactsState = {};
|
||||
vi.clearAllMocks();
|
||||
|
||||
mocks.getContacts.mockImplementation(() => contactsState);
|
||||
mocks.hydrateWormholeContacts.mockImplementation(async () => contactsState);
|
||||
mocks.fetchWormholeStatus.mockResolvedValue({ ready: true, transport_tier: 'private_strong' });
|
||||
mocks.prepareWormholeInteractiveLane.mockResolvedValue({
|
||||
ready: true,
|
||||
settingsEnabled: true,
|
||||
transportTier: 'private_transitional',
|
||||
identity: { node_id: '!sb_local', public_key: 'local-pub' },
|
||||
});
|
||||
mocks.isWormholeSecureRequired.mockResolvedValue(false);
|
||||
mocks.getNodeIdentity.mockReturnValue({
|
||||
nodeId: '!sb_local',
|
||||
publicKey: 'local-pub',
|
||||
privateKey: 'local-priv',
|
||||
});
|
||||
mocks.hasSovereignty.mockReturnValue(true);
|
||||
mocks.buildMailboxClaims.mockResolvedValue([]);
|
||||
mocks.pollDmMailboxes.mockResolvedValue({ ok: true, messages: [] });
|
||||
mocks.countDmMailboxes.mockResolvedValue({ ok: true, count: 0 });
|
||||
mocks.ensureRegisteredDmKey.mockResolvedValue({ dhPubKey: 'local-dh', dhAlgo: 'X25519' });
|
||||
mocks.fetchDmPublicKey.mockResolvedValue({ dh_pub_key: 'peer-dh', dh_algo: 'X25519' });
|
||||
mocks.sendOffLedgerConsentMessage.mockResolvedValue({ ok: true, transport: 'relay' });
|
||||
mocks.canUseWormholeBootstrap.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('blocks unknown first contact until a signed invite is imported', async () => {
|
||||
renderMessagesView();
|
||||
await openComposeForRecipient('!sb_unknown', 'hello from first contact');
|
||||
|
||||
expect(await screen.findByText('Verified First Contact Required')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Secure request bootstrap is blocked until you import a signed invite/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Send Secure Mail' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('can jump directly from the downgrade warning into invite import flow', async () => {
|
||||
renderMessagesView();
|
||||
await openComposeForRecipient('!sb_unknown', 'hello from first contact');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Import Signed Invite' }));
|
||||
|
||||
expect(await screen.findByText('Import Verified Invite')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Local Alias/i)).toHaveValue('!sb_unknown');
|
||||
});
|
||||
|
||||
it('does not expose a TOFU downgrade button for first contact anymore', async () => {
|
||||
renderMessagesView();
|
||||
await openComposeForRecipient('!sb_unknown', 'hello from first contact');
|
||||
|
||||
expect(screen.queryByRole('button', { name: /Explicitly Allow TOFU/i })).not.toBeInTheDocument();
|
||||
expect(mocks.sendOffLedgerConsentMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not require the TOFU override when the contact is invite-pinned already', async () => {
|
||||
contactsState = {
|
||||
'!sb_invited': {
|
||||
alias: 'Pinned Peer',
|
||||
blocked: false,
|
||||
trust_level: 'invite_pinned',
|
||||
invitePinnedTrustFingerprint: 'abcdef123456',
|
||||
invitePinnedRootFingerprint: 'rootabcdef123456',
|
||||
invitePinnedRootManifestFingerprint: 'manifestabcdef123456',
|
||||
invitePinnedRootWitnessPolicyFingerprint: 'policyabcdef123456',
|
||||
invitePinnedRootWitnessThreshold: 2,
|
||||
invitePinnedRootWitnessCount: 3,
|
||||
invitePinnedRootManifestGeneration: 1,
|
||||
invitePinnedRootRotationProven: true,
|
||||
invitePinnedAt: 123,
|
||||
remotePrekeyFingerprint: 'abcdef123456',
|
||||
remotePrekeyRootFingerprint: 'rootabcdef123456',
|
||||
remotePrekeyRootManifestFingerprint: 'manifestabcdef123456',
|
||||
remotePrekeyRootWitnessPolicyFingerprint: 'policyabcdef123456',
|
||||
remotePrekeyRootWitnessThreshold: 2,
|
||||
remotePrekeyRootWitnessCount: 3,
|
||||
remotePrekeyRootManifestGeneration: 1,
|
||||
remotePrekeyRootRotationProven: true,
|
||||
},
|
||||
};
|
||||
|
||||
renderMessagesView();
|
||||
await openComposeForRecipient('!sb_invited', 'hello to pinned peer');
|
||||
|
||||
expect(screen.queryByText('Unverified First Contact')).not.toBeInTheDocument();
|
||||
expect(await screen.findByText('ROOT LOCAL QUORUM')).toBeInTheDocument();
|
||||
expect(await screen.findByText(/Local quorum root rootabcd\.\.123456/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Send Secure Mail' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('warms the private lane in the background before sending secure mail', async () => {
|
||||
contactsState = {
|
||||
'!sb_pinned': {
|
||||
alias: 'Pinned Peer',
|
||||
blocked: false,
|
||||
trust_level: 'invite_pinned',
|
||||
dhPubKey: 'peer-dh',
|
||||
remotePrekeyFingerprint: 'abcdef123456',
|
||||
},
|
||||
};
|
||||
mocks.fetchWormholeStatus.mockResolvedValue({ ready: false, transport_tier: 'public_degraded' });
|
||||
|
||||
renderMessagesView();
|
||||
await openComposeForRecipient('!sb_pinned', 'hello after warmup');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Send Secure Mail' }));
|
||||
|
||||
await screen.findByText(/Mail delivered to Pinned Peer/i);
|
||||
expect(mocks.prepareWormholeInteractiveLane).toHaveBeenCalled();
|
||||
expect(mocks.sendDmMessage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not flatten witness policy not met into a generic witnessed root label', async () => {
|
||||
contactsState = {
|
||||
'!sb_policy': {
|
||||
alias: 'Policy Peer',
|
||||
blocked: false,
|
||||
trust_level: 'invite_pinned',
|
||||
invitePinnedTrustFingerprint: 'policyfingerprint123456',
|
||||
invitePinnedRootFingerprint: 'rootpolicyabcdef123456',
|
||||
invitePinnedRootManifestFingerprint: 'manifestpolicyabcdef123456',
|
||||
invitePinnedRootWitnessPolicyFingerprint: 'policyabcdef123456',
|
||||
invitePinnedRootWitnessThreshold: 2,
|
||||
invitePinnedRootWitnessCount: 1,
|
||||
invitePinnedRootManifestGeneration: 1,
|
||||
invitePinnedRootRotationProven: true,
|
||||
invitePinnedAt: 123,
|
||||
remotePrekeyFingerprint: 'policyfingerprint123456',
|
||||
remotePrekeyRootFingerprint: 'rootpolicyabcdef123456',
|
||||
remotePrekeyRootManifestFingerprint: 'manifestpolicyabcdef123456',
|
||||
remotePrekeyRootWitnessPolicyFingerprint: 'policyabcdef123456',
|
||||
remotePrekeyRootWitnessThreshold: 2,
|
||||
remotePrekeyRootWitnessCount: 1,
|
||||
remotePrekeyRootManifestGeneration: 1,
|
||||
remotePrekeyRootRotationProven: true,
|
||||
},
|
||||
};
|
||||
|
||||
renderMessagesView();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
|
||||
|
||||
expect(await screen.findByText(/Witness-policy root rootpoli\.\.123456/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Witnessed root rootpoli\.\.123456/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an import-invite shortcut for unpinned contacts in the contact list', async () => {
|
||||
contactsState = {
|
||||
'!sb_unpinned': {
|
||||
alias: 'Weak Peer',
|
||||
blocked: false,
|
||||
dhPubKey: 'peer-dh',
|
||||
trust_level: 'unpinned',
|
||||
},
|
||||
};
|
||||
|
||||
renderMessagesView();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
|
||||
|
||||
const importButton = await screen.findByRole('button', { name: 'Import Invite' });
|
||||
fireEvent.click(importButton);
|
||||
expect(screen.getByLabelText(/Local Alias/i)).toHaveValue('!sb_unpinned');
|
||||
});
|
||||
|
||||
it('routes continuity reverify from Secure Messages into Dead Drop with SAS visible', async () => {
|
||||
contactsState = {
|
||||
'!sb_reverify': {
|
||||
alias: 'Broken Root Peer',
|
||||
blocked: false,
|
||||
trust_level: 'continuity_broken',
|
||||
remotePrekeyObservedFingerprint: 'observed123456',
|
||||
remotePrekeyObservedRootFingerprint: 'rootobserved123456',
|
||||
remotePrekeyRootMismatch: true,
|
||||
},
|
||||
};
|
||||
const onOpenDeadDrop = vi.fn();
|
||||
|
||||
renderMessagesView({ onOpenDeadDrop });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
|
||||
|
||||
const reverifyButton = await screen.findByRole('button', { name: 'REVERIFY NOW' });
|
||||
fireEvent.click(reverifyButton);
|
||||
|
||||
expect(onOpenDeadDrop).toHaveBeenCalledWith('!sb_reverify', { showSas: true });
|
||||
});
|
||||
|
||||
it('still blocks first contact when legacy verified flags and a dh key are seeded on an unpinned contact', async () => {
|
||||
contactsState = {
|
||||
'!sb_seeded': {
|
||||
alias: 'Seeded Peer',
|
||||
blocked: false,
|
||||
dhPubKey: 'forged-dh',
|
||||
verify_inband: true,
|
||||
verify_registry: true,
|
||||
verified: true,
|
||||
trust_level: 'unpinned',
|
||||
trustSummary: {
|
||||
state: 'unpinned',
|
||||
label: 'UNVERIFIED',
|
||||
severity: 'warn',
|
||||
detail: 'invite required',
|
||||
verifiedFirstContact: false,
|
||||
recommendedAction: 'import_invite',
|
||||
legacyLookup: false,
|
||||
inviteAttested: false,
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderMessagesView();
|
||||
await openComposeForRecipient('!sb_seeded', 'hello from forged first contact');
|
||||
|
||||
expect(await screen.findByText('Verified First Contact Required')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Secure request bootstrap is blocked until you import a signed invite/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Send Secure Mail' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('blocks ambient legacy lookup for verified contacts that still lack an invite handle', async () => {
|
||||
contactsState = {
|
||||
'!sb_legacy': {
|
||||
alias: 'Legacy Peer',
|
||||
blocked: false,
|
||||
trust_level: 'sas_verified',
|
||||
remotePrekeyLookupMode: 'legacy_agent_id',
|
||||
trustSummary: {
|
||||
state: 'sas_verified',
|
||||
label: 'SAS VERIFIED',
|
||||
severity: 'good',
|
||||
detail: 'legacy lookup still active',
|
||||
verifiedFirstContact: true,
|
||||
recommendedAction: 'import_invite',
|
||||
legacyLookup: true,
|
||||
inviteAttested: false,
|
||||
registryMismatch: false,
|
||||
transparencyConflict: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderMessagesView();
|
||||
await openComposeForRecipient('!sb_legacy', 'hello from a legacy lookup contact');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Send Secure Mail' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
/Import or re-import a signed invite before sending a contact request; legacy direct lookup is disabled\./i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(mocks.fetchDmPublicKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('announces attested invite imports as INVITE PINNED', async () => {
|
||||
mocks.importWormholeDmInvite.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
peer_id: '!sb_attested',
|
||||
trust_fingerprint: 'invitefp-attested',
|
||||
trust_level: 'invite_pinned',
|
||||
contact: {},
|
||||
});
|
||||
|
||||
renderMessagesView();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
|
||||
expect(await screen.findByText('Import Verified Invite')).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Signed Invite JSON/i), {
|
||||
target: { value: JSON.stringify({ invite: { event_type: 'dm_invite', payload: {} } }) },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Import Signed Invite' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(/INVITE PINNED for !sb_attested \(invitefp\.\.tested\)\./i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('announces compat invite imports as TOFU PINNED with backend detail', async () => {
|
||||
mocks.importWormholeDmInvite.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
peer_id: '!sb_compat',
|
||||
trust_fingerprint: 'invitefp-compat',
|
||||
trust_level: 'tofu_pinned',
|
||||
detail: 'legacy invite imported as tofu_pinned; SAS verification required before first contact',
|
||||
contact: {},
|
||||
});
|
||||
|
||||
renderMessagesView();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
|
||||
expect(await screen.findByText('Import Verified Invite')).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Signed Invite JSON/i), {
|
||||
target: { value: JSON.stringify({ invite: { event_type: 'dm_invite', payload: {} } }) },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Import Signed Invite' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(/TOFU PINNED for !sb_compat \(invitefp\.\.compat\)\./i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/legacy invite imported as tofu_pinned; SAS verification required before first contact/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces stable root continuity breaks on invite re-import', async () => {
|
||||
contactsState = {
|
||||
'!sb_attested': {
|
||||
alias: 'Pinned Peer',
|
||||
blocked: false,
|
||||
trust_level: 'continuity_broken',
|
||||
invitePinnedTrustFingerprint: 'oldfingerprint123456',
|
||||
invitePinnedRootFingerprint: 'rootold123456',
|
||||
remotePrekeyFingerprint: 'newfingerprint654321',
|
||||
remotePrekeyObservedFingerprint: 'newfingerprint654321',
|
||||
remotePrekeyRootFingerprint: 'rootold123456',
|
||||
remotePrekeyObservedRootFingerprint: 'rootnew654321',
|
||||
remotePrekeyRootMismatch: true,
|
||||
},
|
||||
};
|
||||
const error = Object.assign(
|
||||
new Error(
|
||||
'signed invite root continuity mismatch; re-verify SAS or replace the signed invite before trusting this root change',
|
||||
),
|
||||
{
|
||||
result: {
|
||||
ok: false,
|
||||
peer_id: '!sb_attested',
|
||||
trust_level: 'continuity_broken',
|
||||
detail:
|
||||
'signed invite root continuity mismatch; re-verify SAS or replace the signed invite before trusting this root change',
|
||||
contact: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
mocks.importWormholeDmInvite.mockRejectedValueOnce(error);
|
||||
|
||||
renderMessagesView();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
|
||||
expect(await screen.findByText('Import Verified Invite')).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Signed Invite JSON/i), {
|
||||
target: { value: JSON.stringify({ invite: { event_type: 'dm_invite', payload: {} } }) },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Import Signed Invite' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(/CONTINUITY BROKEN for Pinned Peer\. Stable root continuity changed\./i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/re-verify SAS in Dead Drop or replace the signed invite before trusting this contact again/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses non-blocking secure-mail startup language while the DM lane warms', async () => {
|
||||
mocks.fetchWormholeStatus.mockResolvedValueOnce({ ready: false, transport_tier: 'public_degraded' });
|
||||
mocks.prepareWormholeInteractiveLane.mockImplementation(
|
||||
() =>
|
||||
new Promise(() => {
|
||||
/* keep background warm-up pending for this assertion */
|
||||
}),
|
||||
);
|
||||
|
||||
renderMessagesView();
|
||||
|
||||
expect(
|
||||
await screen.findByText(/Preparing secure mail in the background/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(/LOCKED/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/enter the Wormhole/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import React from 'react';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const deferred = <T,>() => {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
const fetchWormholeStatus = vi.fn(async () => ({
|
||||
ready: false,
|
||||
running: false,
|
||||
transport_tier: 'public_degraded',
|
||||
transport_active: 'public_degraded',
|
||||
}));
|
||||
const prepareWormholeInteractiveLane = vi.fn();
|
||||
const fetchWormholeSettings = vi.fn(async () => ({
|
||||
enabled: false,
|
||||
anonymous_mode: false,
|
||||
}));
|
||||
const purgeBrowserContactGraph = vi.fn();
|
||||
const purgeBrowserSigningMaterial = vi.fn();
|
||||
const setSecureModeCached = vi.fn();
|
||||
const getNodeIdentity = vi.fn(() => null);
|
||||
const generateNodeKeys = vi.fn(async () => ({}));
|
||||
const purgeBrowserDmState = vi.fn(async () => {});
|
||||
const fetchInfonetNodeStatusSnapshot = vi.fn(async () => ({
|
||||
enabled: false,
|
||||
peers_ready: false,
|
||||
identity_ready: false,
|
||||
}));
|
||||
const requestMeshTerminalOpen = vi.fn();
|
||||
const subscribeSecureMeshTerminalLauncherOpen = vi.fn(() => () => {});
|
||||
const classifyUpdateRuntime = vi.fn(() => ({
|
||||
action: 'auto_apply',
|
||||
detail: 'test',
|
||||
}));
|
||||
const getDesktopUpdateContext = vi.fn(() => ({
|
||||
packaged: false,
|
||||
ownsLocalBackend: false,
|
||||
}));
|
||||
const getPreferredManualUpdateUrl = vi.fn(() => 'https://example.test/releases/latest');
|
||||
const getUpdateAction = vi.fn(() => 'auto_apply');
|
||||
const controlPlaneFetch = vi.fn();
|
||||
|
||||
vi.mock('@/mesh/wormholeIdentityClient', () => ({
|
||||
fetchWormholeStatus,
|
||||
prepareWormholeInteractiveLane,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/wormholeClient', () => ({
|
||||
fetchWormholeSettings,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshIdentity', () => ({
|
||||
purgeBrowserContactGraph,
|
||||
purgeBrowserSigningMaterial,
|
||||
setSecureModeCached,
|
||||
getNodeIdentity,
|
||||
generateNodeKeys,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/meshDmWorkerClient', () => ({
|
||||
purgeBrowserDmState,
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/controlPlaneStatusClient', () => ({
|
||||
fetchInfonetNodeStatusSnapshot,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/meshTerminalLauncher', () => ({
|
||||
requestMeshTerminalOpen,
|
||||
subscribeSecureMeshTerminalLauncherOpen,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/updateRuntime', () => ({
|
||||
classifyUpdateRuntime,
|
||||
getDesktopUpdateContext,
|
||||
getPreferredManualUpdateUrl,
|
||||
getUpdateAction,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/controlPlane', () => ({
|
||||
controlPlaneFetch,
|
||||
}));
|
||||
|
||||
describe('TopRightControls terminal launcher', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
fetchWormholeStatus.mockResolvedValue({
|
||||
ready: false,
|
||||
running: false,
|
||||
transport_tier: 'public_degraded',
|
||||
transport_active: 'public_degraded',
|
||||
});
|
||||
fetchWormholeSettings.mockResolvedValue({
|
||||
enabled: false,
|
||||
anonymous_mode: false,
|
||||
});
|
||||
fetchInfonetNodeStatusSnapshot.mockResolvedValue({
|
||||
enabled: false,
|
||||
peers_ready: false,
|
||||
identity_ready: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('opens the terminal immediately while Wormhole prep continues in the background', async () => {
|
||||
const prep = deferred<{
|
||||
ready: boolean;
|
||||
settingsEnabled: boolean;
|
||||
transportTier: string;
|
||||
identity: null;
|
||||
}>();
|
||||
prepareWormholeInteractiveLane.mockReturnValue(prep.promise);
|
||||
|
||||
const { default: TopRightControls } = await import('@/components/TopRightControls');
|
||||
const onTerminalToggle = vi.fn();
|
||||
|
||||
render(<TopRightControls onTerminalToggle={onTerminalToggle} />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /terminal/i }));
|
||||
expect(await screen.findByRole('button', { name: /activate wormhole/i })).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /activate wormhole/i }));
|
||||
|
||||
await waitFor(() => expect(onTerminalToggle).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('button', { name: /activate wormhole/i })).toBeNull(),
|
||||
);
|
||||
expect(prepareWormholeInteractiveLane).toHaveBeenCalledWith({ bootstrapIdentity: true });
|
||||
|
||||
prep.resolve({
|
||||
ready: true,
|
||||
settingsEnabled: true,
|
||||
transportTier: 'private_control_only',
|
||||
identity: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
formatLegacyCompatibilitySeenAt,
|
||||
hasLegacyCompatibilityActivity,
|
||||
summarizeLegacyCompatibility,
|
||||
type LegacyCompatibilitySnapshot,
|
||||
} from '@/mesh/wormholeCompatibility';
|
||||
|
||||
describe('wormholeCompatibility helpers', () => {
|
||||
it('summarizes empty snapshots with zeroed metrics', () => {
|
||||
const items = summarizeLegacyCompatibility(undefined);
|
||||
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0]).toMatchObject({
|
||||
key: 'legacy_node_id_binding',
|
||||
blocked: false,
|
||||
count: 0,
|
||||
blockedCount: 0,
|
||||
targetVersion: 'n/a',
|
||||
targetDate: 'n/a',
|
||||
recentTargets: [],
|
||||
});
|
||||
expect(items[1]).toMatchObject({
|
||||
key: 'legacy_agent_id_lookup',
|
||||
blocked: false,
|
||||
count: 0,
|
||||
blockedCount: 0,
|
||||
targetVersion: 'n/a',
|
||||
targetDate: 'n/a',
|
||||
recentTargets: [],
|
||||
});
|
||||
expect(hasLegacyCompatibilityActivity(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('formats legacy usage, block state, and recent targets', () => {
|
||||
const snapshot: LegacyCompatibilitySnapshot = {
|
||||
sunset: {
|
||||
legacy_node_id_binding: {
|
||||
target_version: '0.10.0',
|
||||
target_date: '2026-06-01',
|
||||
blocked: true,
|
||||
},
|
||||
legacy_agent_id_lookup: {
|
||||
target_version: '0.10.0',
|
||||
target_date: '2026-06-01',
|
||||
blocked: false,
|
||||
},
|
||||
},
|
||||
usage: {
|
||||
legacy_node_id_binding: {
|
||||
count: 4,
|
||||
blocked_count: 2,
|
||||
last_seen_at: 1712345678,
|
||||
recent_targets: [
|
||||
{
|
||||
node_id: 'abcdef0123456789',
|
||||
current_node_id: 'fedcba9876543210abcdef0123456789',
|
||||
},
|
||||
],
|
||||
},
|
||||
legacy_agent_id_lookup: {
|
||||
count: 3,
|
||||
blocked_count: 1,
|
||||
last_seen_at: 1712345000,
|
||||
recent_targets: [
|
||||
{
|
||||
agent_id: 'agent-xyz-0123456789',
|
||||
lookup_kinds: ['prekey_bundle', 'dh_pubkey'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const items = summarizeLegacyCompatibility(snapshot);
|
||||
|
||||
expect(items[0]).toMatchObject({
|
||||
blocked: true,
|
||||
count: 4,
|
||||
blockedCount: 2,
|
||||
targetVersion: '0.10.0',
|
||||
targetDate: '2026-06-01',
|
||||
});
|
||||
expect(items[0].recentTargets[0]).toContain('abcdef0123...');
|
||||
expect(items[0].recentTargets[0]).toContain('fedcba9876...');
|
||||
expect(items[1]).toMatchObject({
|
||||
blocked: false,
|
||||
count: 3,
|
||||
blockedCount: 1,
|
||||
targetVersion: '0.10.0',
|
||||
targetDate: '2026-06-01',
|
||||
});
|
||||
expect(items[1].recentTargets[0]).toContain('agent-xyz-...');
|
||||
expect(items[1].recentTargets[0]).toContain('prekey_bundle, dh_pubkey');
|
||||
expect(hasLegacyCompatibilityActivity(snapshot)).toBe(true);
|
||||
});
|
||||
|
||||
it('formats seen timestamps as stable UTC text', () => {
|
||||
expect(formatLegacyCompatibilitySeenAt(0)).toBe('never');
|
||||
expect(formatLegacyCompatibilitySeenAt(1712345678)).toBe('2024-04-05 19:34Z');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Sprint 4D behavioral tests — page.tsx wormhole teardown and layer sync.
|
||||
*
|
||||
* These tests exercise actual runtime logic:
|
||||
* 1. teardownWormholeOnClose — calls leaveWormhole only when state is ready or running
|
||||
* 2. Layer sync first-mount suppression — initial sync does NOT dispatch LAYER_TOGGLE_EVENT
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { teardownWormholeOnClose } from '@/lib/wormholeTeardown';
|
||||
import { LAYER_TOGGLE_EVENT } from '@/hooks/useDataPolling';
|
||||
|
||||
// ─── teardownWormholeOnClose ──────────────────────────────────────────────
|
||||
|
||||
describe('page.tsx behavior — teardownWormholeOnClose', () => {
|
||||
let fetchState: ReturnType<typeof vi.fn>;
|
||||
let leave: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchState = vi.fn();
|
||||
leave = vi.fn().mockResolvedValue({});
|
||||
});
|
||||
|
||||
it('calls leaveWormhole when state is ready', async () => {
|
||||
fetchState.mockResolvedValue({ ready: true, running: false });
|
||||
await teardownWormholeOnClose(fetchState, leave);
|
||||
expect(fetchState).toHaveBeenCalledWith(false);
|
||||
expect(leave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls leaveWormhole when state is running', async () => {
|
||||
fetchState.mockResolvedValue({ ready: false, running: true });
|
||||
await teardownWormholeOnClose(fetchState, leave);
|
||||
expect(leave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls leaveWormhole when state is both ready and running', async () => {
|
||||
fetchState.mockResolvedValue({ ready: true, running: true });
|
||||
await teardownWormholeOnClose(fetchState, leave);
|
||||
expect(leave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does NOT call leaveWormhole when state is neither ready nor running', async () => {
|
||||
fetchState.mockResolvedValue({ ready: false, running: false });
|
||||
await teardownWormholeOnClose(fetchState, leave);
|
||||
expect(fetchState).toHaveBeenCalledWith(false);
|
||||
expect(leave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT call leaveWormhole when state is null', async () => {
|
||||
fetchState.mockResolvedValue(null);
|
||||
await teardownWormholeOnClose(fetchState, leave);
|
||||
expect(leave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('swallows fetchState errors gracefully', async () => {
|
||||
fetchState.mockRejectedValue(new Error('network down'));
|
||||
await teardownWormholeOnClose(fetchState, leave);
|
||||
expect(leave).not.toHaveBeenCalled();
|
||||
// No error thrown — handler is best-effort
|
||||
});
|
||||
|
||||
it('swallows leaveWormhole errors gracefully', async () => {
|
||||
fetchState.mockResolvedValue({ ready: true });
|
||||
leave.mockRejectedValue(new Error('leave failed'));
|
||||
await teardownWormholeOnClose(fetchState, leave);
|
||||
// No error thrown — handler is best-effort
|
||||
});
|
||||
|
||||
it('always passes force=false to fetchState', async () => {
|
||||
fetchState.mockResolvedValue({ ready: true });
|
||||
await teardownWormholeOnClose(fetchState, leave);
|
||||
expect(fetchState).toHaveBeenCalledWith(false);
|
||||
expect(fetchState).not.toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Layer sync first-mount suppression ───────────────────────────────────
|
||||
|
||||
describe('page.tsx behavior — layer sync first-mount suppression', () => {
|
||||
it('LAYER_TOGGLE_EVENT is the expected string constant', () => {
|
||||
expect(LAYER_TOGGLE_EVENT).toBe('sb:layer-toggle');
|
||||
});
|
||||
|
||||
it('first-mount ref pattern suppresses dispatch, subsequent calls dispatch', () => {
|
||||
// Simulate the initialLayerSyncRef pattern from page.tsx
|
||||
const initialSyncDone = { current: false };
|
||||
const dispatched: boolean[] = [];
|
||||
|
||||
const syncLayers = (triggerRefetch: boolean) => {
|
||||
if (triggerRefetch) {
|
||||
dispatched.push(true);
|
||||
} else {
|
||||
dispatched.push(false);
|
||||
}
|
||||
};
|
||||
|
||||
// First call (mount): should pass false → no dispatch
|
||||
if (!initialSyncDone.current) {
|
||||
initialSyncDone.current = true;
|
||||
syncLayers(false);
|
||||
} else {
|
||||
syncLayers(true);
|
||||
}
|
||||
expect(dispatched).toEqual([false]);
|
||||
|
||||
// Second call (layer change): should pass true → dispatch
|
||||
if (!initialSyncDone.current) {
|
||||
initialSyncDone.current = true;
|
||||
syncLayers(false);
|
||||
} else {
|
||||
syncLayers(true);
|
||||
}
|
||||
expect(dispatched).toEqual([false, true]);
|
||||
|
||||
// Third call (another layer change): should still dispatch
|
||||
if (!initialSyncDone.current) {
|
||||
initialSyncDone.current = true;
|
||||
syncLayers(false);
|
||||
} else {
|
||||
syncLayers(true);
|
||||
}
|
||||
expect(dispatched).toEqual([false, true, true]);
|
||||
});
|
||||
|
||||
it('page.tsx uses initialLayerSyncRef for first-mount suppression', () => {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const page = fs.readFileSync(
|
||||
path.resolve(__dirname, '../../app/page.tsx'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(page).toContain('initialLayerSyncRef');
|
||||
expect(page).toContain('void syncLayers(false)');
|
||||
expect(page).toContain('void syncLayers(true)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Sprint 4B regression tests — page.tsx decomposition boundary checks.
|
||||
*
|
||||
* These tests validate the frozen contract for page.tsx decomposition:
|
||||
* 1. InfonetTerminal onClose still calls leaveWormhole when wormhole is ready/running
|
||||
* 2. Initial /api/layers sync does NOT dispatch LAYER_TOGGLE_EVENT on first mount
|
||||
* 3. launchMeshChatTab preserves atomic leftOpen + leftMeshExpanded + meshChatLaunchRequest
|
||||
* 4. LocateBar extracted to page-local module
|
||||
* 5. SentinelInfoModal extracted to page-local module
|
||||
* 6. page.tsx retains all frozen-contract orchestration items
|
||||
* 7. MeshChat and MaplibreViewer integration boundaries remain intact
|
||||
* 8. No admin-session or proxy regression introduced
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const APP_DIR = path.resolve(__dirname, '../../app');
|
||||
|
||||
function readAppFile(name: string): string {
|
||||
return fs.readFileSync(path.join(APP_DIR, name), 'utf-8');
|
||||
}
|
||||
|
||||
// ─── Extraction verification ────────────────────────────────────────────────
|
||||
|
||||
describe('page.tsx decomposition — extraction targets', () => {
|
||||
it('LocateBar is defined in its own page-local module', () => {
|
||||
const locateBar = readAppFile('LocateBar.tsx');
|
||||
expect(locateBar).toMatch(/export\s+function\s+LocateBar/);
|
||||
expect(locateBar).toContain('onLocate');
|
||||
expect(locateBar).toContain('onOpenChange');
|
||||
});
|
||||
|
||||
it('SentinelInfoModal is defined in its own page-local module', () => {
|
||||
const modal = readAppFile('SentinelInfoModal.tsx');
|
||||
expect(modal).toMatch(/export\s+function\s+SentinelInfoModal/);
|
||||
expect(modal).toContain('onClose');
|
||||
expect(modal).toContain('SENTINEL HUB IMAGERY');
|
||||
});
|
||||
|
||||
it('page.tsx imports LocateBar from page-local module', () => {
|
||||
const page = readAppFile('page.tsx');
|
||||
expect(page).toMatch(/import\s*\{.*LocateBar.*\}\s*from\s+['"]\.\/LocateBar['"]/);
|
||||
});
|
||||
|
||||
it('page.tsx imports SentinelInfoModal from page-local module', () => {
|
||||
const page = readAppFile('page.tsx');
|
||||
expect(page).toMatch(/import\s*\{.*SentinelInfoModal.*\}\s*from\s+['"]\.\/SentinelInfoModal['"]/);
|
||||
});
|
||||
|
||||
it('page.tsx no longer defines LocateBar inline', () => {
|
||||
const page = readAppFile('page.tsx');
|
||||
// Should not have the old inline function definition
|
||||
expect(page).not.toMatch(/^function\s+LocateBar\s*\(/m);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── InfonetTerminal onClose wormhole teardown ──────────────────────────────
|
||||
|
||||
describe('page.tsx decomposition — InfonetTerminal onClose wormhole teardown', () => {
|
||||
const page = readAppFile('page.tsx');
|
||||
|
||||
it('InfonetTerminal onClose delegates to teardownWormholeOnClose', () => {
|
||||
const infonetSection = page.slice(
|
||||
page.indexOf('<InfonetTerminal'),
|
||||
page.indexOf('</InfonetTerminal>') !== -1
|
||||
? page.indexOf('</InfonetTerminal>')
|
||||
: page.indexOf('/>', page.indexOf('<InfonetTerminal')) + 2,
|
||||
);
|
||||
expect(infonetSection).toContain('teardownWormholeOnClose');
|
||||
expect(infonetSection).toContain('fetchWormholeState');
|
||||
expect(infonetSection).toContain('leaveWormhole');
|
||||
});
|
||||
|
||||
it('page.tsx imports teardownWormholeOnClose from wormholeTeardown', () => {
|
||||
expect(page).toMatch(
|
||||
/import\s*\{[^}]*teardownWormholeOnClose[^}]*\}\s*from\s+['"]@\/lib\/wormholeTeardown['"]/,
|
||||
);
|
||||
});
|
||||
|
||||
it('page.tsx imports leaveWormhole and fetchWormholeState from wormholeClient', () => {
|
||||
expect(page).toMatch(
|
||||
/import\s*\{[^}]*leaveWormhole[^}]*\}\s*from\s+['"]@\/mesh\/wormholeClient['"]/,
|
||||
);
|
||||
expect(page).toMatch(
|
||||
/import\s*\{[^}]*fetchWormholeState[^}]*\}\s*from\s+['"]@\/mesh\/wormholeClient['"]/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── /api/layers sync: first mount vs later changes ─────────────────────────
|
||||
|
||||
describe('page.tsx decomposition — /api/layers sync behavior', () => {
|
||||
const page = readAppFile('page.tsx');
|
||||
|
||||
it('uses initialLayerSyncRef to distinguish first sync from later changes', () => {
|
||||
expect(page).toContain('initialLayerSyncRef');
|
||||
// Check that initialLayerSyncRef is created as a ref
|
||||
expect(page).toMatch(/initialLayerSyncRef\s*=\s*useRef\s*\(\s*false\s*\)/);
|
||||
});
|
||||
|
||||
it('first mount sync passes false to triggerRefetch (no LAYER_TOGGLE_EVENT)', () => {
|
||||
// The code checks if initialLayerSyncRef.current is false, then calls syncLayers(false)
|
||||
expect(page).toMatch(/if\s*\(\s*!initialLayerSyncRef\.current\s*\)/);
|
||||
// After the check, it sets the ref to true and calls with false
|
||||
expect(page).toContain('syncLayers(false)');
|
||||
});
|
||||
|
||||
it('subsequent changes dispatch LAYER_TOGGLE_EVENT via syncLayers(true)', () => {
|
||||
expect(page).toContain('syncLayers(true)');
|
||||
});
|
||||
|
||||
it('LAYER_TOGGLE_EVENT is imported and dispatched inside syncLayers when triggerRefetch=true', () => {
|
||||
expect(page).toMatch(/import\s*\{[^}]*LAYER_TOGGLE_EVENT[^}]*\}/);
|
||||
expect(page).toMatch(/LAYER_TOGGLE_EVENT/);
|
||||
// dispatched conditionally on triggerRefetch
|
||||
expect(page).toMatch(/if\s*\(\s*triggerRefetch\s*\)/);
|
||||
expect(page).toContain('new Event(LAYER_TOGGLE_EVENT)');
|
||||
});
|
||||
|
||||
it('activeLayers state is defined in page.tsx (not moved to hook/context)', () => {
|
||||
expect(page).toMatch(/\[activeLayers,\s*setActiveLayers\]\s*=\s*useState/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── launchMeshChatTab atomic update ────────────────────────────────────────
|
||||
|
||||
describe('page.tsx decomposition — launchMeshChatTab atomicity', () => {
|
||||
const page = readAppFile('page.tsx');
|
||||
|
||||
it('launchMeshChatTab sets leftOpen to true', () => {
|
||||
// Extract the launchMeshChatTab definition
|
||||
const idx = page.indexOf('launchMeshChatTab');
|
||||
const block = page.slice(idx, idx + 300);
|
||||
expect(block).toContain('setLeftOpen(true)');
|
||||
});
|
||||
|
||||
it('launchMeshChatTab sets leftMeshExpanded to true', () => {
|
||||
const idx = page.indexOf('launchMeshChatTab');
|
||||
const block = page.slice(idx, idx + 300);
|
||||
expect(block).toContain('setLeftMeshExpanded(true)');
|
||||
});
|
||||
|
||||
it('launchMeshChatTab sets meshChatLaunchRequest with tab, gate, peerId, showSas, and nonce', () => {
|
||||
const idx = page.indexOf('launchMeshChatTab');
|
||||
const block = page.slice(idx, idx + 500);
|
||||
expect(block).toContain('setMeshChatLaunchRequest');
|
||||
expect(block).toMatch(/tab.*gate.*peerId.*showSas.*nonce|nonce.*Date\.now/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── MeshChat and MaplibreViewer integration boundaries ─────────────────────
|
||||
|
||||
describe('page.tsx decomposition — child component integration', () => {
|
||||
const page = readAppFile('page.tsx');
|
||||
|
||||
it('MeshChat receives onFlyTo, expanded, onExpandedChange, onSettingsClick, onTerminalToggle, launchRequest props', () => {
|
||||
const meshChatIdx = page.indexOf('<MeshChat');
|
||||
const meshChatBlock = page.slice(meshChatIdx, meshChatIdx + 500);
|
||||
expect(meshChatBlock).toContain('onFlyTo');
|
||||
expect(meshChatBlock).toContain('expanded=');
|
||||
expect(meshChatBlock).toContain('onExpandedChange');
|
||||
expect(meshChatBlock).toContain('onSettingsClick');
|
||||
expect(meshChatBlock).toContain('onTerminalToggle');
|
||||
expect(meshChatBlock).toContain('launchRequest');
|
||||
});
|
||||
|
||||
it('MaplibreViewer receives activeLayers and viewBoundsRef props', () => {
|
||||
const mapIdx = page.indexOf('<MaplibreViewer');
|
||||
const mapBlock = page.slice(mapIdx, mapIdx + 1500);
|
||||
expect(mapBlock).toContain('activeLayers');
|
||||
expect(mapBlock).toContain('viewBoundsRef');
|
||||
});
|
||||
|
||||
it('page.tsx imports MeshChat from @/components/MeshChat', () => {
|
||||
expect(page).toMatch(/import\s+MeshChat\s+from\s+['"]@\/components\/MeshChat['"]/);
|
||||
});
|
||||
|
||||
it('page.tsx imports MaplibreViewer dynamically', () => {
|
||||
expect(page).toMatch(/dynamic\s*\(\s*\(\)\s*=>\s*import\s*\(\s*['"]@\/components\/MaplibreViewer['"]\s*\)/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── No admin-session or proxy regression ───────────────────────────────────
|
||||
|
||||
describe('page.tsx decomposition — no admin-session/proxy regression', () => {
|
||||
const page = readAppFile('page.tsx');
|
||||
|
||||
it('page.tsx still uses useDataPolling at top level', () => {
|
||||
expect(page).toMatch(/useDataPolling\s*\(\s*\)/);
|
||||
});
|
||||
|
||||
it('page.tsx still uses useBackendStatus', () => {
|
||||
expect(page).toContain('useBackendStatus');
|
||||
});
|
||||
|
||||
it('page.tsx does not import admin session utilities directly (they stay in hooks)', () => {
|
||||
// Admin session handling is in useDataPolling and backend hooks, not page.tsx
|
||||
expect(page).not.toMatch(/adminSession|admin_session/i);
|
||||
});
|
||||
|
||||
it('LocateBar uses backend proxy for geocoding (not direct-only)', () => {
|
||||
const locateBar = readAppFile('LocateBar.tsx');
|
||||
expect(locateBar).toContain('API_BASE');
|
||||
expect(locateBar).toContain('/api/geocode/search');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── page.tsx retains all frozen-contract orchestration ─────────────────────
|
||||
|
||||
describe('page.tsx decomposition — retained orchestration', () => {
|
||||
const page = readAppFile('page.tsx');
|
||||
|
||||
it('page.tsx retains cycleStyle with atomic activeStyle + highres_satellite update', () => {
|
||||
expect(page).toMatch(/cycleStyle/);
|
||||
const idx = page.indexOf('cycleStyle');
|
||||
const block = page.slice(idx, idx + 300);
|
||||
expect(block).toContain('setActiveStyle');
|
||||
expect(block).toContain('highres_satellite');
|
||||
});
|
||||
|
||||
it('page.tsx retains viewBoundsRef', () => {
|
||||
expect(page).toMatch(/viewBoundsRef\s*=\s*useRef/);
|
||||
});
|
||||
|
||||
it('page.tsx retains SSR-safe localStorage hydration', () => {
|
||||
expect(page).toContain('localStorage.getItem');
|
||||
expect(page).toContain('sb_left_open');
|
||||
expect(page).toContain('sb_right_open');
|
||||
});
|
||||
|
||||
it('page.tsx retains infonetOpen state', () => {
|
||||
expect(page).toMatch(/\[infonetOpen,\s*setInfonetOpen\]\s*=\s*useState/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* Sprint 1C: Proxy admin-key injection coverage tests.
|
||||
*
|
||||
* Verifies that the server-side catch-all proxy injects X-Admin-Key on the
|
||||
* backend leg for routes guarded by require_local_operator:
|
||||
* - /api/mesh/peers (Sprint 1C addition)
|
||||
* - /api/tools/* (Sprint 1C addition)
|
||||
* - /api/wormhole/* (pre-existing, regression)
|
||||
* - /api/settings/* (pre-existing, regression)
|
||||
*
|
||||
* Also verifies that:
|
||||
* - non-sensitive mesh paths (e.g. mesh/events) do NOT receive injected key
|
||||
* - browser-supplied x-admin-key is stripped before forwarding (not trusted)
|
||||
* - no-store cache headers are set on all sensitive paths
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
import { GET as proxyGet, POST as proxyPost } from '@/app/api/[...path]/route';
|
||||
import {
|
||||
POST as postAdminSession,
|
||||
} from '@/app/api/admin/session/route';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function extractSessionCookie(setCookie: string): string {
|
||||
return setCookie.split(';')[0] || '';
|
||||
}
|
||||
|
||||
/** Mint a valid admin session and return the raw cookie string. */
|
||||
async function mintSession(adminKey: string): Promise<string> {
|
||||
const verifyMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', verifyMock);
|
||||
|
||||
const req = new NextRequest('http://localhost/api/admin/session', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ adminKey }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
const res = await postAdminSession(req);
|
||||
return extractSessionCookie(res.headers.get('set-cookie') || '');
|
||||
}
|
||||
|
||||
/** Return the Headers object forwarded to the upstream fetch call. */
|
||||
function capturedHeaders(fetchMock: ReturnType<typeof vi.fn>): Headers {
|
||||
const forwarded = fetchMock.mock.calls[0]?.[1];
|
||||
return new Headers((forwarded as RequestInit | undefined)?.headers);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('proxy admin-key injection coverage', () => {
|
||||
const ADMIN_KEY = 'a-valid-admin-key-that-is-at-least-32chars!!';
|
||||
const originalAdminKey = process.env.ADMIN_KEY;
|
||||
const originalBackendUrl = process.env.BACKEND_URL;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.ADMIN_KEY = ADMIN_KEY;
|
||||
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();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Sprint 1C: mesh/peers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('GET /api/mesh/peers with valid session injects X-Admin-Key', async () => {
|
||||
const cookie = await mintSession(ADMIN_KEY);
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ peers: [] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const req = new NextRequest('http://localhost/api/mesh/peers', {
|
||||
method: 'GET',
|
||||
headers: { cookie },
|
||||
});
|
||||
const res = await proxyGet(req, {
|
||||
params: Promise.resolve({ path: ['mesh', 'peers'] }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBe(ADMIN_KEY);
|
||||
});
|
||||
|
||||
it('POST /api/mesh/peers with valid session injects X-Admin-Key', async () => {
|
||||
const cookie = await mintSession(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/mesh/peers', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url: 'http://peer.example.com:8000' }),
|
||||
headers: { cookie, 'Content-Type': 'application/json' },
|
||||
});
|
||||
const res = await proxyPost(req, {
|
||||
params: Promise.resolve({ path: ['mesh', 'peers'] }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBe(ADMIN_KEY);
|
||||
});
|
||||
|
||||
it('GET /api/mesh/peers applies no-store cache headers', async () => {
|
||||
const cookie = await mintSession(ADMIN_KEY);
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ peers: [] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const req = new NextRequest('http://localhost/api/mesh/peers', {
|
||||
method: 'GET',
|
||||
headers: { cookie },
|
||||
});
|
||||
const res = await proxyGet(req, {
|
||||
params: Promise.resolve({ path: ['mesh', 'peers'] }),
|
||||
});
|
||||
|
||||
expect(res.headers.get('cache-control')).toContain('no-store');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Sprint 1C: tools/*
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('POST /api/tools/shodan/search with valid session injects X-Admin-Key', async () => {
|
||||
const cookie = await mintSession(ADMIN_KEY);
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ results: [] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const req = new NextRequest('http://localhost/api/tools/shodan/search', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ query: 'port:22' }),
|
||||
headers: { cookie, 'Content-Type': 'application/json' },
|
||||
});
|
||||
const res = await proxyPost(req, {
|
||||
params: Promise.resolve({ path: ['tools', 'shodan', 'search'] }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBe(ADMIN_KEY);
|
||||
});
|
||||
|
||||
it('GET /api/tools/uw/status with valid session injects X-Admin-Key', async () => {
|
||||
const cookie = await mintSession(ADMIN_KEY);
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ configured: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const req = new NextRequest('http://localhost/api/tools/uw/status', {
|
||||
method: 'GET',
|
||||
headers: { cookie },
|
||||
});
|
||||
const res = await proxyGet(req, {
|
||||
params: Promise.resolve({ path: ['tools', 'uw', 'status'] }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBe(ADMIN_KEY);
|
||||
});
|
||||
|
||||
it('GET /api/tools/shodan/status applies no-store cache headers', async () => {
|
||||
const cookie = await mintSession(ADMIN_KEY);
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ configured: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const req = new NextRequest('http://localhost/api/tools/shodan/status', {
|
||||
method: 'GET',
|
||||
headers: { cookie },
|
||||
});
|
||||
const res = await proxyGet(req, {
|
||||
params: Promise.resolve({ path: ['tools', 'shodan', 'status'] }),
|
||||
});
|
||||
|
||||
expect(res.headers.get('cache-control')).toContain('no-store');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Regression: wormhole/* and settings/* unchanged
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('GET /api/wormhole/identity with valid session still injects X-Admin-Key', async () => {
|
||||
const cookie = await mintSession(ADMIN_KEY);
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ identity: null }), {
|
||||
status: 200,
|
||||
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'] }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBe(ADMIN_KEY);
|
||||
});
|
||||
|
||||
it('GET /api/settings/node with valid session still injects X-Admin-Key', async () => {
|
||||
const cookie = await mintSession(ADMIN_KEY);
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ node: {} }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const req = new NextRequest('http://localhost/api/settings/node', {
|
||||
method: 'GET',
|
||||
headers: { cookie },
|
||||
});
|
||||
const res = await proxyGet(req, {
|
||||
params: Promise.resolve({ path: ['settings', 'node'] }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBe(ADMIN_KEY);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Non-sensitive mesh paths must NOT receive injected admin key
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('GET /api/mesh/events does NOT inject X-Admin-Key', async () => {
|
||||
const cookie = await mintSession(ADMIN_KEY);
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response('data: {}\n\n', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const req = new NextRequest('http://localhost/api/mesh/events', {
|
||||
method: 'GET',
|
||||
headers: { cookie },
|
||||
});
|
||||
await proxyGet(req, {
|
||||
params: Promise.resolve({ path: ['mesh', 'events'] }),
|
||||
});
|
||||
|
||||
expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBeNull();
|
||||
});
|
||||
|
||||
it('GET /api/mesh/infonet/feed does NOT inject X-Admin-Key', async () => {
|
||||
const cookie = await mintSession(ADMIN_KEY);
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ items: [] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const req = new NextRequest('http://localhost/api/mesh/infonet/feed', {
|
||||
method: 'GET',
|
||||
headers: { cookie },
|
||||
});
|
||||
await proxyGet(req, {
|
||||
params: Promise.resolve({ path: ['mesh', 'infonet', 'feed'] }),
|
||||
});
|
||||
|
||||
expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBeNull();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Browser-supplied x-admin-key is stripped on all paths
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('browser-supplied x-admin-key is stripped on mesh/peers path', async () => {
|
||||
process.env.ADMIN_KEY = '';
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ peers: [] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const req = new NextRequest('http://localhost/api/mesh/peers', {
|
||||
method: 'GET',
|
||||
headers: { 'x-admin-key': 'browser-injected-key' },
|
||||
});
|
||||
await proxyGet(req, {
|
||||
params: Promise.resolve({ path: ['mesh', 'peers'] }),
|
||||
});
|
||||
|
||||
expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBeNull();
|
||||
});
|
||||
|
||||
it('browser-supplied x-admin-key is stripped on tools path', async () => {
|
||||
process.env.ADMIN_KEY = '';
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ configured: false }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const req = new NextRequest('http://localhost/api/tools/shodan/status', {
|
||||
method: 'GET',
|
||||
headers: { 'x-admin-key': 'browser-injected-key' },
|
||||
});
|
||||
await proxyGet(req, {
|
||||
params: Promise.resolve({ path: ['tools', 'shodan', 'status'] }),
|
||||
});
|
||||
|
||||
expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user