Integration and End-to-End Test Architecture¶
Version: 2.0 (revised — resolves blockers B1-B5 from review) Status: Approved for implementation Date: 2026-06-11
1. Overview¶
This document defines the complete test architecture for OnePlatform integration and end-to-end tests. It supersedes any earlier drafts and is the authoritative reference a developer should follow to implement the test infrastructure.
1.1 Scope¶
This document covers three test levels:
- Level 1 — In-process HTTP: The Hono app is loaded in the test process via a
createApp()factory, callingapp.fetch()directly. Real PostgreSQL and Redis on test ports. No network server started. - Level 2 — Out-of-process single service: The service is spawned as a real Node process. HTTP requests go over
localhost. Tests target one service at a time. - Level 3 — Full stack E2E: All services are started. Tests exercise multi-service flows across the full request path.
The architecture does not cover unit tests (those already exist in each service's __tests__/ directory using mocked dependencies) or load/performance tests.
1.2 What is NOT in scope¶
- PgBouncer. Tests connect to PostgreSQL directly. PgBouncer is a production concern.
- MinIO in Level 1. Plugin bundle and app artifact tests at Level 2/3 bring up a MinIO container.
- The execution service's Docker sandbox proxy. That path is tested at Level 3 only.
2. Blocker Resolutions¶
B1 — Services don't export the Hono app (resolved by createApp() factory refactor)¶
Every service index.ts currently follows the pattern:
async function main(): Promise<void> {
const config = loadConfig();
const db = createDbClient({ ... });
// ... build all deps ...
const app = createApp({ ... }); // local variable, not exported
// ... register routes ...
server.listen(port, ...);
}
main().catch(...)
The Hono app is local to main(). There is no export. app.fetch() is unreachable from tests.
Resolution: Each service index.ts is refactored to export a createServiceApp() factory that builds the full wired application and returns it alongside a cleanup handle. The existing main() becomes a thin wrapper that calls the factory and starts the HTTP server.
Section 3 specifies the exact factory contract.
B2 — TEST_* env vars don't work with loadConfig() (resolved by using OP_* vars directly)¶
loadConfig() reads process.env and parses keys with the OP_ prefix via Zod. There is no alias or remapping layer. TEST_* vars are invisible to it.
Resolution: Tests set OP_* variables directly in process.env before importing the service factory. Level 2/3 globalSetup passes OP_* vars in the env option of child_process.spawn. A committed .env.test file contains the values for the test infrastructure. No TEST_* prefix is used anywhere in test code.
B3 — Level 2 port assignments need explicit PORT overrides (resolved by documented per-service PORT mapping)¶
When Level 2 spawns a service process, the process reads process.env["PORT"] ?? "<default>". Without an explicit override, multiple spawned services could share a port or use a port that conflicts with a developer's running stack.
Resolution: The globalSetup for Level 2 and Level 3 explicitly passes PORT=<test-port> in the spawn env for every service. Section 5.2 documents the test port map.
B4 — Transaction rollback can't isolate cross-connection writes (resolved by per-test tenant UUID + RLS)¶
Per-test transaction rollback only isolates the test's own connection. Service code uses separate pool connections that the test does not own. Rolling back the test connection leaves service-written rows intact.
Resolution: Level 1 tests use per-test unique tenant UUIDs. Because every multi-tenant table (auth, ingestion, pipeline, app) enforces RLS via current_setting('app.tenant_id'), each test's data is invisible to other tests as long as tenant UUIDs are distinct. Tests clean up their tenant rows in afterAll, not via rollback. This is fast: DELETE FROM <schema>.<table> WHERE tenant_id = $1 on small datasets is sub-millisecond.
Services that do not use RLS on some tables (ontology, plugin — see Section 6.1 for per-service details) use a different isolation strategy: unique slug or name prefixes that are cleaned up in afterAll.
B5 — runMigrations() path resolution depends on compiled vs source (resolved by requiring build before tests)¶
runMigrations() computes MIGRATIONS_DIR via import.meta.url pointing to the compiled .js file in dist/. When the test imports the TypeScript source directly, import.meta.url resolves to the src/ path, and the migrations directory is not found.
Resolution: Integration tests import from the compiled dist/ directory, not from src/. turbo run build must be run before running Level 1 integration tests. This is already standard for the monorepo: the turbo.json test task has "dependsOn": ["^build"], so pnpm test in the root already enforces it. For developers running tests manually in a single service directory, the README must state: run pnpm build first.
3. The createServiceApp() Factory Contract¶
Every service must export a createServiceApp() function. This is a mechanical refactor of the existing main() function: extract the wiring logic into a named export, keep main() as the entry point that calls it.
3.1 Return type¶
export interface ServiceApp {
// The fully wired Hono application. Use app.fetch() in Level 1 tests.
app: Hono<{ Variables: AppVariables }>;
// Releases all held resources: closes DB pool, disconnects Redis,
// stops BullMQ workers, stops background schedulers.
// Must be called in afterAll to prevent test process hangs.
cleanup: () => Promise<void>;
}
3.2 Config type per service¶
Each service defines its own config type based on what main() already reads. The config type must cover every env var read during wiring. All fields should have sensible test defaults.
Example for auth service:
export interface AuthServiceConfig {
databaseUrl: string;
redisUrl: string;
jwtSecret: string;
masterKey: string; // base64-encoded 32 bytes
allowedOrigins: string[];
bootstrapTokenPath?: string; // defaults to /data/init/bootstrap.token
serviceKeysDir?: string; // defaults to /data/service-keys (returns {} on ENOENT)
port?: number; // only used when main() starts the server
}
3.3 Factory function signature¶
The factory: 1. Calls loadConfig() by ensuring the relevant OP_* vars are set in process.env before the call, or accepts pre-parsed config values and constructs the Hono app directly (bypassing loadConfig() for test flexibility). 2. Creates real DB pool and Redis client pointing at test infrastructure. 3. Runs runMigrations() (idempotent — safe to call multiple times). 4. Builds all repositories and services. 5. Creates and returns the Hono app with the full middleware stack. 6. Returns a cleanup function that terminates the pool and Redis connection.
3.4 The revised main() entry point¶
async function main(): Promise<void> {
// Set env vars from process.env, then delegate to factory.
const { app, cleanup } = await createServiceApp({
databaseUrl: process.env["OP_DATABASE_URL"] ?? "",
redisUrl: process.env["OP_REDIS_URL"] ?? "",
jwtSecret: process.env["OP_JWT_SECRET"] ?? "",
masterKey: process.env["OP_MASTER_KEY"] ?? "",
allowedOrigins: (process.env["OP_ALLOWED_ORIGINS"] ?? "http://localhost:3000")
.split(",").map(s => s.trim()),
serviceKeysDir: process.env["OP_SERVICE_KEYS_DIR"],
});
const port = parseInt(process.env["PORT"] ?? "3001", 10);
const server = createServer(nodeHttpAdapter(app));
server.listen(port, () => {
console.info(`Auth service started on port ${port}`);
});
process.on("SIGTERM", async () => {
server.close();
await cleanup();
process.exit(0);
});
}
main().catch((err: unknown) => {
console.error("Auth service failed to start:", err);
process.exit(1);
});
The nodeHttpAdapter is the existing Node HTTP bridge code that already appears in every service (the createServer + IncomingMessage bridge block). This should be extracted into @oneplatform/core as createNodeAdapter(app) in a follow-up task. Until then, each service keeps its inline bridge.
3.5 Service-specific factory notes¶
Auth service has additional complexity: the bootstrap token file and the in-memory token lifecycle. The factory accepts optional bootstrapToken?: string | null so tests can inject a token directly without touching the filesystem.
Ontology service has cleanupService.startBackgroundJob(). The factory should accept startBackgroundJobs?: boolean (defaults true) so tests can skip the background job to avoid timer leaks.
Plugin service has the MinIO ping in startup. The factory accepts skipMinioVerification?: boolean (defaults false). Level 1 tests pass true and will fail at the route level if a test actually exercises bundle storage — that is correct behavior.
Pipeline service verifies advisory lock capability on startup. This works fine in Level 1 since the test pool connects to real PostgreSQL. No special handling needed.
Ingestion service spawns BullMQ workers. Level 1 tests pass startWorkers: false to the factory. Worker behavior is tested at Level 2.
App service spawns a BullMQ retention worker and queue scheduler. Same pattern: startWorkers: false for Level 1.
4. Test Infrastructure¶
4.1 Prerequisites¶
| Dependency | Version | Purpose |
|---|---|---|
| PostgreSQL | 16 | Persistent storage for all services |
| Redis | 7 | Queues, cache, pub/sub, auth state |
| Vitest | 1.x | Test runner (already in use) |
| testcontainers (optional) | latest | Manages Docker containers per test run |
For CI, a docker-compose.test.yml starts PostgreSQL and Redis on dedicated test ports before the test suite runs. For local development, developers can point tests at an already-running local stack.
4.2 Test PostgreSQL setup¶
A single PostgreSQL instance is used for all test levels. Each service migrates its own schema on first run. Tests are isolated via tenant UUID (see Section 7), not via separate databases.
-- No special test database is required.
-- Each service creates its own schema (auth, ontology, ingestion, plugin, pipeline, app).
-- The test user must have CREATE SCHEMA rights.
The test PostgreSQL user must be a superuser or have SET SESSION AUTHORIZATION capability, because the bypass_rls GUC is set during tests that need to read across tenants (admin views). In practice, a dedicated op_test user with SUPERUSER is simplest for test infrastructure.
4.3 Test Redis setup¶
A single Redis instance on a test port. No Redis password required in test environment (keeps .env.test simple). Redis logical databases are not required for tests — services use the default DB 0.
4.4 .env.test file¶
This file is committed to the repository at the monorepo root. It contains test-only values. It is not .gitignored — the values are not secrets (they're test credentials for a local Docker container).
# .env.test
# Used by Level 1 integration tests and Level 2/3 globalSetup.
# These values point at containers started by docker-compose.test.yml.
# Infrastructure
OP_DATABASE_URL=postgresql://op_test:op_test@localhost:5433/op_test
OP_REDIS_URL=redis://localhost:6380
# Security — test-only values, not real secrets
# OP_MASTER_KEY: 32 bytes = 256 bits, base64-encoded.
# Generated with: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
# This specific value is committed only for test use.
OP_MASTER_KEY=dGVzdG1hc3Rlcmtlc3RrZXkxMjM0NTY3ODkwYWJjZA==
OP_JWT_SECRET=test-jwt-secret-for-integration-tests-32c
OP_CURSOR_SECRET=test-cursor-secret-for-integration-32c
# Service identity
OP_BASE_URL=http://localhost:3000
OP_ALLOWED_ORIGINS=http://localhost:3000
OP_REQUIRE_EMAIL_VERIFICATION=false
OP_WEBHOOK_ALLOW_HTTP=true
# Service token (for service-to-service internal calls in Level 2/3)
OP_SERVICE_TOKEN_SECRET=test-service-token-secret-not-real
# Sensible defaults for optional configs
OP_GLOBAL_RATE_LIMIT=10000
OP_SANDBOX_POOL_SIZE=1
OP_CONNECTOR_TIMEOUT_SECONDS=10
OP_INGESTION_BATCH_SIZE=100
OP_LARGE_SYNC_CONCURRENCY=1
OP_MIGRATION_TIMEOUT=60
OP_ONTOLOGY_POLL_INTERVAL=1
Note on OP_MASTER_KEY format: The value must decode to exactly 32 bytes. The example above is a placeholder — generate a real test value with node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" and commit it to .env.test. Since this is a test-only value with no real data encrypted, committing it is safe.
4.5 docker-compose.test.yml¶
# docker/docker-compose.test.yml
# Starts test infrastructure on non-default ports to avoid conflicts.
services:
postgres-test:
image: postgres:16
environment:
POSTGRES_USER: op_test
POSTGRES_PASSWORD: op_test
POSTGRES_DB: op_test
ports:
- "5433:5432"
tmpfs:
- /var/lib/postgresql/data # in-memory for speed, data not persisted
redis-test:
image: redis:7
ports:
- "6380:6379"
tmpfs:
- /data
Start with: docker compose -f docker/docker-compose.test.yml up -d Stop with: docker compose -f docker/docker-compose.test.yml down
5. Test Organization¶
5.1 Directory structure¶
services/
auth/
src/
__tests__/ # unit tests (existing — not changed)
integration/
level1/
bootstrap.test.ts
login-flow.test.ts
api-keys.test.ts
rbac.test.ts
level2/
globalSetup.ts
auth-service.test.ts
helpers/
test-app.ts # creates a Level 1 app instance
tenant.ts # per-test tenant UUID helpers
fixtures.ts # shared data builders
ontology/
integration/
level1/
entity-crud.test.ts
mapping.test.ts
migration-status.test.ts
level2/
globalSetup.ts
ontology-service.test.ts
helpers/
test-app.ts
tenant.ts
fixtures.ts
# ... same structure for ingestion, plugin, pipeline, app
tests/
level3/
globalSetup.ts # starts all services
globalTeardown.ts
auth-to-ontology.test.ts
ingestion-pipeline.test.ts
full-stack-app-deploy.test.ts
helpers/
service-clients.ts # typed HTTP clients for each service
wait-for-ready.ts # polls /healthz until 200
tenant.ts
5.2 Test port map (Level 2 and Level 3)¶
| Service | Default port | Test port (Level 2) | Test port (Level 3) |
|---|---|---|---|
| Auth | 3001 | 13001 | 13001 |
| Ingestion | 3002 | 13002 | 13002 |
| Ontology | 3003 | 13003 | 13003 |
| Pipeline | 3004 | 13004 | 13004 |
| Execution | 3005 | 13005 | 13005 |
| App | 3006 | 13006 | 13006 |
| Logging | 3007 | 13007 | 13007 |
| Plugin | 3008 | 13008 | 13008 |
| Gateway | 3000 | 13000 | 13000 |
Test ports are in the 13000 range to make them memorable and to avoid conflicts with common development ports.
5.3 Vitest configuration per level¶
Each level has a dedicated vitest config file:
// services/auth/vitest.integration.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["integration/level1/**/*.test.ts"],
globalSetup: ["integration/helpers/global-setup.ts"],
environment: "node",
testTimeout: 30_000,
pool: "forks", // separate process per test file to avoid env pollution
poolOptions: {
forks: { singleFork: false },
},
// Load .env.test before any test file is imported
env: Object.fromEntries(
Object.entries(
await import("dotenv").then(d => d.config({ path: "../../.env.test" }).parsed ?? {})
)
),
},
});
The pool: "forks" setting is important: each test file runs in a fork, preventing process.env mutations from one test file leaking into another.
// tests/level3/vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["level3/**/*.test.ts"],
globalSetup: ["level3/globalSetup.ts"],
globalTeardown: ["level3/globalTeardown.ts"],
environment: "node",
testTimeout: 60_000, // E2E tests can be slow
pool: "forks",
sequence: {
sequential: true, // Level 3 tests run sequentially to avoid port contention
},
},
});
6. RLS and Isolation Strategy¶
6.1 Per-service RLS status¶
This table reflects the actual schema in each service's 001_initial_schema.sql:
| Service | Schema | RLS enforced | Isolation method |
|---|---|---|---|
| Auth | auth | Yes — users, roles, api_keys, oauth_clients have ENABLE ROW LEVEL SECURITY with users_tenant_isolation policy using current_setting('app.tenant_id') and bypass_rls GUC | Per-test tenant UUID |
| Ingestion | ingestion | Yes — connectors, webhook_receivers, upload_jobs have RLS policies | Per-test tenant UUID |
| Pipeline | pipeline | Yes — pipelines, runs, run_steps have RLS policies with bypass_rls GUC | Per-test tenant UUID |
| Ontology | ontology | No RLS policies in schema. Tables have tenant_id column but no ENABLE ROW LEVEL SECURITY statement. | Per-test unique tenant UUID prefix on slug/name fields + DELETE cleanup in afterAll |
| Plugin | plugin | No RLS policies in schema. instances and hooks tables have tenant_id column only. | Per-test unique tenant UUID + DELETE cleanup in afterAll |
| App | app | No RLS policies in schema. apps table has tenant_id column only. | Per-test unique tenant UUID + DELETE cleanup in afterAll |
Important: For services without RLS (ontology, plugin, app), tests must still use unique tenant UUIDs per test. Without RLS, there is no automatic filtering — the tenant UUID is used only to distinguish which rows belong to which test for cleanup purposes. Tests in these services must be written such that they query or assert only on rows with their own tenant_id, not on row counts that would be affected by other concurrent tests.
6.2 The bypass_rls GUC¶
Services that have RLS define a bypass_rls GUC (current_setting('app.bypass_rls', true) = 'true') in some policies. This allows service-level admin operations to cross tenant boundaries. In tests, this GUC is never set — tests always operate within a single tenant context. The test user is a superuser (which also bypasses RLS at the PostgreSQL level), so direct cleanup queries (DELETE FROM auth.users WHERE tenant_id = $1) do not need the GUC.
6.3 Per-test tenant UUID pattern¶
// services/auth/integration/helpers/tenant.ts
import { randomUUID } from "node:crypto";
import type pg from "pg";
/**
* Creates a new unique tenant UUID for a test.
* Each test case must call this once and pass the returned tenantId
* to all API calls and fixture builders.
*/
export function newTenantId(): string {
return randomUUID();
}
/**
* Deletes all rows created by a test in the auth schema.
* Call in afterAll with the test's tenantId.
* Uses SUPERUSER connection — bypasses RLS for direct cleanup.
*/
export async function cleanupAuthTenant(pool: pg.Pool, tenantId: string): Promise<void> {
// Delete in dependency order (foreign key constraints)
await pool.query("DELETE FROM auth.entity_permissions WHERE tenant_id = $1", [tenantId]);
await pool.query("DELETE FROM auth.api_keys WHERE tenant_id = $1", [tenantId]);
await pool.query("DELETE FROM auth.oauth_clients WHERE tenant_id = $1", [tenantId]);
await pool.query("DELETE FROM auth.roles WHERE tenant_id = $1", [tenantId]);
await pool.query("DELETE FROM auth.users WHERE tenant_id = $1", [tenantId]);
}
The pool passed to cleanup functions is the service's pool (connected via OP_DATABASE_URL). Since the test PostgreSQL user is a superuser, these deletes bypass RLS and succeed regardless of the session GUC state.
7. Level 1 Test Pattern¶
Level 1 tests import the service's createServiceApp() factory, create a real DB + Redis connection, and call app.fetch() directly.
7.1 Test app setup¶
// services/auth/integration/helpers/test-app.ts
import { createDbClient, createRedisClient } from "@oneplatform/core";
// Import from compiled dist/ — required for runMigrations() path resolution (B5)
import { createServiceApp } from "../../dist/index.js";
let sharedDb: ReturnType<typeof createDbClient> | null = null;
let sharedRedis: ReturnType<typeof createRedisClient> | null = null;
/**
* Creates a Level 1 test instance of the auth service.
* Migrations are run once on first call (idempotent).
* Returns app + cleanup.
*/
export async function buildTestApp() {
const db = createDbClient({
connectionString: process.env["OP_DATABASE_URL"]!,
maxConnections: 5,
});
const { app, cleanup } = await createServiceApp({
databaseUrl: process.env["OP_DATABASE_URL"]!,
redisUrl: process.env["OP_REDIS_URL"]!,
jwtSecret: process.env["OP_JWT_SECRET"]!,
masterKey: process.env["OP_MASTER_KEY"]!,
allowedOrigins: ["http://localhost:3000"],
// Skip filesystem reads in tests
bootstrapToken: null,
serviceKeysDir: undefined, // returns {} silently
startBackgroundJobs: false,
startWorkers: false,
skipMinioVerification: true,
});
return { app, cleanup, db };
}
7.2 Example Level 1 test¶
// services/auth/integration/level1/login-flow.test.ts
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { buildTestApp } from "../helpers/test-app.js";
import { newTenantId, cleanupAuthTenant } from "../helpers/tenant.js";
import type pg from "pg";
import { createDbClient } from "@oneplatform/core";
describe("Auth service — login flow", () => {
let app: Awaited<ReturnType<typeof buildTestApp>>["app"];
let cleanup: () => Promise<void>;
let db: pg.Pool;
beforeAll(async () => {
const result = await buildTestApp();
app = result.app;
cleanup = result.cleanup;
db = result.db;
});
afterAll(async () => {
await cleanup();
await db.end();
});
it("registers a user and issues tokens", async () => {
const tenantId = newTenantId();
try {
const res = await app.fetch(
new Request("http://localhost/api/v1/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: `user-${tenantId}@example.com`,
password: "Correct-Horse-Battery-Staple-99",
tenantName: `test-tenant-${tenantId}`,
}),
})
);
expect(res.status).toBe(201);
const body = await res.json() as { data: { accessToken: string; refreshToken: string } };
expect(body.data.accessToken).toBeTruthy();
expect(body.data.refreshToken).toBeTruthy();
} finally {
await cleanupAuthTenant(db, tenantId);
}
});
it("returns 401 for wrong password", async () => {
const tenantId = newTenantId();
// Register first
await app.fetch(
new Request("http://localhost/api/v1/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: `user-${tenantId}@example.com`,
password: "Correct-Horse-Battery-Staple-99",
tenantName: `test-tenant-${tenantId}`,
}),
})
);
try {
const res = await app.fetch(
new Request("http://localhost/api/v1/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: `user-${tenantId}@example.com`,
password: "wrong-password",
}),
})
);
expect(res.status).toBe(401);
} finally {
await cleanupAuthTenant(db, tenantId);
}
});
});
7.3 Why try/finally for cleanup¶
Cleanup goes in a try/finally block inside each test, not only in afterAll. This ensures the test's tenant rows are deleted even when the test assertion fails. afterAll cleanup is a second safety net for rows that survived the finally block (e.g., rows written by the service in a background job before startBackgroundJobs: false took effect).
8. Level 2 Test Pattern¶
Level 2 tests spawn a real service process and talk to it over HTTP. This validates the Node HTTP adapter, port binding, startup sequence, and SIGTERM handling.
8.1 globalSetup.ts (auth service example)¶
// services/auth/integration/level2/globalSetup.ts
import { spawn, type ChildProcess } from "node:child_process";
import { resolve } from "node:path";
let proc: ChildProcess | null = null;
export async function setup(): Promise<void> {
// Require build artifact (B5)
const entryPoint = resolve(
import.meta.dirname,
"../../dist/index.js"
);
proc = spawn("node", [entryPoint], {
env: {
// Pass all OP_* vars from .env.test (already loaded by dotenv in vitest config)
...process.env,
// B3: explicit PORT to avoid conflict with other services
PORT: "13001",
// Override URLs to point at test infrastructure
OP_DATABASE_URL: process.env["OP_DATABASE_URL"]!,
OP_REDIS_URL: process.env["OP_REDIS_URL"]!,
},
stdio: ["ignore", "pipe", "pipe"],
});
proc.stdout?.on("data", (d: Buffer) => process.stdout.write(`[auth-l2] ${d}`));
proc.stderr?.on("data", (d: Buffer) => process.stderr.write(`[auth-l2] ${d}`));
// Wait for the service to be ready
await waitForHealthy("http://localhost:13001/healthz", 30_000);
}
export async function teardown(): Promise<void> {
if (proc !== null) {
proc.kill("SIGTERM");
await new Promise<void>((resolve) => proc!.on("close", resolve));
}
}
async function waitForHealthy(url: string, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const res = await fetch(url);
if (res.status === 200) return;
} catch {
// Not ready yet
}
await new Promise<void>((r) => setTimeout(r, 200));
}
throw new Error(`Service at ${url} did not become healthy within ${timeoutMs}ms`);
}
8.2 Level 2 test example¶
// services/auth/integration/level2/auth-service.test.ts
import { describe, it, expect } from "vitest";
import { newTenantId } from "../helpers/tenant.js";
import { createDbClient } from "@oneplatform/core";
const BASE = "http://localhost:13001";
const db = createDbClient({
connectionString: process.env["OP_DATABASE_URL"]!,
maxConnections: 2,
});
describe("Auth service — Level 2 HTTP", () => {
it("GET /healthz returns 200", async () => {
const res = await fetch(`${BASE}/healthz`);
expect(res.status).toBe(200);
});
it("POST /api/v1/auth/register returns 201", async () => {
const tenantId = newTenantId();
try {
const res = await fetch(`${BASE}/api/v1/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: `user-${tenantId}@example.com`,
password: "Correct-Horse-Battery-Staple-99",
tenantName: `test-tenant-${tenantId}`,
}),
});
expect(res.status).toBe(201);
} finally {
// Direct DB cleanup — see Section 6.3
await db.query("DELETE FROM auth.users WHERE tenant_id = $1", [tenantId]);
}
});
});
9. Level 3 (Full Stack E2E) Pattern¶
Level 3 starts all services and exercises cross-service flows. The globalSetup spawns every service, waits for all health endpoints to return 200, then the test suite runs.
9.1 globalSetup.ts¶
// tests/level3/globalSetup.ts
import { spawn, type ChildProcess } from "node:child_process";
import { resolve } from "node:path";
const SERVICE_MAP = [
{ name: "auth", entry: "services/auth/dist/index.js", port: 13001 },
{ name: "ingestion", entry: "services/ingestion/dist/index.js", port: 13002 },
{ name: "ontology", entry: "services/ontology/dist/index.js", port: 13003 },
{ name: "pipeline", entry: "services/pipeline/dist/index.js", port: 13004 },
{ name: "app", entry: "services/app/dist/index.js", port: 13006 },
{ name: "plugin", entry: "services/plugin/dist/index.js", port: 13008 },
] as const;
const procs: ChildProcess[] = [];
export async function setup(): Promise<void> {
const root = resolve(import.meta.dirname, "../..");
for (const svc of SERVICE_MAP) {
const proc = spawn("node", [resolve(root, svc.entry)], {
env: {
...process.env,
PORT: String(svc.port),
OP_DATABASE_URL: process.env["OP_DATABASE_URL"]!,
OP_REDIS_URL: process.env["OP_REDIS_URL"]!,
// Inter-service URLs — all point at test ports
AUTH_SERVICE_URL: "http://localhost:13001",
INGESTION_SERVICE_URL: "http://localhost:13002",
ONTOLOGY_SERVICE_URL: "http://localhost:13003",
EXECUTION_SERVICE_URL: "http://localhost:13005",
PLUGIN_SERVICE_URL: "http://localhost:13008",
},
stdio: ["ignore", "pipe", "pipe"],
});
proc.stdout?.on("data", (d: Buffer) =>
process.stdout.write(`[${svc.name}] ${d}`)
);
proc.stderr?.on("data", (d: Buffer) =>
process.stderr.write(`[${svc.name}] ${d}`)
);
procs.push(proc);
}
// Wait for all services to be healthy in parallel
await Promise.all(
SERVICE_MAP.map((svc) =>
waitForHealthy(`http://localhost:${svc.port}/healthz`, 60_000)
)
);
}
export async function teardown(): Promise<void> {
for (const proc of procs) {
proc.kill("SIGTERM");
}
await Promise.all(
procs.map(
(proc) => new Promise<void>((resolve) => proc.on("close", resolve))
)
);
}
async function waitForHealthy(url: string, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const res = await fetch(url);
if (res.status === 200) return;
} catch { /* not ready */ }
await new Promise<void>((r) => setTimeout(r, 300));
}
throw new Error(`${url} not healthy after ${timeoutMs}ms`);
}
9.2 E2E test example — auth to ontology flow¶
// tests/level3/auth-to-ontology.test.ts
import { describe, it, expect } from "vitest";
import { createDbClient } from "@oneplatform/core";
import { newTenantId } from "../helpers/tenant.js";
const AUTH_URL = "http://localhost:13001";
const ONTOLOGY_URL = "http://localhost:13003";
const db = createDbClient({
connectionString: process.env["OP_DATABASE_URL"]!,
maxConnections: 2,
});
describe("E2E: tenant registers, then creates an ontology entity", () => {
it("full flow", async () => {
const tenantId = newTenantId();
// Step 1: Register a tenant
const regRes = await fetch(`${AUTH_URL}/api/v1/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: `e2e-${tenantId}@example.com`,
password: "Correct-Horse-Battery-Staple-99",
tenantName: `e2e-tenant-${tenantId}`,
}),
});
expect(regRes.status).toBe(201);
const { data: { accessToken } } = await regRes.json() as {
data: { accessToken: string };
};
// Step 2: Create an entity in the ontology service
const entityRes = await fetch(`${ONTOLOGY_URL}/api/v1/entities`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${accessToken}`,
},
body: JSON.stringify({
slug: `product-${tenantId.slice(0, 8)}`,
label: "Product",
fields: [{ name: "sku", type: "string", required: true }],
}),
});
expect(entityRes.status).toBe(201);
// Cleanup
await db.query("DELETE FROM ontology.entities WHERE tenant_id = $1", [tenantId]);
await db.query("DELETE FROM auth.users WHERE tenant_id = $1", [tenantId]);
});
});
10. Service Init Container Secrets (W2)¶
In production, the init container generates OP_MASTER_KEY, OP_JWT_SECRET, and OP_CURSOR_SECRET on first start and writes them to /data/init/. Services read them at startup.
In tests, these secrets are provided via .env.test as environment variables. There is no init container in test mode. The service factory reads them from process.env directly.
For Level 1, the factory accepts them directly in its config parameter. For Level 2/3, they are injected via the spawn env (...process.env picks them up from the .env.test loader).
Bootstrap token (auth service only): In production, the bootstrap token is read from /data/init/bootstrap.token. In Level 1 tests, the factory accepts bootstrapToken: string | null directly, bypassing the filesystem. In Level 2/3 tests that exercise bootstrap, a temporary file is created in beforeAll and the BOOTSTRAP_TOKEN_PATH override env var is set in the spawn env:
// In globalSetup or beforeAll for bootstrap tests:
import { writeFile, unlink, mkdtemp } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
const tmpDir = await mkdtemp(join(tmpdir(), "op-test-"));
const tokenPath = join(tmpDir, "bootstrap.token");
await writeFile(tokenPath, "a".repeat(64), "utf-8");
// Pass to service spawn: BOOTSTRAP_TOKEN_PATH=<tokenPath>
11. Service Keys Directory (W3)¶
Current inconsistency in the codebase:
| Service | Env var used |
|---|---|
| Auth | Hardcoded /data/service-keys (no env var override) |
| Ontology | Hardcoded /data/service-keys (no env var override) |
| Ingestion | Hardcoded /data/service-keys (no env var override) |
| Plugin | OP_SERVICE_KEYS_DIR with fallback /data/service-keys |
| Pipeline | OP_SERVICE_KEYS_DIR with fallback /data/service-keys |
| App | SERVICE_KEYS_DIR (different name) with fallback /data/service-keys |
| Execution | OP_SERVICE_KEYS_DIR with fallback /data/service-keys |
All loadServicePublicKeys() functions already have a catch {} block that returns {} on any error (ENOENT on the directory is swallowed). This means tests do not need to create a fake service keys directory — the function returns an empty map and service-to-service auth simply fails with 401 if attempted.
For Level 1/2 tests: Pass OP_SERVICE_KEYS_DIR=/tmp/nonexistent-dir (or leave it unset) — the function silently returns {}. Internal routes that require service auth are not exercised in Level 1. Level 3 tests that need inter-service calls must either generate real keys or use a shared test key pair.
Recommended fix (not blocking tests, but should be done as a follow-up): Normalize all services to use OP_SERVICE_KEYS_DIR with fallback /data/service-keys. App service has a typo (SERVICE_KEYS_DIR instead of OP_SERVICE_KEYS_DIR).
12. Key Test Scenarios¶
12.1 Auth service — Level 1¶
| Scenario | Test file |
|---|---|
| Bootstrap flow: status, bootstrap, attempt duplicate | level1/bootstrap.test.ts |
| Register → login → logout | level1/login-flow.test.ts |
| Forgot password → reset password | level1/login-flow.test.ts |
| Refresh token rotation | level1/login-flow.test.ts |
| API key: create, validate, revoke | level1/api-keys.test.ts |
| RBAC: assign role, check permission | level1/rbac.test.ts |
| Rate limit: 5 failed logins in 60s → locked | level1/login-flow.test.ts |
12.2 Ingestion service — Level 1¶
| Scenario | Test file |
|---|---|
| Connector: create, read, update, delete | level1/connector-crud.test.ts |
| Credential encryption round-trip | level1/credentials.test.ts |
| Webhook: register, receive payload, verify HMAC | level1/webhooks.test.ts |
| Upload: initiate job, simulate completion | level1/uploads.test.ts |
12.3 Ontology service — Level 1¶
| Scenario | Test file |
|---|---|
| Entity: create, update fields, soft-delete | level1/entity-crud.test.ts |
| Relationship: define, validate cardinality | level1/relationships.test.ts |
| Migration: start, status, cancel | level1/migration-status.test.ts |
| Mapping rule: create, validate, dry-run | level1/mapping.test.ts |
12.4 Pipeline service — Level 1¶
| Scenario | Test file |
|---|---|
| Pipeline: create, get, update | level1/pipeline-crud.test.ts |
| Run: trigger manual run, poll status | level1/run-lifecycle.test.ts |
| Schedule: create cron, list | level1/schedules.test.ts |
12.5 Plugin service — Level 1¶
| Scenario | Test file |
|---|---|
| Plugin manifest: register, list, get | level1/plugin-crud.test.ts |
| Instance: create, enable, disable | level1/instance-lifecycle.test.ts |
| Hook: register, verify hook payload shape | level1/hooks.test.ts |
12.6 App service — Level 1¶
| Scenario | Test file |
|---|---|
| App: create, get, update slug | level1/app-crud.test.ts |
| Version: create version, list | level1/versions.test.ts |
| Deployment: deploy version, check current | level1/deployments.test.ts |
| Permission: grant access, revoke | level1/permissions.test.ts |
12.7 Level 3 — Full stack flows¶
| Scenario | Test file |
|---|---|
| User registers, creates entity, creates pipeline reading that entity | auth-to-ontology-to-pipeline.test.ts |
| Ingestion connector write triggers pipeline via event trigger | ingestion-pipeline.test.ts |
| Plugin installed, hook fires during pipeline step | plugin-hook-in-pipeline.test.ts |
| App deployed, guest session issued, BFF call returns data | app-deploy-and-serve.test.ts |
13. CI Integration¶
13.1 CI pipeline steps¶
# .github/workflows/integration-tests.yml (conceptual — adapt to your CI system)
jobs:
integration:
steps:
- name: Start test infrastructure
run: docker compose -f docker/docker-compose.test.yml up -d
- name: Wait for infrastructure readiness
run: |
# Wait for PostgreSQL
until pg_isready -h localhost -p 5433 -U op_test; do sleep 1; done
# Wait for Redis
until redis-cli -p 6380 ping | grep PONG; do sleep 1; done
- name: Build all packages and services
run: pnpm turbo run build
# turbo.json test task already depends on build, but we run it
# explicitly here so the step is visible in CI logs.
- name: Run Level 1 integration tests
run: pnpm turbo run test:integration:level1
env:
OP_DATABASE_URL: postgresql://op_test:op_test@localhost:5433/op_test
OP_REDIS_URL: redis://localhost:6380
# Other OP_* vars from .env.test are loaded by vitest config
- name: Run Level 2 integration tests
run: pnpm turbo run test:integration:level2
- name: Run Level 3 E2E tests
run: pnpm test:e2e
- name: Stop test infrastructure
if: always()
run: docker compose -f docker/docker-compose.test.yml down
13.2 turbo.json additions¶
Add these tasks to turbo.json:
{
"tasks": {
"test:integration:level1": {
"dependsOn": ["build"],
"outputs": ["coverage/**"]
},
"test:integration:level2": {
"dependsOn": ["build"],
"outputs": []
}
}
}
The Level 3 E2E task is defined at the monorepo root, not per-package, so it does not go in turbo.json task definitions.
13.3 Package.json additions per service¶
{
"scripts": {
"build": "tsc --project tsconfig.json",
"test": "vitest run --passWithNoTests",
"test:integration:level1": "vitest run --config vitest.integration.ts",
"test:integration:level2": "vitest run --config vitest.integration-l2.ts"
}
}
14. Implementation Order¶
Implement in this order to validate each layer before building on top of it:
Step 1 — Infrastructure and helpers (no service changes) 1. Write docker/docker-compose.test.yml 2. Commit .env.test to repository root 3. Write shared helpers: tests/helpers/tenant.ts, tests/helpers/wait-for-ready.ts
Step 2 — Auth service (first, as all other services depend on tokens) 1. Refactor services/auth/src/index.ts to export createServiceApp() 2. Write services/auth/integration/helpers/test-app.ts and tenant.ts 3. Write Level 1 tests: bootstrap, login flow, API keys, RBAC 4. Write Level 2 globalSetup and smoke test 5. Run, fix, confirm passing
Step 3 — Ontology service 1. Refactor services/ontology/src/index.ts 2. Write Level 1 tests: entity CRUD, relationships, migrations 3. Write Level 2 smoke test
Step 4 — Ingestion service 1. Refactor services/ingestion/src/index.ts (note: skip BullMQ workers in factory) 2. Write Level 1 tests: connector CRUD, credentials, webhooks 3. Write Level 2 smoke test
Step 5 — Pipeline, Plugin, App services Same refactor + Level 1 + Level 2 pattern.
Step 6 — Level 3 E2E 1. Write tests/level3/globalSetup.ts 2. Write cross-service flows in priority order: auth-to-ontology, ingestion-pipeline, app-deploy
15. Security Considerations for Test Code¶
Test credentials must not leak into production: - .env.test values are trivially weak. The OP_JWT_SECRET and OP_MASTER_KEY in .env.test are known values. Any data encrypted or signed with them in production would be compromised. - Production deployments must generate their own secrets via the init container. The .env.test file has no path to production (no CI step deploys it).
Test PostgreSQL user: - The op_test superuser should only exist in the test database container. It must not be created in production PostgreSQL. - The docker-compose.test.yml container uses tmpfs for data storage — it is ephemeral and is destroyed on docker compose down.
No real credentials in tests: - Connector credential tests use plaintext test strings. They are encrypted with the test OP_MASTER_KEY. If anyone examines the test database, the "credentials" are meaningless test strings.
16. Appendix: Complete env var reference for test setup¶
The following table covers every OP_* variable read by loadConfig() plus service-specific variables. All must be present or have defaults before loadConfig() is called in Level 1 tests.
| Variable | Required by loadConfig() | Test value (from .env.test) | Notes |
|---|---|---|---|
OP_MASTER_KEY | Yes | dGVzdG1hc3Rlcmtlc3RrZXkxMjM0NTY3ODkwYWJjZA== | Must decode to exactly 32 bytes |
OP_JWT_SECRET | Yes | test-jwt-secret-for-integration-tests-32c | Must be >= 32 chars |
OP_CURSOR_SECRET | Yes | test-cursor-secret-for-integration-32c | Must be >= 32 chars |
OP_BASE_URL | Yes | http://localhost:3000 | URL format required |
OP_DATABASE_URL | Yes | postgresql://op_test:op_test@localhost:5433/op_test | Points at test container |
OP_REDIS_URL | Yes | redis://localhost:6380 | Points at test container |
OP_ALLOWED_ORIGINS | No (defaults to http://localhost:3000) | http://localhost:3000 | |
OP_GLOBAL_RATE_LIMIT | No (defaults to 10000) | 10000 | |
OP_SANDBOX_POOL_SIZE | No (defaults to 5) | 1 | Smaller pool for tests |
OP_CONNECTOR_TIMEOUT_SECONDS | No (defaults to 300) | 10 | Fast timeout for tests |
OP_INGESTION_BATCH_SIZE | No (defaults to 1000) | 100 | |
OP_LARGE_SYNC_CONCURRENCY | No (defaults to 3) | 1 | |
OP_MIGRATION_TIMEOUT | No (defaults to 3600) | 60 | |
OP_ONTOLOGY_POLL_INTERVAL | No (defaults to 15) | 1 | Faster for tests |
OP_REQUIRE_EMAIL_VERIFICATION | No (defaults to false) | false | Skip email in tests |
OP_WEBHOOK_ALLOW_HTTP | No (defaults to false) | true | Allow http:// in tests |
PORT | Not loadConfig() — read directly | Per service (Level 2/3 only) | See Section 5.2 |
OP_SERVICE_TOKEN_SECRET | Not loadConfig() — read directly in plugin service | test-service-token-secret-not-real | |
OP_SERVICE_KEYS_DIR | Not loadConfig() — read in loadServicePublicKeys() | Not set (returns {} on ENOENT) |