diff --git a/src/lib/PostgresMetaPolicies.ts b/src/lib/PostgresMetaPolicies.ts index fa476c12..fa00597d 100644 --- a/src/lib/PostgresMetaPolicies.ts +++ b/src/lib/PostgresMetaPolicies.ts @@ -112,6 +112,9 @@ export default class PostgresMetaPolicies { command?: string roles?: string[] }): Promise<PostgresMetaResult<PostgresPolicy>> { + if (!table || !name) { + return { data: null, error: { message: 'Missing required name or table parameter' } } + } const definitionClause = definition === undefined ? '' : `USING (${definition})` const checkClause = check === undefined ? '' : `WITH CHECK (${check})` const sql = ` diff --git a/test/index.test.ts b/test/index.test.ts index 9a315921..caa43983 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -16,10 +16,19 @@ import './lib/types' import './lib/version' import './lib/views' import './server/column-privileges' +import './server/config' +import './server/extensions' +import './server/format' +import './server/functions' +import './server/generators' import './server/indexes' import './server/materialized-views' +import './server/policies' +import './server/publications' import './server/query' import './server/ssl' import './server/table-privileges' +import './server/triggers' +import './server/types' import './server/typegen' import './server/result-size-limit' diff --git a/test/server/config.ts b/test/server/config.ts new file mode 100644 index 00000000..b683c097 --- /dev/null +++ b/test/server/config.ts @@ -0,0 +1,23 @@ +import { expect, test } from 'vitest' +import { app } from './utils' + +test('config version endpoint', async () => { + const res = await app.inject({ + method: 'GET', + path: '/config/version', + }) + expect(res.statusCode).toBe(200) + const data = res.json() + expect(data).toHaveProperty('version') + expect(typeof data.version).toBe('string') + // Accept any version string format + expect(data.version).toContain('PostgreSQL') +}) + +test('config with invalid endpoint', async () => { + const res = await app.inject({ + method: 'GET', + path: '/config/invalid', + }) + expect(res.statusCode).toBe(404) +}) diff --git a/test/server/extensions.ts b/test/server/extensions.ts new file mode 100644 index 00000000..f4110415 --- /dev/null +++ b/test/server/extensions.ts @@ -0,0 +1,43 @@ +import { expect, test } from 'vitest' +import { app } from './utils' + +test('extension list filtering', async () => { + const res = await app.inject({ + method: 'GET', + path: '/extensions?limit=5', + }) + expect(res.statusCode).toBe(200) + const extensions = res.json() + expect(Array.isArray(extensions)).toBe(true) + expect(extensions.length).toBeLessThanOrEqual(5) +}) + +test('extension with invalid id', async () => { + const res = await app.inject({ + method: 'GET', + path: '/extensions/99999999', + }) + expect(res.statusCode).toBe(404) +}) + +test('create extension with invalid name', async () => { + const res = await app.inject({ + method: 'POST', + path: '/extensions', + payload: { + name: 'invalid_extension_name_that_doesnt_exist', + schema: 'public', + version: '1.0', + cascade: false, + }, + }) + expect(res.statusCode).toBe(400) +}) + +test('delete extension with invalid id', async () => { + const res = await app.inject({ + method: 'DELETE', + path: '/extensions/99999999', + }) + expect(res.statusCode).toBe(404) +}) diff --git a/test/server/format.ts b/test/server/format.ts new file mode 100644 index 00000000..78704aa0 --- /dev/null +++ b/test/server/format.ts @@ -0,0 +1,89 @@ +import { expect, test } from 'vitest' +import { app } from './utils' + +test('format SQL query', async () => { + const res = await app.inject({ + method: 'POST', + path: '/query/format', + payload: { query: "SELECT id,name FROM users WHERE status='ACTIVE'" }, + }) + expect(res.statusCode).toBe(200) + expect(res.headers['content-type']).toContain('text/plain') + const formattedQuery = res.body + expect(formattedQuery).toMatchInlineSnapshot(` + "SELECT + id, + name + FROM + users + WHERE + status = 'ACTIVE' + " + `) +}) + +test('format complex SQL query', async () => { + const res = await app.inject({ + method: 'POST', + path: '/query/format', + payload: { + query: + "SELECT u.id, u.name, p.title, p.created_at FROM users u JOIN posts p ON u.id = p.user_id WHERE u.status = 'ACTIVE' AND p.published = true ORDER BY p.created_at DESC LIMIT 10", + }, + }) + expect(res.statusCode).toBe(200) + expect(res.headers['content-type']).toContain('text/plain') + expect(res.body).toMatchInlineSnapshot(` + "SELECT + u.id, + u.name, + p.title, + p.created_at + FROM + users u + JOIN posts p ON u.id = p.user_id + WHERE + u.status = 'ACTIVE' + AND p.published = true + ORDER BY + p.created_at DESC + LIMIT + 10 + " + `) +}) + +test('format invalid SQL query', async () => { + const res = await app.inject({ + method: 'POST', + path: '/query/format', + payload: { query: 'SELECT FROM WHERE;' }, + }) + expect(res.statusCode).toBe(200) + expect(res.headers['content-type']).toContain('text/plain') + expect(res.body).toMatchInlineSnapshot(` + "SELECT + FROM + WHERE; + " + `) +}) + +// TODO(andrew): Those should return 400 error code for invalid parameter +test('format empty query', async () => { + const res = await app.inject({ + method: 'POST', + path: '/query/format', + payload: { query: '' }, + }) + expect(res.statusCode).toBe(500) +}) + +test('format with missing query parameter', async () => { + const res = await app.inject({ + method: 'POST', + path: '/query/format', + payload: {}, + }) + expect(res.statusCode).toBe(500) +}) diff --git a/test/server/functions.ts b/test/server/functions.ts new file mode 100644 index 00000000..010372d6 --- /dev/null +++ b/test/server/functions.ts @@ -0,0 +1,81 @@ +import { expect, test } from 'vitest' +import { app } from './utils' + +test('function list filtering', async () => { + const res = await app.inject({ + method: 'GET', + path: '/functions?limit=5', + }) + expect(res.statusCode).toBe(200) + const functions = res.json() + expect(Array.isArray(functions)).toBe(true) + expect(functions.length).toBeLessThanOrEqual(5) +}) + +test('function list with specific included schema', async () => { + const res = await app.inject({ + method: 'GET', + path: '/functions?includedSchemas=public', + }) + expect(res.statusCode).toBe(200) + const functions = res.json() + expect(Array.isArray(functions)).toBe(true) + // All functions should be in the public schema + functions.forEach((func) => { + expect(func.schema).toBe('public') + }) +}) + +test('function list exclude system schemas', async () => { + const res = await app.inject({ + method: 'GET', + path: '/functions?includeSystemSchemas=false', + }) + expect(res.statusCode).toBe(200) + const functions = res.json() + expect(Array.isArray(functions)).toBe(true) + // No functions should be in pg_ schemas + functions.forEach((func) => { + expect(func.schema).not.toMatch(/^pg_/) + }) +}) + +test('function with invalid id', async () => { + const res = await app.inject({ + method: 'GET', + path: '/functions/99999999', + }) + expect(res.statusCode).toBe(404) +}) + +test('create function with invalid arguments', async () => { + const res = await app.inject({ + method: 'POST', + path: '/functions', + payload: { + name: 'invalid_function', + schema: 'public', + // Missing required args + }, + }) + expect(res.statusCode).toBe(400) +}) + +test('update function with invalid id', async () => { + const res = await app.inject({ + method: 'PATCH', + path: '/functions/99999999', + payload: { + name: 'renamed_function', + }, + }) + expect(res.statusCode).toBe(404) +}) + +test('delete function with invalid id', async () => { + const res = await app.inject({ + method: 'DELETE', + path: '/functions/99999999', + }) + expect(res.statusCode).toBe(404) +}) diff --git a/test/server/generators.ts b/test/server/generators.ts new file mode 100644 index 00000000..b6272a0b --- /dev/null +++ b/test/server/generators.ts @@ -0,0 +1,51 @@ +import { expect, test } from 'vitest' +import { app } from './utils' + +test('typescript generator route', async () => { + const res = await app.inject({ + method: 'GET', + path: '/generators/typescript', + }) + expect(res.statusCode).toBe(200) + expect(res.headers['content-type']).toContain('text/plain') + expect(res.body).contain('public') +}) + +test('go generator route', async () => { + const res = await app.inject({ + method: 'GET', + path: '/generators/go', + }) + expect(res.statusCode).toBe(200) + expect(res.headers['content-type']).toContain('text/plain') + expect(res.body).toBeTruthy() +}) + +test('swift generator route', async () => { + const res = await app.inject({ + method: 'GET', + path: '/generators/swift', + }) + expect(res.statusCode).toBe(200) + expect(res.headers['content-type']).toContain('text/plain') + expect(res.body).toBeTruthy() +}) + +test('generator routes with includedSchemas parameter', async () => { + const res = await app.inject({ + method: 'GET', + path: '/generators/typescript?included_schemas=private', + }) + expect(res.statusCode).toBe(200) + expect(res.headers['content-type']).toContain('text/plain') + // the only schema is excluded database should be empty + expect(res.body).toContain('Database = {}') +}) + +test('invalid generator route', async () => { + const res = await app.inject({ + method: 'GET', + path: '/generators/openapi', + }) + expect(res.statusCode).toBe(404) +}) diff --git a/test/server/policies.ts b/test/server/policies.ts new file mode 100644 index 00000000..5838bc87 --- /dev/null +++ b/test/server/policies.ts @@ -0,0 +1,71 @@ +import { expect, test } from 'vitest' +import { app } from './utils' + +test('policy list filtering', async () => { + const res = await app.inject({ + method: 'GET', + path: '/policies?limit=5', + }) + expect(res.statusCode).toBe(200) + const policies = res.json() + expect(Array.isArray(policies)).toBe(true) + expect(policies.length).toBeLessThanOrEqual(5) +}) + +test('policy list with specific included schema', async () => { + const res = await app.inject({ + method: 'GET', + path: '/policies?included_schema=public', + }) + expect(res.statusCode).toBe(200) + const policies = res.json() + expect(Array.isArray(policies)).toBe(true) + // All policies should be in the public schema + policies.forEach((policy) => { + expect(policy.schema).toBe('public') + }) +}) + +test('policy with invalid id', async () => { + const res = await app.inject({ + method: 'GET', + path: '/policies/99999999', + }) + expect(res.statusCode).toBe(404) +}) + +test('create policy with missing required field', async () => { + const res = await app.inject({ + method: 'POST', + path: '/policies', + payload: { + name: 'test_policy', + schema: 'public', + // Missing required table field + definition: 'true', + check: 'true', + action: 'SELECT', + command: 'PERMISSIVE', + }, + }) + expect(res.statusCode).toBe(400) +}) + +test('update policy with invalid id', async () => { + const res = await app.inject({ + method: 'PATCH', + path: '/policies/99999999', + payload: { + name: 'renamed_policy', + }, + }) + expect(res.statusCode).toBe(404) +}) + +test('delete policy with invalid id', async () => { + const res = await app.inject({ + method: 'DELETE', + path: '/policies/99999999', + }) + expect(res.statusCode).toBe(404) +}) diff --git a/test/server/publications.ts b/test/server/publications.ts new file mode 100644 index 00000000..749d6640 --- /dev/null +++ b/test/server/publications.ts @@ -0,0 +1,74 @@ +import { expect, test } from 'vitest' +import { app } from './utils' + +test('publication list filtering', async () => { + const res = await app.inject({ + method: 'GET', + path: '/publications?limit=5', + }) + expect(res.statusCode).toBe(200) + const publications = res.json() + expect(Array.isArray(publications)).toBe(true) + expect(publications.length).toBeLessThanOrEqual(5) +}) + +test('publication with invalid id', async () => { + const res = await app.inject({ + method: 'GET', + path: '/publications/99999999', + }) + expect(res.statusCode).toBe(404) +}) + +test('create publication with invalid options', async () => { + const res = await app.inject({ + method: 'POST', + path: '/publications', + payload: { + name: 'test_publication', + publish_insert: 'invalid', // Should be boolean but seems to be converted automatically + publish_update: true, + publish_delete: true, + publish_truncate: true, + tables: ['public.users'], + }, + }) + // API accepts invalid type and converts it + // TODO: This should error out with invalid parameter + expect(res.statusCode).toBe(200) +}) + +test('create publication with empty name', async () => { + const res = await app.inject({ + method: 'POST', + path: '/publications', + payload: { + name: '', + publish_insert: true, + publish_update: true, + publish_delete: true, + publish_truncate: true, + tables: ['public.users'], + }, + }) + expect(res.statusCode).toBe(400) +}) + +test('update publication with invalid id', async () => { + const res = await app.inject({ + method: 'PATCH', + path: '/publications/99999999', + payload: { + name: 'renamed_publication', + }, + }) + expect(res.statusCode).toBe(404) +}) + +test('delete publication with invalid id', async () => { + const res = await app.inject({ + method: 'DELETE', + path: '/publications/99999999', + }) + expect(res.statusCode).toBe(404) +}) diff --git a/test/server/triggers.ts b/test/server/triggers.ts new file mode 100644 index 00000000..b930d567 --- /dev/null +++ b/test/server/triggers.ts @@ -0,0 +1,75 @@ +import { expect, test } from 'vitest' +import { app } from './utils' + +test('trigger list filtering', async () => { + const res = await app.inject({ + method: 'GET', + path: '/triggers?limit=5', + }) + expect(res.statusCode).toBe(200) + const triggers = res.json() + expect(Array.isArray(triggers)).toBe(true) + expect(triggers.length).toBeLessThanOrEqual(5) +}) + +test('trigger list with specific included schema', async () => { + const res = await app.inject({ + method: 'GET', + path: '/triggers?includedSchemas=public', + }) + expect(res.statusCode).toBe(200) + const triggers = res.json() + expect(Array.isArray(triggers)).toBe(true) + // All triggers should be in the public schema + triggers.forEach((trigger) => { + expect(trigger.schema).toBe('public') + }) +}) + +test('trigger with invalid id', async () => { + const res = await app.inject({ + method: 'GET', + path: '/triggers/99999999', + }) + expect(res.statusCode).toBe(404) +}) + +test('create trigger with invalid parameters', async () => { + const res = await app.inject({ + method: 'POST', + path: '/triggers', + payload: { + name: 'test_trigger', + schema: 'public', + table: 'non_existent_table', + function_schema: 'public', + function_name: 'test_trigger_function', + function_args: [], + activation: 'BEFORE', + events: ['INSERT'], + orientation: 'ROW', + condition: null, + }, + }) + // Should fail because table doesn't exist + expect(res.statusCode).toBe(400) +}) + +test('update trigger with invalid id', async () => { + const res = await app.inject({ + method: 'PATCH', + path: '/triggers/99999999', + payload: { + enabled: false, + }, + }) + expect(res.statusCode).toBe(404) +}) + +test('delete trigger with invalid id', async () => { + const res = await app.inject({ + method: 'DELETE', + path: '/triggers/99999999', + }) + expect(res.statusCode).toBe(404) +}) diff --git a/test/server/types.ts b/test/server/types.ts new file mode 100644 index 00000000..8ae12beb --- /dev/null +++ b/test/server/types.ts @@ -0,0 +1,62 @@ +import { expect, test } from 'vitest' +import { app } from './utils' + +test('type list filtering', async () => { + const res = await app.inject({ + method: 'GET', + path: '/types?limit=5', + }) + expect(res.statusCode).toBe(200) + const types = res.json() + expect(Array.isArray(types)).toBe(true) + expect(types.length).toBeLessThanOrEqual(5) +}) + +test('type list with specific included schema', async () => { + const res = await app.inject({ + method: 'GET', + path: '/types?includedSchemas=public', + }) + expect(res.statusCode).toBe(200) + const types = res.json() + expect(Array.isArray(types)).toBe(true) + // All types should be in the public schema + types.forEach((type) => { + expect(type.schema).toBe('public') + }) +}) + +test('type list excluding array types', async () => { + const res = await app.inject({ + method: 'GET', + path: '/types?includeArrayTypes=false', + }) + expect(res.statusCode).toBe(200) + const types = res.json() + expect(Array.isArray(types)).toBe(true) + // Should not include array types + const arrayTypes = types.filter((type) => type.name.startsWith('_')) + expect(arrayTypes.length).toBe(0) +}) + +test('type with invalid id', async () => { + const res = await app.inject({ + method: 'GET', + path: '/types/99999999', + }) + expect(res.statusCode).toBe(404) +}) + +test('type with enum values', async () => { + // Find an enum type first + const listRes = await app.inject({ + method: 'GET', + path: '/types', + }) + expect(listRes.statusCode).toBe(200) + const types = listRes.json() + const enumType = types.find((t) => t.name === 'meme_status') + + expect(Array.isArray(enumType.enums)).toBe(true) + expect(enumType.enums.length).toBeGreaterThan(0) +})