182 lines
5.6 KiB
TypeScript
182 lines
5.6 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
|
|
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
|
const repoRoot = path.resolve(currentDir, '../../..');
|
|
const migrationDir = path.resolve(currentDir, '../../src/infra/database/migrations');
|
|
const migrationPath = path.resolve(migrationDir, '044_prefer-request-jwt-claims.sql');
|
|
|
|
const historicalDottedClaimMigrations = new Set([
|
|
'backend/src/infra/database/migrations/001_create-helper-functions.sql',
|
|
'backend/src/infra/database/migrations/013_create-auth-schema-functions.sql',
|
|
]);
|
|
|
|
function toPosixRelative(filePath: string): string {
|
|
return path.relative(repoRoot, filePath).split(path.sep).join('/');
|
|
}
|
|
|
|
function collectFiles(dir: string, predicate: (filePath: string) => boolean): string[] {
|
|
const ignoredDirs = new Set(['.git', 'node_modules', 'dist', 'coverage']);
|
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
const files: string[] = [];
|
|
|
|
for (const entry of entries) {
|
|
const entryPath = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
if (!ignoredDirs.has(entry.name)) {
|
|
files.push(...collectFiles(entryPath, predicate));
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (entry.isFile() && predicate(entryPath)) {
|
|
files.push(entryPath);
|
|
}
|
|
}
|
|
|
|
return files;
|
|
}
|
|
|
|
function readRepoFiles(
|
|
paths: string[],
|
|
extensions: string[]
|
|
): Array<{ path: string; content: string }> {
|
|
return paths.flatMap((relativePath) => {
|
|
const absolutePath = path.resolve(repoRoot, relativePath);
|
|
if (!fs.existsSync(absolutePath)) {
|
|
return [];
|
|
}
|
|
|
|
return collectFiles(absolutePath, (filePath) =>
|
|
extensions.some((extension) => filePath.endsWith(extension))
|
|
).map((filePath) => ({
|
|
path: toPosixRelative(filePath),
|
|
content: fs.readFileSync(filePath, 'utf8'),
|
|
}));
|
|
});
|
|
}
|
|
|
|
function readSql(): string {
|
|
return fs.readFileSync(migrationPath, 'utf8');
|
|
}
|
|
|
|
describe('044_prefer-request-jwt-claims migration', () => {
|
|
it('migration file exists', () => {
|
|
expect(fs.existsSync(migrationPath)).toBe(true);
|
|
});
|
|
|
|
it.each(['uid', 'role', 'email'])('redefines auth.%s with CREATE OR REPLACE', (fnName) => {
|
|
const sql = readSql();
|
|
expect(sql).toMatch(new RegExp(`CREATE OR REPLACE FUNCTION auth\\.${fnName}\\(\\)`, 'i'));
|
|
});
|
|
|
|
it.each([
|
|
['uid', 'sub'],
|
|
['role', 'role'],
|
|
['email', 'email'],
|
|
])('auth.%s reads only canonical auth.jwt() claim %s', (_fnName, claimName) => {
|
|
const sql = readSql();
|
|
expect(sql).toMatch(
|
|
new RegExp(`SELECT nullif\\(auth\\.jwt\\(\\)\\s*->>\\s*'${claimName}',\\s*''\\)::`, 'i')
|
|
);
|
|
});
|
|
|
|
it('does not keep legacy dotted claim fallback', () => {
|
|
const sql = readSql();
|
|
expect(sql).not.toMatch(/request\.jwt\.claim\./i);
|
|
});
|
|
|
|
it('does not add or replace public helper functions', () => {
|
|
const sql = readSql();
|
|
expect(sql).not.toMatch(/CREATE OR REPLACE FUNCTION public\./i);
|
|
});
|
|
|
|
it('grants only missing storage runtime roles required before RLS can evaluate', () => {
|
|
const sql = readSql();
|
|
expect(sql).toMatch(/GRANT\s+USAGE\s+ON\s+SCHEMA\s+storage\s+TO\s+anon,\s*project_admin/i);
|
|
expect(sql).toMatch(
|
|
/GRANT\s+SELECT,\s*INSERT,\s*UPDATE,\s*DELETE\s+ON\s+storage\.objects\s+TO\s+anon,\s*project_admin/i
|
|
);
|
|
expect(sql).not.toMatch(/GRANT\s+SELECT\s+ON\s+storage\.buckets/i);
|
|
});
|
|
|
|
it('runs after migration 043', () => {
|
|
const migrations = fs
|
|
.readdirSync(migrationDir)
|
|
.filter((file) => file.endsWith('.sql'))
|
|
.sort();
|
|
|
|
expect(migrations.indexOf('044_prefer-request-jwt-claims.sql')).toBeGreaterThan(
|
|
migrations.indexOf('043_drop-deprecated-ai-configs-and-usage.sql')
|
|
);
|
|
});
|
|
|
|
it('keeps dotted jwt claims confined to immutable historical migrations', () => {
|
|
const scannedFiles = readRepoFiles(
|
|
[
|
|
'backend/src',
|
|
'backend/tests',
|
|
'docs',
|
|
'packages',
|
|
'openapi',
|
|
'.agents',
|
|
'.codex',
|
|
'.claude',
|
|
],
|
|
['.ts', '.tsx', '.js', '.jsx', '.sql', '.md', '.mdx', '.sh', '.json']
|
|
);
|
|
|
|
const offenders = scannedFiles
|
|
.filter(({ content }) => /request\.jwt\.claim\./i.test(content))
|
|
.map(({ path: filePath }) => filePath)
|
|
.filter((filePath) => !historicalDottedClaimMigrations.has(filePath));
|
|
|
|
expect(offenders).toEqual([]);
|
|
});
|
|
|
|
it('does not reference the removed db user-context service path', () => {
|
|
const scannedFiles = readRepoFiles(
|
|
['backend/src', 'backend/tests', '.agents', '.codex', '.claude'],
|
|
['.ts', '.tsx', '.js', '.jsx', '.md', '.mdx']
|
|
);
|
|
|
|
const offenders = scannedFiles
|
|
.filter(({ content }) => /services\/db\/user-context/i.test(content))
|
|
.map(({ path: filePath }) => filePath);
|
|
|
|
expect(offenders).toEqual([]);
|
|
});
|
|
|
|
it('does not reference the removed admin/database context abstraction', () => {
|
|
const scannedFiles = readRepoFiles(
|
|
[
|
|
'backend/src',
|
|
'backend/tests',
|
|
'docs',
|
|
'packages',
|
|
'openapi',
|
|
'.agents',
|
|
'.codex',
|
|
'.claude',
|
|
],
|
|
['.ts', '.tsx', '.js', '.jsx', '.sql', '.md', '.mdx', '.sh', '.json']
|
|
);
|
|
const removedContextPattern = new RegExp(
|
|
[
|
|
['ADMIN', 'DATABASE', 'CONTEXT'].join('_'),
|
|
['Database', 'Context'].join(''),
|
|
['with', 'Database', 'Context'].join(''),
|
|
].join('|'),
|
|
'i'
|
|
);
|
|
|
|
const offenders = scannedFiles
|
|
.filter(({ content }) => removedContextPattern.test(content))
|
|
.map(({ path: filePath }) => filePath);
|
|
|
|
expect(offenders).toEqual([]);
|
|
});
|
|
});
|