Files
TREK/server/tests/unit/nest/integrations/airtrailWriteback.test.ts
T
Maurice 724c88c201 style: format server and shared with prettier
A mechanical prettier --write over server/src, server/tests, server/scripts
and shared/src with the workspaces' .prettierrc; nothing but layout and
import order changes, and every file keeps the same set of imports. shared
is formatted throughout and its format baseline is empty. The server
baseline keeps the 35 files that Prettier would grow past their lint:size
limit (they need a split first, which is not a formatting change), and the
generated entity files stay as their generator writes them.
2026-10-10 21:23:37 +02:00

119 lines
5.7 KiB
TypeScript

import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
// ATC-TX-001 reads Nest's own emitted constructor-param metadata (the same
// mechanism `discovery.test.ts` uses) — needs the polyfill `src/index.ts`
// normally provides, absent from the vitest entrypoint.
import 'reflect-metadata';
import { db } from '../../../../src/db/database';
import { AuditLog } from '../../../../src/db/entities/AuditLog.entity';
import { Users } from '../../../../src/db/entities/Users.entity';
import { UserAirtrailRepository } from '../../../../src/db/repositories/UserAirtrail.repository';
import { AuditService } from '../../../../src/nest/audit/audit.service';
import { AirtrailClient } from '../../../../src/nest/integrations/airtrail.client';
import { AirtrailService } from '../../../../src/nest/integrations/airtrail.service';
import { createUser } from '../../../helpers/factories';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
// Avoid any real DNS/network from the SSRF guard during saveSettings.
vi.mock('../../../../src/utils/ssrfGuard', () => ({
checkSsrf: vi.fn(async () => ({ allowed: true, isPrivate: false })),
safeFetch: vi.fn(),
}));
// The free functions became methods with the airtrail fold; same SQL, same
// behaviour, one instance over the same db handle.
let svc: AirtrailService;
let getConnectionSettings: (
...args: Parameters<AirtrailService['getConnectionSettings']>
) => ReturnType<AirtrailService['getConnectionSettings']>;
let isAirtrailWriteEnabled: (
...args: Parameters<AirtrailService['isAirtrailWriteEnabled']>
) => ReturnType<AirtrailService['isAirtrailWriteEnabled']>;
let saveSettings: (...args: Parameters<AirtrailService['saveSettings']>) => ReturnType<AirtrailService['saveSettings']>;
let t: TestOrm;
beforeAll(async () => {
t = await createTestOrm(db);
svc = new AirtrailService(
new UserAirtrailRepository(t.orm.em),
new AuditService(t.repo(AuditLog), t.repo(Users)),
new AirtrailClient(),
);
getConnectionSettings = (...args) => svc.getConnectionSettings(...args);
isAirtrailWriteEnabled = (...args) => svc.isAirtrailWriteEnabled(...args);
saveSettings = (...args) => svc.saveSettings(...args);
});
afterAll(async () => {
await t.close();
});
describe('airtrail writeback opt-in persistence (#1240)', () => {
it('defaults the writeback opt-in to off for a new user', async () => {
const { user } = createUser(db);
expect(await isAirtrailWriteEnabled(user.id)).toBe(false);
expect((await getConnectionSettings(user.id)).writeEnabled).toBe(false);
});
it('persists the opt-in and lets it be toggled back off without dropping the key', async () => {
const { user } = createUser(db);
await saveSettings(user.id, 'https://at.example.com', 'secret-key', false, true, null);
expect(await isAirtrailWriteEnabled(user.id)).toBe(true);
const on = await getConnectionSettings(user.id);
expect(on.writeEnabled).toBe(true);
expect(on.connected).toBe(true); // key stored
// No key supplied keeps the stored key; only the opt-in flips back off.
await saveSettings(user.id, 'https://at.example.com', undefined, false, false, null);
expect(await isAirtrailWriteEnabled(user.id)).toBe(false);
expect((await getConnectionSettings(user.id)).connected).toBe(true);
});
});
describe('airtrail saveSettings preserves its pre-existing NO-transaction asymmetry with Dawarich (Plan 3h Task 4, R7/Risks)', () => {
it('ATC-TX-001: AirtrailService is constructed with no UnitOfWork at all — saveSettings has nothing to wrap its writes in', () => {
// `DawarichService#saveSettings`'s equivalent DOES take a `UnitOfWork` and
// wraps its writes in `uow.transactional`; AirtrailService's own
// constructor (`UserAirtrailRepository`, `AuditService`, `AirtrailClient`) never
// gained one during the Plan 3h Task 4 conversion — a structural
// guarantee that a future edit re-introducing a transactional wrap here
// would have to ALSO add a new constructor param to do it, not just
// touch `saveSettings`'s body.
const paramTypes = Reflect.getMetadata('design:paramtypes', AirtrailService) as unknown[] | undefined;
expect(paramTypes).toBeDefined();
expect(paramTypes!.map((p) => (p as { name?: string })?.name)).toEqual([
'UserAirtrailRepository',
'AuditService',
'AirtrailClient',
]);
});
it('ATC-TX-002: the URL-cleared branch performs its two writes as genuinely separate repository calls, not one atomic statement', async () => {
const { user } = createUser(db);
await saveSettings(user.id, 'https://at.example.com', 'secret-key', false, false, null);
const airtrailRepo = (svc as unknown as { airtrailRepo: UserAirtrailRepository }).airtrailRepo;
const setSpy = vi.spyOn(airtrailRepo, 'setAirtrailSettings');
const clearSpy = vi.spyOn(airtrailRepo, 'clearAirtrailApiKey');
try {
// Clearing the URL with no key left drops the key too (ATC4 then ATC5) —
// TWO independent nativeUpdate calls, not a single transactional unit.
await saveSettings(user.id, '', undefined, false, false, null);
expect(setSpy).toHaveBeenCalledTimes(1);
expect(clearSpy).toHaveBeenCalledTimes(1);
// clearAirtrailApiKey only runs AFTER setAirtrailSettings's own write
// resolved (sequential awaits in the service body, never Promise.all
// or a single wrapped call) — the same "report, don't fix" asymmetry
// the plan's Risks section flags.
const setOrder = setSpy.mock.invocationCallOrder[0];
const clearOrder = clearSpy.mock.invocationCallOrder[0];
expect(clearOrder).toBeGreaterThan(setOrder);
} finally {
setSpy.mockRestore();
clearSpy.mockRestore();
}
});
});