Files
electric-cherry/tests/unit/telemetry-auth.test.ts

157 lines
6.8 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { authenticateTelemetry } from '../../src/lib/server/auth/telemetry.js';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import { gameEvent } from '../helpers/telemetry.js';
const token = 'ec_telemetry_0123456789abcdef0123456789abcdef';
const otherToken = 'ec_telemetry_fedcba9876543210fedcba9876543210';
const request = (
values: Record<string, string | undefined> = {},
url = 'http://localhost/api/telemetry'
) => {
const headers = new Headers({ 'Content-Type': 'application/json' });
for (const [key, value] of Object.entries(values))
if (value !== undefined) headers.set(key, value);
return new Request(url, {
method: 'POST',
headers,
body: JSON.stringify(gameEvent('heartbeat'))
});
};
beforeEach(() => {
vi.stubEnv('EC_TELEMETRY_TOKEN', token);
});
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
describe('machine telemetry credentials', () => {
it.each([
{ Authorization: `Bearer ${token}` },
{ authorization: `bearer ${token}` },
{ 'X-EC-Telemetry-Token': token },
{ Authorization: `Bearer ${token}`, 'X-EC-Telemetry-Token': token }
])('accepts an explicit telemetry secret without a browser session: %j', (headers) => {
expect(authenticateTelemetry(request(headers))).toEqual({ ok: true });
});
it.each([undefined, '', 'too-short', 'x'.repeat(513), 'x'.repeat(40) + ' not-header-safe'])(
'fails closed when configuration is unusable: %s',
(configured) => {
vi.stubEnv('EC_TELEMETRY_TOKEN', configured);
expect(
authenticateTelemetry(request({ Authorization: `Bearer ${token}` }))
).toMatchObject({ ok: false, status: 503 });
}
);
it.each([
{},
{ Cookie: `ec_session=${token}` },
{ Authorization: `Bearer ${otherToken}` },
{ 'X-EC-Telemetry-Token': otherToken },
{ Authorization: `Basic ${token}`, 'X-EC-Telemetry-Token': token },
{ Authorization: `Bearer ${token}` },
{ Authorization: `Bearer ${token}, Bearer ${token}` },
{ Authorization: `Bearer ${token}`, 'X-EC-Telemetry-Token': otherToken },
{ Authorization: `Bearer ${otherToken}`, 'X-EC-Telemetry-Token': token },
{ Authorization: 'Bearer short', 'X-EC-Telemetry-Token': token },
{ Authorization: `Bearer ec_host_${'1'.repeat(43)}` },
{ Authorization: `Bearer ec_enroll_${'1'.repeat(43)}` }
])('rejects missing, wrong, ambiguous or unrelated credentials: %j', (headers) => {
const authentication = authenticateTelemetry(request(headers));
expect(authentication).toMatchObject({ ok: false, status: 401 });
expect(JSON.stringify(authentication)).not.toContain(token);
expect(JSON.stringify(authentication)).not.toContain(otherToken);
});
it('does not accept tokens placed in query parameters', () => {
expect(
authenticateTelemetry(request({}, `http://localhost/api/telemetry?token=${token}`))
).toMatchObject({ ok: false, status: 401 });
});
it('retains existing transport behavior for authenticated local machine relays', () => {
expect(
authenticateTelemetry(
request(
{ 'X-EC-Telemetry-Token': token },
'http://controller.internal/api/telemetry'
)
)
).toEqual({ ok: true });
});
it('uses the current configured token after rotation and safely rejects differing token lengths', () => {
expect(authenticateTelemetry(request({ Authorization: `Bearer ${token}` }))).toEqual({
ok: true
});
vi.stubEnv('EC_TELEMETRY_TOKEN', otherToken);
expect(authenticateTelemetry(request({ Authorization: `Bearer ${token}` }))).toMatchObject({
ok: false,
status: 401
});
expect(authenticateTelemetry(request({ Authorization: `Bearer ${otherToken}` }))).toEqual({
ok: true
});
expect(
authenticateTelemetry(request({ Authorization: `Bearer ${'x'.repeat(32)}` }))
).toMatchObject({ ok: false, status: 401 });
});
});
describe('telemetry endpoint authentication boundary', () => {
let fixture: Fixture;
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture();
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
await seedServerRegistry();
});
afterEach(async () => {
await fixture.cleanup();
});
it.each([401, 503])(
'returns %s before parsing or storing untrusted evidence',
async (status) => {
if (status === 503) vi.stubEnv('EC_TELEMETRY_TOKEN', undefined);
const { POST } = await import('../../src/routes/api/telemetry/+server.js');
const { db } = await import('../../src/lib/server/db/index.js');
const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
const { subscribe } = await import('../../src/lib/server/pipeline/bus.js');
const incoming = request({ Cookie: 'ec_session=browser-user' });
const parse = vi.spyOn(incoming, 'json');
const listener = vi.fn();
const unsubscribe = subscribe(listener);
const response = await POST({ request: incoming });
unsubscribe();
expect(response.status).toBe(status);
expect(response.headers.get('cache-control')).toBe('no-store');
expect(parse).not.toHaveBeenCalled();
expect(incoming.bodyUsed).toBe(false);
expect(listTelemetry()).toEqual([]);
expect(db().prepare('SELECT COUNT(*) AS count FROM telemetry_requests').get()).toEqual({
count: 0
});
expect(listener).not.toHaveBeenCalled();
}
);
it('accepts authenticated automation with the unchanged event payload and excludes headers from diagnostics', async () => {
const { POST } = await import('../../src/routes/api/telemetry/+server.js');
const { listTelemetryRequests } =
await import('../../src/lib/server/fleet/telemetry-requests.js');
const payload = gameEvent('heartbeat');
const response = await POST({ request: request({ 'X-EC-Telemetry-Token': token }) });
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ ok: true, telemetry: payload });
const history = listTelemetryRequests(payload.server);
expect(history).toHaveLength(1);
expect(JSON.stringify(history)).not.toContain(token);
expect(JSON.parse(history[0].payload!)).toEqual(payload);
});
});