Files
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

320 lines
10 KiB
JSON

{
"name": "twenty-server",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"tags": ["scope:backend"],
"targets": {
"build": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "packages/twenty-server",
"parallel": false,
"commands": [
"rimraf dist",
"nest build --path ./tsconfig.build.json",
"mkdir -p dist/assets/twenty-client-sdk && cp ../twenty-client-sdk/package.json dist/assets/twenty-client-sdk/ && cp -r ../twenty-client-sdk/dist dist/assets/twenty-client-sdk/dist"
]
},
"dependsOn": ["^build"]
},
"test:integration:secure": {
"executor": "nx:run-commands",
"options": {
"cwd": "packages/twenty-server",
"commands": [
"NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=6144\" nx jest --config ./jest-integration-secure.config.ts --logHeapUsage"
]
},
"parallel": false
},
"test:integration": {
"executor": "nx:run-commands",
"options": {
"cwd": "packages/twenty-server",
"commands": [
"NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=6144\" nx jest --config ./jest-integration.config.ts --logHeapUsage"
]
},
"parallel": false,
"configurations": {
"with-db-reset": {
"cwd": "packages/twenty-server",
"commands": [
"NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=12288 --import tsx/esm\" nx database:reset > reset-logs.log && NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=6144\" nx jest --config ./jest-integration.config.ts --logHeapUsage"
]
}
}
},
"test:ci": {
"executor": "nx:run-commands",
"dependsOn": ["^build"],
"options": {
"cwd": "packages/twenty-server",
"command": "jest --config ./jest.config.mjs --ci --runInBand"
}
},
"build:packageJson": {
"executor": "@nx/js:tsc",
"options": {
"main": "packages/twenty-server/dist/main.js",
"tsConfig": "packages/twenty-server/tsconfig.json",
"outputPath": "packages/twenty-server/dist",
"updateBuildableProjectDepsInPackageJson": true
}
},
"typecheck": {
"dependsOn": ["^build"]
},
"start": {
"executor": "nx:run-commands",
"cache": false,
"dependsOn": ["^build"],
"options": {
"cwd": "packages/twenty-server",
"command": "rimraf dist && NODE_ENV=development nest start --watch"
}
},
"start:ci": {
"executor": "nx:run-commands",
"options": {
"cwd": "packages/twenty-server",
"command": "NODE_ENV=development nest start"
}
},
"start:ci-if-needed": {
"executor": "nx:run-commands",
"dependsOn": ["build"],
"options": {
"cwd": "packages/twenty-server",
"command": "curl -f -s http://localhost:3000/healthz > /dev/null 2>&1 && echo '✅ Server already running' || (echo '🚀 Server not running, starting...' && nohup nest start &)"
}
},
"start:debug": {
"executor": "nx:run-commands",
"dependsOn": ["^build"],
"options": {
"cwd": "packages/twenty-server",
"command": "rimraf dist && NODE_ENV=development nest start --watch --debug"
}
},
"reset:env": {
"executor": "nx:run-commands",
"inputs": ["{projectRoot}/.env.example"],
"outputs": ["{projectRoot}/.env"],
"cache": true,
"options": {
"cwd": "{projectRoot}",
"command": "cp .env.example .env"
}
},
"reset:env:e2e-testing-server": {
"executor": "nx:run-commands",
"inputs": ["{projectRoot}/.env.e2e-testing-server"],
"outputs": ["{projectRoot}/.env"],
"cache": true,
"options": {
"cwd": "{projectRoot}",
"command": "cp .env.e2e-testing-server .env"
}
},
"command": {
"executor": "nx:run-commands",
"dependsOn": ["build"],
"options": {
"cwd": "packages/twenty-server",
"command": "node dist/command/command.js"
}
},
"command-no-deps": {
"executor": "nx:run-commands",
"options": {
"cwd": "packages/twenty-server",
"command": "node dist/command/command.js"
}
},
"worker": {
"executor": "nx:run-commands",
"dependsOn": ["^build"],
"options": {
"cwd": "packages/twenty-server",
"command": "NODE_ENV=development nest start --watch --entryFile queue-worker/queue-worker"
}
},
"ts-node": {
"executor": "nx:run-commands",
"dependsOn": ["^build"],
"options": {
"cwd": "packages/twenty-server",
"command": "ts-node"
}
},
"ts-node-no-deps": {
"executor": "nx:run-commands",
"options": {
"cwd": "packages/twenty-server",
"command": "ts-node"
}
},
"ts-node-no-deps-transpile-only": {
"executor": "nx:run-commands",
"options": {
"cwd": "packages/twenty-server",
"command": "ts-node --transpile-only"
}
},
"lint": {
"executor": "nx:run-commands",
"cache": true,
"dependsOn": ["twenty-oxlint-rules:build"],
"inputs": [
"{projectRoot}/src/**/*.{ts,tsx}",
"{projectRoot}/tsconfig*.json",
"{workspaceRoot}/tsconfig.base.json",
"{projectRoot}/.oxlintrc.json",
"{workspaceRoot}/packages/twenty-oxlint-rules/dist/oxlint-plugin.mjs"
],
"options": {
"cwd": "{projectRoot}",
"command": "npx oxlint --type-aware -c .oxlintrc.json src/ && (npx oxfmt --check src/ || (echo 'ERROR: oxfmt formatting check failed! Fix with: npx nx lint twenty-server --configuration=fix' && false))"
},
"configurations": {
"ci": {},
"fix": {
"command": "npx oxlint --type-aware --fix -c .oxlintrc.json src/ && npx oxfmt src/"
}
}
},
"lint:diff-with-main": {
"executor": "nx:run-commands",
"dependsOn": ["twenty-oxlint-rules:build"],
"options": {
"cwd": "{projectRoot}",
"command": "FILES=$(git diff --name-only --relative --diff-filter=d main...HEAD -- src/ | grep -E '\\.(ts|tsx)$'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint --type-aware -c .oxlintrc.json $FILES && (npx oxfmt --check $FILES || (echo 'ERROR: oxfmt formatting check failed! Fix with: npx nx lint:diff-with-main twenty-server --configuration=fix' && false)))"
},
"configurations": {
"fix": {
"command": "FILES=$(git diff --name-only --relative --diff-filter=d main...HEAD -- src/ | grep -E '\\.(ts|tsx)$'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint --type-aware --fix -c .oxlintrc.json $FILES && npx oxfmt $FILES)"
}
}
},
"test": {},
"test:debug": {
"executor": "nx:run-commands",
"options": {
"cwd": "packages/twenty-server",
"command": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register ../../node_modules/.bin/jest --runInBand"
}
},
"jest": {
"executor": "nx:run-commands",
"options": {
"cwd": "packages/twenty-server",
"command": "jest"
}
},
"database:migrate": {
"executor": "nx:run-commands",
"dependsOn": ["build"],
"options": {
"cwd": "packages/twenty-server",
"command": "node dist/command/command.js run-instance-commands --force"
}
},
"database:init": {
"executor": "nx:run-commands",
"dependsOn": ["build"],
"options": {
"cwd": "packages/twenty-server",
"commands": [
"node dist/database/scripts/setup-db.js",
"nx database:migrate -- --include-slow"
],
"parallel": false
}
},
"database:migrate:generate": {
"executor": "nx:run-commands",
"dependsOn": ["build"],
"options": {
"cwd": "packages/twenty-server",
"command": "node dist/command/command.js generate:instance-command"
},
"cache": false
},
"generate:integration-test": {
"executor": "nx:run-commands",
"options": {
"cwd": "packages/twenty-server",
"commands": [
"nx ts-node-no-deps -- ./test/integration/graphql/codegen/index.ts"
],
"parallel": false
}
},
"database:reset": {
"executor": "nx:run-commands",
"dependsOn": ["build"],
"configurations": {
"no-seed": {
"cwd": "packages/twenty-server",
"commands": [
"node dist/database/scripts/truncate-db.js",
"nx database:init",
"nx command-no-deps -- cache:flush"
],
"parallel": false
},
"seed": {
"cwd": "packages/twenty-server",
"commands": [
"node dist/database/scripts/truncate-db.js",
"nx database:init",
"nx command-no-deps -- cache:flush",
"nx command-no-deps -- workspace:seed:dev"
],
"parallel": false
}
},
"defaultConfiguration": "seed"
},
"clickhouse:migrate": {
"executor": "nx:run-commands",
"options": {
"cwd": "packages/twenty-server",
"command": "nx ts-node-no-deps-transpile-only -- src/database/clickHouse/migrations/run-migrations.ts"
}
},
"clickhouse:seed": {
"executor": "nx:run-commands",
"options": {
"cwd": "packages/twenty-server",
"command": "nx ts-node-no-deps-transpile-only -- src/database/clickHouse/seeds/run-seeds.ts"
}
},
"lingui:extract": {
"executor": "nx:run-commands",
"dependsOn": ["^build"],
"options": {
"cwd": "{projectRoot}",
"command": "lingui extract --overwrite --clean"
}
},
"lingui:compile": {
"executor": "nx:run-commands",
"dependsOn": ["^build"],
"options": {
"cwd": "{projectRoot}",
"command": "lingui compile --typescript"
}
},
"version:bump": {
"executor": "nx:run-commands",
"options": {
"cwd": "{projectRoot}",
"command": "npx tsx scripts/bump-version.ts"
}
}
}
}