Files
twenty/packages/twenty-server/jest-integration.config.ts
T
Paul Rastoin 997b2c38de Add cookie-session integration test suite (#23715)
Stacked on #23642. Integration suite for the cookie-session surface,
organized as one successful/failing spec pair per stage of the session
lifecycle. 14 spec files, ~36 tests, all over real HTTP against the
booted app.

## Coverage by stage

**1. Session creation on auth exchanges**
(`successful-`/`failing-session-creation`)
Flag gating (default off: tokens, no cookie, no row); httpOnly cookie
snapshot with 180d expiry window; SHA-256 hash-at-rest with the row
bound to the apple seed workspace; scripted sign-ins without an Origin
header still get the cookie; login-CSRF refuses the cookie for
disallowed origins while returning the token pair; sign-in over an
existing session revokes it as `SUPERSEDED`; a failed credentials
exchange mints nothing.

**2. Cookie delivery** (`successful-session-cookie-delivery`,
`secure-deployment-session-cookie`)
The runtime side door (`AUTH_COOKIE_SAME_SITE=none` forces the secure
path) pins the `__Host-`/`Secure`/`SameSite=None` variant in the default
CI run. The exact production combination (`__Host-`, `Secure`,
`SameSite=Lax`) is covered by a dedicated spec that requires the app to
boot with an https `SERVER_URL`: the secure branch is decided by config,
never the transport, so no TLS is needed. It skips itself on plain-http
boots; CI runs it as an extra step on one shard with
`SERVER_URL=https://localhost:3000`, including the `__Host-` round-trip
and the plain-cookie-name downgrade refusal.

**3. Per-request authentication and the CSRF read gate**
(`successful-`/`failing-session-cookie-authentication`)
A cookie-only request resolves the seeded user; a `sess_` token
presented as Bearer is rejected; cookie-authenticated unsafe requests
with a disallowed or missing Origin get 403 `CSRF_ORIGIN_MISMATCH`; an
unknown session token is unauthenticated and its dead cookie is cleared.

**3b. Workspace binding** (`successful-session-workspace-binding`)
Tim signs into both seeded workspaces (apple and yc); each session row
is bound to the workspace its exchange selected (`workspaceId` and
`userWorkspaceId` pinned to the seed ids), and each cookie resolves to
its own workspace context, with no request-side input able to pivot a
session across workspaces.

**3c. Credentialed CORS** (`cors-credentialed-origins`)
Allowlisted origins get the reflected `Access-Control-Allow-Origin` plus
`Access-Control-Allow-Credentials: true` and `Vary: Origin`, preflight
included; other origins keep the public wildcard. See tooling notes:
this surface was previously untestable.

**4. Sessions API** (`successful-`/`failing-user-sessions-api`)
`currentUserSessions` marks exactly the presented session as current;
`revokeUserSession` revokes by id (`USER_REVOKED`) and drops it from the
listing; `revokeAllOtherUserSessions` spares the presented session;
cross-user revocation and unauthenticated listing are refused.

**5. Exits** (`successful-sign-out`, `failing-session-expiration`)
`signOut` revokes with `USER_SIGN_OUT`, clears the cookie, and reuse
fails immediately (cache invalidated, not TTL-bound); a cookie-less
sign-out clears nothing, so a cross-site POST cannot log a visitor out;
absolute-lifetime and idle-timeout expiry both reject and clear the
cookie.

**7. Cleanup cron** (`user-session-cleanup-cron`)
Both halves run in-process against fixtures spanning the 30d retention
boundary. Sessions: expired/revoked-beyond-retention deleted; active,
recently-expired, and idle-expired rows survive (the idle case pins the
known predicate gap). Refresh tokens: old-expired and old-revoked
deleted, fresh kept, and a long-expired token of another type survives,
pinning the `type` filter that keeps the shared `appToken` table safe
from the hard-delete.

Not covered here by design: the impersonation park/restore sub-funnel
(stage 6, follow-up) and the client-side funnel (stage 8, front-end
scope). Password-change revocation and the renewal bridge are also left
to follow-ups.

## How the flag is flipped

`AUTH_COOKIE_SESSIONS_ENABLED` (and `AUTH_COOKIE_SAME_SITE` for the
secure side door) are toggled at runtime through the admin panel config
API, reusing the `twenty-config` test utils: `DatabaseConfigDriver.set`
updates its cache synchronously and `TwentyConfigService` consults the
DB driver before the env driver. No `.env.test` change, no app reboot,
runs in the default CI environment without the `ci:auth-cookie-sessions`
label. `SERVER_URL` is env-only, hence the dedicated CI step for the
production secure-deployment spec.

## Shared tooling changes

- **`applyCredentialedCors` extraction (src change)**: the integration
harness booted with Nest's wildcard `cors: true`, not the
credentialed-allowlist setup living in `main.ts`, so the CORS surface
was untestable by construction. The setup moved into
`applyCredentialedCors`, now called by both the production bootstrap and
`createApp`, making the harness's CORS behavior the deployed one.
Behavior-neutral for production.
- `makeMetadataAPIRequest` accepts an explicit `null` token for
unauthenticated requests. Passing `undefined` silently fell back to the
default admin token (parameter defaults apply to `undefined`), which
made supposedly public requests Bearer-authenticated, bypassing both the
cookie auth path and the CSRF middleware. Existing call sites are
unaffected.
- The `GetLoginTokenFromCredentials` / `GetAuthTokensFromLoginToken`
documents moved into shared query factories; the workspace-origin
builder is extracted and generalized to any seeded subdomain
(`buildWorkspaceOriginForSubdomain`, reused by
`getAccessTokenForCredentials`).
- Suite-local helpers: `signInWithCookieCapture` (full credentials
exchange returning the raw supertest response, with a
`workspaceSubdomain` option), `postMetadataOperationWithHeaders`
(Origin/Cookie header control), cookie extraction for both cookie names,
clearing-cookie detection, snapshot normalization (token and expiry
redacted), and shared `ALLOWED_ORIGIN`/`DISALLOWED_ORIGIN` constants
derived from `FRONTEND_URL`.

Verified locally: full suite green in CI mode on both plain-http and
https-`SERVER_URL` boots; oxlint and tsc clean.

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
2026-08-04 10:05:12 +00:00

99 lines
3.5 KiB
TypeScript

import dotenv from 'dotenv';
import { type JestConfigWithTsJest, pathsToModuleNameMapper } from 'ts-jest';
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
import testTokens from './test/integration/constants/test-tokens.json';
// Load .env vars at jest boot time
if (process.env.NODE_ENV === 'test') {
dotenv.config({ path: '.env.test', override: true });
} else {
dotenv.config({ path: '.env', override: true });
}
const isBillingEnabled = process.env.IS_BILLING_ENABLED === 'true';
const isClickhouseEnabled = process.env.CLICKHOUSE_URL !== undefined;
const tsConfig = require('./tsconfig.json');
const jestConfig: JestConfigWithTsJest = {
// For more information please have a look to official docs https://jestjs.io/docs/configuration/#prettierpath-string
// Prettier v3 should be supported in jest v30 https://github.com/jestjs/jest/releases/tag/v30.0.0-alpha.1
prettierPath: null,
silent: false,
errorOnDeprecated: true,
maxConcurrency: 1,
moduleFileExtensions: ['js', 'mjs', 'json', 'ts'],
rootDir: '.',
testEnvironment: 'node',
testPathIgnorePatterns: [
...(isBillingEnabled ? [] : ['<rootDir>/test/integration/billing']),
...(isClickhouseEnabled ? [] : ['<rootDir>/test/integration/audit']),
// Requires an app booted as a secure deployment; run through
// jest-integration-secure.config.ts (nx test:integration:secure).
'<rootDir>/test/integration/secure-deployment',
],
testRegex: '\\.integration-spec\\.ts$',
modulePathIgnorePatterns: ['<rootDir>/dist'],
globalSetup: '<rootDir>/test/integration/utils/setup-test.ts',
globalTeardown: '<rootDir>/test/integration/utils/teardown-test.ts',
setupFilesAfterEnv: ['<rootDir>/test/integration/utils/setup-wait-for-all-jobs-between-tests.ts'],
testTimeout: 20000,
maxWorkers: 1,
// jsdom 29 and msw ship ESM-only transitive deps (parse5, entities,
// tough-cookie, @exodus/bytes via html-encoding-sniffer, @csstools/@asamuzakjp
// css engine, @mswjs/interceptors and friends); let swc transform them
// (and .mjs below) so jest can require them.
transformIgnorePatterns: [
'/node_modules/(?!(jsdom|html-encoding-sniffer|whatwg-encoding|@exodus|parse5|entities|tough-cookie|@csstools|@asamuzakjp|msw|@mswjs|until-async|@bundled-es-modules|@open-draft|strict-event-emitter|headers-polyfill|outvariant|is-node-process|path-to-regexp|statuses|cookie|digest-fetch|md5|email-reply-parser)/)',
],
transform: {
'^.+\\.(t|j|mj)s$': [
'@swc/jest',
{
jsc: {
parser: {
syntax: 'typescript',
tsx: false,
decorators: true,
},
transform: {
decoratorMetadata: true,
},
baseUrl: '.',
paths: {
'src/*': ['./src/*'],
'test/*': ['./test/*'],
},
experimental: {
plugins: [
[
'@lingui/swc-plugin',
{
stripNonEssentialFields: false,
},
],
],
},
},
},
],
},
moduleNameMapper: {
...pathsToModuleNameMapper(tsConfig.compilerOptions.paths, {
prefix: '<rootDir>/',
}),
'^test/(.*)$': '<rootDir>/test/$1',
},
globals: {
APP_PORT: 4000,
NODE_ENV: NodeEnvironment.TEST,
// Test tokens are loaded from a shared JSON file to ensure consistency
// with CI workflows and other tools that need these tokens
...testTokens,
},
};
export default jestConfig;