From d0848c0a8510bf1f656f432bc4717ad234dba018 Mon Sep 17 00:00:00 2001 From: Artem Niehrieiev Date: Fri, 3 Apr 2026 06:53:09 +0000 Subject: [PATCH 01/11] feat: add Postgres proxy and mock API for E2E testing --- .../saas-postgres-proxy-e2e.test.ts | 404 ++++++++++++++++++ docker-compose.tst.yml | 63 +++ docker-compose.yml | 45 ++ shared-code/src/knex-manager/knex-manager.ts | 7 +- test/proxy-mock-api/Dockerfile | 8 + test/proxy-mock-api/server.js | 60 +++ 6 files changed, 584 insertions(+), 3 deletions(-) create mode 100644 backend/test/ava-tests/saas-tests/saas-postgres-proxy-e2e.test.ts create mode 100644 test/proxy-mock-api/Dockerfile create mode 100644 test/proxy-mock-api/server.js diff --git a/backend/test/ava-tests/saas-tests/saas-postgres-proxy-e2e.test.ts b/backend/test/ava-tests/saas-tests/saas-postgres-proxy-e2e.test.ts new file mode 100644 index 000000000..d1947efec --- /dev/null +++ b/backend/test/ava-tests/saas-tests/saas-postgres-proxy-e2e.test.ts @@ -0,0 +1,404 @@ +import { faker } from '@faker-js/faker'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import test from 'ava'; +import { ValidationError } from 'class-validator'; +import cookieParser from 'cookie-parser'; +import net from 'net'; +import request from 'supertest'; +import { ApplicationModule } from '../../../src/app.module.js'; +import { AllExceptionsFilter } from '../../../src/exceptions/all-exceptions.filter.js'; +import { ValidationException } from '../../../src/exceptions/custom-exceptions/validation-exception.js'; +import { Cacher } from '../../../src/helpers/cache/cacher.js'; +import { DatabaseModule } from '../../../src/shared/database/database.module.js'; +import { DatabaseService } from '../../../src/shared/database/database.service.js'; +import { WinstonLogger } from '../../../src/entities/logging/winston-logger.js'; +import { createTestTable } from '../../utils/create-test-table.js'; +import { + createInitialTestUser, + registerUserAndReturnUserInfo, +} from '../../utils/register-user-and-return-user-info.js'; +import { setSaasEnvVariable } from '../../utils/set-saas-env-variable.js'; +import { TestUtils } from '../../utils/test.utils.js'; + +let app: INestApplication; +let _testUtils: TestUtils; +let skipAll = false; + +const PROXY_HOST = process.env.POSTGRES_PROXY_HOST || 'postgres-proxy'; +const PROXY_PORT = parseInt(process.env.POSTGRES_PROXY_PORT || '5432', 10); + +// Direct connection to the upstream Postgres (for seeding test data) +const upstreamConnectionParams = { + type: 'postgres', + host: process.env.UPSTREAM_PG_HOST || 'testPg-proxy-e2e', + port: parseInt(process.env.UPSTREAM_PG_PORT || '5432', 10), + username: 'postgres', + password: 'proxy_test_123', + database: 'postgres', + ssh: false, +}; + +// Connection DTO that points to the proxy (used in rocketadmin API) +function createProxyConnectionDto() { + return { + title: 'Test connection through Postgres Proxy', + type: 'postgres', + host: PROXY_HOST, + port: PROXY_PORT, + username: 'proxy_user', + password: 'proxy_pass', + database: 'postgres', + ssh: false, + ssl: false, + }; +} + +async function isProxyReachable(): Promise { + return new Promise((resolve) => { + const socket = new net.Socket(); + socket.setTimeout(2000); + socket.on('connect', () => { + socket.destroy(); + resolve(true); + }); + socket.on('error', () => resolve(false)); + socket.on('timeout', () => { + socket.destroy(); + resolve(false); + }); + socket.connect(PROXY_PORT, PROXY_HOST); + }); +} + +test.before(async () => { + const reachable = await isProxyReachable(); + if (!reachable) { + console.log(`[postgres-proxy e2e] Proxy not reachable at ${PROXY_HOST}:${PROXY_PORT}, skipping tests`); + skipAll = true; + return; + } + + setSaasEnvVariable(); + const moduleFixture = await Test.createTestingModule({ + imports: [ApplicationModule, DatabaseModule], + providers: [DatabaseService, TestUtils], + }).compile(); + + app = moduleFixture.createNestApplication() as any; + _testUtils = moduleFixture.get(TestUtils); + + app.use(cookieParser()); + app.useGlobalFilters(new AllExceptionsFilter(app.get(WinstonLogger))); + app.useGlobalPipes( + new ValidationPipe({ + exceptionFactory(validationErrors: ValidationError[] = []) { + return new ValidationException(validationErrors); + }, + }), + ); + await app.init(); + await createInitialTestUser(app); + app.getHttpServer().listen(0); +}); + +test.after(async () => { + if (skipAll) return; + try { + await Cacher.clearAllCache(); + await app.close(); + } catch (e) { + console.error('After tests error ' + e); + } +}); + +function maybeSkip(t: any): boolean { + if (skipAll) { + t.pass('skipped: proxy not available'); + return true; + } + return false; +} + +test.serial( + 'should list tables through the proxy via rocketadmin API', + async (t) => { + if (maybeSkip(t)) return; + try { + // 1. Seed a test table directly on the upstream Postgres + const { testTableName } = await createTestTable(upstreamConnectionParams, 5); + + // 2. Register user and create connection pointing to the proxy + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + const proxyConnectionDto = createProxyConnectionDto(); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(proxyConnectionDto) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(createConnectionResponse.status, 201); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + + // 3. Get tables through the proxy + const getTablesResponse = await request(app.getHttpServer()) + .get(`/connection/tables/${createConnectionRO.id}`) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(getTablesResponse.status, 200); + + const tables = JSON.parse(getTablesResponse.text); + t.true(Array.isArray(tables)); + t.true(tables.length > 0); + + const testTable = tables.find((tbl: any) => tbl.table === testTableName); + t.truthy(testTable, `Table "${testTableName}" should be visible through the proxy`); + t.is(testTable.permissions.visibility, true); + t.is(testTable.permissions.readonly, false); + t.is(testTable.permissions.add, true); + t.is(testTable.permissions.delete, true); + t.is(testTable.permissions.edit, true); + } catch (e) { + console.error(e); + throw e; + } + }, +); + +test.serial( + 'should get table rows through the proxy via rocketadmin API', + async (t) => { + if (maybeSkip(t)) return; + try { + const seedCount = 10; + const { testTableName, testTableColumnName, testTableSecondColumnName } = + await createTestTable(upstreamConnectionParams, seedCount); + + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + const proxyConnectionDto = createProxyConnectionDto(); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(proxyConnectionDto) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(createConnectionResponse.status, 201); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + + const getRowsResponse = await request(app.getHttpServer()) + .get(`/table/rows/${createConnectionRO.id}?tableName=${testTableName}`) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(getRowsResponse.status, 200); + + const rowsRO = JSON.parse(getRowsResponse.text); + t.true(Object.hasOwn(rowsRO, 'rows')); + t.true(Object.hasOwn(rowsRO, 'primaryColumns')); + t.true(Object.hasOwn(rowsRO, 'pagination')); + t.is(rowsRO.rows.length, seedCount); + t.true(Object.hasOwn(rowsRO.rows[0], 'id')); + t.true(Object.hasOwn(rowsRO.rows[0], testTableColumnName)); + t.true(Object.hasOwn(rowsRO.rows[0], testTableSecondColumnName)); + } catch (e) { + console.error(e); + throw e; + } + }, +); + +test.serial( + 'should add a row through the proxy via rocketadmin API', + async (t) => { + if (maybeSkip(t)) return; + try { + const { testTableName, testTableColumnName, testTableSecondColumnName } = + await createTestTable(upstreamConnectionParams, 3); + + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + const proxyConnectionDto = createProxyConnectionDto(); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(proxyConnectionDto) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(createConnectionResponse.status, 201); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + + const newName = faker.person.firstName(); + const newEmail = faker.internet.email(); + + const addRowResponse = await request(app.getHttpServer()) + .post(`/table/row/${createConnectionRO.id}?tableName=${testTableName}`) + .send( + JSON.stringify({ + [testTableColumnName]: newName, + [testTableSecondColumnName]: newEmail, + }), + ) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(addRowResponse.status, 201); + + const addRowRO = JSON.parse(addRowResponse.text); + t.true(Object.hasOwn(addRowRO, 'row')); + t.is(addRowRO.row[testTableColumnName], newName); + t.is(addRowRO.row[testTableSecondColumnName], newEmail); + + // Verify row count increased + const getRowsResponse = await request(app.getHttpServer()) + .get(`/table/rows/${createConnectionRO.id}?tableName=${testTableName}`) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(getRowsResponse.status, 200); + + const rowsRO = JSON.parse(getRowsResponse.text); + t.is(rowsRO.rows.length, 4); // 3 seeded + 1 added + } catch (e) { + console.error(e); + throw e; + } + }, +); + +test.serial( + 'should update a row through the proxy via rocketadmin API', + async (t) => { + if (maybeSkip(t)) return; + try { + const { testTableName, testTableColumnName, testTableSecondColumnName } = + await createTestTable(upstreamConnectionParams, 3); + + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + const proxyConnectionDto = createProxyConnectionDto(); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(proxyConnectionDto) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(createConnectionResponse.status, 201); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + + const updatedName = faker.person.firstName(); + const updatedEmail = faker.internet.email(); + + const updateRowResponse = await request(app.getHttpServer()) + .put(`/table/row/${createConnectionRO.id}?tableName=${testTableName}&id=1`) + .send( + JSON.stringify({ + [testTableColumnName]: updatedName, + [testTableSecondColumnName]: updatedEmail, + }), + ) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(updateRowResponse.status, 200); + + const updateRowRO = JSON.parse(updateRowResponse.text); + t.true(Object.hasOwn(updateRowRO, 'row')); + t.is(updateRowRO.row[testTableColumnName], updatedName); + t.is(updateRowRO.row[testTableSecondColumnName], updatedEmail); + } catch (e) { + console.error(e); + throw e; + } + }, +); + +test.serial( + 'should delete a row through the proxy via rocketadmin API', + async (t) => { + if (maybeSkip(t)) return; + try { + const { testTableName } = await createTestTable(upstreamConnectionParams, 5); + + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + const proxyConnectionDto = createProxyConnectionDto(); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(proxyConnectionDto) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(createConnectionResponse.status, 201); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + + const deleteRowResponse = await request(app.getHttpServer()) + .delete(`/table/row/${createConnectionRO.id}?tableName=${testTableName}&id=1`) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(deleteRowResponse.status, 200); + + // Verify row count decreased + const getRowsResponse = await request(app.getHttpServer()) + .get(`/table/rows/${createConnectionRO.id}?tableName=${testTableName}`) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(getRowsResponse.status, 200); + + const rowsRO = JSON.parse(getRowsResponse.text); + t.is(rowsRO.rows.length, 4); // 5 seeded - 1 deleted + } catch (e) { + console.error(e); + throw e; + } + }, +); + +test.serial( + 'should get table structure through the proxy via rocketadmin API', + async (t) => { + if (maybeSkip(t)) return; + try { + const { testTableName, testTableColumnName, testTableSecondColumnName } = + await createTestTable(upstreamConnectionParams, 3); + + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + const proxyConnectionDto = createProxyConnectionDto(); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(proxyConnectionDto) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(createConnectionResponse.status, 201); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + + const getStructureResponse = await request(app.getHttpServer()) + .get(`/table/structure/${createConnectionRO.id}?tableName=${testTableName}`) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(getStructureResponse.status, 200); + + const structureRO = JSON.parse(getStructureResponse.text); + t.true(typeof structureRO === 'object'); + t.true(Array.isArray(structureRO.structure)); + t.true(structureRO.structure.length > 0); + t.true(Object.hasOwn(structureRO, 'primaryColumns')); + t.true(Object.hasOwn(structureRO, 'foreignKeys')); + + const columnNames = structureRO.structure.map((col: any) => col.column_name); + t.true(columnNames.includes('id')); + t.true(columnNames.includes(testTableColumnName)); + t.true(columnNames.includes(testTableSecondColumnName)); + } catch (e) { + console.error(e); + throw e; + } + }, +); diff --git a/docker-compose.tst.yml b/docker-compose.tst.yml index 92753e203..09b4f8018 100644 --- a/docker-compose.tst.yml +++ b/docker-compose.tst.yml @@ -32,6 +32,8 @@ services: condition: service_healthy test-clickhouse-e2e-testing: condition: service_healthy + postgres-proxy: + condition: service_healthy command: ["/bin/sh", "-c", "yarn test-all $EXTRA_ARGS"] develop: watch: @@ -237,3 +239,64 @@ services: interval: 30s timeout: 10s retries: 3 + + # === Postgres Proxy E2E Testing === + + testPg-proxy-e2e: + image: postgres:16 + environment: + POSTGRES_PASSWORD: proxy_test_123 + POSTGRES_HOST_AUTH_METHOD: md5 + POSTGRES_INITDB_ARGS: "--auth-host=md5" + command: postgres -c 'max_connections=300' -c 'password_encryption=md5' + tmpfs: + - /var/lib/postgresql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 10s + + proxy-mock-api: + build: + context: ./test/proxy-mock-api + environment: + MOCK_API_PORT: 3333 + PROXY_API_KEY: test-proxy-api-key + UPSTREAM_PG_HOST: testPg-proxy-e2e + UPSTREAM_PG_PORT: 5432 + UPSTREAM_PG_DATABASE: postgres + UPSTREAM_PG_USERNAME: postgres + UPSTREAM_PG_PASSWORD: proxy_test_123 + depends_on: + testPg-proxy-e2e: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://localhost:3333/healthz || exit 1"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 5s + + postgres-proxy: + build: + context: ../postgres-proxy + environment: + PROXY_LISTEN_ADDR: ":5432" + PROXY_HEALTH_ADDR: ":8080" + PROXY_BACKEND_API_URL: "http://proxy-mock-api:3333" + PROXY_API_KEY: "test-proxy-api-key" + PROXY_USAGE_REPORT_INTERVAL: "10s" + PROXY_LOG_LEVEL: "debug" + depends_on: + proxy-mock-api: + condition: service_healthy + testPg-proxy-e2e: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://localhost:8080/healthz || exit 1"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 5s diff --git a/docker-compose.yml b/docker-compose.yml index 4b71cc05d..018890397 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -236,6 +236,51 @@ services: timeout: 10s retries: 3 + # === Postgres Proxy E2E Testing === + + testPg-proxy-e2e: + image: postgres:16 + ports: + - 9003:5432 + environment: + POSTGRES_PASSWORD: proxy_test_123 + POSTGRES_HOST_AUTH_METHOD: md5 + POSTGRES_INITDB_ARGS: "--auth-host=md5" + command: postgres -c 'max_connections=300' -c 'password_encryption=md5' + + proxy-mock-api: + build: + context: ./test/proxy-mock-api + ports: + - 3333:3333 + environment: + MOCK_API_PORT: 3333 + PROXY_API_KEY: test-proxy-api-key + UPSTREAM_PG_HOST: testPg-proxy-e2e + UPSTREAM_PG_PORT: 5432 + UPSTREAM_PG_DATABASE: postgres + UPSTREAM_PG_USERNAME: postgres + UPSTREAM_PG_PASSWORD: proxy_test_123 + depends_on: + - testPg-proxy-e2e + + postgres-proxy: + build: + context: ../postgres-proxy + ports: + - 9005:5432 + - 8085:8080 + environment: + PROXY_LISTEN_ADDR: ":5432" + PROXY_HEALTH_ADDR: ":8080" + PROXY_BACKEND_API_URL: "http://proxy-mock-api:3333" + PROXY_API_KEY: "test-proxy-api-key" + PROXY_USAGE_REPORT_INTERVAL: "10s" + PROXY_LOG_LEVEL: "debug" + depends_on: + - proxy-mock-api + - testPg-proxy-e2e + rocketadmin-agent_mongo: build: context: . diff --git a/shared-code/src/knex-manager/knex-manager.ts b/shared-code/src/knex-manager/knex-manager.ts index 19521d9b8..f79de83e0 100644 --- a/shared-code/src/knex-manager/knex-manager.ts +++ b/shared-code/src/knex-manager/knex-manager.ts @@ -125,7 +125,7 @@ export class KnexManager { } private static getPostgresKnex(connection: ConnectionParams): Knex { - const { host, username, password, database, port, type, cert, ssl } = connection; + const { host, username, password, database, port, type, cert, ssl, id } = connection; if (process.env.NODE_ENV === 'test') { const newKnex = knex({ client: type, @@ -135,6 +135,7 @@ export class KnexManager { password: password, database: database, port: port, + application_name: id, }, }); return newKnex; @@ -149,7 +150,7 @@ export class KnexManager { password: password, database: database, port: port, - application_name: 'rocketadmin', + application_name: id, ssl: { rejectUnauthorized: false, }, @@ -165,7 +166,7 @@ export class KnexManager { password: password, database: database, port: port, - application_name: 'rocketadmin', + application_name: id, ssl: ssl ? { ca: cert ?? undefined, rejectUnauthorized: !cert } : false, }, }); diff --git a/test/proxy-mock-api/Dockerfile b/test/proxy-mock-api/Dockerfile new file mode 100644 index 000000000..bfdfb30f4 --- /dev/null +++ b/test/proxy-mock-api/Dockerfile @@ -0,0 +1,8 @@ +FROM node:24-alpine + +WORKDIR /app +COPY server.js . + +EXPOSE 3333 + +CMD ["node", "server.js"] diff --git a/test/proxy-mock-api/server.js b/test/proxy-mock-api/server.js new file mode 100644 index 000000000..09d748992 --- /dev/null +++ b/test/proxy-mock-api/server.js @@ -0,0 +1,60 @@ +const http = require('http'); + +const PORT = process.env.MOCK_API_PORT || 3333; +const EXPECTED_API_KEY = process.env.PROXY_API_KEY || 'test-proxy-api-key'; + +// Connection info for the test Postgres behind the proxy +const TEST_CONNECTION = { + host: process.env.UPSTREAM_PG_HOST || 'testPg-proxy-e2e', + port: parseInt(process.env.UPSTREAM_PG_PORT || '5432', 10), + database: process.env.UPSTREAM_PG_DATABASE || 'postgres', + username: process.env.UPSTREAM_PG_USERNAME || 'postgres', + password: process.env.UPSTREAM_PG_PASSWORD || 'proxy_test_123', + companyId: 'test-company-001', + subscriptionLevel: 'pro', +}; + +const server = http.createServer((req, res) => { + // Health check endpoint (no auth required) + if (req.url === '/healthz') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ status: 'ok' })); + return; + } + + const apiKey = req.headers['x-proxy-api-key']; + if (apiKey !== EXPECTED_API_KEY) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + + // GET /api/proxy/connections/:id + const connMatch = req.url.match(/^\/api\/proxy\/connections\/(.+)$/); + if (req.method === 'GET' && connMatch) { + const connectionId = connMatch[1]; + console.log(`[mock-api] GET connection: ${connectionId}`); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(TEST_CONNECTION)); + return; + } + + // POST /api/proxy/usage + if (req.method === 'POST' && req.url === '/api/proxy/usage') { + let body = ''; + req.on('data', (chunk) => (body += chunk)); + req.on('end', () => { + console.log(`[mock-api] POST usage: ${body}`); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); + return; + } + + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Not found' })); +}); + +server.listen(PORT, () => { + console.log(`[mock-api] Proxy mock API listening on port ${PORT}`); +}); From 709b710ccb68fd254145e3fe19efbe195c1aceb5 Mon Sep 17 00:00:00 2001 From: Artem Niehrieiev Date: Tue, 7 Apr 2026 11:56:07 +0000 Subject: [PATCH 02/11] Update subscription level in TEST_CONNECTION to TEAM_PLAN --- test/proxy-mock-api/server.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/proxy-mock-api/server.js b/test/proxy-mock-api/server.js index 09d748992..2803994cd 100644 --- a/test/proxy-mock-api/server.js +++ b/test/proxy-mock-api/server.js @@ -11,7 +11,7 @@ const TEST_CONNECTION = { username: process.env.UPSTREAM_PG_USERNAME || 'postgres', password: process.env.UPSTREAM_PG_PASSWORD || 'proxy_test_123', companyId: 'test-company-001', - subscriptionLevel: 'pro', + subscriptionLevel: 'TEAM_PLAN', }; const server = http.createServer((req, res) => { From cb79f15c3b8aac3a6e1d1e54eda2fee0f4a60abd Mon Sep 17 00:00:00 2001 From: Artem Niehrieiev Date: Tue, 7 Apr 2026 14:38:13 +0000 Subject: [PATCH 03/11] Add hostedDatabaseId to connection creation and deletion tests --- .../saas-tests/hosted-connection-e2e.test.ts | 32 +++++++++---------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/backend/test/ava-tests/saas-tests/hosted-connection-e2e.test.ts b/backend/test/ava-tests/saas-tests/hosted-connection-e2e.test.ts index 95a47fd46..4d1a9dc7a 100644 --- a/backend/test/ava-tests/saas-tests/hosted-connection-e2e.test.ts +++ b/backend/test/ava-tests/saas-tests/hosted-connection-e2e.test.ts @@ -79,6 +79,7 @@ test.serial(`${currentTest} should create a hosted postgres connection with admi .send({ companyId: companyId, userId: userId, + hostedDatabaseId: faker.string.uuid(), databaseName: 'postgres', hostname: 'testPg-e2e-testing', port: 5432, @@ -89,24 +90,14 @@ test.serial(`${currentTest} should create a hosted postgres connection with admi .set('Content-Type', 'application/json') .set('Accept', 'application/json'); - t.is(createHostedConnectionResult.status, 201); - const createdConnection = JSON.parse(createHostedConnectionResult.text); - const connectionId = createdConnection.id; + console.log('🚀 ~ createdConnection:', createdConnection); + + t.is(createHostedConnectionResult.status, 201); + const connectionId = createdConnection.connectionId; // Verify connection was created t.truthy(connectionId); - t.is(createdConnection.type, 'postgres'); - t.is(createdConnection.database, 'postgres'); - t.is(createdConnection.host, 'testPg-e2e-testing'); - t.is(createdConnection.port, 5432); - - // Verify admin group was created - t.truthy(createdConnection.groups); - t.is(createdConnection.groups.length, 1); - const adminGroup = createdConnection.groups[0]; - t.truthy(adminGroup.id); - t.is(adminGroup.isMain, true); // Verify connection is accessible via connection groups endpoint const groupsResponse = await request(app.getHttpServer()) @@ -119,6 +110,7 @@ test.serial(`${currentTest} should create a hosted postgres connection with admi const groups = JSON.parse(groupsResponse.text); t.is(groups.length, 1); t.is(groups[0].accessLevel, AccessLevelEnum.edit); + const adminGroup = groups[0]; // Verify tables endpoint works with this connection const findTablesResponse = await request(app.getHttpServer()) @@ -132,8 +124,9 @@ test.serial(`${currentTest} should create a hosted postgres connection with admi t.true(Array.isArray(tables)); // Verify user permissions - user should have full access + const groupId = adminGroup.group.id; const permissionsResponse = await request(app.getHttpServer()) - .get(`/connection/permissions?connectionId=${connectionId}&groupId=${adminGroup.id}`) + .get(`/connection/permissions?connectionId=${connectionId}&groupId=${groupId}`) .set('Content-Type', 'application/json') .set('Cookie', token) .set('Accept', 'application/json'); @@ -177,6 +170,7 @@ test.serial(`${currentTest} should return error when userId does not exist`, asy .send({ companyId: faker.string.uuid(), userId: faker.string.uuid(), + hostedDatabaseId: faker.string.uuid(), databaseName: 'postgres', hostname: 'testPg-e2e-testing', port: 5432, @@ -187,6 +181,8 @@ test.serial(`${currentTest} should return error when userId does not exist`, asy .set('Content-Type', 'application/json') .set('Accept', 'application/json'); + const responseBody = JSON.parse(result.text); + console.log('🚀 ~ responseBody:', responseBody); t.is(result.status, 500); } catch (e) { console.error('Test error:', e); @@ -219,6 +215,7 @@ test.serial(`${currentTest} should delete a hosted connection`, async (t) => { .send({ companyId: companyId, userId: userId, + hostedDatabaseId: faker.string.uuid(), databaseName: 'postgres', hostname: 'testPg-e2e-testing', port: 5432, @@ -229,9 +226,10 @@ test.serial(`${currentTest} should delete a hosted connection`, async (t) => { .set('Content-Type', 'application/json') .set('Accept', 'application/json'); - t.is(createResult.status, 201); const createdConnection = JSON.parse(createResult.text); - const connectionId = createdConnection.id; + console.log('🚀 ~ createdConnection:', createdConnection); + t.is(createResult.status, 201); + const connectionId = createdConnection.connectionId; // Verify connection exists const connectionsBeforeDelete = await request(app.getHttpServer()) From ba2b8420bf58c2dcf92684dfed62edc4392e1b3b Mon Sep 17 00:00:00 2001 From: Artem Niehrieiev Date: Thu, 9 Apr 2026 08:19:54 +0000 Subject: [PATCH 04/11] Refactor connection handling in proxy mock API and update Postgres connection parameters --- shared-code/src/knex-manager/knex-manager.ts | 8 ++--- test/proxy-mock-api/server.js | 33 ++++++++++++++------ 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/shared-code/src/knex-manager/knex-manager.ts b/shared-code/src/knex-manager/knex-manager.ts index f79de83e0..c8c2ba8ea 100644 --- a/shared-code/src/knex-manager/knex-manager.ts +++ b/shared-code/src/knex-manager/knex-manager.ts @@ -125,7 +125,7 @@ export class KnexManager { } private static getPostgresKnex(connection: ConnectionParams): Knex { - const { host, username, password, database, port, type, cert, ssl, id } = connection; + const { host, username, password, database, port, type, cert, ssl } = connection; if (process.env.NODE_ENV === 'test') { const newKnex = knex({ client: type, @@ -135,7 +135,7 @@ export class KnexManager { password: password, database: database, port: port, - application_name: id, + application_name: 'rocketadmin', }, }); return newKnex; @@ -150,7 +150,7 @@ export class KnexManager { password: password, database: database, port: port, - application_name: id, + application_name: 'rocketadmin', ssl: { rejectUnauthorized: false, }, @@ -166,7 +166,7 @@ export class KnexManager { password: password, database: database, port: port, - application_name: id, + application_name: 'rocketadmin', ssl: ssl ? { ca: cert ?? undefined, rejectUnauthorized: !cert } : false, }, }); diff --git a/test/proxy-mock-api/server.js b/test/proxy-mock-api/server.js index 2803994cd..4310462f1 100644 --- a/test/proxy-mock-api/server.js +++ b/test/proxy-mock-api/server.js @@ -1,10 +1,18 @@ const http = require('http'); +const url = require('url'); const PORT = process.env.MOCK_API_PORT || 3333; const EXPECTED_API_KEY = process.env.PROXY_API_KEY || 'test-proxy-api-key'; -// Connection info for the test Postgres behind the proxy +// The client (rocketadmin) connects to the proxy with these credentials. +// The proxy extracts username+database from the startup message and looks up the +// real upstream connection info here. +const PROXY_USERNAME = process.env.PROXY_CLIENT_USERNAME || 'proxy_user'; +const PROXY_DATABASE = process.env.PROXY_CLIENT_DATABASE || 'postgres'; + +// Upstream connection info returned when the proxy asks for the above client credentials const TEST_CONNECTION = { + connectionId: 'test-connection-001', host: process.env.UPSTREAM_PG_HOST || 'testPg-proxy-e2e', port: parseInt(process.env.UPSTREAM_PG_PORT || '5432', 10), database: process.env.UPSTREAM_PG_DATABASE || 'postgres', @@ -29,18 +37,25 @@ const server = http.createServer((req, res) => { return; } - // GET /api/proxy/connections/:id - const connMatch = req.url.match(/^\/api\/proxy\/connections\/(.+)$/); - if (req.method === 'GET' && connMatch) { - const connectionId = connMatch[1]; - console.log(`[mock-api] GET connection: ${connectionId}`); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(TEST_CONNECTION)); + const parsedUrl = url.parse(req.url, true); + + // GET /api/proxy/connections?username=X&database=Y + if (req.method === 'GET' && parsedUrl.pathname === '/api/proxy/connections') { + const { username, database } = parsedUrl.query; + console.log(`[mock-api] GET connection: username=${username}, database=${database}`); + + if (username === PROXY_USERNAME && database === PROXY_DATABASE) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(TEST_CONNECTION)); + } else { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Connection not found' })); + } return; } // POST /api/proxy/usage - if (req.method === 'POST' && req.url === '/api/proxy/usage') { + if (req.method === 'POST' && parsedUrl.pathname === '/api/proxy/usage') { let body = ''; req.on('data', (chunk) => (body += chunk)); req.on('end', () => { From e0a3bb4ba1053ab6069b04fcd9da1323d365673f Mon Sep 17 00:00:00 2001 From: Artem Niehrieiev Date: Tue, 14 Apr 2026 14:26:26 +0000 Subject: [PATCH 05/11] Add mock API endpoints for subscription level management and usage reports --- .../saas-postgres-proxy-e2e.test.ts | 178 ++++++++++++++++++ test/proxy-mock-api/server.js | 52 ++++- 2 files changed, 227 insertions(+), 3 deletions(-) diff --git a/backend/test/ava-tests/saas-tests/saas-postgres-proxy-e2e.test.ts b/backend/test/ava-tests/saas-tests/saas-postgres-proxy-e2e.test.ts index d1947efec..76ffab6f6 100644 --- a/backend/test/ava-tests/saas-tests/saas-postgres-proxy-e2e.test.ts +++ b/backend/test/ava-tests/saas-tests/saas-postgres-proxy-e2e.test.ts @@ -4,6 +4,7 @@ import { Test } from '@nestjs/testing'; import test from 'ava'; import { ValidationError } from 'class-validator'; import cookieParser from 'cookie-parser'; +import http from 'http'; import net from 'net'; import request from 'supertest'; import { ApplicationModule } from '../../../src/app.module.js'; @@ -27,6 +28,8 @@ let skipAll = false; const PROXY_HOST = process.env.POSTGRES_PROXY_HOST || 'postgres-proxy'; const PROXY_PORT = parseInt(process.env.POSTGRES_PROXY_PORT || '5432', 10); +const MOCK_API_HOST = process.env.PROXY_MOCK_API_HOST || 'proxy-mock-api'; +const MOCK_API_PORT = parseInt(process.env.PROXY_MOCK_API_PORT || '3333', 10); // Direct connection to the upstream Postgres (for seeding test data) const upstreamConnectionParams = { @@ -71,6 +74,45 @@ async function isProxyReachable(): Promise { }); } +// Helper to call mock API test endpoints +function mockApiRequest(method: string, path: string, body?: any): Promise { + return new Promise((resolve, reject) => { + const options = { + hostname: MOCK_API_HOST, + port: MOCK_API_PORT, + path, + method, + headers: { 'Content-Type': 'application/json' }, + }; + const req = http.request(options, (res) => { + let data = ''; + res.on('data', (chunk: string) => (data += chunk)); + res.on('end', () => { + try { + resolve(JSON.parse(data)); + } catch { + resolve(data); + } + }); + }); + req.on('error', reject); + if (body) req.write(JSON.stringify(body)); + req.end(); + }); +} + +async function getUsageReports(): Promise { + return mockApiRequest('GET', '/api/test/usage-reports'); +} + +async function setSubscriptionLevel(level: string): Promise { + await mockApiRequest('PUT', '/api/test/subscription-level', { subscriptionLevel: level }); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + test.before(async () => { const reachable = await isProxyReachable(); if (!reachable) { @@ -402,3 +444,139 @@ test.serial( } }, ); + +// ─── Usage reporting test ─────────────────────────────────────────────────── +// +// Verifies that the proxy actually reports usage metrics back to the backend +// (mock API) after queries. Uses a baseline/delta approach so it doesn't +// conflict with any state accumulated by earlier tests in the same run. +// +// Requires the proxy-mock-api container to be rebuilt with the test-only +// endpoints (/api/test/usage-reports). If not available, test is skipped. + +test.serial( + 'should report usage metrics to mock API after queries through proxy', + async (t) => { + if (maybeSkip(t)) return; + try { + // Probe mock API capability — skip if test endpoints are absent (old mock build) + const probe = await getUsageReports(); + if (!Array.isArray(probe)) { + t.pass('skipped: proxy-mock-api does not expose test endpoints (rebuild required)'); + return; + } + const baselineReportCount = probe.length; + + const { testTableName } = await createTestTable(upstreamConnectionParams, 3); + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + const proxyConnectionDto = createProxyConnectionDto(); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(proxyConnectionDto) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(createConnectionResponse.status, 201); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + + // A single query is enough — the proxy reports usage periodically regardless + const getRowsResponse = await request(app.getHttpServer()) + .get(`/table/rows/${createConnectionRO.id}?tableName=${testTableName}`) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(getRowsResponse.status, 200); + + // Wait for the proxy's usage report interval (configured at 10s in docker-compose) + // plus a small buffer for the HTTP call to complete + await sleep(12000); + + const reports = await getUsageReports(); + t.true(Array.isArray(reports), 'Usage reports should be an array'); + t.true( + reports.length > baselineReportCount, + `Expected new usage reports after queries (baseline=${baselineReportCount}, got=${reports.length})`, + ); + + // Check structure of the latest reports + const latestReports = reports.slice(baselineReportCount); + const relevantReports = latestReports.filter((r: any) => r.connectionId === 'test-connection-001'); + t.true(relevantReports.length > 0, 'Should have at least one report for test-connection-001'); + + const report = relevantReports[0]; + t.true(Object.hasOwn(report, 'connectionId')); + t.true(Object.hasOwn(report, 'companyId')); + t.true(Object.hasOwn(report, 'queryTimeMs')); + t.true(Object.hasOwn(report, 'queryCount')); + t.true(Object.hasOwn(report, 'timestamp')); + t.is(report.companyId, 'test-company-001'); + + // Total query count across the new reports should reflect our queries + const totalQueryCount = relevantReports.reduce((sum: number, r: any) => sum + r.queryCount, 0); + t.true(totalQueryCount > 0, `Expected positive query count, got ${totalQueryCount}`); + + // Total query time should be non-negative (could be 0 for very fast queries, + // but with millisecond resolution a real query should register some time) + const totalQueryTimeMs = relevantReports.reduce((sum: number, r: any) => sum + r.queryTimeMs, 0); + t.true(totalQueryTimeMs >= 0, `Expected non-negative query time, got ${totalQueryTimeMs}`); + } catch (e) { + console.error(e); + throw e; + } + }, +); + +// ─── Frozen plan / connection rejection test ──────────────────────────────── +// +// This test MUST run LAST because it flips the mock-api into `frozen` state. +// After this test, no further proxy tests will succeed until the mock-api is +// reset. By running last, we avoid polluting state for other tests. +// +// Requires the proxy-mock-api container to be rebuilt with the test-only +// endpoints. If not available, test is skipped. + +test.serial( + '[zzz-last] should reject connection when subscription plan is frozen', + async (t) => { + if (maybeSkip(t)) return; + + // Probe mock API capability — skip if test endpoints are absent + const probe = await mockApiRequest('GET', '/api/test/usage-reports'); + if (!Array.isArray(probe)) { + t.pass('skipped: proxy-mock-api does not expose test endpoints (rebuild required)'); + return; + } + + try { + // Set plan to frozen via mock API + await setSubscriptionLevel('frozen'); + + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + const proxyConnectionDto = createProxyConnectionDto(); + proxyConnectionDto.title = 'Frozen-plan test connection'; + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(proxyConnectionDto) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(createConnectionResponse.status, 201); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + + // Attempting to list tables should fail because the proxy rejects the connection + const getTablesResponse = await request(app.getHttpServer()) + .get(`/connection/tables/${createConnectionRO.id}`) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + // The proxy should refuse the connection, resulting in an error from the backend + t.not(getTablesResponse.status, 200, 'Should not succeed with frozen plan'); + } finally { + // Best-effort restore so manual runs of the full file behave sanely + await setSubscriptionLevel('TEAM_PLAN').catch(() => undefined); + } + }, +); diff --git a/test/proxy-mock-api/server.js b/test/proxy-mock-api/server.js index 4310462f1..3da712056 100644 --- a/test/proxy-mock-api/server.js +++ b/test/proxy-mock-api/server.js @@ -22,6 +22,13 @@ const TEST_CONNECTION = { subscriptionLevel: 'TEAM_PLAN', }; +// Configurable subscription level — e2e tests can change this at runtime +// to test throttling / frozen plan behaviour. +let currentSubscriptionLevel = TEST_CONNECTION.subscriptionLevel; + +// Store received usage reports so tests can verify them via GET /api/proxy/usage-reports +const usageReports = []; + const server = http.createServer((req, res) => { // Health check endpoint (no auth required) if (req.url === '/healthz') { @@ -30,6 +37,37 @@ const server = http.createServer((req, res) => { return; } + const parsedUrl = url.parse(req.url, true); + + // Test-only: configure subscription level at runtime (no auth required) + if (req.method === 'PUT' && parsedUrl.pathname === '/api/test/subscription-level') { + let body = ''; + req.on('data', (chunk) => (body += chunk)); + req.on('end', () => { + const parsed = JSON.parse(body); + currentSubscriptionLevel = parsed.subscriptionLevel; + console.log(`[mock-api] Subscription level changed to: ${currentSubscriptionLevel}`); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, subscriptionLevel: currentSubscriptionLevel })); + }); + return; + } + + // Test-only: get collected usage reports (no auth required) + if (req.method === 'GET' && parsedUrl.pathname === '/api/test/usage-reports') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(usageReports)); + return; + } + + // Test-only: clear collected usage reports (no auth required) + if (req.method === 'DELETE' && parsedUrl.pathname === '/api/test/usage-reports') { + usageReports.length = 0; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + return; + } + const apiKey = req.headers['x-proxy-api-key']; if (apiKey !== EXPECTED_API_KEY) { res.writeHead(401, { 'Content-Type': 'application/json' }); @@ -37,8 +75,6 @@ const server = http.createServer((req, res) => { return; } - const parsedUrl = url.parse(req.url, true); - // GET /api/proxy/connections?username=X&database=Y if (req.method === 'GET' && parsedUrl.pathname === '/api/proxy/connections') { const { username, database } = parsedUrl.query; @@ -46,7 +82,7 @@ const server = http.createServer((req, res) => { if (username === PROXY_USERNAME && database === PROXY_DATABASE) { res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(TEST_CONNECTION)); + res.end(JSON.stringify({ ...TEST_CONNECTION, subscriptionLevel: currentSubscriptionLevel })); } else { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Connection not found' })); @@ -60,6 +96,16 @@ const server = http.createServer((req, res) => { req.on('data', (chunk) => (body += chunk)); req.on('end', () => { console.log(`[mock-api] POST usage: ${body}`); + try { + const reports = JSON.parse(body); + if (Array.isArray(reports)) { + usageReports.push(...reports); + } else { + usageReports.push(reports); + } + } catch (e) { + console.error(`[mock-api] Failed to parse usage body: ${e.message}`); + } res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true })); }); From 0d1a680483dc674321924e878b6a83eea0e7252a Mon Sep 17 00:00:00 2001 From: Artem Niehrieiev Date: Wed, 15 Apr 2026 11:23:39 +0000 Subject: [PATCH 06/11] Enhance proxy connection handling and usage reporting - Updated the `createProxyConnectionDto` function to generate unique usernames for each test, allowing for isolated connection pools. - Implemented `expectedCompanyId` and `expectedConnectionId` functions to derive company and connection IDs based on the generated usernames. - Modified the mock API server to handle dynamic usernames and return appropriate connection and company IDs. - Improved usage reporting tests to ensure metrics are accurately reported after queries through the proxy. --- .../saas-postgres-proxy-e2e.test.ts | 1017 +++++++++-------- test/proxy-mock-api/server.js | 49 +- 2 files changed, 560 insertions(+), 506 deletions(-) diff --git a/backend/test/ava-tests/saas-tests/saas-postgres-proxy-e2e.test.ts b/backend/test/ava-tests/saas-tests/saas-postgres-proxy-e2e.test.ts index 76ffab6f6..090b5fb63 100644 --- a/backend/test/ava-tests/saas-tests/saas-postgres-proxy-e2e.test.ts +++ b/backend/test/ava-tests/saas-tests/saas-postgres-proxy-e2e.test.ts @@ -4,6 +4,7 @@ import { Test } from '@nestjs/testing'; import test from 'ava'; import { ValidationError } from 'class-validator'; import cookieParser from 'cookie-parser'; +import { createHash, randomBytes } from 'crypto'; import http from 'http'; import net from 'net'; import request from 'supertest'; @@ -16,8 +17,8 @@ import { DatabaseService } from '../../../src/shared/database/database.service.j import { WinstonLogger } from '../../../src/entities/logging/winston-logger.js'; import { createTestTable } from '../../utils/create-test-table.js'; import { - createInitialTestUser, - registerUserAndReturnUserInfo, + createInitialTestUser, + registerUserAndReturnUserInfo, } from '../../utils/register-user-and-return-user-info.js'; import { setSaasEnvVariable } from '../../utils/set-saas-env-variable.js'; import { TestUtils } from '../../utils/test.utils.js'; @@ -33,417 +34,434 @@ const MOCK_API_PORT = parseInt(process.env.PROXY_MOCK_API_PORT || '3333', 10); // Direct connection to the upstream Postgres (for seeding test data) const upstreamConnectionParams = { - type: 'postgres', - host: process.env.UPSTREAM_PG_HOST || 'testPg-proxy-e2e', - port: parseInt(process.env.UPSTREAM_PG_PORT || '5432', 10), - username: 'postgres', - password: 'proxy_test_123', - database: 'postgres', - ssh: false, + type: 'postgres', + host: process.env.UPSTREAM_PG_HOST || 'testPg-proxy-e2e', + port: parseInt(process.env.UPSTREAM_PG_PORT || '5432', 10), + username: 'postgres', + password: 'proxy_test_123', + database: 'postgres', + ssh: false, }; -// Connection DTO that points to the proxy (used in rocketadmin API) -function createProxyConnectionDto() { - return { - title: 'Test connection through Postgres Proxy', - type: 'postgres', - host: PROXY_HOST, - port: PROXY_PORT, - username: 'proxy_user', - password: 'proxy_pass', - database: 'postgres', - ssh: false, - ssl: false, - }; +// Connection DTO that points to the proxy (used in rocketadmin API). +// Each call returns a unique username so the mock-api derives a unique +// companyId — this isolates each test's connection pool inside the proxy's +// per-company concurrency limiter. +function createProxyConnectionDto(): { + title: string; + type: string; + host: string; + port: number; + username: string; + password: string; + database: string; + ssh: boolean; + ssl: boolean; +} { + const username = `proxy_user_${randomBytes(4).toString('hex')}`; + return { + title: 'Test connection through Postgres Proxy', + type: 'postgres', + host: PROXY_HOST, + port: PROXY_PORT, + username, + password: 'proxy_pass', + database: 'postgres', + ssh: false, + ssl: false, + }; +} + +// Mirrors the mock-api's deriveCompanyId / deriveConnectionId so tests can +// predict which companyId/connectionId the proxy will see for a username. +function expectedCompanyId(username: string): string { + if (username === 'proxy_user') return 'test-company-001'; + return `test-company-${createHash('sha1').update(username).digest('hex').slice(0, 12)}`; +} + +function expectedConnectionId(username: string): string { + if (username === 'proxy_user') return 'test-connection-001'; + return `test-connection-${createHash('sha1').update(username).digest('hex').slice(0, 12)}`; } async function isProxyReachable(): Promise { - return new Promise((resolve) => { - const socket = new net.Socket(); - socket.setTimeout(2000); - socket.on('connect', () => { - socket.destroy(); - resolve(true); - }); - socket.on('error', () => resolve(false)); - socket.on('timeout', () => { - socket.destroy(); - resolve(false); - }); - socket.connect(PROXY_PORT, PROXY_HOST); - }); + return new Promise((resolve) => { + const socket = new net.Socket(); + socket.setTimeout(2000); + socket.on('connect', () => { + socket.destroy(); + resolve(true); + }); + socket.on('error', () => resolve(false)); + socket.on('timeout', () => { + socket.destroy(); + resolve(false); + }); + socket.connect(PROXY_PORT, PROXY_HOST); + }); } // Helper to call mock API test endpoints function mockApiRequest(method: string, path: string, body?: any): Promise { - return new Promise((resolve, reject) => { - const options = { - hostname: MOCK_API_HOST, - port: MOCK_API_PORT, - path, - method, - headers: { 'Content-Type': 'application/json' }, - }; - const req = http.request(options, (res) => { - let data = ''; - res.on('data', (chunk: string) => (data += chunk)); - res.on('end', () => { - try { - resolve(JSON.parse(data)); - } catch { - resolve(data); - } - }); - }); - req.on('error', reject); - if (body) req.write(JSON.stringify(body)); - req.end(); - }); + return new Promise((resolve, reject) => { + const options = { + hostname: MOCK_API_HOST, + port: MOCK_API_PORT, + path, + method, + headers: { 'Content-Type': 'application/json' }, + }; + const req = http.request(options, (res) => { + let data = ''; + res.on('data', (chunk: string) => (data += chunk)); + res.on('end', () => { + try { + resolve(JSON.parse(data)); + } catch { + resolve(data); + } + }); + }); + req.on('error', reject); + if (body) req.write(JSON.stringify(body)); + req.end(); + }); } async function getUsageReports(): Promise { - return mockApiRequest('GET', '/api/test/usage-reports'); + return mockApiRequest('GET', '/api/test/usage-reports'); } async function setSubscriptionLevel(level: string): Promise { - await mockApiRequest('PUT', '/api/test/subscription-level', { subscriptionLevel: level }); + await mockApiRequest('PUT', '/api/test/subscription-level', { subscriptionLevel: level }); } function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); + return new Promise((resolve) => setTimeout(resolve, ms)); } test.before(async () => { - const reachable = await isProxyReachable(); - if (!reachable) { - console.log(`[postgres-proxy e2e] Proxy not reachable at ${PROXY_HOST}:${PROXY_PORT}, skipping tests`); - skipAll = true; - return; - } - - setSaasEnvVariable(); - const moduleFixture = await Test.createTestingModule({ - imports: [ApplicationModule, DatabaseModule], - providers: [DatabaseService, TestUtils], - }).compile(); - - app = moduleFixture.createNestApplication() as any; - _testUtils = moduleFixture.get(TestUtils); - - app.use(cookieParser()); - app.useGlobalFilters(new AllExceptionsFilter(app.get(WinstonLogger))); - app.useGlobalPipes( - new ValidationPipe({ - exceptionFactory(validationErrors: ValidationError[] = []) { - return new ValidationException(validationErrors); - }, - }), - ); - await app.init(); - await createInitialTestUser(app); - app.getHttpServer().listen(0); + const reachable = await isProxyReachable(); + if (!reachable) { + console.log(`[postgres-proxy e2e] Proxy not reachable at ${PROXY_HOST}:${PROXY_PORT}, skipping tests`); + skipAll = true; + return; + } + + setSaasEnvVariable(); + const moduleFixture = await Test.createTestingModule({ + imports: [ApplicationModule, DatabaseModule], + providers: [DatabaseService, TestUtils], + }).compile(); + + app = moduleFixture.createNestApplication() as any; + _testUtils = moduleFixture.get(TestUtils); + + app.use(cookieParser()); + app.useGlobalFilters(new AllExceptionsFilter(app.get(WinstonLogger))); + app.useGlobalPipes( + new ValidationPipe({ + exceptionFactory(validationErrors: ValidationError[] = []) { + return new ValidationException(validationErrors); + }, + }), + ); + await app.init(); + await createInitialTestUser(app); + app.getHttpServer().listen(0); }); test.after(async () => { - if (skipAll) return; - try { - await Cacher.clearAllCache(); - await app.close(); - } catch (e) { - console.error('After tests error ' + e); - } + if (skipAll) return; + try { + await Cacher.clearAllCache(); + await app.close(); + } catch (e) { + console.error('After tests error ' + e); + } }); function maybeSkip(t: any): boolean { - if (skipAll) { - t.pass('skipped: proxy not available'); - return true; - } - return false; + if (skipAll) { + t.pass('skipped: proxy not available'); + return true; + } + return false; } -test.serial( - 'should list tables through the proxy via rocketadmin API', - async (t) => { - if (maybeSkip(t)) return; - try { - // 1. Seed a test table directly on the upstream Postgres - const { testTableName } = await createTestTable(upstreamConnectionParams, 5); - - // 2. Register user and create connection pointing to the proxy - const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; - const proxyConnectionDto = createProxyConnectionDto(); - - const createConnectionResponse = await request(app.getHttpServer()) - .post('/connection') - .send(proxyConnectionDto) - .set('Cookie', firstUserToken) - .set('Content-Type', 'application/json') - .set('Accept', 'application/json'); - t.is(createConnectionResponse.status, 201); - const createConnectionRO = JSON.parse(createConnectionResponse.text); - - // 3. Get tables through the proxy - const getTablesResponse = await request(app.getHttpServer()) - .get(`/connection/tables/${createConnectionRO.id}`) - .set('Cookie', firstUserToken) - .set('Content-Type', 'application/json') - .set('Accept', 'application/json'); - t.is(getTablesResponse.status, 200); - - const tables = JSON.parse(getTablesResponse.text); - t.true(Array.isArray(tables)); - t.true(tables.length > 0); - - const testTable = tables.find((tbl: any) => tbl.table === testTableName); - t.truthy(testTable, `Table "${testTableName}" should be visible through the proxy`); - t.is(testTable.permissions.visibility, true); - t.is(testTable.permissions.readonly, false); - t.is(testTable.permissions.add, true); - t.is(testTable.permissions.delete, true); - t.is(testTable.permissions.edit, true); - } catch (e) { - console.error(e); - throw e; - } - }, -); - -test.serial( - 'should get table rows through the proxy via rocketadmin API', - async (t) => { - if (maybeSkip(t)) return; - try { - const seedCount = 10; - const { testTableName, testTableColumnName, testTableSecondColumnName } = - await createTestTable(upstreamConnectionParams, seedCount); - - const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; - const proxyConnectionDto = createProxyConnectionDto(); - - const createConnectionResponse = await request(app.getHttpServer()) - .post('/connection') - .send(proxyConnectionDto) - .set('Cookie', firstUserToken) - .set('Content-Type', 'application/json') - .set('Accept', 'application/json'); - t.is(createConnectionResponse.status, 201); - const createConnectionRO = JSON.parse(createConnectionResponse.text); - - const getRowsResponse = await request(app.getHttpServer()) - .get(`/table/rows/${createConnectionRO.id}?tableName=${testTableName}`) - .set('Cookie', firstUserToken) - .set('Content-Type', 'application/json') - .set('Accept', 'application/json'); - t.is(getRowsResponse.status, 200); - - const rowsRO = JSON.parse(getRowsResponse.text); - t.true(Object.hasOwn(rowsRO, 'rows')); - t.true(Object.hasOwn(rowsRO, 'primaryColumns')); - t.true(Object.hasOwn(rowsRO, 'pagination')); - t.is(rowsRO.rows.length, seedCount); - t.true(Object.hasOwn(rowsRO.rows[0], 'id')); - t.true(Object.hasOwn(rowsRO.rows[0], testTableColumnName)); - t.true(Object.hasOwn(rowsRO.rows[0], testTableSecondColumnName)); - } catch (e) { - console.error(e); - throw e; - } - }, -); - -test.serial( - 'should add a row through the proxy via rocketadmin API', - async (t) => { - if (maybeSkip(t)) return; - try { - const { testTableName, testTableColumnName, testTableSecondColumnName } = - await createTestTable(upstreamConnectionParams, 3); - - const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; - const proxyConnectionDto = createProxyConnectionDto(); - - const createConnectionResponse = await request(app.getHttpServer()) - .post('/connection') - .send(proxyConnectionDto) - .set('Cookie', firstUserToken) - .set('Content-Type', 'application/json') - .set('Accept', 'application/json'); - t.is(createConnectionResponse.status, 201); - const createConnectionRO = JSON.parse(createConnectionResponse.text); - - const newName = faker.person.firstName(); - const newEmail = faker.internet.email(); - - const addRowResponse = await request(app.getHttpServer()) - .post(`/table/row/${createConnectionRO.id}?tableName=${testTableName}`) - .send( - JSON.stringify({ - [testTableColumnName]: newName, - [testTableSecondColumnName]: newEmail, - }), - ) - .set('Cookie', firstUserToken) - .set('Content-Type', 'application/json') - .set('Accept', 'application/json'); - t.is(addRowResponse.status, 201); - - const addRowRO = JSON.parse(addRowResponse.text); - t.true(Object.hasOwn(addRowRO, 'row')); - t.is(addRowRO.row[testTableColumnName], newName); - t.is(addRowRO.row[testTableSecondColumnName], newEmail); - - // Verify row count increased - const getRowsResponse = await request(app.getHttpServer()) - .get(`/table/rows/${createConnectionRO.id}?tableName=${testTableName}`) - .set('Cookie', firstUserToken) - .set('Content-Type', 'application/json') - .set('Accept', 'application/json'); - t.is(getRowsResponse.status, 200); - - const rowsRO = JSON.parse(getRowsResponse.text); - t.is(rowsRO.rows.length, 4); // 3 seeded + 1 added - } catch (e) { - console.error(e); - throw e; - } - }, -); - -test.serial( - 'should update a row through the proxy via rocketadmin API', - async (t) => { - if (maybeSkip(t)) return; - try { - const { testTableName, testTableColumnName, testTableSecondColumnName } = - await createTestTable(upstreamConnectionParams, 3); - - const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; - const proxyConnectionDto = createProxyConnectionDto(); - - const createConnectionResponse = await request(app.getHttpServer()) - .post('/connection') - .send(proxyConnectionDto) - .set('Cookie', firstUserToken) - .set('Content-Type', 'application/json') - .set('Accept', 'application/json'); - t.is(createConnectionResponse.status, 201); - const createConnectionRO = JSON.parse(createConnectionResponse.text); - - const updatedName = faker.person.firstName(); - const updatedEmail = faker.internet.email(); - - const updateRowResponse = await request(app.getHttpServer()) - .put(`/table/row/${createConnectionRO.id}?tableName=${testTableName}&id=1`) - .send( - JSON.stringify({ - [testTableColumnName]: updatedName, - [testTableSecondColumnName]: updatedEmail, - }), - ) - .set('Cookie', firstUserToken) - .set('Content-Type', 'application/json') - .set('Accept', 'application/json'); - t.is(updateRowResponse.status, 200); - - const updateRowRO = JSON.parse(updateRowResponse.text); - t.true(Object.hasOwn(updateRowRO, 'row')); - t.is(updateRowRO.row[testTableColumnName], updatedName); - t.is(updateRowRO.row[testTableSecondColumnName], updatedEmail); - } catch (e) { - console.error(e); - throw e; - } - }, -); - -test.serial( - 'should delete a row through the proxy via rocketadmin API', - async (t) => { - if (maybeSkip(t)) return; - try { - const { testTableName } = await createTestTable(upstreamConnectionParams, 5); - - const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; - const proxyConnectionDto = createProxyConnectionDto(); - - const createConnectionResponse = await request(app.getHttpServer()) - .post('/connection') - .send(proxyConnectionDto) - .set('Cookie', firstUserToken) - .set('Content-Type', 'application/json') - .set('Accept', 'application/json'); - t.is(createConnectionResponse.status, 201); - const createConnectionRO = JSON.parse(createConnectionResponse.text); - - const deleteRowResponse = await request(app.getHttpServer()) - .delete(`/table/row/${createConnectionRO.id}?tableName=${testTableName}&id=1`) - .set('Cookie', firstUserToken) - .set('Content-Type', 'application/json') - .set('Accept', 'application/json'); - t.is(deleteRowResponse.status, 200); - - // Verify row count decreased - const getRowsResponse = await request(app.getHttpServer()) - .get(`/table/rows/${createConnectionRO.id}?tableName=${testTableName}`) - .set('Cookie', firstUserToken) - .set('Content-Type', 'application/json') - .set('Accept', 'application/json'); - t.is(getRowsResponse.status, 200); - - const rowsRO = JSON.parse(getRowsResponse.text); - t.is(rowsRO.rows.length, 4); // 5 seeded - 1 deleted - } catch (e) { - console.error(e); - throw e; - } - }, -); - -test.serial( - 'should get table structure through the proxy via rocketadmin API', - async (t) => { - if (maybeSkip(t)) return; - try { - const { testTableName, testTableColumnName, testTableSecondColumnName } = - await createTestTable(upstreamConnectionParams, 3); - - const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; - const proxyConnectionDto = createProxyConnectionDto(); - - const createConnectionResponse = await request(app.getHttpServer()) - .post('/connection') - .send(proxyConnectionDto) - .set('Cookie', firstUserToken) - .set('Content-Type', 'application/json') - .set('Accept', 'application/json'); - t.is(createConnectionResponse.status, 201); - const createConnectionRO = JSON.parse(createConnectionResponse.text); - - const getStructureResponse = await request(app.getHttpServer()) - .get(`/table/structure/${createConnectionRO.id}?tableName=${testTableName}`) - .set('Cookie', firstUserToken) - .set('Content-Type', 'application/json') - .set('Accept', 'application/json'); - t.is(getStructureResponse.status, 200); - - const structureRO = JSON.parse(getStructureResponse.text); - t.true(typeof structureRO === 'object'); - t.true(Array.isArray(structureRO.structure)); - t.true(structureRO.structure.length > 0); - t.true(Object.hasOwn(structureRO, 'primaryColumns')); - t.true(Object.hasOwn(structureRO, 'foreignKeys')); - - const columnNames = structureRO.structure.map((col: any) => col.column_name); - t.true(columnNames.includes('id')); - t.true(columnNames.includes(testTableColumnName)); - t.true(columnNames.includes(testTableSecondColumnName)); - } catch (e) { - console.error(e); - throw e; - } - }, -); +test.serial('should list tables through the proxy via rocketadmin API', async (t) => { + if (maybeSkip(t)) return; + try { + // 1. Seed a test table directly on the upstream Postgres + const { testTableName } = await createTestTable(upstreamConnectionParams, 5); + + // 2. Register user and create connection pointing to the proxy + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + const proxyConnectionDto = createProxyConnectionDto(); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(proxyConnectionDto) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(createConnectionResponse.status, 201); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + + // 3. Get tables through the proxy + const getTablesResponse = await request(app.getHttpServer()) + .get(`/connection/tables/${createConnectionRO.id}`) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(getTablesResponse.status, 200); + + const tables = JSON.parse(getTablesResponse.text); + console.log('🚀 ~ tables:', tables); + t.true(Array.isArray(tables)); + t.true(tables.length > 0); + + const testTable = tables.find((tbl: any) => tbl.table === testTableName); + t.truthy(testTable, `Table "${testTableName}" should be visible through the proxy`); + t.is(testTable.permissions.visibility, true); + t.is(testTable.permissions.readonly, false); + t.is(testTable.permissions.add, true); + t.is(testTable.permissions.delete, true); + t.is(testTable.permissions.edit, true); + } catch (e) { + console.error(e); + throw e; + } +}); + +test.serial('should get table rows through the proxy via rocketadmin API', async (t) => { + if (maybeSkip(t)) return; + try { + const seedCount = 10; + const { testTableName, testTableColumnName, testTableSecondColumnName } = await createTestTable( + upstreamConnectionParams, + seedCount, + ); + + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + const proxyConnectionDto = createProxyConnectionDto(); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(proxyConnectionDto) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(createConnectionResponse.status, 201); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + + const getRowsResponse = await request(app.getHttpServer()) + .get(`/table/rows/${createConnectionRO.id}?tableName=${testTableName}`) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(getRowsResponse.status, 200); + + const rowsRO = JSON.parse(getRowsResponse.text); + t.true(Object.hasOwn(rowsRO, 'rows')); + t.true(Object.hasOwn(rowsRO, 'primaryColumns')); + t.true(Object.hasOwn(rowsRO, 'pagination')); + t.is(rowsRO.rows.length, seedCount); + t.true(Object.hasOwn(rowsRO.rows[0], 'id')); + t.true(Object.hasOwn(rowsRO.rows[0], testTableColumnName)); + t.true(Object.hasOwn(rowsRO.rows[0], testTableSecondColumnName)); + } catch (e) { + console.error(e); + throw e; + } +}); + +test.serial('should add a row through the proxy via rocketadmin API', async (t) => { + if (maybeSkip(t)) return; + try { + const { testTableName, testTableColumnName, testTableSecondColumnName } = await createTestTable( + upstreamConnectionParams, + 3, + ); + + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + const proxyConnectionDto = createProxyConnectionDto(); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(proxyConnectionDto) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(createConnectionResponse.status, 201); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + + const newName = faker.person.firstName(); + const newEmail = faker.internet.email(); + + const addRowResponse = await request(app.getHttpServer()) + .post(`/table/row/${createConnectionRO.id}?tableName=${testTableName}`) + .send( + JSON.stringify({ + [testTableColumnName]: newName, + [testTableSecondColumnName]: newEmail, + }), + ) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(addRowResponse.status, 201); + + const addRowRO = JSON.parse(addRowResponse.text); + t.true(Object.hasOwn(addRowRO, 'row')); + t.is(addRowRO.row[testTableColumnName], newName); + t.is(addRowRO.row[testTableSecondColumnName], newEmail); + + // Verify row count increased + const getRowsResponse = await request(app.getHttpServer()) + .get(`/table/rows/${createConnectionRO.id}?tableName=${testTableName}`) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(getRowsResponse.status, 200); + + const rowsRO = JSON.parse(getRowsResponse.text); + t.is(rowsRO.rows.length, 4); // 3 seeded + 1 added + } catch (e) { + console.error(e); + throw e; + } +}); + +test.serial('should update a row through the proxy via rocketadmin API', async (t) => { + if (maybeSkip(t)) return; + try { + const { testTableName, testTableColumnName, testTableSecondColumnName } = await createTestTable( + upstreamConnectionParams, + 3, + ); + + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + const proxyConnectionDto = createProxyConnectionDto(); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(proxyConnectionDto) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(createConnectionResponse.status, 201); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + + const updatedName = faker.person.firstName(); + const updatedEmail = faker.internet.email(); + + const updateRowResponse = await request(app.getHttpServer()) + .put(`/table/row/${createConnectionRO.id}?tableName=${testTableName}&id=1`) + .send( + JSON.stringify({ + [testTableColumnName]: updatedName, + [testTableSecondColumnName]: updatedEmail, + }), + ) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(updateRowResponse.status, 200); + + const updateRowRO = JSON.parse(updateRowResponse.text); + t.true(Object.hasOwn(updateRowRO, 'row')); + t.is(updateRowRO.row[testTableColumnName], updatedName); + t.is(updateRowRO.row[testTableSecondColumnName], updatedEmail); + } catch (e) { + console.error(e); + throw e; + } +}); + +test.serial('should delete a row through the proxy via rocketadmin API', async (t) => { + if (maybeSkip(t)) return; + try { + const { testTableName } = await createTestTable(upstreamConnectionParams, 5); + + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + const proxyConnectionDto = createProxyConnectionDto(); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(proxyConnectionDto) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(createConnectionResponse.status, 201); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + + const deleteRowResponse = await request(app.getHttpServer()) + .delete(`/table/row/${createConnectionRO.id}?tableName=${testTableName}&id=1`) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(deleteRowResponse.status, 200); + + // Verify row count decreased + const getRowsResponse = await request(app.getHttpServer()) + .get(`/table/rows/${createConnectionRO.id}?tableName=${testTableName}`) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(getRowsResponse.status, 200); + + const rowsRO = JSON.parse(getRowsResponse.text); + t.is(rowsRO.rows.length, 4); // 5 seeded - 1 deleted + } catch (e) { + console.error(e); + throw e; + } +}); + +test.serial('should get table structure through the proxy via rocketadmin API', async (t) => { + if (maybeSkip(t)) return; + try { + const { testTableName, testTableColumnName, testTableSecondColumnName } = await createTestTable( + upstreamConnectionParams, + 3, + ); + + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + const proxyConnectionDto = createProxyConnectionDto(); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(proxyConnectionDto) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(createConnectionResponse.status, 201); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + + const getStructureResponse = await request(app.getHttpServer()) + .get(`/table/structure/${createConnectionRO.id}?tableName=${testTableName}`) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(getStructureResponse.status, 200); + + const structureRO = JSON.parse(getStructureResponse.text); + t.true(typeof structureRO === 'object'); + t.true(Array.isArray(structureRO.structure)); + t.true(structureRO.structure.length > 0); + t.true(Object.hasOwn(structureRO, 'primaryColumns')); + t.true(Object.hasOwn(structureRO, 'foreignKeys')); + + const columnNames = structureRO.structure.map((col: any) => col.column_name); + t.true(columnNames.includes('id')); + t.true(columnNames.includes(testTableColumnName)); + t.true(columnNames.includes(testTableSecondColumnName)); + } catch (e) { + console.error(e); + throw e; + } +}); // ─── Usage reporting test ─────────────────────────────────────────────────── // @@ -454,78 +472,80 @@ test.serial( // Requires the proxy-mock-api container to be rebuilt with the test-only // endpoints (/api/test/usage-reports). If not available, test is skipped. -test.serial( - 'should report usage metrics to mock API after queries through proxy', - async (t) => { - if (maybeSkip(t)) return; - try { - // Probe mock API capability — skip if test endpoints are absent (old mock build) - const probe = await getUsageReports(); - if (!Array.isArray(probe)) { - t.pass('skipped: proxy-mock-api does not expose test endpoints (rebuild required)'); - return; - } - const baselineReportCount = probe.length; - - const { testTableName } = await createTestTable(upstreamConnectionParams, 3); - const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; - const proxyConnectionDto = createProxyConnectionDto(); - - const createConnectionResponse = await request(app.getHttpServer()) - .post('/connection') - .send(proxyConnectionDto) - .set('Cookie', firstUserToken) - .set('Content-Type', 'application/json') - .set('Accept', 'application/json'); - t.is(createConnectionResponse.status, 201); - const createConnectionRO = JSON.parse(createConnectionResponse.text); - - // A single query is enough — the proxy reports usage periodically regardless - const getRowsResponse = await request(app.getHttpServer()) - .get(`/table/rows/${createConnectionRO.id}?tableName=${testTableName}`) - .set('Cookie', firstUserToken) - .set('Content-Type', 'application/json') - .set('Accept', 'application/json'); - t.is(getRowsResponse.status, 200); - - // Wait for the proxy's usage report interval (configured at 10s in docker-compose) - // plus a small buffer for the HTTP call to complete - await sleep(12000); - - const reports = await getUsageReports(); - t.true(Array.isArray(reports), 'Usage reports should be an array'); - t.true( - reports.length > baselineReportCount, - `Expected new usage reports after queries (baseline=${baselineReportCount}, got=${reports.length})`, - ); - - // Check structure of the latest reports - const latestReports = reports.slice(baselineReportCount); - const relevantReports = latestReports.filter((r: any) => r.connectionId === 'test-connection-001'); - t.true(relevantReports.length > 0, 'Should have at least one report for test-connection-001'); - - const report = relevantReports[0]; - t.true(Object.hasOwn(report, 'connectionId')); - t.true(Object.hasOwn(report, 'companyId')); - t.true(Object.hasOwn(report, 'queryTimeMs')); - t.true(Object.hasOwn(report, 'queryCount')); - t.true(Object.hasOwn(report, 'timestamp')); - t.is(report.companyId, 'test-company-001'); - - // Total query count across the new reports should reflect our queries - const totalQueryCount = relevantReports.reduce((sum: number, r: any) => sum + r.queryCount, 0); - t.true(totalQueryCount > 0, `Expected positive query count, got ${totalQueryCount}`); - - // Total query time should be non-negative (could be 0 for very fast queries, - // but with millisecond resolution a real query should register some time) - const totalQueryTimeMs = relevantReports.reduce((sum: number, r: any) => sum + r.queryTimeMs, 0); - t.true(totalQueryTimeMs >= 0, `Expected non-negative query time, got ${totalQueryTimeMs}`); - } catch (e) { - console.error(e); - throw e; - } - }, -); +test.serial('should report usage metrics to mock API after queries through proxy', async (t) => { + if (maybeSkip(t)) return; + try { + // Probe mock API capability — skip if test endpoints are absent (old mock build) + const probe = await getUsageReports(); + if (!Array.isArray(probe)) { + t.pass('skipped: proxy-mock-api does not expose test endpoints (rebuild required)'); + return; + } + const baselineReportCount = probe.length; + + const { testTableName } = await createTestTable(upstreamConnectionParams, 3); + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + const proxyConnectionDto = createProxyConnectionDto(); + const expectedConnId = expectedConnectionId(proxyConnectionDto.username); + const expectedCompId = expectedCompanyId(proxyConnectionDto.username); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(proxyConnectionDto) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(createConnectionResponse.status, 201); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + + // A single query is enough — the proxy reports usage periodically regardless + const getRowsResponse = await request(app.getHttpServer()) + .get(`/table/rows/${createConnectionRO.id}?tableName=${testTableName}`) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + const responseText = JSON.parse(getRowsResponse.text); + console.log('🚀 ~ responseText:', responseText); + t.is(getRowsResponse.status, 200); + + // Wait for the proxy's usage report interval (configured at 10s in docker-compose) + // plus a small buffer for the HTTP call to complete + await sleep(12000); + + const reports = await getUsageReports(); + t.true(Array.isArray(reports), 'Usage reports should be an array'); + t.true( + reports.length > baselineReportCount, + `Expected new usage reports after queries (baseline=${baselineReportCount}, got=${reports.length})`, + ); + + // Filter to reports for THIS test's derived connectionId so we don't pick up + // reports from other tests sharing the mock-api. + const latestReports = reports.slice(baselineReportCount); + const relevantReports = latestReports.filter((r: any) => r.connectionId === expectedConnId); + t.true(relevantReports.length > 0, `Should have at least one report for ${expectedConnId}`); + + const report = relevantReports[0]; + t.true(Object.hasOwn(report, 'connectionId')); + t.true(Object.hasOwn(report, 'companyId')); + t.true(Object.hasOwn(report, 'queryTimeMs')); + t.true(Object.hasOwn(report, 'queryCount')); + t.true(Object.hasOwn(report, 'timestamp')); + t.is(report.companyId, expectedCompId); + + // Total query count across the new reports should reflect our queries + const totalQueryCount = relevantReports.reduce((sum: number, r: any) => sum + r.queryCount, 0); + t.true(totalQueryCount > 0, `Expected positive query count, got ${totalQueryCount}`); + + // Total query time should be non-negative (could be 0 for very fast queries, + // but with millisecond resolution a real query should register some time) + const totalQueryTimeMs = relevantReports.reduce((sum: number, r: any) => sum + r.queryTimeMs, 0); + t.true(totalQueryTimeMs >= 0, `Expected non-negative query time, got ${totalQueryTimeMs}`); + } catch (e) { + console.error(e); + throw e; + } +}); // ─── Frozen plan / connection rejection test ──────────────────────────────── // @@ -536,47 +556,44 @@ test.serial( // Requires the proxy-mock-api container to be rebuilt with the test-only // endpoints. If not available, test is skipped. -test.serial( - '[zzz-last] should reject connection when subscription plan is frozen', - async (t) => { - if (maybeSkip(t)) return; - - // Probe mock API capability — skip if test endpoints are absent - const probe = await mockApiRequest('GET', '/api/test/usage-reports'); - if (!Array.isArray(probe)) { - t.pass('skipped: proxy-mock-api does not expose test endpoints (rebuild required)'); - return; - } - - try { - // Set plan to frozen via mock API - await setSubscriptionLevel('frozen'); - - const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; - const proxyConnectionDto = createProxyConnectionDto(); - proxyConnectionDto.title = 'Frozen-plan test connection'; - - const createConnectionResponse = await request(app.getHttpServer()) - .post('/connection') - .send(proxyConnectionDto) - .set('Cookie', firstUserToken) - .set('Content-Type', 'application/json') - .set('Accept', 'application/json'); - t.is(createConnectionResponse.status, 201); - const createConnectionRO = JSON.parse(createConnectionResponse.text); - - // Attempting to list tables should fail because the proxy rejects the connection - const getTablesResponse = await request(app.getHttpServer()) - .get(`/connection/tables/${createConnectionRO.id}`) - .set('Cookie', firstUserToken) - .set('Content-Type', 'application/json') - .set('Accept', 'application/json'); - - // The proxy should refuse the connection, resulting in an error from the backend - t.not(getTablesResponse.status, 200, 'Should not succeed with frozen plan'); - } finally { - // Best-effort restore so manual runs of the full file behave sanely - await setSubscriptionLevel('TEAM_PLAN').catch(() => undefined); - } - }, -); +test.serial('[zzz-last] should reject connection when subscription plan is frozen', async (t) => { + if (maybeSkip(t)) return; + + // Probe mock API capability — skip if test endpoints are absent + const probe = await mockApiRequest('GET', '/api/test/usage-reports'); + if (!Array.isArray(probe)) { + t.pass('skipped: proxy-mock-api does not expose test endpoints (rebuild required)'); + return; + } + + try { + // Set plan to frozen via mock API + await setSubscriptionLevel('frozen'); + + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + const proxyConnectionDto = createProxyConnectionDto(); + proxyConnectionDto.title = 'Frozen-plan test connection'; + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(proxyConnectionDto) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(createConnectionResponse.status, 201); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + + // Attempting to list tables should fail because the proxy rejects the connection + const getTablesResponse = await request(app.getHttpServer()) + .get(`/connection/tables/${createConnectionRO.id}`) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + // The proxy should refuse the connection, resulting in an error from the backend + t.not(getTablesResponse.status, 200, 'Should not succeed with frozen plan'); + } finally { + // Best-effort restore so manual runs of the full file behave sanely + await setSubscriptionLevel('TEAM_PLAN').catch(() => undefined); + } +}); diff --git a/test/proxy-mock-api/server.js b/test/proxy-mock-api/server.js index 3da712056..42452810e 100644 --- a/test/proxy-mock-api/server.js +++ b/test/proxy-mock-api/server.js @@ -1,15 +1,34 @@ const http = require('http'); const url = require('url'); +const crypto = require('crypto'); const PORT = process.env.MOCK_API_PORT || 3333; const EXPECTED_API_KEY = process.env.PROXY_API_KEY || 'test-proxy-api-key'; -// The client (rocketadmin) connects to the proxy with these credentials. -// The proxy extracts username+database from the startup message and looks up the -// real upstream connection info here. -const PROXY_USERNAME = process.env.PROXY_CLIENT_USERNAME || 'proxy_user'; +// Client → proxy credential pattern. +// Tests use unique usernames like `proxy_user_` so each test gets its +// own derived companyId (and therefore its own limiter slot). +const PROXY_USERNAME_PREFIX = process.env.PROXY_CLIENT_USERNAME_PREFIX || 'proxy_user'; const PROXY_DATABASE = process.env.PROXY_CLIENT_DATABASE || 'postgres'; +function deriveCompanyId(username) { + // Single-username case (e.g. plain "proxy_user") keeps the legacy id so existing + // tests/clients that don't randomize still hit a stable companyId. + if (username === PROXY_USERNAME_PREFIX) { + return 'test-company-001'; + } + const hash = crypto.createHash('sha1').update(username).digest('hex').slice(0, 12); + return `test-company-${hash}`; +} + +function deriveConnectionId(username) { + if (username === PROXY_USERNAME_PREFIX) { + return 'test-connection-001'; + } + const hash = crypto.createHash('sha1').update(username).digest('hex').slice(0, 12); + return `test-connection-${hash}`; +} + // Upstream connection info returned when the proxy asks for the above client credentials const TEST_CONNECTION = { connectionId: 'test-connection-001', @@ -80,10 +99,28 @@ const server = http.createServer((req, res) => { const { username, database } = parsedUrl.query; console.log(`[mock-api] GET connection: username=${username}, database=${database}`); - if (username === PROXY_USERNAME && database === PROXY_DATABASE) { + const usernameMatches = + username === PROXY_USERNAME_PREFIX || (typeof username === 'string' && username.startsWith(PROXY_USERNAME_PREFIX + '_')); + + if (usernameMatches && database === PROXY_DATABASE) { + const companyId = deriveCompanyId(username); + const connectionId = deriveConnectionId(username); + console.log( + `[mock-api] -> returning connection, companyId=${companyId} connectionId=${connectionId} subscriptionLevel=${currentSubscriptionLevel}`, + ); res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ...TEST_CONNECTION, subscriptionLevel: currentSubscriptionLevel })); + res.end( + JSON.stringify({ + ...TEST_CONNECTION, + connectionId, + companyId, + subscriptionLevel: currentSubscriptionLevel, + }), + ); } else { + console.log( + `[mock-api] -> 404: username/database mismatch (expected ${PROXY_USERNAME_PREFIX}[_*]/${PROXY_DATABASE})`, + ); res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Connection not found' })); } From dc12ea4825b41908dfbb96b1fcc02dd507d50718 Mon Sep 17 00:00:00 2001 From: Artem Niehrieiev Date: Wed, 15 Apr 2026 14:09:36 +0000 Subject: [PATCH 07/11] Add tests for query-time budget exhaustion and rejection handling --- .../saas-postgres-proxy-e2e.test.ts | 81 ++++++++++++++++++- 1 file changed, 78 insertions(+), 3 deletions(-) diff --git a/backend/test/ava-tests/saas-tests/saas-postgres-proxy-e2e.test.ts b/backend/test/ava-tests/saas-tests/saas-postgres-proxy-e2e.test.ts index 090b5fb63..c065ef1c0 100644 --- a/backend/test/ava-tests/saas-tests/saas-postgres-proxy-e2e.test.ts +++ b/backend/test/ava-tests/saas-tests/saas-postgres-proxy-e2e.test.ts @@ -537,16 +537,91 @@ test.serial('should report usage metrics to mock API after queries through proxy const totalQueryCount = relevantReports.reduce((sum: number, r: any) => sum + r.queryCount, 0); t.true(totalQueryCount > 0, `Expected positive query count, got ${totalQueryCount}`); - // Total query time should be non-negative (could be 0 for very fast queries, - // but with millisecond resolution a real query should register some time) + // Total query time must be strictly positive — /table/rows runs several + // metadata queries plus the row fetch, which must register more than a + // single millisecond of billed time. A zero here would indicate the + // timer never fired or was dropped on disconnect. const totalQueryTimeMs = relevantReports.reduce((sum: number, r: any) => sum + r.queryTimeMs, 0); - t.true(totalQueryTimeMs >= 0, `Expected non-negative query time, got ${totalQueryTimeMs}`); + t.true(totalQueryTimeMs > 0, `Expected positive query time, got ${totalQueryTimeMs}`); + // Sanity upper bound: a single API call should not accrue minutes of + // proxy-measured time. Guards against a Pop(ok=false)-style regression + // where time.Since(zeroTime) could produce decade-scale elapsed. + t.true( + totalQueryTimeMs < 60000, + `Expected <60s total query time for a single API call, got ${totalQueryTimeMs} (overbilling?)`, + ); } catch (e) { console.error(e); throw e; } }); +// ─── Budget exhaustion via rocketadmin API ────────────────────────────────── +// +// Drives the token bucket to negative balance under the TEST_TINY_PLAN (a +// deliberately tiny 2s/hour budget used only for tests). Uses supertest — +// each /connection/tables call fans out into several metadata queries, which +// is more than enough to exhaust 2 seconds of cumulative query time across a +// few calls. The next API call should surface the proxy's 53400 rejection. + +test.serial('should reject queries once query-time budget is exhausted', async (t) => { + if (maybeSkip(t)) return; + t.timeout(60000); + + const probe = await getUsageReports(); + if (!Array.isArray(probe)) { + t.pass('skipped: proxy-mock-api does not expose test endpoints (rebuild required)'); + return; + } + + await setSubscriptionLevel('TEST_TINY_PLAN'); + try { + const firstUserToken = (await registerUserAndReturnUserInfo(app)).token; + const proxyConnectionDto = createProxyConnectionDto(); + + const createConnectionResponse = await request(app.getHttpServer()) + .post('/connection') + .send(proxyConnectionDto) + .set('Cookie', firstUserToken) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + t.is(createConnectionResponse.status, 201); + const createConnectionRO = JSON.parse(createConnectionResponse.text); + + // Burn through the 2-second budget by repeatedly listing tables. Each + // call executes several introspection queries; within a handful of calls + // the post-consume balance goes negative and subsequent calls hit the + // pre-query CheckBudget rejection. + let rejected = false; + let lastStatus = 0; + let lastBody = ''; + for (let i = 0; i < 30; i++) { + const resp = await request(app.getHttpServer()) + .get(`/connection/tables/${createConnectionRO.id}`) + .set('Cookie', firstUserToken) + .set('Accept', 'application/json'); + lastStatus = resp.status; + lastBody = resp.text || ''; + if (resp.status !== 200) { + rejected = true; + break; + } + } + + t.true( + rejected, + `expected a 30-call burst under TEST_TINY_PLAN to hit a budget rejection; last status=${lastStatus}`, + ); + t.regex( + lastBody, + /budget|exceeded|plan|53400/i, + `rejection response should mention budget/plan, got status=${lastStatus} body=${lastBody.slice(0, 200)}`, + ); + } finally { + await setSubscriptionLevel('TEAM_PLAN'); + } +}); + // ─── Frozen plan / connection rejection test ──────────────────────────────── // // This test MUST run LAST because it flips the mock-api into `frozen` state. From 86bc7e77fc80c70de47be34006262f8d88936ef6 Mon Sep 17 00:00:00 2001 From: Artem Niehrieiev Date: Thu, 16 Apr 2026 11:50:04 +0000 Subject: [PATCH 08/11] Add GetHostedConnectionCredentials feature with DTO and use case implementation --- backend/src/common/data-injection.tokens.ts | 1 + .../get-hosted-connection-credentials.dto.ts | 9 +++++ .../hosted-connection-credentials.ro.ts | 24 +++++++++++ .../saas-microservice/saas.controller.ts | 18 +++++++++ .../saas-microservice/saas.module.ts | 6 +++ ...-hosted-connection-credentials.use.case.ts | 40 +++++++++++++++++++ .../use-cases/saas-use-cases.interface.ts | 6 +++ 7 files changed, 104 insertions(+) create mode 100644 backend/src/microservices/saas-microservice/data-structures/get-hosted-connection-credentials.dto.ts create mode 100644 backend/src/microservices/saas-microservice/data-structures/hosted-connection-credentials.ro.ts create mode 100644 backend/src/microservices/saas-microservice/use-cases/get-hosted-connection-credentials.use.case.ts diff --git a/backend/src/common/data-injection.tokens.ts b/backend/src/common/data-injection.tokens.ts index 2b930729e..79058a533 100644 --- a/backend/src/common/data-injection.tokens.ts +++ b/backend/src/common/data-injection.tokens.ts @@ -119,6 +119,7 @@ export enum UseCaseType { SAAS_DELETE_CONNECTION_FOR_HOSTED_DB = 'SAAS_DELETE_CONNECTION_FOR_HOSTED_DB', SAAS_UPDATE_HOSTED_CONNECTION_PASSWORD = 'SAAS_UPDATE_HOSTED_CONNECTION_PASSWORD', SAAS_GET_CONNECTIONS_INFO_BY_IDS = 'SAAS_GET_CONNECTIONS_INFO_BY_IDS', + SAAS_GET_HOSTED_CONNECTION_CREDENTIALS = 'SAAS_GET_HOSTED_CONNECTION_CREDENTIALS', INVITE_USER_IN_COMPANY_AND_CONNECTION_GROUP = 'INVITE_USER_IN_COMPANY_AND_CONNECTION_GROUP', VERIFY_INVITE_USER_IN_COMPANY_AND_CONNECTION_GROUP = 'VERIFY_INVITE_USER_IN_COMPANY_AND_CONNECTION_GROUP', diff --git a/backend/src/microservices/saas-microservice/data-structures/get-hosted-connection-credentials.dto.ts b/backend/src/microservices/saas-microservice/data-structures/get-hosted-connection-credentials.dto.ts new file mode 100644 index 000000000..07e9a511d --- /dev/null +++ b/backend/src/microservices/saas-microservice/data-structures/get-hosted-connection-credentials.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class GetHostedConnectionCredentialsDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + hostedDatabaseId: string; +} diff --git a/backend/src/microservices/saas-microservice/data-structures/hosted-connection-credentials.ro.ts b/backend/src/microservices/saas-microservice/data-structures/hosted-connection-credentials.ro.ts new file mode 100644 index 000000000..54ad58a8e --- /dev/null +++ b/backend/src/microservices/saas-microservice/data-structures/hosted-connection-credentials.ro.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class HostedConnectionCredentialsRO { + @ApiProperty() + connectionId: string; + + @ApiProperty() + host: string; + + @ApiProperty() + port: number; + + @ApiProperty() + database: string; + + @ApiProperty() + username: string; + + @ApiProperty() + password: string; + + @ApiProperty() + is_frozen: boolean; +} diff --git a/backend/src/microservices/saas-microservice/saas.controller.ts b/backend/src/microservices/saas-microservice/saas.controller.ts index 3d743860d..38bfa3ab1 100644 --- a/backend/src/microservices/saas-microservice/saas.controller.ts +++ b/backend/src/microservices/saas-microservice/saas.controller.ts @@ -29,6 +29,8 @@ import { CreateConnectionForHostedDbDto } from './data-structures/create-connect import { DeleteConnectionForHostedDbDto } from './data-structures/delete-connection-for-hosted-db.dto.js'; import { FoundConnectionInfoRO } from './data-structures/found-connection-info.ro.js'; import { GetConnectionsInfoByIdsDS } from './data-structures/get-connections-info-by-ids.ds.js'; +import { GetHostedConnectionCredentialsDto } from './data-structures/get-hosted-connection-credentials.dto.js'; +import { HostedConnectionCredentialsRO } from './data-structures/hosted-connection-credentials.ro.js'; import { RegisterCompanyWebhookDS } from './data-structures/register-company.ds.js'; import { RegisteredCompanyDS } from './data-structures/registered-company.ds.js'; import { SaasRegisterUserWithGithub } from './data-structures/saas-register-user-with-github.js'; @@ -41,6 +43,7 @@ import { IDeleteConnectionForHostedDb, IFreezeConnectionsInCompany, IGetConnectionsInfoByIds, + IGetHostedConnectionCredentials, IGetUserInfo, ILoginUserWithGitHub, ILoginUserWithGoogle, @@ -100,6 +103,8 @@ export class SaasController { private readonly updateHostedConnectionPasswordUseCase: IUpdateHostedConnectionPassword, @Inject(UseCaseType.SAAS_GET_CONNECTIONS_INFO_BY_IDS) private readonly getConnectionsInfoByIdsUseCase: IGetConnectionsInfoByIds, + @Inject(UseCaseType.SAAS_GET_HOSTED_CONNECTION_CREDENTIALS) + private readonly getHostedConnectionCredentialsUseCase: IGetHostedConnectionCredentials, ) {} @ApiOperation({ summary: 'Company registered webhook' }) @@ -344,4 +349,17 @@ export class SaasController { ): Promise> { return await this.getConnectionsInfoByIdsUseCase.execute(connectionsData); } + + @ApiOperation({ summary: 'Get decrypted credentials for a hosted connection' }) + @ApiBody({ type: GetHostedConnectionCredentialsDto }) + @ApiResponse({ + status: 200, + type: HostedConnectionCredentialsRO, + }) + @Post('/connection/hosted/credentials') + async getHostedConnectionCredentials( + @Body() data: GetHostedConnectionCredentialsDto, + ): Promise { + return await this.getHostedConnectionCredentialsUseCase.execute(data); + } } diff --git a/backend/src/microservices/saas-microservice/saas.module.ts b/backend/src/microservices/saas-microservice/saas.module.ts index 282d502f8..40d367ff0 100644 --- a/backend/src/microservices/saas-microservice/saas.module.ts +++ b/backend/src/microservices/saas-microservice/saas.module.ts @@ -12,6 +12,7 @@ import { DeleteConnectionForHostedDbUseCase } from './use-cases/delete-connectio import { FreezeConnectionsInCompanyUseCase } from './use-cases/freeze-connections-in-company.use.case.js'; import { GetConnectionsInfoByIdsUseCase } from './use-cases/get-connections-info-by-ids.use.case.js'; import { GetFullCompanyInfoByUserIdUseCase } from './use-cases/get-full-company-info-by-user-id.use.case.js'; +import { GetHostedConnectionCredentialsUseCase } from './use-cases/get-hosted-connection-credentials.use.case.js'; import { GetUserInfoUseCase } from './use-cases/get-user-info.use.case.js'; import { GetUsersCountInCompanyByIdUseCase } from './use-cases/get-users-count-in-company.use.case.js'; import { GetUsersInfosByEmailUseCase } from './use-cases/get-users-infos-by-email.use.case.js'; @@ -105,6 +106,10 @@ import { UpdateHostedConnectionPasswordUseCase } from './use-cases/update-hosted provide: UseCaseType.SAAS_GET_CONNECTIONS_INFO_BY_IDS, useClass: GetConnectionsInfoByIdsUseCase, }, + { + provide: UseCaseType.SAAS_GET_HOSTED_CONNECTION_CREDENTIALS, + useClass: GetHostedConnectionCredentialsUseCase, + }, SignInAuditService, ], controllers: [SaasController], @@ -131,6 +136,7 @@ export class SaasModule { { path: 'saas/connection/hosted', method: RequestMethod.POST }, { path: 'saas/connection/hosted/delete', method: RequestMethod.POST }, { path: 'saas/connection/hosted/password', method: RequestMethod.POST }, + { path: 'saas/connection/hosted/credentials', method: RequestMethod.POST }, { path: 'saas/connections/info', method: RequestMethod.POST }, ); } diff --git a/backend/src/microservices/saas-microservice/use-cases/get-hosted-connection-credentials.use.case.ts b/backend/src/microservices/saas-microservice/use-cases/get-hosted-connection-credentials.use.case.ts new file mode 100644 index 000000000..24d25c346 --- /dev/null +++ b/backend/src/microservices/saas-microservice/use-cases/get-hosted-connection-credentials.use.case.ts @@ -0,0 +1,40 @@ +import { Inject, Injectable, NotFoundException, Scope } from '@nestjs/common'; +import AbstractUseCase from '../../../common/abstract-use.case.js'; +import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js'; +import { BaseType } from '../../../common/data-injection.tokens.js'; +import { Messages } from '../../../exceptions/text/messages.js'; +import { GetHostedConnectionCredentialsDto } from '../data-structures/get-hosted-connection-credentials.dto.js'; +import { HostedConnectionCredentialsRO } from '../data-structures/hosted-connection-credentials.ro.js'; +import { IGetHostedConnectionCredentials } from './saas-use-cases.interface.js'; + +@Injectable({ scope: Scope.REQUEST }) +export class GetHostedConnectionCredentialsUseCase + extends AbstractUseCase + implements IGetHostedConnectionCredentials +{ + constructor( + @Inject(BaseType.GLOBAL_DB_CONTEXT) + protected _dbContext: IGlobalDatabaseContext, + ) { + super(); + } + + protected async implementation( + inputData: GetHostedConnectionCredentialsDto, + ): Promise { + const connection = await this._dbContext.connectionRepository.findOneById(inputData.hostedDatabaseId); + if (!connection) { + throw new NotFoundException(Messages.CONNECTION_NOT_FOUND); + } + + return { + connectionId: connection.id, + host: connection.host, + port: connection.port, + database: connection.database, + username: connection.username, + password: connection.password, + is_frozen: connection.is_frozen, + }; + } +} diff --git a/backend/src/microservices/saas-microservice/use-cases/saas-use-cases.interface.ts b/backend/src/microservices/saas-microservice/use-cases/saas-use-cases.interface.ts index 37cef3caa..979a397f5 100644 --- a/backend/src/microservices/saas-microservice/use-cases/saas-use-cases.interface.ts +++ b/backend/src/microservices/saas-microservice/use-cases/saas-use-cases.interface.ts @@ -11,6 +11,8 @@ import { DeleteConnectionForHostedDbDto } from '../data-structures/delete-connec import { FoundConnectionInfoRO } from '../data-structures/found-connection-info.ro.js'; import { FreezeConnectionsInCompanyDS } from '../data-structures/freeze-connections-in-company.ds.js'; import { GetConnectionsInfoByIdsDS } from '../data-structures/get-connections-info-by-ids.ds.js'; +import { GetHostedConnectionCredentialsDto } from '../data-structures/get-hosted-connection-credentials.dto.js'; +import { HostedConnectionCredentialsRO } from '../data-structures/hosted-connection-credentials.ro.js'; import { GetUserInfoByIdDS } from '../data-structures/get-user-info.ds.js'; import { GetUsersInfosByEmailDS } from '../data-structures/get-users-infos-by-email.ds.js'; import { RegisterCompanyWebhookDS } from '../data-structures/register-company.ds.js'; @@ -88,3 +90,7 @@ export interface IUpdateHostedConnectionPassword { export interface IGetConnectionsInfoByIds { execute(inputData: GetConnectionsInfoByIdsDS): Promise>; } + +export interface IGetHostedConnectionCredentials { + execute(inputData: GetHostedConnectionCredentialsDto): Promise; +} From 385da272c63fbcbcf3289fb613daf97f4fdce2a3 Mon Sep 17 00:00:00 2001 From: Artem Niehrieiev Date: Fri, 17 Apr 2026 09:08:37 +0000 Subject: [PATCH 09/11] Update rocketadmin-private-microservice configuration for testing environment --- docker-compose.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 018890397..3847fba40 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -95,10 +95,17 @@ services: rocketadmin-private-microservice: build: context: ../rocketadmin-saas - dockerfile: ../rocketadmin-saas/Dockerfile + dockerfile: Dockerfile.test ports: - 3001:3001 env_file: ../rocketadmin-saas/.env + environment: + # Path to the Go proxy's contract fixture (mounted below). Lets the + # SaaS contract test find it regardless of on-disk layout. + PROXY_CONTRACT_FIXTURE: /contract/fixtures.json + volumes: + # Shared wire-contract source of truth with the Go postgres-proxy. + - ../postgres-proxy/contract:/contract:ro links: - backend - rocketadmin-private-microservice-test-database From 13ba6c77e8e5f60e43f6275a1dc45de07714dc15 Mon Sep 17 00:00:00 2001 From: Artem Niehrieiev Date: Fri, 17 Apr 2026 09:45:56 +0000 Subject: [PATCH 10/11] Refactor GetHostedConnectionCredentials implementation to use findOne with query object --- .../get-hosted-connection-credentials.use.case.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/src/microservices/saas-microservice/use-cases/get-hosted-connection-credentials.use.case.ts b/backend/src/microservices/saas-microservice/use-cases/get-hosted-connection-credentials.use.case.ts index 24d25c346..2afe3a77c 100644 --- a/backend/src/microservices/saas-microservice/use-cases/get-hosted-connection-credentials.use.case.ts +++ b/backend/src/microservices/saas-microservice/use-cases/get-hosted-connection-credentials.use.case.ts @@ -19,10 +19,10 @@ export class GetHostedConnectionCredentialsUseCase super(); } - protected async implementation( - inputData: GetHostedConnectionCredentialsDto, - ): Promise { - const connection = await this._dbContext.connectionRepository.findOneById(inputData.hostedDatabaseId); + protected async implementation(inputData: GetHostedConnectionCredentialsDto): Promise { + const connection = await this._dbContext.connectionRepository.findOne({ + where: { id: inputData.hostedDatabaseId }, + }); if (!connection) { throw new NotFoundException(Messages.CONNECTION_NOT_FOUND); } From d921aa118e31aafb47c48f7895aafa08159f9eba Mon Sep 17 00:00:00 2001 From: Artem Niehrieiev Date: Mon, 20 Apr 2026 08:28:27 +0000 Subject: [PATCH 11/11] Refactor docker-compose configuration: streamline healthcheck commands and remove unused postgres proxy service --- docker-compose.tst.yml | 92 ++++++++---------------------------------- 1 file changed, 16 insertions(+), 76 deletions(-) diff --git a/docker-compose.tst.yml b/docker-compose.tst.yml index 09b4f8018..f00d81576 100644 --- a/docker-compose.tst.yml +++ b/docker-compose.tst.yml @@ -7,6 +7,7 @@ services: - "DATABASE_URL=postgres://postgres:abc123@postgres:5432/postgres" - "EXTRA_ARGS=$EXTRA_ARGS" - LOG_LEVEL=warn + - MICROSERVICE_HOST= volumes: - ./backend/src/migrations:/app/src/migrations depends_on: @@ -32,9 +33,7 @@ services: condition: service_healthy test-clickhouse-e2e-testing: condition: service_healthy - postgres-proxy: - condition: service_healthy - command: ["/bin/sh", "-c", "yarn test-all $EXTRA_ARGS"] + command: [ "/bin/sh", "-c", "yarn test $EXTRA_ARGS" ] develop: watch: - action: rebuild @@ -50,8 +49,7 @@ services: MYSQL_ROOT_PASSWORD: 123 MYSQL_DATABASE: testDB healthcheck: - test: - ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p123"] + test: [ "CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p123" ] interval: 10s timeout: 5s retries: 10 @@ -65,7 +63,7 @@ services: POSTGRES_PASSWORD: 123 command: postgres -c 'max_connections=300' healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] + test: [ "CMD-SHELL", "pg_isready -U postgres" ] interval: 10s timeout: 5s retries: 10 @@ -80,7 +78,7 @@ services: ports: - 27017:27017 healthcheck: - test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] + test: [ "CMD", "mongosh", "--eval", "db.adminCommand('ping')" ] interval: 10s timeout: 5s retries: 10 @@ -96,7 +94,7 @@ services: tmpfs: - /var/lib/postgresql healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] + test: [ "CMD-SHELL", "pg_isready -U postgres" ] interval: 10s timeout: 5s retries: 10 @@ -113,7 +111,10 @@ services: test: [ "CMD-SHELL", - "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'yNuXf@6T#BgoQ%U6knMp' -C -Q 'SELECT 1' || /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'yNuXf@6T#BgoQ%U6knMp' -Q 'SELECT 1'", + "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P + 'yNuXf@6T#BgoQ%U6knMp' -C -Q 'SELECT 1' || + /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P + 'yNuXf@6T#BgoQ%U6knMp' -Q 'SELECT 1'", ] interval: 10s timeout: 5s @@ -127,7 +128,7 @@ services: environment: ORACLE_PASSWORD: 12345 healthcheck: - test: ["CMD-SHELL", "healthcheck.sh"] + test: [ "CMD-SHELL", "healthcheck.sh" ] interval: 10s timeout: 5s retries: 30 @@ -172,9 +173,9 @@ services: test-redis-e2e-testing: image: redis:7.0.11 - command: ["redis-server", "--requirepass", "SuperSecretRedisPassword"] + command: [ "redis-server", "--requirepass", "SuperSecretRedisPassword" ] healthcheck: - test: ["CMD", "redis-cli", "ping"] + test: [ "CMD", "redis-cli", "ping" ] interval: 30s timeout: 10s retries: 3 @@ -202,7 +203,7 @@ services: ports: - 50000:50000 healthcheck: - test: ["CMD-SHELL", "su - db2inst1 -c 'db2 connect to testdb'"] + test: [ "CMD-SHELL", "su - db2inst1 -c 'db2 connect to testdb'" ] interval: 30s timeout: 30s retries: 20 @@ -214,7 +215,7 @@ services: - AWS_ACCESS_KEY_ID=SuperSecretAwsAccessKey - AWS_SECRET=SuperSecretAwsSecret healthcheck: - test: ["CMD-SHELL", "curl -s http://localhost:8000 || exit 1"] + test: [ "CMD-SHELL", "curl -s http://localhost:8000 || exit 1" ] interval: 10s timeout: 5s retries: 10 @@ -235,68 +236,7 @@ services: soft: 262144 hard: 262144 healthcheck: - test: ["CMD", "wget", "--spider", "-q", "http://localhost:8123/ping"] + test: [ "CMD", "wget", "--spider", "-q", "http://localhost:8123/ping" ] interval: 30s timeout: 10s retries: 3 - - # === Postgres Proxy E2E Testing === - - testPg-proxy-e2e: - image: postgres:16 - environment: - POSTGRES_PASSWORD: proxy_test_123 - POSTGRES_HOST_AUTH_METHOD: md5 - POSTGRES_INITDB_ARGS: "--auth-host=md5" - command: postgres -c 'max_connections=300' -c 'password_encryption=md5' - tmpfs: - - /var/lib/postgresql - healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] - interval: 10s - timeout: 5s - retries: 10 - start_period: 10s - - proxy-mock-api: - build: - context: ./test/proxy-mock-api - environment: - MOCK_API_PORT: 3333 - PROXY_API_KEY: test-proxy-api-key - UPSTREAM_PG_HOST: testPg-proxy-e2e - UPSTREAM_PG_PORT: 5432 - UPSTREAM_PG_DATABASE: postgres - UPSTREAM_PG_USERNAME: postgres - UPSTREAM_PG_PASSWORD: proxy_test_123 - depends_on: - testPg-proxy-e2e: - condition: service_healthy - healthcheck: - test: ["CMD-SHELL", "wget -q --spider http://localhost:3333/healthz || exit 1"] - interval: 5s - timeout: 3s - retries: 10 - start_period: 5s - - postgres-proxy: - build: - context: ../postgres-proxy - environment: - PROXY_LISTEN_ADDR: ":5432" - PROXY_HEALTH_ADDR: ":8080" - PROXY_BACKEND_API_URL: "http://proxy-mock-api:3333" - PROXY_API_KEY: "test-proxy-api-key" - PROXY_USAGE_REPORT_INTERVAL: "10s" - PROXY_LOG_LEVEL: "debug" - depends_on: - proxy-mock-api: - condition: service_healthy - testPg-proxy-e2e: - condition: service_healthy - healthcheck: - test: ["CMD-SHELL", "wget -q --spider http://localhost:8080/healthz || exit 1"] - interval: 5s - timeout: 3s - retries: 10 - start_period: 5s