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
430 changes: 430 additions & 0 deletions graphql/server-test/__fixtures__/seed/oauth-sso/real-runtime.ts

Large diffs are not rendered by default.

427 changes: 427 additions & 0 deletions graphql/server-test/__tests__/oauth-sso.integration.test.ts

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions graphql/server-test/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"test:watch": "jest --watch"
},
"devDependencies": {
"12factor-env": "workspace:^",
"@0no-co/graphql.web": "^1.3.3",
"@agentic-kit/ollama": "workspace:*",
"@constructive-io/graphql-codegen": "workspace:^",
Expand Down
27 changes: 27 additions & 0 deletions graphql/server-test/src/constructive-db-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { existsSync } from 'node:fs';
import path from 'node:path';

import { cleanEnv, str, withDefault } from '12factor-env';

const runtimeEnv = (): { applicationPath: string } => {
const parsed = cleanEnv(process.env, {
CONSTRUCTIVE_DB_APPLICATION_PATH: withDefault(str, '')
});
return { applicationPath: parsed.CONSTRUCTIVE_DB_APPLICATION_PATH.trim() };
};

/**
* Resolve an explicitly pinned generated Constructive DB application checkout.
* Empty means the cross-repository suite is not part of the current test run.
*/
export const getConstructiveDbApplicationPath = (): string | null => {
const configured = runtimeEnv().applicationPath;
if (!configured) return null;
const resolved = path.resolve(configured);
if (!existsSync(path.join(resolved, 'pgpm.plan'))) {
throw new Error(
`CONSTRUCTIVE_DB_APPLICATION_PATH does not contain a generated pgpm application: ${resolved}`
);
}
return resolved;
};
3 changes: 2 additions & 1 deletion graphql/server-test/src/get-connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ export const getConnections = async (
exposedSchemas: input.schemas,
...(input.authRole && { anonRole: input.authRole, roleName: input.authRole })
},
graphile: input.graphile
graphile: input.graphile,
oauth: input.server?.oauth
});

// Start the HTTP server. Suites default to the production scoped-routing
Expand Down
2 changes: 2 additions & 0 deletions graphql/server-test/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export { getConstructiveDbApplicationPath } from './constructive-db-runtime';

// Export types
export * from './types';

Expand Down
5 changes: 4 additions & 1 deletion graphql/server-test/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ export const createTestServer = async (
server: {
...opts.server,
host,
port
port,
...(serverOpts.trustProxy !== undefined && {
trustProxy: serverOpts.trustProxy
})
}
};

Expand Down
10 changes: 9 additions & 1 deletion graphql/server-test/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type { ApiOptions,GraphileOptions } from '@constructive-io/graphql-types';
import type {
ApiOptions,
GraphileOptions,
OAuthServerOptions
} from '@constructive-io/graphql-types';
import type { DocumentNode, GraphQLError } from 'graphql';
import type { Server } from 'http';
import type { PgTestClient } from 'pgsql-test/test-client';
Expand All @@ -12,6 +16,8 @@ export interface ServerOptions {
port?: number;
/** Host to bind the server to (defaults to localhost) */
host?: string;
/** Trust the forwarded protocol when a test exercises an HTTPS callback. */
trustProxy?: boolean;
/**
* Which server to run this suite against:
* - `true` (default): the production `@constructive-io/graphql-server`, which
Expand Down Expand Up @@ -39,6 +45,8 @@ export interface ServerOptions {
* ```
*/
api?: Partial<ApiOptions>;
/** GraphQL-server OAuth options forwarded through the normal typed config path. */
oauth?: OAuthServerOptions;
}

/**
Expand Down
1 change: 1 addition & 0 deletions graphql/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"@constructive-io/graphql-env": "workspace:^",
"@constructive-io/graphql-types": "workspace:^",
"@constructive-io/llm-env": "workspace:^",
"@constructive-io/oauth": "workspace:^",
"@constructive-io/query-builder": "workspace:^",
"@constructive-io/s3-utils": "workspace:^",
"@constructive-io/url-domains": "workspace:^",
Expand Down
116 changes: 116 additions & 0 deletions graphql/server/src/auth/oauth/__tests__/router.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { errors } from '@constructive-io/errors';
import type { ConstructiveContext } from '@constructive-io/express-context';
import express from 'express';
import supertest from 'supertest';

import { createOAuthRouter } from '../router';
import {
completeProviderAuthentication,
createProviderAuthorizationUrl
} from '../service';

jest.mock('../service', () => ({
completeProviderAuthentication: jest.fn(),
createProviderAuthorizationUrl: jest.fn()
}));

const mockedAuthorize = jest.mocked(createProviderAuthorizationUrl);
const mockedComplete = jest.mocked(completeProviderAuthentication);
const opaqueState = 's'.repeat(43);

const makeApp = () => {
const app = express();
const context = {
useModule: jest.fn(async () => ({ privateSchema: 'tenant_sso_private' }))
} as unknown as ConstructiveContext;
app.use((req, _res, next) => {
req.constructive = context;
req.cookies = { csrf_token: 'b'.repeat(64) };
req.deviceToken = 'device-token';
req.api = {
dbname: 'tenant',
anonRole: 'anonymous',
roleName: 'anonymous',
schema: [],
authSettings: {
cookieDomain: '.example.com',
cookieSecure: false,
cookieHttponly: false
}
};
next();
});
app.use('/auth/oauth', createOAuthRouter({ requestTimeoutMs: 1000 }));
return app;
};

describe('OAuth HTTP routes', () => {
beforeEach(() => jest.clearAllMocks());

it('redirects authorize using only the server-restored adapter URL', async () => {
mockedAuthorize.mockResolvedValue(
'https://github.com/login/oauth/authorize?state=provider-state'
);

const response = await supertest(makeApp())
.get(`/auth/oauth/authorize?state=${opaqueState}`)
.expect(303);

expect(response.headers.location).toBe(
'https://github.com/login/oauth/authorize?state=provider-state'
);
expect(response.headers['cache-control']).toBe('no-store');
expect(response.headers['referrer-policy']).toBe('no-referrer');
});

it('sets a Secure HttpOnly host-only auth-center cookie after callback', async () => {
mockedComplete.mockResolvedValue({
credentialId: '00000000-0000-0000-0000-000000000001',
userId: '00000000-0000-0000-0000-000000000002',
accessToken: 'cnc_auth_center_token',
accessTokenExpiresAt: '2026-08-10T12:00:00.000Z',
isVerified: true,
totpEnabled: false,
continuationUrl:
'https://portal.example.com/auth/complete?handoff=handoff-code&site_state=site-state'
});

const response = await supertest(makeApp())
.get(`/auth/oauth/callback?state=${opaqueState}&code=provider-code`)
.expect(303);

const cookie = response.headers['set-cookie'][0] as string;
expect(cookie).toContain('constructive_session=cnc_auth_center_token');
expect(cookie).toContain('Secure');
expect(cookie).toContain('HttpOnly');
expect(cookie).not.toContain('Domain=');
expect(response.headers.location).toBe(
'https://portal.example.com/auth/complete?handoff=handoff-code&site_state=site-state'
);
expect(response.text).not.toContain('cnc_auth_center_token');
expect(mockedComplete).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ deviceToken: 'device-token' })
);
});

it('returns only a stable safe cancellation classification', async () => {
mockedComplete.mockRejectedValue(errors.OAUTH_AUTHORIZATION_CANCELLED());

const response = await supertest(makeApp())
.get(
`/auth/oauth/callback?state=${opaqueState}` +
'&error=access_denied&error_description=provider-secret-detail'
)
.expect(400);

expect(response.text).toContain('OAUTH_AUTHORIZATION_CANCELLED');
expect(response.text).not.toContain('provider-secret-detail');
expect(mockedComplete).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ providerReturnedError: true })
);
});
});
Loading