Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions graphql/server/__fixtures__/sql/meta.seed.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
BEGIN;

INSERT INTO collections_public.database (id, name)
VALUES ('0b22e268-16d6-582b-950a-24e108688849', 'meta-test');

INSERT INTO meta_public.apis (id, database_id, name, is_public, role_name, anon_role)
VALUES
('ee00f3ca-aeed-4b5e-a0ad-ac9668e4275f', '0b22e268-16d6-582b-950a-24e108688849', 'public', true, 'authenticated', 'anonymous'),
('777d373e-104a-48af-bf2a-713406d0a965', '0b22e268-16d6-582b-950a-24e108688849', 'private', false, 'administrator', 'administrator');

INSERT INTO meta_public.domains (id, database_id, api_id, domain, subdomain)
VALUES
('7d5abbd9-4b0c-4ace-bcf8-50b4d9d6f0df', '0b22e268-16d6-582b-950a-24e108688849', 'ee00f3ca-aeed-4b5e-a0ad-ac9668e4275f', 'constructive.io', 'api'),
('45ba9888-7709-417e-9d16-34e28a28f945', '0b22e268-16d6-582b-950a-24e108688849', '777d373e-104a-48af-bf2a-713406d0a965', 'constructive.io', 'meta');

INSERT INTO meta_public.api_extensions (id, database_id, api_id, schema_name)
VALUES
('00d38b99-ea6e-4ec3-9035-96a6fae34a1f', '0b22e268-16d6-582b-950a-24e108688849', 'ee00f3ca-aeed-4b5e-a0ad-ac9668e4275f', 'collections_public'),
('baee7eca-a010-4b3a-90f5-502e658b791f', '0b22e268-16d6-582b-950a-24e108688849', 'ee00f3ca-aeed-4b5e-a0ad-ac9668e4275f', 'meta_public'),
('aeab326d-a5ad-4e2e-8d61-312523b584dd', '0b22e268-16d6-582b-950a-24e108688849', '777d373e-104a-48af-bf2a-713406d0a965', 'collections_public'),
('847d9cf6-ff59-46a8-a0a8-d92293f279c1', '0b22e268-16d6-582b-950a-24e108688849', '777d373e-104a-48af-bf2a-713406d0a965', 'meta_public');

COMMIT;
361 changes: 361 additions & 0 deletions graphql/server/__tests__/routes.basic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,361 @@
process.env.LOG_SCOPE = 'graphile-test';

import { dirname, join } from 'path';
import type { Server as HttpServer } from 'http';
import request from 'supertest';
import { getEnvOptions } from '@constructive-io/graphql-env';
import type { ConstructiveOptions } from '@constructive-io/graphql-types';
import { PgpmInit, PgpmMigrate } from '@pgpmjs/core';
import { seed, getConnections } from 'pgsql-test';
import { Server as GraphQLServer } from '../src/server';

jest.setTimeout(30000);

const appSchemas = ['app_public'];
const metaSchemas = ['collections_public', 'meta_public'];
const sql = (f: string) => join(__dirname, '../../test/sql', f);
const metaSql = (f: string) => join(__dirname, '../__fixtures__/sql', f);
// Stable seeded UUID used for JWT claims and meta fixtures; dbname is set dynamically per test DB.
const seededDatabaseId = '0b22e268-16d6-582b-950a-24e108688849';

const metaDbExtensions = ['citext', 'uuid-ossp', 'unaccent', 'pgcrypto', 'hstore'];

const getPgpmModulePath = (pkgName: string): string =>
dirname(require.resolve(`${pkgName}/pgpm.plan`));

const metaSeedModules = [
getPgpmModulePath('@pgpm/verify'),
getPgpmModulePath('@pgpm/types'),
getPgpmModulePath('@pgpm/inflection'),
getPgpmModulePath('@pgpm/database-jobs'),
getPgpmModulePath('@pgpm/db-meta-schema'),
getPgpmModulePath('@pgpm/db-meta-modules'),
];

type PgsqlConnections = Awaited<ReturnType<typeof getConnections>>;
type PgTestClient = PgsqlConnections['db'];
type SeededConnections = Pick<PgsqlConnections, 'db' | 'pg' | 'teardown'>;

type RouteCase = {
name: string;
enableMetaApi: boolean;
isPublic: boolean;
seed: () => SeededConnections;
getHeaders?: () => Record<string, string>;
};

const metaHosts = {
publicHost: 'api.constructive.io',
privateHost: 'meta.constructive.io',
};

const bootstrapAdminUsers = seed.fn(async ({ admin, config, connect }) => {
const roles = connect?.roles;
const connections = connect?.connections;

if (!roles || !connections) {
throw new Error('Missing pgpm role or connection defaults for admin users.');
}

const init = new PgpmInit(config);
try {
await init.bootstrapRoles(roles);
await init.bootstrapTestRoles(roles, connections);
} finally {
await init.close();
}

const appUser = connections.app?.user;
if (appUser) {
await admin.grantRole(roles.administrator, appUser, config.database);
}
});

const deployMetaModules = seed.fn(async ({ config }) => {
const migrator = new PgpmMigrate(config);
for (const modulePath of metaSeedModules) {
const result = await migrator.deploy({ modulePath, usePlan: false });
if (result.failed) {
throw new Error(`Failed to deploy ${modulePath}: ${result.failed}`);
}
}
});

const requireConnections = (
connections: SeededConnections | null,
label: string
): SeededConnections => {
if (!connections) {
throw new Error(`${label} connections not initialized`);
}
return connections;
};

const setHeaders = <T extends { set: (field: string, value: string) => T }>(
req: T,
headers?: Record<string, string>
): T => {
if (!headers) return req;
for (const [key, value] of Object.entries(headers)) {
req.set(key, value);
}
return req;
};

type StartedServer = {
server: GraphQLServer;
httpServer: HttpServer;
};

const startServer = async (
opts: ConstructiveOptions
): Promise<StartedServer> => {
const server = new GraphQLServer(opts);
server.addEventListener();

const httpServer = server.listen();
if (!httpServer.listening) {
await new Promise<void>((resolve) => {
httpServer.once('listening', () => resolve());
});
}

return { server, httpServer };
};

const stopServer = async (started: StartedServer | null): Promise<void> => {
if (!started) return;

await started.server.close();
await new Promise<void>((resolve, reject) => {
started.httpServer.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
};

const createAppDb = async (): Promise<SeededConnections> => {
const { db, pg, teardown } = await getConnections(
{},
[
bootstrapAdminUsers,
seed.sqlfile([
sql('test.sql'),
sql('grants.sql'),
]),
]
);

return { db, pg, teardown };
};

const createMetaDb = async (): Promise<SeededConnections> => {
const { db, pg, teardown } = await getConnections(
{ db: { extensions: metaDbExtensions } },
[
bootstrapAdminUsers,
deployMetaModules,
seed.sqlfile([metaSql('meta.seed.sql')]),
]
);

await db.begin();
db.setContext({
role: 'administrator',
'jwt.claims.database_id': seededDatabaseId,
});
try {
await db.query('UPDATE meta_public.apis SET dbname = current_database()');
await db.commit();
} catch (error) {
await db.client.query('ROLLBACK;');
throw error;
} finally {
db.clearContext();
}

return { db, pg, teardown };
};

const buildOptions = ({
db,
enableMetaApi,
isPublic,
}: {
db: PgTestClient;
enableMetaApi: boolean;
isPublic: boolean;
}): ConstructiveOptions => {
const api = enableMetaApi
? {
enableMetaApi: true,
isPublic,
metaSchemas,
}
: {
enableMetaApi: false,
isPublic,
exposedSchemas: appSchemas,
defaultDatabaseId: seededDatabaseId,
};

return getEnvOptions({
pg: {
host: db.config.host,
port: db.config.port,
user: db.config.user,
password: db.config.password,
database: db.config.database,
},
server: {
host: '127.0.0.1',
port: 0,
},
api,
});
};

let metaDb: SeededConnections | null = null;
let appDb: SeededConnections | null = null;

beforeAll(async () => {
appDb = await createAppDb();
metaDb = await createMetaDb();
});

afterAll(async () => {
await GraphQLServer.closeCaches();
if (metaDb) {
await metaDb.teardown();
}
if (appDb) {
await appDb.teardown();
}
});

const cases: RouteCase[] = [
{
name: 'meta enabled, public',
enableMetaApi: true,
isPublic: true,
seed: () => requireConnections(metaDb, 'meta'),
getHeaders: () => ({ Host: metaHosts.publicHost }),
},
{
name: 'meta enabled, private',
enableMetaApi: true,
isPublic: false,
seed: () => requireConnections(metaDb, 'meta'),
getHeaders: () => ({ Host: metaHosts.privateHost }),
},
{
name: 'meta disabled, public',
enableMetaApi: false,
isPublic: true,
seed: () => requireConnections(appDb, 'app'),
},
{
name: 'meta disabled, private',
enableMetaApi: false,
isPublic: false,
seed: () => requireConnections(appDb, 'app'),
},
];

describe.each(cases)(
'$name',
({ enableMetaApi, isPublic, seed, getHeaders }) => {
let started: StartedServer | null = null;
let db: PgTestClient;
let headers: Record<string, string> | undefined;

beforeAll(async () => {
const connections = seed();
db = connections.db;
headers = getHeaders?.();
started = await startServer(
buildOptions({
db,
enableMetaApi,
isPublic,
})
);
});

beforeEach(async () => {
db.setContext({
role: enableMetaApi && isPublic ? 'anonymous' : 'administrator',
'jwt.claims.database_id': seededDatabaseId,
});
await db.beforeEach();
});

afterEach(async () => {
await db.afterEach();
});

afterAll(async () => {
await stopServer(started);
started = null;
});

it('GET /healthz returns ok', async () => {
if (!started) {
throw new Error('HTTP server not started');
}
const req = request.agent(started.httpServer);
setHeaders(req, headers);
const res = await req.get('/healthz');

expect(res.status).toBe(200);
expect(res.text).toBe('ok');
});

it('GET /graphiql returns HTML', async () => {
if (!started) {
throw new Error('HTTP server not started');
}
const req = request.agent(started.httpServer);
setHeaders(req, headers);
const res = await req.get('/graphiql');

expect(res.status).toBe(200);
expect(res.text).toContain('GraphiQL');
});

it('GET /graphql accepts query params', async () => {
if (!started) {
throw new Error('HTTP server not started');
}
const req = request.agent(started.httpServer);
setHeaders(req, headers);
const res = await req
.get('/graphql')
.query({ query: '{ __typename }' });

if (res.status === 200) {
expect(res.body?.data?.__typename).toBe('Query');
} else {
expect(res.status).toBe(405);
}
});

it('POST /graphql accepts JSON body', async () => {
if (!started) {
throw new Error('HTTP server not started');
}
const req = request.agent(started.httpServer);
setHeaders(req, headers);
const res = await req
.post('/graphql')
.send({ query: '{ __typename }' });

expect(res.status).toBe(200);
expect(res.body?.data?.__typename).toBe('Query');
});
}
);
Loading