mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 11:47:11 +00:00
c677b8bb66
* 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.
130 lines
4.6 KiB
TypeScript
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');
|
|
});
|
|
});
|