Files
TREK/server/tests/unit/eslint-rules/restricted-syntax-selectors.test.ts
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

303 lines
13 KiB
TypeScript

import tsParser from '@typescript-eslint/parser';
import { ESLint, Linter } from 'eslint';
import fs from 'fs';
import path from 'path';
import { beforeAll, describe, expect, it } from 'vitest';
// The outbound-timeout and raw-SQL guards are no-restricted-syntax selectors in
// eslint.config.mjs, and a selector that stops matching fails silently: the
// lint stays green and nothing is checked. This asks ESLint for the options it
// really applies to a given path (so the flat-config blocks and their ignores
// are part of what is tested) and runs those selectors over small snippets.
const serverRoot = path.resolve(__dirname, '../../..');
const eslint = new ESLint({ cwd: serverRoot });
type RuleEntry = [string, ...unknown[]];
async function selectorsFor(file: string): Promise<unknown[]> {
const config = (await eslint.calculateConfigForFile(path.join(serverRoot, file))) as {
rules?: Record<string, RuleEntry>;
};
const entry = config.rules?.['no-restricted-syntax'];
if (!entry) throw new Error(`no-restricted-syntax is not configured for ${file}`);
return entry.slice(1);
}
function messages(options: unknown[], code: string): string[] {
const linter = new Linter({ configType: 'flat' });
const result = linter.verify(
code,
[
{
files: ['**/*.ts'],
languageOptions: { parser: tsParser },
rules: { 'no-restricted-syntax': ['error', ...(options as object[])] },
},
],
'probe.ts',
);
return result.map((m) => m.message);
}
const PRELUDE = `
declare function fetch(url: string, init?: object): Promise<unknown>;
declare function safeFetch(url: string, init?: object, options?: object): Promise<unknown>;
declare function safeFetchFollow(url: string, init?: object, options?: object): Promise<unknown>;
declare function safeFetchAdminConfigured(url: string, init?: object): Promise<unknown>;
declare function safeFetchLlm(url: string, init?: object): Promise<unknown>;
declare const url: string;
declare const init: object;
declare const signal: AbortSignal;
`;
const TIMEOUT =
'Outbound fetch needs a timeout: pass { signal: AbortSignal.timeout(ms) }, or { timeoutMs } in the options of safeFetch/safeFetchFollow.';
describe('outbound fetch timeout selectors', () => {
let options: unknown[];
beforeAll(async () => {
options = await selectorsFor('src/nest/memories/probe.service.ts');
});
it.each([
['fetch(url)'],
["fetch(url, { method: 'POST' })"],
['safeFetch(url)'],
['safeFetch(url, undefined, { rejectUnauthorized: false })'],
['safeFetchFollow(url, undefined, { bypassInternalIpAllowed: true })'],
["safeFetchFollow(url, { headers: { a: 'b' } })"],
['safeFetchAdminConfigured(url, {})'],
// Other spellings of "no init" must not slip past the undefined case.
['safeFetch(url, void 0, { rejectUnauthorized: false })'],
['safeFetch(url, null, { rejectUnauthorized: false })'],
['safeFetch(url, null as never, { maxBytes: 1024 })'],
['safeFetch(url, undefined as never, {})'],
// A timeoutMs nested in the init is not the options deadline.
['safeFetch(url, { headers: { timeoutMs: 5 } })'],
['safeFetch(url, undefined, { maxBytes: 1024 }, { timeoutMs: 5000 })'],
])('flags %s', (call) => {
expect(messages(options, `${PRELUDE}\nvoid ${call};`)).toEqual([TIMEOUT]);
});
it.each([
['fetch(url, init)'],
['fetch(url, { signal })'],
['safeFetch(url, { signal: AbortSignal.timeout(5000) })'],
['safeFetch(url, { ...init })'],
['safeFetch(url, init, { rejectUnauthorized: false })'],
// The options object in third place carries no signal and must not be mistaken for the init.
['safeFetchFollow(url, { signal }, { bypassInternalIpAllowed: true })'],
// safeFetchLlm takes its deadline from LLM_TIMEOUT_MS inside the wrapper.
["safeFetchLlm(url, { method: 'POST' })"],
// timeoutMs in the options is a deadline over the whole exchange.
['safeFetch(url, undefined, { timeoutMs: 5000 })'],
['safeFetch(url, void 0, { timeoutMs: 5000, maxBytes: 1024 })'],
['safeFetch(url, { headers: {} }, { timeoutMs: 5000 })'],
['safeFetchFollow(url, undefined, { timeoutMs: 5000 })'],
// A cast init is still an init: the "no init" casts must not match it.
['fetch(url, { ...init, signal } as object)'],
['safeFetch(url, init as object, { maxBytes: 1024 })'],
])('accepts %s', (call) => {
expect(messages(options, `${PRELUDE}\nvoid ${call};`)).toEqual([]);
});
it('holds in the repositories too, whose block restates the selectors', async () => {
const repo = await selectorsFor('src/db/repositories/Probe.repository.ts');
expect(messages(repo, `${PRELUDE}\nvoid safeFetch(url, undefined, {});`)).toEqual([TIMEOUT]);
});
});
describe('raw SQL string selectors', () => {
const SQL = `
declare const connection: { execute(sql: string, params?: unknown[], mode?: string): Promise<unknown> };
declare const qb: { execute(mode: string): Promise<unknown> };
declare const repo: { run(sql: string, params?: unknown[]): Promise<unknown>; get(key: string): unknown };
`;
const hits = (options: unknown[], code: string) =>
messages(options, `${SQL}\n${code}`).filter((m) => m.startsWith('A SQL string handed to the connection'));
it('flags a SQL string handed to the connection in a repository and in a seeder', async () => {
for (const file of ['src/db/repositories/Probe.repository.ts', 'src/db/seeders/ProbeSeeder.ts']) {
const options = await selectorsFor(file);
expect(hits(options, "void connection.execute('INSERT OR IGNORE INTO t (a) VALUES (?)', [1]);")).toHaveLength(1);
expect(hits(options, 'void connection.execute(` select * from t where id = ?`, [1]);')).toHaveLength(1);
expect(hits(options, "void repo.run('PRAGMA wal_checkpoint(TRUNCATE)');")).toHaveLength(1);
// A result mode and a map lookup are not SQL.
expect(hits(options, "void qb.execute('run'); void repo.get('key');")).toEqual([]);
}
});
it('still forbids raw() and the sql tag in a repository', async () => {
const options = await selectorsFor('src/db/repositories/Probe.repository.ts');
const found = messages(options, "declare function raw(s: string): unknown;\nvoid raw('now()');");
expect(found.some((m) => m.startsWith('Dialect SQL goes through'))).toBe(true);
});
// The files eslint.config.mjs exempts from the SQL string rule, with the SQL
// strings each holds today. A count may only go down: a higher one is new SQL
// text slipping in under the exemption, a lower one means the pin (and at 0
// the ignores entry) has to follow.
const EXEMPT_SQL_STRINGS: Record<string, number> = {
'src/db/repositories/MaintenanceRepository.ts': 4,
'src/db/seeders/AdminSeeder.ts': 2,
'src/db/seeders/CategorySeeder.ts': 2,
};
const SQL_MESSAGE_PREFIX = 'A SQL string handed to the connection';
function filesUnder(dir: string): string[] {
return fs
.readdirSync(path.join(serverRoot, dir), { recursive: true, encoding: 'utf8' })
.filter((name) => name.endsWith('.ts'))
.map((name) => `${dir}/${name.split(path.sep).join('/')}`);
}
it('exempts exactly the pinned files and keeps the rest of the guard on them', async () => {
const exempt: string[] = [];
for (const file of [...filesUnder('src/db/repositories'), ...filesUnder('src/db/seeders')]) {
const options = (await selectorsFor(file)) as { message?: string }[];
if (!options.some((o) => o.message?.startsWith(SQL_MESSAGE_PREFIX))) exempt.push(file);
}
expect(exempt.sort()).toEqual(Object.keys(EXEMPT_SQL_STRINGS).sort());
for (const file of exempt) {
const options = await selectorsFor(file);
expect(hits(options, "void connection.execute('PRAGMA wal_checkpoint(TRUNCATE)');"), file).toEqual([]);
expect(
messages(
options,
`${PRELUDE}
void safeFetch(url);`,
),
file,
).toContain(TIMEOUT);
}
});
it('holds each exempt file to the SQL strings it has today', async () => {
// The selectors of a file the rule does apply to, run over the exempt file's source.
const options = await selectorsFor('src/db/repositories/Probe.repository.ts');
const counts: Record<string, number> = {};
for (const file of Object.keys(EXEMPT_SQL_STRINGS)) {
const source = fs.readFileSync(path.join(serverRoot, file), 'utf8');
counts[file] = messages(options, source).filter((m) => m.startsWith(SQL_MESSAGE_PREFIX)).length;
}
expect(counts, 'new SQL text in an exempt file fails; after removing some, lower its pin').toEqual(
EXEMPT_SQL_STRINGS,
);
for (const [file, n] of Object.entries(counts)) {
expect(n, `${file} holds no SQL string any more: drop it from the ignores in eslint.config.mjs`).toBeGreaterThan(
0,
);
}
});
it('does not reach outside src/db', async () => {
const options = await selectorsFor('src/nest/memories/probe.service.ts');
expect(hits(options, "void connection.execute('SELECT 1');")).toEqual([]);
});
});
describe('insertId selectors', () => {
const INSERT = `
declare const result: { insertId: bigint | undefined };
declare const query: { executeTakeFirstOrThrow(): Promise<{ insertId: bigint | undefined }> };
`;
const insertIdHits = (options: unknown[], code: string) =>
messages(options, `${INSERT}\n${code}`).filter((m) => m.startsWith('InsertResult.insertId'));
it('flags a read of insertId in every repository, the SQL-exempt one included, and in a seeder', async () => {
for (const file of [
'src/db/repositories/Probe.repository.ts',
'src/db/repositories/MaintenanceRepository.ts',
'src/db/seeders/ProbeSeeder.ts',
]) {
const options = await selectorsFor(file);
expect(insertIdHits(options, 'void Number(result.insertId);'), file).toHaveLength(1);
expect(
insertIdHits(options, 'void query.executeTakeFirstOrThrow().then(({ insertId }) => insertId);'),
file,
).toHaveLength(1);
}
});
it('leaves a returning() read alone', async () => {
const options = await selectorsFor('src/db/repositories/Probe.repository.ts');
expect(insertIdHits(options, 'declare const row: { id: number };\nvoid row.id;')).toEqual([]);
});
});
describe('app setting selectors', () => {
const APP_SETTING =
"Read an app setting through readAppSetting()/resolveAppSetting() from src/nest/common/app-settings.registry.ts, not getValue('<key>'): the register types the key and owns its env override.";
const PROBE = 'declare const repo: { getValue(key: string): Promise<string | null> };\ndeclare const key: string;\n';
it('flags a literal key handed to getValue, quoted or as a template, in a service and in a repository', async () => {
const service = await selectorsFor('src/nest/maps/probe.service.ts');
expect(messages(service, `${PROBE}void repo.getValue('smtp_host');`)).toEqual([APP_SETTING]);
expect(messages(service, `${PROBE}void repo.getValue(\`smtp_host\`);`)).toEqual([APP_SETTING]);
const repo = await selectorsFor('src/db/repositories/Probe.repository.ts');
expect(messages(repo, `${PROBE}void repo.getValue('smtp_host');`)).toEqual([APP_SETTING]);
});
it('leaves a key passed as a variable or built from parts alone', async () => {
const service = await selectorsFor('src/nest/common/app-settings.registry.ts');
expect(messages(service, `${PROBE}void repo.getValue(key);`)).toEqual([]);
expect(messages(service, `${PROBE}void repo.getValue(\`pref_\${key}\`);`)).toEqual([]);
});
});
describe('MCP demo gate selectors', () => {
const DEMO = 'MCP tools do not check demo mode themselves';
const demoHits = (options: unknown[], code: string) => messages(options, code).filter((m) => m.startsWith(DEMO));
const CODE = `
declare const auth: { isDemoUser(id: number): Promise<boolean> };
declare const demo: { isDemoUserId(id: number): Promise<boolean> };
declare function demoDenied(): unknown;
`;
it('refuses a demo check of its own in a *.mcp.ts file, and keeps the env and fetch guards there', async () => {
const options = await selectorsFor('src/nest/memories/probe.mcp.ts');
expect(
demoHits(
options,
`${CODE}
void auth.isDemoUser(1);`,
),
).toHaveLength(1);
expect(
demoHits(
options,
`${CODE}
void demo.isDemoUserId(1);`,
),
).toHaveLength(1);
expect(
demoHits(
options,
`${CODE}
void demoDenied();`,
),
).toHaveLength(1);
expect(messages(options, 'void process.env.X;')).not.toEqual([]);
expect(
messages(
options,
`${PRELUDE}
void fetch(url);`,
),
).not.toEqual([]);
});
it('leaves the same call alone outside the tool files', async () => {
const options = await selectorsFor('src/nest/memories/probe.service.ts');
expect(
demoHits(
options,
`${CODE}
void demo.isDemoUserId(1);`,
),
).toEqual([]);
});
});