Skip to content
Closed
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
101 changes: 101 additions & 0 deletions __fixtures__/seed/compute/setup.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
-- Shared fixture: compute (functions) module DDL
--
-- Creates the minimum tables needed to emulate the compute module in a
-- production Constructive database: the metaschema module registration
-- tables (function_module, function_invocation_module) plus the generated
-- compute tables (function_definitions, function_api_bindings,
-- function_invocations).
--
-- The function_invocations INSERT policy mirrors the api arm of the
-- production policy: the row is only insertable when the transaction-local
-- `jwt.claims.api_id` claim matches a binding for the invoked function.
--
-- Depends on: services/setup.sql

-- =====================================================
-- METASCHEMA MODULE REGISTRATION TABLES
-- =====================================================

CREATE TABLE IF NOT EXISTS metaschema_modules_public.function_module (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
database_id uuid NOT NULL,
schema_id uuid NOT NULL,
definitions_table_name text NOT NULL DEFAULT 'function_definitions',
scope text NOT NULL DEFAULT 'app'
);

CREATE TABLE IF NOT EXISTS metaschema_modules_public.function_invocation_module (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
database_id uuid NOT NULL,
schema_id uuid NOT NULL,
invocations_table_name text NOT NULL DEFAULT 'function_invocations',
scope text NOT NULL DEFAULT 'app'
);

GRANT SELECT ON metaschema_modules_public.function_module TO administrator, authenticated, anonymous;
GRANT SELECT ON metaschema_modules_public.function_invocation_module TO administrator, authenticated, anonymous;

-- =====================================================
-- COMPUTE SCHEMA + GENERATED TABLES
-- =====================================================

CREATE SCHEMA IF NOT EXISTS compute_public;
GRANT USAGE ON SCHEMA compute_public TO administrator, authenticated, anonymous;

CREATE TABLE IF NOT EXISTS compute_public.function_definitions (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
database_id uuid NOT NULL,
name text NOT NULL,
task_identifier text NOT NULL
);

CREATE TABLE IF NOT EXISTS compute_public.function_api_bindings (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
function_definition_id uuid NOT NULL REFERENCES compute_public.function_definitions (id) ON DELETE CASCADE,
api_id uuid NOT NULL,
alias text NOT NULL,
config jsonb NOT NULL DEFAULT '{}',
UNIQUE (api_id, alias)
);

CREATE TABLE IF NOT EXISTS compute_public.function_invocations (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
database_id uuid NOT NULL,
task_identifier text NOT NULL,
payload jsonb,
status text NOT NULL DEFAULT 'pending',
result jsonb,
error jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
started_at timestamptz,
completed_at timestamptz,
duration_ms int
);

GRANT SELECT ON compute_public.function_definitions TO administrator, authenticated, anonymous;
GRANT SELECT ON compute_public.function_api_bindings TO administrator, authenticated, anonymous;
GRANT SELECT, INSERT ON compute_public.function_invocations TO administrator, authenticated, anonymous;

-- =====================================================
-- RLS — api-arm provenance policy
-- =====================================================

ALTER TABLE compute_public.function_invocations ENABLE ROW LEVEL SECURITY;

-- INSERT is only allowed when the server-injected jwt.claims.api_id claim
-- matches a binding for the invoked function (api provenance arm).
CREATE POLICY function_invocations_api_insert ON compute_public.function_invocations
FOR INSERT TO administrator, authenticated, anonymous
WITH CHECK (
EXISTS (
SELECT 1
FROM compute_public.function_api_bindings b
JOIN compute_public.function_definitions d ON d.id = b.function_definition_id
WHERE d.task_identifier = function_invocations.task_identifier
AND b.api_id = nullif(current_setting('jwt.claims.api_id', true), '')::uuid
)
);

CREATE POLICY function_invocations_select ON compute_public.function_invocations
FOR SELECT TO administrator, authenticated, anonymous
USING (true);
71 changes: 71 additions & 0 deletions __fixtures__/seed/compute/test-data.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
-- Shared fixture: compute (functions) module test data
--
-- Registers the compute_public schema in the metaschema, provisions the
-- function/invocation modules, and seeds one function definition with two
-- API bindings on the "app" API (6c9997a4-591b-4cb3-9313-4ef45d6f134e):
-- - alias "resize" → rest enabled ({"rest": {"path": "/resize", "methods": ["POST"]}})
-- - alias "graphql-only" → rest disabled (absent "rest" key)
--
-- Depends on: services/setup.sql, services/test-data.sql, compute/setup.sql

SET session_replication_role TO replica;

-- Register compute_public in the metaschema
INSERT INTO metaschema_public.schema (id, database_id, name, schema_name, description, is_public)
VALUES (
'9dba0001-0000-4000-8000-000000000001',
'80a2eaaf-f77e-4bfe-8506-df929ef1b8d9',
'compute_public',
'compute_public',
NULL,
true
) ON CONFLICT (id) DO NOTHING;

-- Module registrations (what the compute loader resolves)
INSERT INTO metaschema_modules_public.function_module (id, database_id, schema_id, definitions_table_name, scope)
VALUES (
'9dba0002-0000-4000-8000-000000000002',
'80a2eaaf-f77e-4bfe-8506-df929ef1b8d9',
'9dba0001-0000-4000-8000-000000000001',
'function_definitions',
'app'
) ON CONFLICT (id) DO NOTHING;

INSERT INTO metaschema_modules_public.function_invocation_module (id, database_id, schema_id, invocations_table_name, scope)
VALUES (
'9dba0003-0000-4000-8000-000000000003',
'80a2eaaf-f77e-4bfe-8506-df929ef1b8d9',
'9dba0001-0000-4000-8000-000000000001',
'function_invocations',
'app'
) ON CONFLICT (id) DO NOTHING;

-- Function definition
INSERT INTO compute_public.function_definitions (id, database_id, name, task_identifier)
VALUES (
'9dba0004-0000-4000-8000-000000000004',
'80a2eaaf-f77e-4bfe-8506-df929ef1b8d9',
'resize-image',
'app/resize_image'
) ON CONFLICT (id) DO NOTHING;

-- Bindings on the "app" API
INSERT INTO compute_public.function_api_bindings (id, function_definition_id, api_id, alias, config)
VALUES
(
'9dba0005-0000-4000-8000-000000000005',
'9dba0004-0000-4000-8000-000000000004',
'6c9997a4-591b-4cb3-9313-4ef45d6f134e',
'resize',
'{"graphql": true, "rest": {"path": "/resize", "methods": ["POST"]}}'
),
(
'9dba0006-0000-4000-8000-000000000006',
'9dba0004-0000-4000-8000-000000000004',
'6c9997a4-591b-4cb3-9313-4ef45d6f134e',
'graphql-only',
'{"graphql": true}'
)
ON CONFLICT (id) DO NOTHING;

SET session_replication_role TO DEFAULT;
147 changes: 147 additions & 0 deletions graphql/server-test/__tests__/fn-routes.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/**
* REST Function Route Integration Tests
*
* Verifies the /fn REST routes and the server-injected API provenance
* claim (`jwt.claims.api_id`):
*
* POST /fn/:alias → 202 { invocationId } for a rest-enabled binding
* GET /fn/invocations/:id → invocation status/result (RLS-guarded read)
*
* The seeded function_invocations INSERT policy only passes when
* `jwt.claims.api_id` (set transaction-locally by the server from the
* hostname → services_public.domains → api_id resolution) matches the
* binding's api_id — so the happy path also proves the claim is injected.
*
* Run tests:
* pnpm test -- --testPathPattern=fn-routes.integration
*/

import path from 'path';
import { getConnections, seed } from '../src';
import type supertest from 'supertest';

jest.setTimeout(30000);

const sharedSeedRoot = path.join(__dirname, '..', '..', '..', '__fixtures__', 'seed');
const shared = (...segments: string[]) =>
path.join(sharedSeedRoot, ...segments);
const schemas = ['simple-pets-public', 'simple-pets-pets-public'];
const metaSchemas = [
'services_public',
'metaschema_public',
'metaschema_modules_public',
];

const seedFiles = [
shared('services', 'setup.sql'),
shared('compute', 'setup.sql'),
shared('app-schemas', 'simple-pets', 'schema.sql'),
shared('services', 'test-data.sql'),
shared('compute', 'test-data.sql'),
shared('app-schemas', 'simple-pets', 'test-data.sql'),
];

const API_HOST = 'app.test.constructive.io';

let request: supertest.Agent;
let teardown: () => Promise<void>;

beforeAll(async () => {
({ request, teardown } = await getConnections(
{
schemas,
authRole: 'anonymous',
server: {
api: {
enableServicesApi: true,
isPublic: true,
metaSchemas,
},
},
},
[seed.sqlfile(seedFiles)]
));
});

afterAll(async () => {
await teardown();
});

describe('POST /fn/:alias', () => {
it('returns 202 with an invocationId for a rest-enabled binding', async () => {
const res = await request
.post('/fn/resize')
.set('Host', API_HOST)
.send({ width: 100, height: 100 });

expect(res.status).toBe(202);
expect(res.body.invocationId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
);
});

it('the created invocation passed the jwt.claims.api_id RLS policy', async () => {
// The INSERT policy on function_invocations requires
// jwt.claims.api_id to match the binding's api_id, so a successful
// insert proves the server injected the claim transaction-locally.
const create = await request
.post('/fn/resize')
.set('Host', API_HOST)
.send({ check: 'claim' });

expect(create.status).toBe(202);

const read = await request
.get(`/fn/invocations/${create.body.invocationId}`)
.set('Host', API_HOST);

expect(read.status).toBe(200);
expect(read.body.id).toBe(create.body.invocationId);
expect(read.body.status).toBe('pending');
});

it('returns 404 for a missing binding', async () => {
const res = await request
.post('/fn/nonexistent')
.set('Host', API_HOST)
.send({});

expect(res.status).toBe(404);
});

it('returns 404 for a rest-disabled binding (absent rest config)', async () => {
const res = await request
.post('/fn/graphql-only')
.set('Host', API_HOST)
.send({});

expect(res.status).toBe(404);
});

it('returns 404 when the HTTP method is not allowed by config.rest', async () => {
const res = await request
.put('/fn/resize')
.set('Host', API_HOST)
.send({});

expect(res.status).toBe(404);
});
});

describe('GET /fn/invocations/:id', () => {
it('returns 404 for an unknown invocation id', async () => {
const res = await request
.get('/fn/invocations/00000000-0000-4000-8000-000000000000')
.set('Host', API_HOST);

expect(res.status).toBe(404);
});

it('returns 404 for a non-uuid id', async () => {
const res = await request
.get('/fn/invocations/not-a-uuid')
.set('Host', API_HOST);

expect(res.status).toBe(404);
});
});
Loading
Loading