Skip to content
Draft
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
62 changes: 43 additions & 19 deletions graphql/server-test/__tests__/server.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@
import path from 'path';
import type supertest from 'supertest';

import { getConnections, seed } from '../src';
import {
getConnections,
seed,
TEST_INTERNAL_REQUEST_SECRET
} from '../src';
import type { ServerInfo } from '../src/types';

jest.setTimeout(60000);
Expand Down Expand Up @@ -51,6 +55,7 @@ type Scenario = {
isPublic: boolean;
metaSchemas?: string[];
routingSchema?: string;
allowMetaSchemaHeader?: boolean;
};
headers?: Record<string, string>;
};
Expand Down Expand Up @@ -87,17 +92,8 @@ const scenarios: Scenario[] = [
api: { isPublic: false, metaSchemas: scopedMetaSchemas },
headers: {
'X-Database-Id': scopedDatabaseId,
'X-Api-Name': 'private'
}
},
{
name: 'scoped private via X-Schemata',
seedDir: 'simple-seed-scoped',
useRouting: true,
api: { isPublic: false, metaSchemas: scopedMetaSchemas },
headers: {
'X-Database-Id': scopedDatabaseId,
'X-Schemata': schemas.join(',')
'X-Api-Name': 'private',
'X-Constructive-Internal-Token': TEST_INTERNAL_REQUEST_SECRET
}
}
];
Expand Down Expand Up @@ -267,6 +263,7 @@ describe('scoped private via X-Meta-Schema', () => {
const headers: Record<string, string> = {
'X-Database-Id': scopedDatabaseId,
'X-Meta-Schema': 'true',
'X-Constructive-Internal-Token': TEST_INTERNAL_REQUEST_SECRET,
...extraHeaders
};
for (const [header, value] of Object.entries(headers)) {
Expand All @@ -284,7 +281,8 @@ describe('scoped private via X-Meta-Schema', () => {
useRouting: true,
api: {
isPublic: false,
metaSchemas: metaApiSchemas
metaSchemas: metaApiSchemas,
allowMetaSchemaHeader: true
}
}
},
Expand Down Expand Up @@ -342,7 +340,7 @@ describe('scoped private via X-Meta-Schema', () => {
* Error path tests
*
* Exercise the api middleware error conditions under scoped routing:
* - Invalid X-Schemata (ApiError with errorHtml → 404)
* - Raw X-Schemata is rejected before database routing (→ 403)
* - Host that resolves to no route (→ 404, no legacy fallback)
* - NO_VALID_SCHEMAS (configured metaSchemas absent → 404)
*/
Expand All @@ -368,16 +366,40 @@ describe('Error paths', () => {
teardowns.push(teardown);
});

describe('Invalid X-Schemata (returns 404)', () => {
it('should return 404 when X-Schemata contains schemas not in the DB', async () => {
describe('Raw X-Schemata (returns 403)', () => {
it('rejects physical schema selection even from an authenticated internal caller', async () => {
const res = await request
.post('/graphql')
.set('X-Database-Id', scopedDatabaseId)
.set('X-Schemata', 'nonexistent_schema_abc,another_fake_schema')
.set('X-Constructive-Internal-Token', TEST_INTERNAL_REQUEST_SECRET)
.send({ query: '{ __typename }' });

expect(res.status).toBe(404);
expect(res.text).toContain('No valid schemas found for the supplied X-Schemata header');
expect(res.status).toBe(403);
expect(res.text).toBe('Forbidden');
});
});

describe('Unauthenticated internal selectors (returns 403)', () => {
it('rejects API/database selectors before any routing query', async () => {
const res = await request
.post('/graphql')
.set('X-Database-Id', scopedDatabaseId)
.set('X-Api-Name', 'private')
.send({ query: '{ __typename }' });

expect(res.status).toBe(403);
expect(res.text).toBe('Forbidden');
});

it('rejects actor claims before any routing query', async () => {
const res = await request
.post('/graphql')
.set('X-Actor-Id', 'attacker-controlled-actor')
.send({ query: '{ __typename }' });

expect(res.status).toBe(403);
expect(res.text).toBe('Forbidden');
});
});

Expand Down Expand Up @@ -408,7 +430,8 @@ describe('Error paths', () => {
useRouting: true,
api: {
isPublic: false,
metaSchemas: scopedMetaSchemas
metaSchemas: scopedMetaSchemas,
allowMetaSchemaHeader: true
}
}
},
Expand All @@ -422,6 +445,7 @@ describe('Error paths', () => {
.post('/graphql')
.set('X-Database-Id', 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
.set('X-Meta-Schema', 'true')
.set('X-Constructive-Internal-Token', TEST_INTERNAL_REQUEST_SECRET)
.send({ query: '{ __typename }' });

expect(res.status).toBe(404);
Expand Down
35 changes: 15 additions & 20 deletions graphql/server-test/__tests__/upload.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@ import path from 'path';
import type { PgTestClient } from 'pgsql-test';
import type supertest from 'supertest';

import { getConnections, seed } from '../src';
import {
getConnections,
seed,
TEST_INTERNAL_REQUEST_SECRET
} from '../src';

jest.setTimeout(120000);

Expand Down Expand Up @@ -288,7 +292,8 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => {
return request
.post('/graphql')
.set('X-Database-Id', aliceDatabaseId)
.set('X-Schemata', aliceSchemas.join(','))
.set('X-Api-Name', 'app')
.set('X-Constructive-Internal-Token', TEST_INTERNAL_REQUEST_SECRET)
.send(payload);
};

Expand All @@ -304,6 +309,7 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => {
.post('/graphql')
.set('X-Database-Id', databaseId)
.set('X-Api-Name', apiName)
.set('X-Constructive-Internal-Token', TEST_INTERNAL_REQUEST_SECRET)
.send(payload);
};

Expand All @@ -319,6 +325,7 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => {
.post('/graphql')
.set('X-Database-Id', databaseId)
.set('X-Schemata', schemas.join(','))
.set('X-Constructive-Internal-Token', TEST_INTERNAL_REQUEST_SECRET)
.send(payload);
};

Expand Down Expand Up @@ -1050,27 +1057,16 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => {
expect(res.status).toBe(404);
});

it('X-Schemata with Bob schema + Alice database_id does NOT leak Alice data', async () => {
it('rejects Bob physical schemas paired with Alice database_id', async () => {
const res = await postGraphQLViaSchemata(aliceDatabaseId, bobSchemas, { query: APP_FILES });
if (res.status === 200 && res.body.data) {
const names = (res.body.data.appFiles?.nodes ?? []).map(
(f: { filename: string }) => f.filename
);
expect(names).not.toContain('hello-public.txt');
expect(names).not.toContain('hello-private.txt');
}
expect(res.status).toBe(403);
expect(res.text).toBe('Forbidden');
});

it('X-Schemata with Mallory schema + Bob database_id does NOT leak Bob data', async () => {
it('rejects Mallory physical schemas paired with Bob database_id', async () => {
const res = await postGraphQLViaSchemata(bobDatabaseId, mallorySchemas, { query: APP_FILES });
if (res.status === 200 && res.body.data) {
const names = (res.body.data.appFiles?.nodes ?? []).map(
(f: { filename: string }) => f.filename
);
expect(names).not.toContain('bob-file.txt');
expect(names).not.toContain('bob-seeded-public.txt');
expect(names).not.toContain('bob-seeded-private.txt');
}
expect(res.status).toBe(403);
expect(res.text).toBe('Forbidden');
});
});

Expand Down Expand Up @@ -1106,4 +1102,3 @@ describe('Integration tests (uploads, tenant isolation, RLS)', () => {
});
});
});

12 changes: 11 additions & 1 deletion graphql/server-test/src/get-connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import type { GetConnectionOpts, GetConnectionResult } from 'pgsql-test';
import { getConnections as getPgConnections } from 'pgsql-test';
import type { SeedAdapter } from 'pgsql-test/seed/types';

import { createDevTestServer, createTestServer } from './server';
import {
createDevTestServer,
createTestServer,
TEST_INTERNAL_REQUEST_SECRET
} from './server';
import { createQueryFn,createSuperTestAgent } from './supertest';
import type { GetConnectionsInput, GetConnectionsResult } from './types';

Expand Down Expand Up @@ -51,6 +55,12 @@ export const getConnections = async (
api: {
// Start with user-provided api options from server.api
...input.server?.api,
// Production routing/identity headers fail closed unless the ingress is
// authenticated. Tests use one fixture-only credential and send it only
// on cases that intentionally exercise the reserved header boundary.
internalRequestSecret:
input.server?.api?.internalRequestSecret
?? TEST_INTERNAL_REQUEST_SECRET,
// Apply convenience properties (these take precedence)
exposedSchemas: input.schemas,
...(input.authRole && { anonRole: input.authRole, roleName: input.authRole })
Expand Down
2 changes: 1 addition & 1 deletion graphql/server-test/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
export * from './types';

// Export server utilities
export { createTestServer } from './server';
export { createTestServer, TEST_INTERNAL_REQUEST_SECRET } from './server';

// Export SuperTest utilities
export { createSuperTestAgent } from './supertest';
Expand Down
4 changes: 4 additions & 0 deletions graphql/server-test/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import { Server as HttpServer } from 'http';

import type { ServerInfo, ServerOptions } from './types';

/** Credential used only by in-process integration fixtures. */
export const TEST_INTERNAL_REQUEST_SECRET =
'graphql-server-test-internal-secret-32-bytes';

/**
* Create a single-tenant dev test server (no scoped routing, no database id).
*
Expand Down
Loading