Files
twenty/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/user-session-cleanup-cron.integration-spec.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

252 lines
8.8 KiB
TypeScript

import { In } from 'typeorm';
import { getAppProviderByClassName } from 'test/integration/utils/get-app-provider-by-class-name.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import {
AppTokenEntity,
AppTokenType,
} from 'src/engine/core-modules/app-token/app-token.entity';
import { UserSessionEntity } from 'src/engine/core-modules/user-session/user-session.entity';
import { UserSessionRevokedReason } from 'src/engine/core-modules/user-session/types/user-session-revoked-reason.type';
import { generateUserSessionToken } from 'src/engine/core-modules/user-session/utils/generate-user-session-token.util';
import { hashUserSessionToken } from 'src/engine/core-modules/user-session/utils/hash-user-session-token.util';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { USER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util';
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
const daysAgo = (days: number): Date => new Date(Date.now() - days * ONE_DAY_MS);
const daysFromNow = (days: number): Date =>
new Date(Date.now() + days * ONE_DAY_MS);
type SeededSessionFixture = {
label: string;
tokenHash: string;
overrides: Partial<UserSessionEntity>;
};
// The retention boundary is 30 days after a session ended, where "ended"
// means absolute expiry or revocation. The cron runs against the same table
// the suite's other specs write to, so fixtures carry their own token hashes
// and are matched individually rather than by table counts.
describe('user session cleanup cron (integration)', () => {
const fixtures: SeededSessionFixture[] = [
{
label: 'expired beyond retention',
tokenHash: hashUserSessionToken(generateUserSessionToken()),
overrides: { expiresAt: daysAgo(31), lastActiveAt: daysAgo(31) },
},
{
label: 'revoked beyond retention',
tokenHash: hashUserSessionToken(generateUserSessionToken()),
overrides: {
expiresAt: daysFromNow(90),
lastActiveAt: daysAgo(31),
revokedAt: daysAgo(31),
revokedReason: UserSessionRevokedReason.UserSignOut,
},
},
{
label: 'active',
tokenHash: hashUserSessionToken(generateUserSessionToken()),
overrides: { expiresAt: daysFromNow(90), lastActiveAt: new Date() },
},
{
label: 'expired within retention',
tokenHash: hashUserSessionToken(generateUserSessionToken()),
overrides: { expiresAt: daysAgo(1), lastActiveAt: daysAgo(1) },
},
{
// Idle-expired sessions are unusable but carry no ended marker, so the
// current predicate retains them until absolute expiry. Pinned here as
// documented behavior; tightening the predicate should flip this case.
label: 'idle-expired but not absolutely expired',
tokenHash: hashUserSessionToken(generateUserSessionToken()),
overrides: { expiresAt: daysFromNow(60), lastActiveAt: daysAgo(120) },
},
];
const allFixtureHashes = fixtures.map((fixture) => fixture.tokenHash);
const findRemainingFixtureHashes = async (): Promise<string[]> => {
const rows = await getCoreRepository<UserSessionEntity>(
UserSessionEntity,
).findBy({ tokenHash: In(allFixtureHashes) });
return rows.map((row) => row.tokenHash);
};
beforeAll(async () => {
const userSessionRepository =
getCoreRepository<UserSessionEntity>(UserSessionEntity);
for (const fixture of fixtures) {
await userSessionRepository.save(
userSessionRepository.create({
tokenHash: fixture.tokenHash,
userId: USER_DATA_SEED_IDS.TIM,
workspaceId: null,
userWorkspaceId: null,
authProvider: AuthProviderEnum.Password,
...fixture.overrides,
}),
);
}
});
afterAll(async () => {
await getCoreRepository<UserSessionEntity>(UserSessionEntity).delete({
tokenHash: In(allFixtureHashes),
});
});
it('should delete sessions ended beyond retention and keep the rest', async () => {
const cleanupJob = getAppProviderByClassName<{
handle: () => Promise<void>;
}>('UserSessionCleanupCronJob');
await cleanupJob.handle();
const remainingHashes = await findRemainingFixtureHashes();
// Strict lookup: a mistyped label must fail the test here, not slip
// through as not.toContain(undefined).
const getFixtureTokenHash = (label: string): string => {
const fixture = fixtures.find((candidate) => candidate.label === label);
if (!fixture) {
throw new Error(`Unknown user session fixture: ${label}`);
}
return fixture.tokenHash;
};
const expectDeleted = (label: string) => {
expect(remainingHashes).not.toContain(getFixtureTokenHash(label));
};
const expectKept = (label: string) => {
expect(remainingHashes).toContain(getFixtureTokenHash(label));
};
expectDeleted('expired beyond retention');
expectDeleted('revoked beyond retention');
expectKept('active');
expectKept('expired within retention');
expectKept('idle-expired but not absolutely expired');
});
describe('refresh-token half', () => {
type SeededAppTokenFixture = {
label: string;
type: AppTokenType;
overrides: Partial<Pick<AppTokenEntity, 'expiresAt' | 'revokedAt'>>;
};
const appTokenFixtures: SeededAppTokenFixture[] = [
{
label: 'refresh token expired beyond retention',
type: AppTokenType.RefreshToken,
overrides: { expiresAt: daysAgo(31) },
},
{
label: 'refresh token revoked beyond retention',
type: AppTokenType.RefreshToken,
overrides: { expiresAt: daysFromNow(30), revokedAt: daysAgo(31) },
},
{
label: 'active refresh token',
type: AppTokenType.RefreshToken,
overrides: { expiresAt: daysFromNow(30) },
},
// The retention boundary itself: without these two, a regression that
// deleted every ended refresh token rather than only those past
// retention would still pass the assertions above.
{
label: 'refresh token expired within retention',
type: AppTokenType.RefreshToken,
overrides: { expiresAt: daysAgo(1) },
},
{
label: 'refresh token revoked within retention',
type: AppTokenType.RefreshToken,
overrides: { expiresAt: daysFromNow(30), revokedAt: daysAgo(1) },
},
{
// The type filter is the safety predicate: appToken is a shared table
// and other token types are routinely long-expired. Losing the filter
// would silently hard-delete password-reset or invitation history.
label: 'long-expired token of another type',
type: AppTokenType.PasswordResetToken,
overrides: { expiresAt: daysAgo(31) },
},
];
const seededAppTokenIds = new Map<string, string>();
beforeAll(async () => {
const appTokenRepository =
getCoreRepository<AppTokenEntity>(AppTokenEntity);
for (const fixture of appTokenFixtures) {
const saved = await appTokenRepository.save(
appTokenRepository.create({
userId: USER_DATA_SEED_IDS.TIM,
type: fixture.type,
value: '',
...fixture.overrides,
}),
);
seededAppTokenIds.set(fixture.label, saved.id);
}
});
afterAll(async () => {
await getCoreRepository<AppTokenEntity>(AppTokenEntity).delete({
id: In([...seededAppTokenIds.values()]),
});
});
it('should delete only refresh tokens ended beyond retention', async () => {
const cleanupJob = getAppProviderByClassName<{
handle: () => Promise<void>;
}>('UserSessionCleanupCronJob');
await cleanupJob.handle();
const remainingRows = await getCoreRepository<AppTokenEntity>(
AppTokenEntity,
).findBy({ id: In([...seededAppTokenIds.values()]) });
const remainingIds = remainingRows.map((row) => row.id);
const getSeededAppTokenId = (label: string): string => {
const id = seededAppTokenIds.get(label);
if (id === undefined) {
throw new Error(`Unknown app token fixture: ${label}`);
}
return id;
};
expect(remainingIds).not.toContain(
getSeededAppTokenId('refresh token expired beyond retention'),
);
expect(remainingIds).not.toContain(
getSeededAppTokenId('refresh token revoked beyond retention'),
);
expect(remainingIds).toContain(getSeededAppTokenId('active refresh token'));
expect(remainingIds).toContain(
getSeededAppTokenId('refresh token expired within retention'),
);
expect(remainingIds).toContain(
getSeededAppTokenId('refresh token revoked within retention'),
);
expect(remainingIds).toContain(
getSeededAppTokenId('long-expired token of another type'),
);
});
});
});