feat: add CASL authorization and AI configuration

This commit is contained in:
2026-07-11 14:25:34 +08:00
parent 8f0991a51f
commit 1e1c476bc3
59 changed files with 7733 additions and 120 deletions

View File

@@ -0,0 +1,178 @@
import { TestingModule, Test } from '@nestjs/testing';
import { DatabaseMigrationsService } from './database-migrations.service';
import { getDataSourceToken } from '@nestjs/typeorm';
interface MockColumn {
name: string;
}
interface MockTable {
name: string;
columns: MockColumn[];
}
function mockRunner(overrides: {
getTables?: MockTable[];
getTable?: MockTable;
queryError?: Error;
} = {}) {
const release = jest.fn();
const connect = jest.fn();
const query = jest.fn();
const getTables = jest.fn().mockResolvedValue(overrides.getTables ?? []);
const getTable = jest.fn().mockResolvedValue(
overrides.getTable ?? { name: 'ai_config', columns: [] },
);
if (overrides.queryError) {
query.mockRejectedValue(overrides.queryError);
}
return { release, connect, query, getTables, getTable };
}
function createDataSource(runner: ReturnType<typeof mockRunner>) {
return {
options: { type: 'better-sqlite3' },
createQueryRunner: jest.fn().mockReturnValue(runner),
transaction: jest.fn(),
};
}
// Type to reach the private ensureAiConfigTable for testing
interface MigrationsPrivate {
ensureAiConfigTable(): Promise<void>;
backfillOrganizations(): Promise<void>;
normalizeClassDates(): Promise<void>;
}
describe('DatabaseMigrationsService — ensureAiConfigTable', () => {
let service: MigrationsPrivate & DatabaseMigrationsService;
async function bootstrap(runner: ReturnType<typeof mockRunner>) {
const dataSource = createDataSource(runner);
const module: TestingModule = await Test.createTestingModule({
providers: [
DatabaseMigrationsService,
{ provide: getDataSourceToken(), useValue: dataSource },
],
}).compile();
service = module.get(
DatabaseMigrationsService,
);
}
it('creates table + index when ai_config does not exist', async () => {
const runner = mockRunner({ getTables: [] });
await bootstrap(runner);
await service.ensureAiConfigTable();
expect(runner.connect).toHaveBeenCalled();
expect(runner.query).toHaveBeenCalledWith(expect.stringContaining('CREATE TABLE ai_config'));
expect(runner.query).toHaveBeenCalledWith(
expect.stringContaining('CREATE UNIQUE INDEX IF NOT EXISTS uq_ai_config_singleton'),
);
expect(runner.release).toHaveBeenCalled();
});
it('skips ALTER when table exists with all columns', async () => {
const allColumns: MockColumn[] = [
{ name: 'id' },
{ name: 'singleton_key' },
{ name: 'provider' },
{ name: 'base_url' },
{ name: 'encrypted_api_key' },
{ name: 'api_key_iv' },
{ name: 'api_key_auth_tag' },
{ name: 'key_last4' },
{ name: 'default_model' },
{ name: 'enabled' },
{ name: 'timeout_ms' },
{ name: 'verified' },
{ name: 'last_tested_at' },
{ name: 'last_test_latency_ms' },
{ name: 'created_at' },
{ name: 'updated_at' },
];
const runner = mockRunner({
getTables: [{ name: 'ai_config', columns: allColumns }],
getTable: { name: 'ai_config', columns: allColumns },
});
await bootstrap(runner);
await service.ensureAiConfigTable();
expect(runner.connect).toHaveBeenCalled();
// Should NOT issue any ALTER TABLE
const alterCalls = (runner.query as jest.Mock).mock.calls.filter(
(c: unknown[]) => typeof c[0] === 'string' && (c[0]).includes('ALTER TABLE'),
);
expect(alterCalls).toHaveLength(0);
expect(runner.release).toHaveBeenCalled();
});
it('adds missing column via ALTER TABLE', async () => {
// Table has most columns but is missing last_test_latency_ms
const missingOne: MockColumn[] = [
{ name: 'id' },
{ name: 'singleton_key' },
{ name: 'provider' },
{ name: 'base_url' },
{ name: 'encrypted_api_key' },
{ name: 'api_key_iv' },
{ name: 'api_key_auth_tag' },
{ name: 'key_last4' },
{ name: 'default_model' },
{ name: 'enabled' },
{ name: 'timeout_ms' },
{ name: 'verified' },
{ name: 'last_tested_at' },
// last_test_latency_ms missing
{ name: 'created_at' },
{ name: 'updated_at' },
];
const runner = mockRunner({
getTables: [{ name: 'ai_config', columns: missingOne }],
getTable: { name: 'ai_config', columns: missingOne },
});
await bootstrap(runner);
await service.ensureAiConfigTable();
expect(runner.connect).toHaveBeenCalled();
expect(runner.query).toHaveBeenCalledWith(
expect.stringContaining('ALTER TABLE ai_config ADD COLUMN last_test_latency_ms INT'),
);
expect(runner.release).toHaveBeenCalled();
});
it('releases runner even when query throws', async () => {
const runner = mockRunner({ getTables: [], queryError: new Error('BOOM') });
await bootstrap(runner);
await expect(service.ensureAiConfigTable()).rejects.toThrow('BOOM');
expect(runner.release).toHaveBeenCalled();
});
});
describe('DatabaseMigrationsService — bootstrap failure handling', () => {
it('fails application bootstrap when the required ai_config migration fails', async () => {
const runner = mockRunner();
const dataSource = createDataSource(runner);
const module: TestingModule = await Test.createTestingModule({
providers: [
DatabaseMigrationsService,
{ provide: getDataSourceToken(), useValue: dataSource },
],
}).compile();
const service = module.get(DatabaseMigrationsService) as DatabaseMigrationsService & MigrationsPrivate;
jest.spyOn(service, 'ensureAiConfigTable').mockRejectedValue(new Error('migration failed'));
const backfill = jest.spyOn(service, 'backfillOrganizations').mockResolvedValue();
const normalize = jest.spyOn(service, 'normalizeClassDates').mockResolvedValue();
await expect(service.onApplicationBootstrap()).rejects.toThrow('migration failed');
expect(backfill).not.toHaveBeenCalled();
expect(normalize).not.toHaveBeenCalled();
});
});