Files
TREK/server/tests/e2e/service-identity.e2e.test.ts
T
Maurice 1da7d0627a fix(server): judge the address search in the admin status by TREK_PHOTON_URL and an own NOMINATIM_URL
The status asked a fixed Photon origin and compared NOMINATIM_URL with the public address, so a moved Photon or an explicitly set public Nominatim showed the wrong service as in use. It now reads the same photonUrl and nominatimOwn the Photon client decides by. TREK_VALHALLA_URL joins the commented compose variables.
2026-10-29 13:34:28 +01:00

242 lines
10 KiB
TypeScript

/**
* /api/admin/service-identity e2e: the real JwtAuthGuard, AdminGuard and
* response contract over ServiceIdentityModule on a migrated temp database,
* against a fake issuer standing in for places.liketrek.com.
*
* Replaced: safeFetch, so the issuer is the fake below, and the audit writer.
* NODE_ENV=test keeps registration off, as it is in every suite.
*/
import { AppSettings } from '../../src/db/entities/AppSettings.entity';
import { servicesConfig } from '../../src/nest/app-config/tokens';
import { AuditService } from '../../src/nest/audit/audit.service';
import { encrypt_api_key } from '../../src/nest/common/crypto/apiKeyCrypto';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { ServiceIdentityModule } from '../../src/nest/service-identity/service-identity.module';
import { createSnapshotTestDb } from '../helpers/db-mock';
import { deleteRows, findRow, upsertRow } from '../helpers/factories/rows';
import { makeAdmin, makeUser } from '../helpers/factories/users';
import { serviceKeyTokenFor } from '../helpers/service-keys';
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { sessionCookie } from './harness';
import { Test } from '@nestjs/testing';
import cookieParser from 'cookie-parser';
import request from 'supertest';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const { safeFetch } = vi.hoisted(() => ({ safeFetch: vi.fn() }));
vi.mock('../../src/utils/ssrfGuard', async (importOriginal) => ({
...(await importOriginal<typeof import('../../src/utils/ssrfGuard')>()),
safeFetch,
}));
const db = createSnapshotTestDb();
const USER = 1;
const ADMIN = 2;
const ENV_KEY = serviceKeyTokenFor({ i: 'from-environment', iat: 1_793_000_000, exp: 4_102_444_800, q: 'own' });
const STORED_KEY = serviceKeyTokenFor({ i: 'registered-one', iat: 1_793_000_000, exp: 4_102_444_800 });
const QUOTA = { tier: 'own', limit: 50_000, used: 1_234, remaining: 48_766, reset_at: 1_793_059_200, per_minute: 600 };
const VIEW = { limit: 50_000, used: 1_234, remaining: 48_766, resetAt: new Date(1_793_059_200_000).toISOString() };
const writeAudit = vi.fn();
let orm: TestOrm;
type ServicesConfig = {
mode: 'auto' | 'off';
key: string | null;
hosts: string[];
photonUrl: string;
valhallaUrl: string;
};
async function build(config: Partial<ServicesConfig> = {}) {
const moduleRef = await Test.createTestingModule({
imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), ServiceIdentityModule],
})
.overrideProvider(servicesConfig.KEY)
.useValue({
mode: 'auto',
key: null,
hosts: ['places.liketrek.com'],
photonUrl: 'https://photon.liketrek.com',
valhallaUrl: 'https://valhalla.liketrek.com',
...config,
})
.overrideProvider(AuditService)
.useValue({ writeAudit })
.compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
nest.useGlobalPipes(new ZodValidationPipe());
await nest.init();
return nest;
}
/** Every liketrek host as it answers /v1/quota. */
function issuerAnswers(status = 200) {
safeFetch.mockImplementation(async () =>
status === 200
? new Response(JSON.stringify(QUOTA), { status, headers: { 'content-type': 'application/json' } })
: new Response('{"error":"unavailable"}', { status }),
);
}
const as = (userId: number) => ({ Cookie: sessionCookie(userId) });
beforeAll(async () => {
orm = await createTestOrm(db);
await makeUser(orm, { id: USER, username: 'traveler', email: 'traveler@example.test' });
await makeAdmin(orm, { id: ADMIN, username: 'admin', email: 'admin@example.test' });
});
beforeEach(async () => {
safeFetch.mockReset();
writeAudit.mockReset();
issuerAnswers();
await deleteRows(orm, AppSettings, { key: { $in: ['service_key', 'service_key_state'] } });
});
afterAll(async () => {
await orm.close();
});
describe('with a key from TREK_SERVICES_KEY', () => {
let app: Awaited<ReturnType<typeof build>>;
beforeAll(async () => {
app = await build({ key: ENV_KEY });
});
afterAll(async () => {
await app.close();
});
it('SIE2E-001: answers 401 without a session and 403 to a user who is no admin', async () => {
const server = app.getHttpServer();
expect((await request(server).get('/api/admin/service-identity')).status).toBe(401);
expect((await request(server).get('/api/admin/service-identity').set(as(USER))).status).toBe(403);
expect((await request(server).post('/api/admin/service-identity/regenerate').set(as(USER))).status).toBe(403);
});
it('SIE2E-002: shows the allowance the issuer reports for that key, and no other host sees it', async () => {
const res = await request(app.getHttpServer()).get('/api/admin/service-identity').set(as(ADMIN));
expect(res.status).toBe(200);
expect(res.body).toEqual({
state: 'connected',
tier: 'own',
keySource: 'env',
services: [
{ service: 'places', inUse: true, quota: VIEW },
{ service: 'photon', inUse: false, quota: null },
{ service: 'valhalla', inUse: false, quota: null },
],
lastSeenAt: null,
});
expect(safeFetch).toHaveBeenCalledTimes(1);
const [url, init] = safeFetch.mock.calls[0] as [string, RequestInit & { headers: Record<string, string> }];
expect(url).toBe('https://places.liketrek.com/v1/quota');
expect(init.headers.Authorization).toBe(`Bearer ${ENV_KEY}`);
expect(init.headers['User-Agent']).toMatch(/^TREK\//);
});
it('SIE2E-003: will not start over a key the environment sets', async () => {
const res = await request(app.getHttpServer()).post('/api/admin/service-identity/regenerate').set(as(ADMIN));
expect(res.status).toBe(409);
expect(res.body).toEqual({ error: 'The key is set by TREK_SERVICES_KEY and is changed there' });
expect(writeAudit).not.toHaveBeenCalled();
});
});
describe('with a key the installation registered', () => {
let app: Awaited<ReturnType<typeof build>>;
beforeAll(async () => {
app = await build();
});
afterAll(async () => {
await app.close();
});
it('SIE2E-010: a stored key counts as connected, and the card still reads while the issuer is down', async () => {
await upsertRow(orm, AppSettings, { key: 'service_key', value: encrypt_api_key(STORED_KEY) });
issuerAnswers(503);
const res = await request(app.getHttpServer()).get('/api/admin/service-identity').set(as(ADMIN));
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ state: 'connected', tier: 'std', keySource: 'registered' });
expect(res.body.services[0]).toEqual({ service: 'places', inUse: true, quota: null });
});
it('SIE2E-011: a new identity drops the stored key, is audited, and leaves the shared pool until the next search', async () => {
await upsertRow(orm, AppSettings, { key: 'service_key', value: encrypt_api_key(STORED_KEY) });
await upsertRow(orm, AppSettings, { key: 'service_key_state', value: '{"revoked":"abc"}' });
const res = await request(app.getHttpServer()).post('/api/admin/service-identity/regenerate').set(as(ADMIN));
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ state: 'legacy', keySource: null });
expect(await findRow(orm, AppSettings, { key: 'service_key' })).toBeNull();
expect(await findRow(orm, AppSettings, { key: 'service_key_state' })).toBeNull();
expect(writeAudit).toHaveBeenCalledWith(
expect.objectContaining({ userId: ADMIN, action: 'admin.service_identity_regenerate' }),
);
const [, init] = safeFetch.mock.calls.at(-1) as [string, RequestInit & { headers: Record<string, string> }];
expect(init.headers).not.toHaveProperty('Authorization');
});
});
describe('with every liketrek host listed in TREK_SERVICES_HOSTS', () => {
const ALL_HOSTS = ['places.liketrek.com', 'photon.liketrek.com', 'valhalla.liketrek.com'];
it('SIE2E-030: asks each host for its own allowance and says which services this instance uses', async () => {
const app = await build({ key: ENV_KEY, hosts: ALL_HOSTS });
try {
const res = await request(app.getHttpServer()).get('/api/admin/service-identity').set(as(ADMIN));
expect(res.status).toBe(200);
expect(res.body.services).toEqual([
{ service: 'places', inUse: true, quota: VIEW },
{ service: 'photon', inUse: true, quota: VIEW },
{ service: 'valhalla', inUse: true, quota: VIEW },
]);
const asked = safeFetch.mock.calls.map(([url]) => String(url)).sort();
expect(asked).toEqual([
'https://photon.liketrek.com/v1/quota',
'https://places.liketrek.com/v1/quota',
'https://valhalla.liketrek.com/v1/quota',
]);
} finally {
await app.close();
}
});
it('SIE2E-031: the routing service is not in use once the admin picks FOSSGIS', async () => {
const app = await build({ key: ENV_KEY, hosts: ALL_HOSTS });
await upsertRow(orm, AppSettings, { key: 'default_user_setting_routing_valhalla_provider', value: '"fossgis"' });
try {
const res = await request(app.getHttpServer()).get('/api/admin/service-identity').set(as(ADMIN));
expect(res.body.services[2]).toEqual({ service: 'valhalla', inUse: false, quota: VIEW });
} finally {
await deleteRows(orm, AppSettings, { key: 'default_user_setting_routing_valhalla_provider' });
await app.close();
}
});
});
describe('with TREK_SERVICES=off', () => {
it('SIE2E-020: says so and asks the issuer nothing', async () => {
const app = await build({ mode: 'off' });
try {
const res = await request(app.getHttpServer()).get('/api/admin/service-identity').set(as(ADMIN));
expect(res.body).toEqual({ state: 'off', tier: null, keySource: null, services: [], lastSeenAt: null });
expect(safeFetch).not.toHaveBeenCalled();
const regenerate = await request(app.getHttpServer())
.post('/api/admin/service-identity/regenerate')
.set(as(ADMIN));
expect(regenerate.status).toBe(409);
} finally {
await app.close();
}
});
});