mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 01:14:14 +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.
151 lines
5.3 KiB
TypeScript
151 lines
5.3 KiB
TypeScript
/**
|
|
* Regression tests for pilot agent auth fallback when a persisted tunnel
|
|
* token is rejected at WebSocket upgrade (401 invalid JWT, 404 unknown node).
|
|
*
|
|
* Without fallback the agent reconnects forever with the same stale
|
|
* pilot.jwt credential even when SENCHO_ENROLL_TOKEN carries a fresh
|
|
* enrollment JWT.
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
|
|
import http from 'http';
|
|
import jwt from 'jsonwebtoken';
|
|
import { WebSocketServer } from 'ws';
|
|
import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
|
import { attachUpgrade } from '../websocket/upgradeHandler';
|
|
import { PilotTunnelManager } from '../services/PilotTunnelManager';
|
|
import { WebSocket } from 'ws';
|
|
|
|
let tmpDir: string;
|
|
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
|
|
|
// agent.ts freezes its pilot.jwt path from DATA_DIR at module load, so it must
|
|
// be imported only after setupTestDb() points DATA_DIR at the writable tmp dir.
|
|
let readPersistedToken: typeof import('../pilot/agent').readPersistedToken;
|
|
let persistToken: typeof import('../pilot/agent').persistToken;
|
|
let clearPersistedToken: typeof import('../pilot/agent').clearPersistedToken;
|
|
|
|
let server: http.Server;
|
|
let port: number;
|
|
let pilotTunnelWss: WebSocketServer;
|
|
let mainWss: WebSocketServer;
|
|
let nodeId: number;
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await setupTestDb();
|
|
({ DatabaseService } = await import('../services/DatabaseService'));
|
|
({ readPersistedToken, persistToken, clearPersistedToken } = await import('../pilot/agent'));
|
|
|
|
server = http.createServer();
|
|
mainWss = new WebSocketServer({ noServer: true });
|
|
pilotTunnelWss = new WebSocketServer({ noServer: true });
|
|
attachUpgrade(server, { wss: mainWss, pilotTunnelWss });
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.once('error', reject);
|
|
server.listen(0, '127.0.0.1', () => {
|
|
const addr = server.address();
|
|
if (!addr || typeof addr === 'string') {
|
|
reject(new Error('listen returned unexpected address'));
|
|
return;
|
|
}
|
|
port = addr.port;
|
|
resolve();
|
|
});
|
|
});
|
|
|
|
nodeId = DatabaseService.getInstance().addNode({
|
|
name: `pilot-auth-fallback-${Date.now()}`,
|
|
type: 'remote',
|
|
mode: 'pilot_agent',
|
|
compose_dir: '/tmp/x',
|
|
is_default: false,
|
|
api_url: '',
|
|
api_token: '',
|
|
});
|
|
});
|
|
|
|
afterAll(async () => {
|
|
const mgr = PilotTunnelManager.getInstance();
|
|
mgr.closeTunnel(nodeId);
|
|
mgr.removeAllListeners('tunnel-up');
|
|
mgr.removeAllListeners('tunnel-down');
|
|
pilotTunnelWss.close();
|
|
mainWss.close();
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
cleanupTestDb(tmpDir);
|
|
});
|
|
|
|
afterEach(() => {
|
|
PilotTunnelManager.getInstance().closeTunnel(nodeId);
|
|
clearPersistedToken();
|
|
});
|
|
|
|
function mintStaleTunnelTokenWrongSecret(): string {
|
|
return jwt.sign(
|
|
{ scope: 'pilot_tunnel', nodeId },
|
|
'wrong-secret-not-the-control-instance',
|
|
{ expiresIn: '365d' },
|
|
);
|
|
}
|
|
|
|
function mintStaleTunnelTokenWrongNode(): string {
|
|
return jwt.sign(
|
|
{ scope: 'pilot_tunnel', nodeId: 99_999_999 },
|
|
TEST_JWT_SECRET,
|
|
{ expiresIn: '365d' },
|
|
);
|
|
}
|
|
|
|
describe('clearPersistedToken', () => {
|
|
it('removes an existing pilot.jwt file', () => {
|
|
persistToken('stale-token');
|
|
expect(readPersistedToken()).toBe('stale-token');
|
|
clearPersistedToken();
|
|
expect(readPersistedToken()).toBeNull();
|
|
});
|
|
|
|
it('does not throw when the file is already absent', () => {
|
|
clearPersistedToken();
|
|
expect(() => clearPersistedToken()).not.toThrow();
|
|
expect(readPersistedToken()).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('pilot tunnel upgrade rejection (in-process integration)', () => {
|
|
it('rejects a stale tunnel JWT signed with the wrong secret at upgrade', async () => {
|
|
const staleToken = mintStaleTunnelTokenWrongSecret();
|
|
const ws = new WebSocket(`ws://127.0.0.1:${port}/api/pilot/tunnel`, {
|
|
headers: {
|
|
Authorization: `Bearer ${staleToken}`,
|
|
'x-sencho-agent-version': 'auth-fallback-test/1.0',
|
|
},
|
|
});
|
|
const result = await new Promise<{ status?: number }>((resolve) => {
|
|
ws.on('unexpected-response', (_req, res) => {
|
|
resolve({ status: res.statusCode });
|
|
res.destroy();
|
|
});
|
|
ws.on('error', () => { /* close follows */ });
|
|
});
|
|
expect(result.status).toBe(401);
|
|
});
|
|
|
|
it('rejects a tunnel JWT for an unknown node with HTTP 404 at upgrade', async () => {
|
|
const staleToken = mintStaleTunnelTokenWrongNode();
|
|
const ws = new WebSocket(`ws://127.0.0.1:${port}/api/pilot/tunnel`, {
|
|
headers: {
|
|
Authorization: `Bearer ${staleToken}`,
|
|
'x-sencho-agent-version': 'auth-fallback-test/1.0',
|
|
},
|
|
});
|
|
const result = await new Promise<{ status?: number }>((resolve) => {
|
|
ws.on('unexpected-response', (_req, res) => {
|
|
resolve({ status: res.statusCode });
|
|
res.destroy();
|
|
});
|
|
ws.on('error', () => { /* close follows */ });
|
|
});
|
|
expect(result.status).toBe(404);
|
|
});
|
|
});
|