269 lines
9.7 KiB
TypeScript
269 lines
9.7 KiB
TypeScript
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
|
import { DatabaseAdvanceService } from '../../src/services/database/database-advance.service';
|
|
import { AppError } from '../../src/utils/errors';
|
|
import { ERROR_CODES } from '@insforge/shared-schemas';
|
|
|
|
describe('DatabaseAdvanceService - sanitizeQuery', () => {
|
|
const service = DatabaseAdvanceService.getInstance();
|
|
|
|
test('blocks database-level operations', () => {
|
|
const queries = [
|
|
'DROP DATABASE customer_project',
|
|
'CREATE DATABASE customer_project',
|
|
'ALTER DATABASE customer_project SET timezone TO UTC',
|
|
];
|
|
|
|
for (const query of queries) {
|
|
expect(() => service.sanitizeQuery(query)).toThrow(AppError);
|
|
}
|
|
});
|
|
|
|
test('blocks role and session authorization management', () => {
|
|
const queries = [
|
|
'SET ROLE postgres',
|
|
'SET LOCAL ROLE postgres',
|
|
'RESET ROLE',
|
|
'SET SESSION AUTHORIZATION postgres',
|
|
'RESET SESSION AUTHORIZATION',
|
|
'RESET ALL',
|
|
'SET search_path TO public',
|
|
"SELECT set_config('search_path', 'public', false)",
|
|
'SET statement_timeout = 0',
|
|
'RESET statement_timeout',
|
|
'CREATE ROLE app_owner',
|
|
'ALTER ROLE project_admin SET search_path TO public',
|
|
'DROP ROLE app_owner',
|
|
'GRANT postgres TO project_admin',
|
|
];
|
|
|
|
for (const query of queries) {
|
|
expect(() => service.sanitizeQuery(query)).toThrow(AppError);
|
|
}
|
|
});
|
|
|
|
test('blocks transaction control in raw SQL', () => {
|
|
const queries = ['BEGIN', 'COMMIT', 'ROLLBACK', 'SAVEPOINT before_change'];
|
|
|
|
for (const query of queries) {
|
|
expect(() => service.sanitizeQuery(query)).toThrow(AppError);
|
|
}
|
|
});
|
|
|
|
test('throws AppError with 403 FORBIDDEN for execution context violations', () => {
|
|
try {
|
|
service.sanitizeQuery('RESET ROLE');
|
|
expect.fail('Should have thrown an error');
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(AppError);
|
|
if (error instanceof AppError) {
|
|
expect(error.statusCode).toBe(403);
|
|
expect(error.code).toBe(ERROR_CODES.FORBIDDEN);
|
|
expect(error.message).toContain('execution role');
|
|
}
|
|
}
|
|
});
|
|
|
|
test('allows managed schema statements to be decided by project_admin database grants', () => {
|
|
const queries = [
|
|
"INSERT INTO auth.users (email, password_hash) VALUES ('demo@example.com', 'hash')",
|
|
'CREATE TRIGGER user_profile_trigger AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION public.create_user_profile()',
|
|
'SELECT * FROM pg_catalog.pg_class LIMIT 1',
|
|
"INSERT INTO storage.objects (bucket_id, key, name) VALUES ('avatars', 'u1/a.png', 'a.png')",
|
|
'ALTER TABLE realtime.messages ENABLE ROW LEVEL SECURITY',
|
|
"UPDATE payments.customers SET email = 'new@example.com' WHERE id = 'cus_123'",
|
|
"INSERT INTO system.custom_migrations (version, name, statements) VALUES ('1', 'manual', ARRAY['SELECT 1'])",
|
|
];
|
|
|
|
for (const query of queries) {
|
|
expect(() => service.sanitizeQuery(query)).not.toThrow();
|
|
}
|
|
});
|
|
|
|
test('allows public schema DDL and grants', () => {
|
|
const queries = [
|
|
'CREATE TABLE public.products (id uuid PRIMARY KEY)',
|
|
'ALTER TABLE public.products ENABLE ROW LEVEL SECURITY',
|
|
'CREATE POLICY products_select ON public.products FOR SELECT TO authenticated USING (true)',
|
|
'GRANT SELECT ON public.products TO authenticated',
|
|
'DROP POLICY products_select ON public.products',
|
|
];
|
|
|
|
for (const query of queries) {
|
|
expect(() => service.sanitizeQuery(query)).not.toThrow();
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// bulkInsert — optional-column data-loss fix (issue #8)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
const { mockClientQuery, mockRelease, mockConnect } = vi.hoisted(() => ({
|
|
mockClientQuery: vi.fn(),
|
|
mockRelease: vi.fn(),
|
|
mockConnect: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../src/infra/database/database.manager', () => ({
|
|
DatabaseManager: {
|
|
getInstance: vi.fn(() => ({
|
|
getPool: vi.fn(() => ({
|
|
query: vi.fn(),
|
|
connect: mockConnect,
|
|
})),
|
|
})),
|
|
},
|
|
}));
|
|
|
|
describe('DatabaseAdvanceService - bulkInsert optional-column fix', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// pool.connect() returns a pg client with query + release
|
|
mockConnect.mockResolvedValue({
|
|
query: mockClientQuery,
|
|
release: mockRelease,
|
|
});
|
|
// Default: all queries succeed
|
|
mockClientQuery.mockResolvedValue({ rows: [], rowCount: 2 });
|
|
});
|
|
|
|
/** Helper: find all INSERT SQL queries executed */
|
|
function captureAllInsertSql(): string[] {
|
|
return mockClientQuery.mock.calls
|
|
.map(([sql]) => sql as string)
|
|
.filter((sql) => typeof sql === 'string' && sql.startsWith('INSERT INTO'));
|
|
}
|
|
|
|
test('groups records by shape and executes shape-coherent batch queries', async () => {
|
|
const svc = DatabaseAdvanceService.getInstance();
|
|
|
|
await svc.bulkInsert('public', 'contacts', [
|
|
{ id: '1', name: 'Alice' },
|
|
{ id: '2', name: 'Bob', phone: '555-0001' },
|
|
{ id: '3', name: 'Carol', phone: '555-0002' },
|
|
]);
|
|
|
|
const sqlQueries = captureAllInsertSql();
|
|
expect(sqlQueries).toHaveLength(2);
|
|
|
|
// First query (Alice) has no phone
|
|
const query1 = sqlQueries[0];
|
|
expect(query1).toContain('id');
|
|
expect(query1).toContain('name');
|
|
expect(query1).not.toContain('phone');
|
|
expect(query1).toContain('Alice');
|
|
|
|
// Second query (Bob and Carol) has phone
|
|
const query2 = sqlQueries[1];
|
|
expect(query2).toContain('id');
|
|
expect(query2).toContain('name');
|
|
expect(query2).toContain('phone');
|
|
expect(query2).toContain('555-0001');
|
|
expect(query2).toContain('555-0002');
|
|
});
|
|
|
|
test('explicit undefined values are converted to NULL', async () => {
|
|
const svc = DatabaseAdvanceService.getInstance();
|
|
|
|
await svc.bulkInsert('public', 'contacts', [{ id: '1', name: 'Alice', phone: undefined }]);
|
|
|
|
const sqlQueries = captureAllInsertSql();
|
|
expect(sqlQueries).toHaveLength(1);
|
|
expect(sqlQueries[0]).toContain('phone');
|
|
expect(sqlQueries[0]).not.toContain('undefined');
|
|
expect(sqlQueries[0]).toContain('NULL');
|
|
});
|
|
|
|
test('uniform records (all same columns) still work correctly', async () => {
|
|
const svc = DatabaseAdvanceService.getInstance();
|
|
|
|
await svc.bulkInsert('public', 'contacts', [
|
|
{ id: '1', name: 'Alice', phone: '555-0001' },
|
|
{ id: '2', name: 'Bob', phone: '555-0002' },
|
|
]);
|
|
|
|
const sqlQueries = captureAllInsertSql();
|
|
expect(sqlQueries).toHaveLength(1);
|
|
const sql = sqlQueries[0];
|
|
expect(sql).toContain('id');
|
|
expect(sql).toContain('name');
|
|
expect(sql).toContain('phone');
|
|
expect(sql).toContain('555-0001');
|
|
expect(sql).toContain('555-0002');
|
|
});
|
|
|
|
test('throws AppError when records array is empty', async () => {
|
|
const svc = DatabaseAdvanceService.getInstance();
|
|
await expect(svc.bulkInsert('public', 'contacts', [])).rejects.toBeInstanceOf(AppError);
|
|
});
|
|
|
|
test('upsert: optional column from later records is included in ON CONFLICT SET for its group', async () => {
|
|
const svc = DatabaseAdvanceService.getInstance();
|
|
|
|
await svc.bulkInsert(
|
|
'public',
|
|
'contacts',
|
|
[
|
|
{ id: '1', name: 'Alice' },
|
|
{ id: '2', name: 'Bob', phone: '555-0001' },
|
|
],
|
|
'id'
|
|
);
|
|
|
|
const sqlQueries = captureAllInsertSql();
|
|
expect(sqlQueries).toHaveLength(2);
|
|
|
|
const query1 = sqlQueries[0]; // Alice: keys ['id', 'name']
|
|
expect(query1).toContain('ON CONFLICT');
|
|
expect(query1).toContain('DO UPDATE SET');
|
|
expect(query1).toContain('name = EXCLUDED.name');
|
|
expect(query1).not.toContain('phone');
|
|
|
|
const query2 = sqlQueries[1]; // Bob: keys ['id', 'name', 'phone']
|
|
expect(query2).toContain('ON CONFLICT');
|
|
expect(query2).toContain('DO UPDATE SET');
|
|
expect(query2).toContain('name = EXCLUDED.name');
|
|
expect(query2).toContain('phone = EXCLUDED.phone');
|
|
});
|
|
|
|
test('explicit null values are preserved in the SQL', async () => {
|
|
const svc = DatabaseAdvanceService.getInstance();
|
|
|
|
await svc.bulkInsert('public', 'contacts', [{ id: '1', name: 'Alice', phone: null }]);
|
|
|
|
const sqlQueries = captureAllInsertSql();
|
|
expect(sqlQueries).toHaveLength(1);
|
|
expect(sqlQueries[0]).toContain('phone');
|
|
expect(sqlQueries[0]).toContain('NULL');
|
|
});
|
|
|
|
test('rolls back entire transaction if one shape group insert fails', async () => {
|
|
const svc = DatabaseAdvanceService.getInstance();
|
|
|
|
mockClientQuery.mockReset();
|
|
mockClientQuery
|
|
.mockResolvedValueOnce({}) // SET ROLE project_admin
|
|
.mockResolvedValueOnce({}) // set_config claims
|
|
.mockResolvedValueOnce({}) // BEGIN
|
|
.mockResolvedValueOnce({ rows: [], rowCount: 1 }) // First INSERT (Alice)
|
|
.mockRejectedValueOnce(new Error('Constraint violation on second group')) // Second INSERT (Bob)
|
|
.mockResolvedValueOnce({}) // ROLLBACK
|
|
.mockResolvedValueOnce({}) // RESET ROLE
|
|
.mockResolvedValueOnce({}); // set_config empty claims
|
|
|
|
const records = [
|
|
{ id: '1', name: 'Alice' },
|
|
{ id: '2', name: 'Bob', phone: '555-0001' },
|
|
];
|
|
|
|
await expect(svc.bulkInsert('public', 'contacts', records)).rejects.toThrow(
|
|
'Constraint violation on second group'
|
|
);
|
|
|
|
const calls = mockClientQuery.mock.calls.map(([sql]) => sql);
|
|
expect(calls).toContain('BEGIN');
|
|
expect(calls).toContain('ROLLBACK');
|
|
expect(calls).not.toContain('COMMIT');
|
|
});
|
|
});
|