Files
sencho/backend/src/__tests__/pilot-agent-auth-fallback-unit.test.ts
T
Anso c677b8bb66 fix: fall back to enroll token when pilot tunnel JWT is rejected (#1566)
* fix: fall back to enroll token when pilot tunnel JWT is rejected

On HTTP 401/404 upgrade rejection, delete stale pilot.jwt and retry with SENCHO_ENROLL_TOKEN.

* test: import pilot agent module after DATA_DIR is set in fallback test

The auth-fallback test statically imported pilot/agent, which freezes its
pilot.jwt path from DATA_DIR at module load, before setupTestDb redirects
DATA_DIR to a writable temp dir. On the Linux CI runner the path resolved
to a non-writable /app/data, so persistToken silently failed and the
round-trip assertion read null. Import the module dynamically in beforeAll
after setupTestDb, matching the sibling unit test.

* fix: remove unexpected-response listener that blocked pilot reconnect

The ws library skips abortHandshake when an unexpected-response listener
exists, so error and close never fire and the agent hangs in CONNECTING.
Detect auth rejection via the abortHandshake error message instead; the
close handler already performs enroll-token fallback and reconnect.
2026-07-05 01:03:09 -04:00

130 lines
4.6 KiB
TypeScript

/**
* Unit tests for PilotAgent auth fallback event handling using a stub WebSocket.
*/
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
const { wsInstances, mockAttachSwitchboard } = vi.hoisted(() => ({
wsInstances: [] as Array<{
emit: (event: string, ...args: unknown[]) => boolean;
readyState: number;
close: ReturnType<typeof vi.fn>;
}>,
mockAttachSwitchboard: vi.fn(() => ({
handleJsonFrame: vi.fn(() => false),
handleBinaryFrame: vi.fn(() => false),
cleanup: vi.fn(),
tcpStreamCount: vi.fn(() => 0),
openReverseStream: vi.fn(() => null),
})),
}));
vi.mock('../mesh/tcpStreamSwitchboard', () => ({
attachTcpStreamSwitchboard: mockAttachSwitchboard,
resolveByComposeLabels: vi.fn(),
}));
vi.mock('ws', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { EventEmitter } = require('events') as typeof import('events');
class MockWebSocket extends EventEmitter {
readyState = 0;
close = vi.fn();
constructor(..._args: unknown[]) {
super();
wsInstances.push(this);
}
}
return { default: MockWebSocket };
});
let tmpDir: string;
let PilotAgent: typeof import('../pilot/agent').PilotAgent;
let readPersistedToken: typeof import('../pilot/agent').readPersistedToken;
let persistToken: typeof import('../pilot/agent').persistToken;
let clearPersistedToken: typeof import('../pilot/agent').clearPersistedToken;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ PilotAgent, readPersistedToken, persistToken, clearPersistedToken } = await import('../pilot/agent'));
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
describe('PilotAgent auth fallback (stub WebSocket)', () => {
beforeEach(() => {
wsInstances.length = 0;
vi.clearAllMocks();
vi.spyOn(console, 'log').mockImplementation(() => { /* swallow */ });
vi.spyOn(console, 'warn').mockImplementation(() => { /* swallow */ });
});
afterEach(() => {
vi.restoreAllMocks();
clearPersistedToken();
});
it('swaps to the enroll token and clears pilot.jwt after HTTP 401 on upgrade', () => {
persistToken('stale-on-disk');
const agent = new PilotAgent({
primaryUrl: 'http://primary.invalid',
loopbackPort: 1,
initialToken: 'stale-token',
enrollToken: 'fresh-enroll-token',
enrolling: false,
});
(agent as unknown as { connect: () => void }).connect();
const firstWs = wsInstances[0]!;
// Real ws (no unexpected-response listener): abortHandshake emits error then close.
firstWs.emit('error', new Error('Unexpected server response: 401'));
firstWs.emit('close', 1006, Buffer.from(''));
expect(readPersistedToken()).toBeNull();
expect((agent as unknown as { token: string }).token).toBe('fresh-enroll-token');
// scheduleReconnect doubles backoff after scheduling the imminent retry.
expect((agent as unknown as { backoff: number }).backoff).toBe(2_000);
});
it('swaps to the enroll token after HTTP 404 on upgrade', () => {
persistToken('stale-on-disk');
const agent = new PilotAgent({
primaryUrl: 'http://primary.invalid',
loopbackPort: 1,
initialToken: 'stale-token',
enrollToken: 'fresh-enroll-token',
enrolling: false,
});
(agent as unknown as { connect: () => void }).connect();
const firstWs = wsInstances[0]!;
firstWs.emit('error', new Error('Unexpected server response: 404'));
firstWs.emit('close', 1006, Buffer.from(''));
expect(readPersistedToken()).toBeNull();
expect((agent as unknown as { token: string }).token).toBe('fresh-enroll-token');
});
it('does not swap when already connecting with the enroll token', () => {
const agent = new PilotAgent({
primaryUrl: 'http://primary.invalid',
loopbackPort: 1,
initialToken: 'only-enroll-token',
enrollToken: 'only-enroll-token',
enrolling: true,
});
(agent as unknown as { connect: () => void }).connect();
const firstWs = wsInstances[0]!;
firstWs.emit('error', new Error('Unexpected server response: 401'));
firstWs.emit('close', 1006, Buffer.from(''));
expect((agent as unknown as { token: string }).token).toBe('only-enroll-token');
});
});