Files
sencho/backend/src/__tests__/pilot-agent-loopback-auth.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

61 lines
2.3 KiB
TypeScript

/**
* Regression guard for the agent-side loopback auth injection.
*
* The central proxy strips browser cookies and the (empty) pilot-agent api_token
* before forwarding through the tunnel. Without an inline auth header on the
* agent's loopback request, the local Sencho's `authMiddleware` would 401 every
* proxied call. The agent injects a `pilot_tunnel`-scoped JWT signed by the
* LOCAL `auth_jwt_secret`, which the loopback `authMiddleware` accepts via the
* existing scope branch with no special-case bypass.
*
* This test verifies:
* 1. `getLoopbackAuthHeader` returns a Bearer header backed by a JWT that
* the local secret verifies, with the expected scope.
* 2. Subsequent calls within the refresh window return the cached header
* (no re-mint per request).
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET } from './helpers/setupTestDb';
import { PilotAgent } from '../pilot/agent';
describe('PilotAgent loopback auth injection', () => {
let tmpDir: string;
let agent: PilotAgent;
let mintHeader: () => string | null;
beforeAll(async () => {
tmpDir = await setupTestDb();
// Importing the index loads DatabaseService against the seeded baseline so
// getGlobalSettings().auth_jwt_secret resolves to TEST_JWT_SECRET.
await import('../index');
agent = new PilotAgent({
primaryUrl: 'http://primary.invalid',
loopbackPort: 1,
initialToken: 'irrelevant-for-this-test',
enrollToken: null,
enrolling: false,
});
mintHeader = (agent as unknown as { getLoopbackAuthHeader: () => string | null }).getLoopbackAuthHeader.bind(agent);
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
it('mints a Bearer header with a pilot_tunnel-scoped JWT signed by the local secret', () => {
const header = mintHeader();
expect(header).toMatch(/^Bearer /);
const token = header!.slice('Bearer '.length);
const decoded = jwt.verify(token, TEST_JWT_SECRET) as { scope?: string; nodeId?: number; exp?: number };
expect(decoded.scope).toBe('pilot_tunnel');
expect(typeof decoded.exp).toBe('number');
});
it('returns the cached header on a quick second call', () => {
const a = mintHeader();
const b = mintHeader();
expect(a).toBe(b);
});
});