snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 3s

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`successful session cookie delivery on a secure deployment (integration) should deliver the host-locked secure cookie variant: secure-session-cookie 1`] = `"__Host-twenty-session=sess_<redacted>; Path=/; Expires=<redacted>; HttpOnly; Secure; SameSite=None"`;

View File

@@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`successful user session creation on auth exchanges (integration) should mint a session and set an httpOnly cookie on sign-in from an allowed origin: session-cookie 1`] = `"twenty-session=sess_<redacted>; Path=/; Expires=<redacted>; HttpOnly; SameSite=Lax"`;

View File

@@ -0,0 +1,6 @@
// The secure/insecure cookie branch is decided by configuration, never by the
// transport: isSecureDeployment() reads SERVER_URL, which is env-only and so
// cannot be flipped at runtime the way the SameSite=None side door can.
export const IS_SECURE_DEPLOYMENT = (process.env.SERVER_URL ?? '').startsWith(
'https://',
);

View File

@@ -0,0 +1,21 @@
import { isNonEmptyString } from '@sniptt/guards';
// The cookie-issuance gate and the CSRF middleware allowlist FRONTEND_URL
// (resolveAllowedCredentialedOrigins), which .env.test sets to
// http://localhost:3001. Deriving it here keeps the suite honest if the test
// environment ever moves, and normalizing to URL.origin keeps the header
// aligned with the server-side comparison when FRONTEND_URL carries a path or
// trailing slash.
const resolveAllowedOrigin = (): string => {
const frontendUrl = process.env.FRONTEND_URL;
if (!isNonEmptyString(frontendUrl)) {
return 'http://localhost:3001';
}
return new URL(frontendUrl).origin;
};
export const ALLOWED_ORIGIN = resolveAllowedOrigin();
export const DISALLOWED_ORIGIN = 'https://attacker.example.com';

View File

@@ -0,0 +1,57 @@
import request from 'supertest';
import {
ALLOWED_ORIGIN,
DISALLOWED_ORIGIN,
} from 'test/integration/graphql/suites/auth/user-sessions/constants/session-origins.constants';
const SERVER_URL = `http://localhost:${APP_PORT}`;
// The browser-side half of cross-origin cookie support, sibling of the CSRF
// middleware: allowlisted origins get credentialed CORS (reflected origin plus
// allow-credentials), everything else keeps the public wildcard. Runs
// unconditionally: the CORS setup does not depend on the cookie-sessions flag.
describe('credentialed CORS origins (integration)', () => {
it('should reflect an allowlisted origin with credentials and Vary: Origin', async () => {
const response = await request(SERVER_URL)
.get('/client-config')
.set('Origin', ALLOWED_ORIGIN)
.expect(200);
expect(response.headers['access-control-allow-origin']).toBe(
ALLOWED_ORIGIN,
);
expect(response.headers['access-control-allow-credentials']).toBe('true');
// Without Vary: Origin a shared cache could serve the wildcard response
// to an allowlisted origin, whose credentialed request the browser would
// then reject.
expect(response.headers.vary).toContain('Origin');
});
it('should answer a preflight for an allowlisted origin with credentials', async () => {
const response = await request(SERVER_URL)
.options('/metadata')
.set('Origin', ALLOWED_ORIGIN)
.set('Access-Control-Request-Method', 'POST')
.set('Access-Control-Request-Headers', 'content-type');
expect(response.status).toBeLessThan(300);
expect(response.headers['access-control-allow-origin']).toBe(
ALLOWED_ORIGIN,
);
expect(response.headers['access-control-allow-credentials']).toBe('true');
// Preflights are cacheable, so this is the response a shared cache is most
// likely to reuse across origins.
expect(response.headers.vary).toContain('Origin');
});
it('should fall back to the public wildcard for a non-allowlisted origin', async () => {
const response = await request(SERVER_URL)
.get('/client-config')
.set('Origin', DISALLOWED_ORIGIN)
.expect(200);
expect(response.headers['access-control-allow-origin']).toBe('*');
expect(response.headers.vary).toContain('Origin');
});
});

View File

@@ -0,0 +1,87 @@
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import {
extractSessionCookie,
hasClearingCookie,
postMetadataOperationWithHeaders,
signInWithCookieCapture,
} from 'test/integration/graphql/suites/auth/user-sessions/utils/sign-in-with-cookie-capture.util';
import { currentUserIdentityQueryFactory } from 'test/integration/graphql/suites/auth/user-sessions/utils/user-session-operations.util';
import { generateUserSessionToken } from 'src/engine/core-modules/user-session/utils/generate-user-session-token.util';
import {
ALLOWED_ORIGIN,
DISALLOWED_ORIGIN,
} from 'test/integration/graphql/suites/auth/user-sessions/constants/session-origins.constants';
describe('failing session cookie authentication (integration)', () => {
let sessionToken: string;
let sessionCookieName: string;
let sessionCookieHeader: string;
beforeAll(async () => {
const signInResponse = await signInWithCookieCapture({
originHeader: ALLOWED_ORIGIN,
});
const sessionCookie = extractSessionCookie(signInResponse);
if (!sessionCookie) {
throw new Error('Expected a session cookie from sign-in');
}
sessionToken = sessionCookie.sessionToken;
sessionCookieName = sessionCookie.cookieName;
sessionCookieHeader = sessionCookie.cookieHeader;
});
it('should reject a session token presented as a Bearer header', async () => {
// Cookie-only by design: accepting sess_ tokens as Bearer would reopen
// the XSS-exfiltration surface cookie sessions close.
const response = await makeMetadataAPIRequest(
currentUserIdentityQueryFactory(),
sessionToken,
).expect(200);
expect(response.body.data).toBeUndefined();
expect(response.body.errors).toBeDefined();
});
it('should return 403 on a cookie-authenticated request from a disallowed origin', async () => {
const response = await postMetadataOperationWithHeaders(
currentUserIdentityQueryFactory(),
{
originHeader: DISALLOWED_ORIGIN,
cookieHeader: sessionCookieHeader,
},
403,
);
expect(response.body.error).toBe('CSRF_ORIGIN_MISMATCH');
});
it('should return 403 on a cookie-authenticated request without an Origin header, failing closed', async () => {
const response = await postMetadataOperationWithHeaders(
currentUserIdentityQueryFactory(),
{ cookieHeader: sessionCookieHeader },
403,
);
expect(response.body.error).toBe('CSRF_ORIGIN_MISMATCH');
});
it('should treat an unknown session token as unauthenticated and clear the dead cookie', async () => {
const unknownSessionToken = generateUserSessionToken();
const response = await postMetadataOperationWithHeaders(
currentUserIdentityQueryFactory(),
{
originHeader: ALLOWED_ORIGIN,
cookieHeader: `${sessionCookieName}=${unknownSessionToken}`,
},
);
expect(response.body.errors).toBeDefined();
expect(hasClearingCookie(response)).toBe(true);
});
});

View File

@@ -0,0 +1,129 @@
import * as jwt from 'jsonwebtoken';
import { buildAppleWorkspaceOrigin } from 'test/integration/graphql/utils/build-apple-workspace-origin.util';
import { forgeLegacyHs256Token } from 'test/integration/graphql/utils/forge-legacy-hs256-token.util';
import { getAuthTokensFromLoginTokenQueryFactory } from 'test/integration/graphql/utils/get-auth-tokens-from-login-token.query-factory.util';
import { getLoginTokenFromCredentialsQueryFactory } from 'test/integration/graphql/utils/get-login-token-from-credentials.query-factory.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import {
extractSessionCookie,
hasClearingCookie,
postMetadataOperationWithHeaders,
signInWithCookieCapture,
} from 'test/integration/graphql/suites/auth/user-sessions/utils/sign-in-with-cookie-capture.util';
import { type LoginTokenJwtPayload } from 'src/engine/core-modules/auth/types/login-token-jwt-payload.type';
import { UserSessionEntity } from 'src/engine/core-modules/user-session/user-session.entity';
import { hashUserSessionToken } from 'src/engine/core-modules/user-session/utils/hash-user-session-token.util';
import {
ALLOWED_ORIGIN,
DISALLOWED_ORIGIN,
} from 'test/integration/graphql/suites/auth/user-sessions/constants/session-origins.constants';
describe('failing user session creation on auth exchanges (integration)', () => {
it('should fail the exchange when a disallowed origin cannot receive its required session cookie', async () => {
const userSessionRepository =
getCoreRepository<UserSessionEntity>(UserSessionEntity);
const sessionCountBefore = await userSessionRepository.count();
const response = await signInWithCookieCapture({
originHeader: DISALLOWED_ORIGIN,
});
expect(response.body.errors).toBeDefined();
expect(response.body.data?.getAuthTokensFromLoginToken).toBeFalsy();
expect(extractSessionCookie(response)).toBeUndefined();
const sessionCountAfter = await userSessionRepository.count();
expect(sessionCountAfter).toBe(sessionCountBefore);
});
it('should preserve the presented session when replacement session creation fails', async () => {
const existingSessionResponse = await signInWithCookieCapture({
originHeader: ALLOWED_ORIGIN,
});
const existingSessionCookie = extractSessionCookie(existingSessionResponse);
if (!existingSessionCookie) {
throw new Error('Expected an existing session cookie');
}
const workspaceOrigin = buildAppleWorkspaceOrigin();
const loginTokenResponse = await postMetadataOperationWithHeaders(
getLoginTokenFromCredentialsQueryFactory({
email: 'tim@apple.dev',
password: 'tim@apple.dev',
origin: workspaceOrigin,
}),
{
originHeader: ALLOWED_ORIGIN,
cookieHeader: existingSessionCookie.cookieHeader,
},
);
const validLoginToken =
loginTokenResponse.body.data.getLoginTokenFromCredentials.loginToken
.token;
const validPayload = jwt.decode(validLoginToken) as LoginTokenJwtPayload;
const loginTokenWithoutProvider = forgeLegacyHs256Token(
{
sub: validPayload.sub,
type: validPayload.type,
workspaceId: validPayload.workspaceId,
},
validPayload.workspaceId,
);
const failedExchangeResponse = await postMetadataOperationWithHeaders(
getAuthTokensFromLoginTokenQueryFactory({
loginToken: loginTokenWithoutProvider,
origin: workspaceOrigin,
}),
{
originHeader: ALLOWED_ORIGIN,
cookieHeader: existingSessionCookie.cookieHeader,
},
);
expect(failedExchangeResponse.body.errors).toBeDefined();
expect(
failedExchangeResponse.body.data?.getAuthTokensFromLoginToken,
).toBeFalsy();
expect(extractSessionCookie(failedExchangeResponse)).toBeUndefined();
expect(hasClearingCookie(failedExchangeResponse)).toBe(false);
const existingSession = await getCoreRepository<UserSessionEntity>(
UserSessionEntity,
).findOneBy({
tokenHash: hashUserSessionToken(existingSessionCookie.sessionToken),
});
expect(existingSession?.revokedAt).toBeNull();
});
it('should mint nothing when the credentials exchange itself fails', async () => {
const userSessionRepository =
getCoreRepository<UserSessionEntity>(UserSessionEntity);
const sessionCountBefore = await userSessionRepository.count();
const response = await makeMetadataAPIRequest(
getLoginTokenFromCredentialsQueryFactory({
email: 'tim@apple.dev',
password: 'wrong-password',
origin: buildAppleWorkspaceOrigin(),
}),
null,
)
.set('Origin', ALLOWED_ORIGIN)
.expect(200);
expect(response.body.errors).toBeDefined();
expect(extractSessionCookie(response)).toBeUndefined();
const sessionCountAfter = await userSessionRepository.count();
expect(sessionCountAfter).toBe(sessionCountBefore);
});
});

View File

@@ -0,0 +1,73 @@
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import {
extractSessionCookie,
hasClearingCookie,
postMetadataOperationWithHeaders,
signInWithCookieCapture,
} from 'test/integration/graphql/suites/auth/user-sessions/utils/sign-in-with-cookie-capture.util';
import { currentUserIdentityQueryFactory } from 'test/integration/graphql/suites/auth/user-sessions/utils/user-session-operations.util';
import { UserSessionEntity } from 'src/engine/core-modules/user-session/user-session.entity';
import { hashUserSessionToken } from 'src/engine/core-modules/user-session/utils/hash-user-session-token.util';
import { ALLOWED_ORIGIN } from 'test/integration/graphql/suites/auth/user-sessions/constants/session-origins.constants';
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
// Sessions created here are never resolved before the row is tampered with,
// so the read-through cache holds no entry and the checks hit the database.
describe('failing session expiration (integration)', () => {
const signInAndTamper = async (
tamper: Partial<Pick<UserSessionEntity, 'expiresAt' | 'lastActiveAt'>>,
): Promise<string> => {
const signInResponse = await signInWithCookieCapture({
originHeader: ALLOWED_ORIGIN,
});
const sessionCookie = extractSessionCookie(signInResponse);
if (!sessionCookie) {
throw new Error('Expected a session cookie from sign-in');
}
await getCoreRepository<UserSessionEntity>(UserSessionEntity).update(
{ tokenHash: hashUserSessionToken(sessionCookie.sessionToken) },
tamper,
);
return sessionCookie.cookieHeader;
};
const expectCookieRejected = async (
sessionCookieHeader: string,
): Promise<void> => {
const response = await postMetadataOperationWithHeaders(
currentUserIdentityQueryFactory(),
{
originHeader: ALLOWED_ORIGIN,
cookieHeader: sessionCookieHeader,
},
);
expect(response.body.errors).toBeDefined();
expect(hasClearingCookie(response)).toBe(true);
};
it('should reject a session past its absolute lifetime and clear the cookie', async () => {
const sessionCookieHeader = await signInAndTamper({
expiresAt: new Date(Date.now() - ONE_DAY_MS),
});
await expectCookieRejected(sessionCookieHeader);
});
it('should reject a session past the idle timeout and clear the cookie', async () => {
// 31 days idle exceeds the 30d SESSION_IDLE_TIMEOUT while the absolute
// 180d lifetime is still far in the future.
const sessionCookieHeader = await signInAndTamper({
lastActiveAt: new Date(Date.now() - 31 * ONE_DAY_MS),
});
await expectCookieRejected(sessionCookieHeader);
});
});

View File

@@ -0,0 +1,66 @@
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import {
extractSessionCookie,
postMetadataOperationWithHeaders,
signInWithCookieCapture,
} from 'test/integration/graphql/suites/auth/user-sessions/utils/sign-in-with-cookie-capture.util';
import {
currentUserSessionsQueryFactory,
revokeUserSessionQueryFactory,
} from 'test/integration/graphql/suites/auth/user-sessions/utils/user-session-operations.util';
import { UserSessionEntity } from 'src/engine/core-modules/user-session/user-session.entity';
import { hashUserSessionToken } from 'src/engine/core-modules/user-session/utils/hash-user-session-token.util';
import { ALLOWED_ORIGIN } from 'test/integration/graphql/suites/auth/user-sessions/constants/session-origins.constants';
describe('failing user sessions API (integration)', () => {
let timSessionId: string;
beforeAll(async () => {
const signInResponse = await signInWithCookieCapture({
originHeader: ALLOWED_ORIGIN,
});
const sessionCookie = extractSessionCookie(signInResponse);
if (!sessionCookie) {
throw new Error('Expected a session cookie from sign-in');
}
const sessionRow = await getCoreRepository<UserSessionEntity>(
UserSessionEntity,
).findOneBy({ tokenHash: hashUserSessionToken(sessionCookie.sessionToken) });
if (!sessionRow) {
throw new Error('Expected a persisted session row');
}
timSessionId = sessionRow.id;
});
it('should reject an unauthenticated sessions listing', async () => {
const response = await postMetadataOperationWithHeaders(
currentUserSessionsQueryFactory(),
{ originHeader: ALLOWED_ORIGIN },
);
expect(response.body.errors).toBeDefined();
});
it("should refuse to revoke another user's session", async () => {
// Default token authenticates Jane, a different seeded user than Tim.
const response = await makeMetadataAPIRequest(
revokeUserSessionQueryFactory({ userSessionId: timSessionId }),
).expect(200);
expect(response.body.errors).toBeDefined();
const sessionRow = await getCoreRepository<UserSessionEntity>(
UserSessionEntity,
).findOneBy({ id: timSessionId });
expect(sessionRow?.revokedAt).toBeNull();
});
});

View File

@@ -0,0 +1,45 @@
import {
extractSessionCookie,
postMetadataOperationWithHeaders,
signInWithCookieCapture,
} from 'test/integration/graphql/suites/auth/user-sessions/utils/sign-in-with-cookie-capture.util';
import { currentUserIdentityQueryFactory } from 'test/integration/graphql/suites/auth/user-sessions/utils/user-session-operations.util';
import { USER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util';
import { ALLOWED_ORIGIN } from 'test/integration/graphql/suites/auth/user-sessions/constants/session-origins.constants';
describe('successful session cookie authentication (integration)', () => {
let sessionCookieHeader: string;
beforeAll(async () => {
const signInResponse = await signInWithCookieCapture({
originHeader: ALLOWED_ORIGIN,
});
const sessionCookie = extractSessionCookie(signInResponse);
if (!sessionCookie) {
throw new Error('Expected a session cookie from sign-in');
}
sessionCookieHeader = sessionCookie.cookieHeader;
});
it('should authenticate a request carrying only the session cookie', async () => {
const response = await postMetadataOperationWithHeaders(
currentUserIdentityQueryFactory(),
{
originHeader: ALLOWED_ORIGIN,
cookieHeader: sessionCookieHeader,
},
);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.currentUser).toMatchObject({
id: USER_DATA_SEED_IDS.TIM,
email: 'tim@apple.dev',
});
});
});

View File

@@ -0,0 +1,49 @@
import {
extractSessionCookie,
normalizeSessionCookieForSnapshot,
signInWithCookieCapture,
} from 'test/integration/graphql/suites/auth/user-sessions/utils/sign-in-with-cookie-capture.util';
import { USER_SESSION_COOKIE_NAME } from 'src/engine/core-modules/user-session/constants/user-session-cookie-name.constant';
import { USER_SESSION_SECURE_COOKIE_NAME } from 'src/engine/core-modules/user-session/constants/user-session-secure-cookie-name.constant';
import { ALLOWED_ORIGIN } from 'test/integration/graphql/suites/auth/user-sessions/constants/session-origins.constants';
import { setupDatabaseConfigOverrideForSuite } from 'test/integration/graphql/suites/auth/user-sessions/utils/setup-database-config-override.util';
// SameSite=None forces Secure (browsers reject the combination without it),
// which is the one secure-deployment trigger reachable at runtime: SERVER_URL
// stays plain http in .env.test. The production combination (https SERVER_URL
// with the SameSite=Lax default) is covered by
// secure-deployment-session-cookie.integration-spec.ts under a dedicated app
// boot.
describe('successful session cookie delivery on a secure deployment (integration)', () => {
setupDatabaseConfigOverrideForSuite('AUTH_COOKIE_SAME_SITE', 'none');
it('should deliver the host-locked secure cookie variant', async () => {
const response = await signInWithCookieCapture({
originHeader: ALLOWED_ORIGIN,
});
expect(
extractSessionCookie(response, USER_SESSION_COOKIE_NAME),
).toBeUndefined();
const secureSessionCookie = extractSessionCookie(
response,
USER_SESSION_SECURE_COOKIE_NAME,
);
expect(secureSessionCookie).toBeDefined();
if (!secureSessionCookie) {
throw new Error('Expected a secure session cookie');
}
// __Host- requires Secure, Path=/ and no Domain; browsers enforce the
// prefix contract, so the snapshot pins host-only scoping.
expect(
normalizeSessionCookieForSnapshot(secureSessionCookie.rawCookie),
).toMatchSnapshot('secure-session-cookie');
});
});

View File

@@ -0,0 +1,163 @@
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import {
extractSessionCookie,
normalizeSessionCookieForSnapshot,
signInWithCookieCapture,
} from 'test/integration/graphql/suites/auth/user-sessions/utils/sign-in-with-cookie-capture.util';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
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 { hashUserSessionToken } from 'src/engine/core-modules/user-session/utils/hash-user-session-token.util';
import { ALLOWED_ORIGIN } from 'test/integration/graphql/suites/auth/user-sessions/constants/session-origins.constants';
import { IS_SECURE_DEPLOYMENT } from 'test/integration/graphql/suites/auth/user-sessions/constants/is-secure-deployment.constant';
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
const SESSION_ABSOLUTE_LIFETIME_MS = 180 * ONE_DAY_MS;
describe('successful user session creation on auth exchanges (integration)', () => {
let firstSessionToken: string;
let firstSessionCookieHeader: string;
it('should mint a session and set an httpOnly cookie on sign-in from an allowed origin', async () => {
const response = await signInWithCookieCapture({
originHeader: ALLOWED_ORIGIN,
});
expect(
response.body.data.getAuthTokensFromLoginToken.tokens
.accessOrWorkspaceAgnosticToken.token,
).toBeDefined();
const sessionCookie = extractSessionCookie(response);
expect(sessionCookie).toBeDefined();
if (!sessionCookie) {
throw new Error('Expected a session cookie');
}
firstSessionToken = sessionCookie.sessionToken;
firstSessionCookieHeader = sessionCookie.cookieHeader;
// Pins name, sess_ prefix, attribute list and order in one place. The
// absences matter as much as the presences: no Secure and no __Host-
// (plain-http test deployment), and no Domain, which is what makes the
// cookie host-only so browsers never send it to sibling workspace
// subdomains. The secure boot pins its own shape in
// secure-deployment-session-cookie.integration-spec.ts, so this snapshot
// only applies to the insecure one.
if (!IS_SECURE_DEPLOYMENT) {
expect(
normalizeSessionCookieForSnapshot(sessionCookie.rawCookie),
).toMatchSnapshot('session-cookie');
}
const expiresAttribute = sessionCookie.rawCookie
.split(';')
.map((part) => part.trim())
.find((part) => part.startsWith('Expires='));
expect(expiresAttribute).toBeDefined();
const cookieLifetimeMs =
new Date(expiresAttribute!.slice('Expires='.length)).getTime() -
Date.now();
expect(cookieLifetimeMs).toBeGreaterThan(
SESSION_ABSOLUTE_LIFETIME_MS - ONE_DAY_MS,
);
expect(cookieLifetimeMs).toBeLessThan(
SESSION_ABSOLUTE_LIFETIME_MS + ONE_DAY_MS,
);
});
it('should store only the token hash at rest, with the expected session shape', async () => {
const userSessionRepository =
getCoreRepository<UserSessionEntity>(UserSessionEntity);
const storedByRawToken = await userSessionRepository.findOneBy({
tokenHash: firstSessionToken,
});
expect(storedByRawToken).toBeNull();
const session = await userSessionRepository.findOneBy({
tokenHash: hashUserSessionToken(firstSessionToken),
});
expect(session).not.toBeNull();
if (!session) {
throw new Error('Expected a persisted session');
}
expect(session).toMatchObject({
userId: expect.any(String),
// Bound to the workspace the GraphQL origin selected: the server-side
// half of workspace scoping, alongside the host-only cookie.
workspaceId: SEED_APPLE_WORKSPACE_ID,
userWorkspaceId: expect.any(String),
isImpersonating: false,
revokedAt: null,
revokedReason: null,
// expect.any(Date) would fail: the entity's Date comes from the app's
// vm context, so it is not an instanceof the test context's Date.
lastActiveAt: expect.anything(),
});
const sessionLifetimeMs =
session.expiresAt.getTime() - session.createdAt.getTime();
expect(sessionLifetimeMs).toBeGreaterThan(
SESSION_ABSOLUTE_LIFETIME_MS - ONE_DAY_MS,
);
expect(sessionLifetimeMs).toBeLessThan(
SESSION_ABSOLUTE_LIFETIME_MS + ONE_DAY_MS,
);
});
it('should set the cookie on a sign-in without an Origin header, keeping scripted sign-ins working', async () => {
const response = await signInWithCookieCapture();
expect(
response.body.data.getAuthTokensFromLoginToken.tokens
.accessOrWorkspaceAgnosticToken.token,
).toBeDefined();
expect(extractSessionCookie(response)).toBeDefined();
});
it('should revoke the presented session as superseded when signing in over it', async () => {
const response = await signInWithCookieCapture({
originHeader: ALLOWED_ORIGIN,
cookieHeader: firstSessionCookieHeader,
});
const newSessionCookie = extractSessionCookie(response);
expect(newSessionCookie).toBeDefined();
expect(newSessionCookie?.sessionToken).not.toBe(firstSessionToken);
const userSessionRepository =
getCoreRepository<UserSessionEntity>(UserSessionEntity);
const supersededSession = await userSessionRepository.findOneBy({
tokenHash: hashUserSessionToken(firstSessionToken),
});
expect(supersededSession).not.toBeNull();
expect(supersededSession?.revokedAt).not.toBeNull();
expect(supersededSession?.revokedReason).toBe(
UserSessionRevokedReason.Superseded,
);
const newSession = await userSessionRepository.findOneBy({
tokenHash: hashUserSessionToken(newSessionCookie?.sessionToken as string),
});
expect(newSession).not.toBeNull();
expect(newSession?.revokedAt).toBeNull();
});
});

View File

@@ -0,0 +1,104 @@
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import {
extractSessionCookie,
postMetadataOperationWithHeaders,
signInWithCookieCapture,
} from 'test/integration/graphql/suites/auth/user-sessions/utils/sign-in-with-cookie-capture.util';
import { currentUserWorkspaceContextQueryFactory } from 'test/integration/graphql/suites/auth/user-sessions/utils/user-session-operations.util';
import { ALLOWED_ORIGIN } from 'test/integration/graphql/suites/auth/user-sessions/constants/session-origins.constants';
import {
SEED_APPLE_WORKSPACE_ID,
SEED_YCOMBINATOR_WORKSPACE_ID,
} from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
import { USER_WORKSPACE_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-user-workspaces.util';
import { UserSessionEntity } from 'src/engine/core-modules/user-session/user-session.entity';
import { hashUserSessionToken } from 'src/engine/core-modules/user-session/utils/hash-user-session-token.util';
// Tim is seeded in both the apple and yc workspaces, which is what makes this
// provable: same user, same credentials, two sessions, and each cookie can
// only ever reach the workspace its exchange selected. No request-side input
// (header, variable, origin) lets a session pivot to another workspace; the
// context is rebuilt from the session row alone.
describe('successful session workspace binding (integration)', () => {
let appleSessionToken: string;
let ycSessionToken: string;
let appleSessionCookieHeader: string;
let ycSessionCookieHeader: string;
const fetchWorkspaceContext = async (sessionCookieHeader: string) => {
const response = await postMetadataOperationWithHeaders(
currentUserWorkspaceContextQueryFactory(),
{
originHeader: ALLOWED_ORIGIN,
cookieHeader: sessionCookieHeader,
},
);
expect(response.body.errors).toBeUndefined();
return response.body.data.currentUser;
};
beforeAll(async () => {
const appleResponse = await signInWithCookieCapture({
originHeader: ALLOWED_ORIGIN,
});
const ycResponse = await signInWithCookieCapture({
workspaceSubdomain: 'yc',
originHeader: ALLOWED_ORIGIN,
});
const appleCookie = extractSessionCookie(appleResponse);
const ycCookie = extractSessionCookie(ycResponse);
if (!appleCookie || !ycCookie) {
throw new Error('Expected session cookies from both workspace sign-ins');
}
appleSessionToken = appleCookie.sessionToken;
ycSessionToken = ycCookie.sessionToken;
appleSessionCookieHeader = appleCookie.cookieHeader;
ycSessionCookieHeader = ycCookie.cookieHeader;
});
it('should persist each session bound to the workspace its exchange selected', async () => {
const userSessionRepository =
getCoreRepository<UserSessionEntity>(UserSessionEntity);
const appleSession = await userSessionRepository.findOneBy({
tokenHash: hashUserSessionToken(appleSessionToken),
});
const ycSession = await userSessionRepository.findOneBy({
tokenHash: hashUserSessionToken(ycSessionToken),
});
expect(appleSession?.workspaceId).toBe(SEED_APPLE_WORKSPACE_ID);
expect(appleSession?.userWorkspaceId).toBe(
USER_WORKSPACE_DATA_SEED_IDS.TIM,
);
expect(ycSession?.workspaceId).toBe(SEED_YCOMBINATOR_WORKSPACE_ID);
expect(ycSession?.userWorkspaceId).toBe(
USER_WORKSPACE_DATA_SEED_IDS.TIM_ACME,
);
});
it('should resolve the auth context of each cookie to its own workspace, with no cross-workspace pivot', async () => {
const appleContext = await fetchWorkspaceContext(appleSessionCookieHeader);
const ycContext = await fetchWorkspaceContext(ycSessionCookieHeader);
expect(appleContext.email).toBe('tim@apple.dev');
expect(appleContext.currentWorkspace.id).toBe(SEED_APPLE_WORKSPACE_ID);
expect(appleContext.currentUserWorkspace.id).toBe(
USER_WORKSPACE_DATA_SEED_IDS.TIM,
);
expect(ycContext.email).toBe('tim@apple.dev');
expect(ycContext.currentWorkspace.id).toBe(SEED_YCOMBINATOR_WORKSPACE_ID);
expect(ycContext.currentUserWorkspace.id).toBe(
USER_WORKSPACE_DATA_SEED_IDS.TIM_ACME,
);
});
});

View File

@@ -0,0 +1,85 @@
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import {
extractSessionCookie,
hasClearingCookie,
postMetadataOperationWithHeaders,
signInWithCookieCapture,
} from 'test/integration/graphql/suites/auth/user-sessions/utils/sign-in-with-cookie-capture.util';
import {
currentUserIdentityQueryFactory,
signOutQueryFactory,
} from 'test/integration/graphql/suites/auth/user-sessions/utils/user-session-operations.util';
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 { hashUserSessionToken } from 'src/engine/core-modules/user-session/utils/hash-user-session-token.util';
import {
ALLOWED_ORIGIN,
DISALLOWED_ORIGIN,
} from 'test/integration/graphql/suites/auth/user-sessions/constants/session-origins.constants';
describe('successful sign-out (integration)', () => {
it('should revoke the presented session, clear the cookie, and reject its reuse', async () => {
const signInResponse = await signInWithCookieCapture({
originHeader: ALLOWED_ORIGIN,
});
const sessionCookie = extractSessionCookie(signInResponse);
if (!sessionCookie) {
throw new Error('Expected a session cookie from sign-in');
}
const signOutResponse = await postMetadataOperationWithHeaders(
signOutQueryFactory(),
{
originHeader: ALLOWED_ORIGIN,
cookieHeader: sessionCookie.cookieHeader,
},
);
expect(signOutResponse.body.errors).toBeUndefined();
expect(signOutResponse.body.data.signOut).toBe(true);
expect(hasClearingCookie(signOutResponse)).toBe(true);
const revokedRow = await getCoreRepository<UserSessionEntity>(
UserSessionEntity,
).findOneBy({
tokenHash: hashUserSessionToken(sessionCookie.sessionToken),
});
expect(revokedRow?.revokedAt).not.toBeNull();
expect(revokedRow?.revokedReason).toBe(
UserSessionRevokedReason.UserSignOut,
);
// Revocation invalidates the cache, so reuse fails immediately, not
// after the cache TTL.
const reuseResponse = await postMetadataOperationWithHeaders(
currentUserIdentityQueryFactory(),
{
originHeader: ALLOWED_ORIGIN,
cookieHeader: sessionCookie.cookieHeader,
},
);
expect(reuseResponse.body.errors).toBeDefined();
expect(hasClearingCookie(reuseResponse)).toBe(true);
});
it('should not clear anything on a cookie-less sign-out, so a cross-site POST cannot log a visitor out', async () => {
// What a cross-site forgery actually looks like server-side: SameSite=Lax
// keeps the cookie off the request, and the attacker page's origin comes
// along. CSRF does not apply (no cookie), so the mutation runs and must
// still clear nothing.
const response = await postMetadataOperationWithHeaders(
signOutQueryFactory(),
{ originHeader: DISALLOWED_ORIGIN },
);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.signOut).toBe(true);
expect(hasClearingCookie(response)).toBe(false);
});
});

View File

@@ -0,0 +1,134 @@
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import {
extractSessionCookie,
postMetadataOperationWithHeaders,
signInWithCookieCapture,
} from 'test/integration/graphql/suites/auth/user-sessions/utils/sign-in-with-cookie-capture.util';
import {
currentUserSessionsQueryFactory,
revokeAllOtherUserSessionsQueryFactory,
revokeUserSessionQueryFactory,
} from 'test/integration/graphql/suites/auth/user-sessions/utils/user-session-operations.util';
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 { hashUserSessionToken } from 'src/engine/core-modules/user-session/utils/hash-user-session-token.util';
import { ALLOWED_ORIGIN } from 'test/integration/graphql/suites/auth/user-sessions/constants/session-origins.constants';
type UserSessionApiEntry = {
id: string;
isCurrent: boolean;
isImpersonating: boolean;
authProvider: string;
};
describe('successful user sessions API (integration)', () => {
let currentSessionCookieHeader: string;
let otherSessionToken: string;
const fetchSessions = async (): Promise<UserSessionApiEntry[]> => {
const response = await postMetadataOperationWithHeaders(
currentUserSessionsQueryFactory(),
{
originHeader: ALLOWED_ORIGIN,
cookieHeader: currentSessionCookieHeader,
},
);
expect(response.body.errors).toBeUndefined();
return response.body.data.currentUserSessions;
};
beforeAll(async () => {
// Two devices: the "other" one signs in first so the second sign-in does
// not supersede it (no cookie presented on either exchange).
const otherDeviceResponse = await signInWithCookieCapture({
originHeader: ALLOWED_ORIGIN,
});
const currentDeviceResponse = await signInWithCookieCapture({
originHeader: ALLOWED_ORIGIN,
});
const otherCookie = extractSessionCookie(otherDeviceResponse);
const currentCookie = extractSessionCookie(currentDeviceResponse);
if (!otherCookie || !currentCookie) {
throw new Error('Expected session cookies from both sign-ins');
}
otherSessionToken = otherCookie.sessionToken;
currentSessionCookieHeader = currentCookie.cookieHeader;
});
it('should list active sessions and mark only the presented one as current', async () => {
const sessions = await fetchSessions();
expect(sessions.length).toBeGreaterThanOrEqual(2);
const currentSessions = sessions.filter((session) => session.isCurrent);
expect(currentSessions).toHaveLength(1);
expect(currentSessions[0].authProvider).toBe('password');
expect(currentSessions[0].isImpersonating).toBe(false);
});
it('should revoke a targeted session by id', async () => {
const otherSessionRow = await getCoreRepository<UserSessionEntity>(
UserSessionEntity,
).findOneBy({ tokenHash: hashUserSessionToken(otherSessionToken) });
if (!otherSessionRow) {
throw new Error('Expected the other session row');
}
const response = await postMetadataOperationWithHeaders(
revokeUserSessionQueryFactory({ userSessionId: otherSessionRow.id }),
{
originHeader: ALLOWED_ORIGIN,
cookieHeader: currentSessionCookieHeader,
},
);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.revokeUserSession).toBe(true);
const revokedRow = await getCoreRepository<UserSessionEntity>(
UserSessionEntity,
).findOneBy({ id: otherSessionRow.id });
expect(revokedRow?.revokedAt).not.toBeNull();
expect(revokedRow?.revokedReason).toBe(UserSessionRevokedReason.UserRevoked);
const sessions = await fetchSessions();
expect(
sessions.find((session) => session.id === otherSessionRow.id),
).toBeUndefined();
});
it('should revoke every other session but keep the presented one alive', async () => {
// A fresh sign-in guarantees at least one other active session to revoke.
await signInWithCookieCapture({ originHeader: ALLOWED_ORIGIN });
const response = await postMetadataOperationWithHeaders(
revokeAllOtherUserSessionsQueryFactory(),
{
originHeader: ALLOWED_ORIGIN,
cookieHeader: currentSessionCookieHeader,
},
);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.revokeAllOtherUserSessions).toBeGreaterThanOrEqual(
1,
);
const sessions = await fetchSessions();
expect(sessions).toHaveLength(1);
expect(sessions[0].isCurrent).toBe(true);
});
});

View File

@@ -0,0 +1,251 @@
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'),
);
});
});
});

View File

@@ -0,0 +1,53 @@
import { type ConfigVariableValue } from 'twenty-shared/types';
import { createConfigVariable } from 'test/integration/twenty-config/utils/create-config-variable.util';
import { deleteConfigVariable } from 'test/integration/twenty-config/utils/delete-config-variable.util';
import { getConfigVariable } from 'test/integration/twenty-config/utils/get-config-variable.util';
import { updateConfigVariable } from 'test/integration/twenty-config/utils/update-config-variable.util';
// Registers beforeAll/afterAll hooks that apply a database config override for
// the suite and restore the previous state afterwards, rather than blindly
// deleting: a suite must not erase an override another suite (or the
// environment) had in place.
export const setupDatabaseConfigOverrideForSuite = (
key: string,
value: ConfigVariableValue,
): void => {
let previousOverrideValue: ConfigVariableValue;
let hadPreviousOverride = false;
beforeAll(async () => {
const { data } = await getConfigVariable({ input: { key } });
hadPreviousOverride = data.getDatabaseConfigVariable.source === 'DATABASE';
if (hadPreviousOverride) {
previousOverrideValue = data.getDatabaseConfigVariable.value;
await updateConfigVariable({ input: { key, value } });
} else {
await createConfigVariable({ input: { key, value } });
}
});
afterAll(async () => {
try {
if (hadPreviousOverride) {
await updateConfigVariable({
input: { key, value: previousOverrideValue },
});
} else {
await deleteConfigVariable({ input: { key } });
}
} catch (error) {
// Reported rather than rethrown: a passing suite should not go red over
// its own teardown. But a swallowed failure leaves the override in place
// and the next suite fails for reasons that point nowhere near here, so
// it has to be visible.
process.stderr.write(
`[config-override] failed to restore ${key}, later suites may see a stale value: ${
error instanceof Error ? error.message : String(error)
}\n`,
);
}
});
};

View File

@@ -0,0 +1,152 @@
import { type Response } from 'supertest';
import { buildWorkspaceOriginForSubdomain } from 'test/integration/graphql/utils/build-apple-workspace-origin.util';
import { getAuthTokensFromLoginTokenQueryFactory } from 'test/integration/graphql/utils/get-auth-tokens-from-login-token.query-factory.util';
import { getLoginTokenFromCredentialsQueryFactory } from 'test/integration/graphql/utils/get-login-token-from-credentials.query-factory.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { USER_SESSION_COOKIE_NAME } from 'src/engine/core-modules/user-session/constants/user-session-cookie-name.constant';
import { USER_SESSION_SECURE_COOKIE_NAME } from 'src/engine/core-modules/user-session/constants/user-session-secure-cookie-name.constant';
type RequestHeaders = {
originHeader?: string;
cookieHeader?: string;
};
// Supertest requests dispatch lazily, so Origin and Cookie can be set on the
// request makeMetadataAPIRequest already built. The explicit null token keeps
// these requests off Bearer authentication, which would bypass both the
// cookie auth path and the CSRF middleware; undefined would fall back to the
// util's default admin token.
export const postMetadataOperationWithHeaders = (
graphqlOperation: Parameters<typeof makeMetadataAPIRequest>[0],
{ originHeader, cookieHeader }: RequestHeaders,
expectedStatus = 200,
) => {
const graphqlRequest = makeMetadataAPIRequest(graphqlOperation, null);
if (originHeader !== undefined) {
graphqlRequest.set('Origin', originHeader);
}
if (cookieHeader !== undefined) {
graphqlRequest.set('Cookie', cookieHeader);
}
return graphqlRequest.expect(expectedStatus);
};
type SignInOptions = RequestHeaders & {
email?: string;
password?: string;
workspaceSubdomain?: string;
};
// Runs the full credentials exchange (credentials -> login token -> auth
// tokens) and returns the raw supertest response of the final exchange, so
// callers can assert on set-cookie headers, not just the GraphQL body.
export const signInWithCookieCapture = async ({
email = 'tim@apple.dev',
password = 'tim@apple.dev',
workspaceSubdomain = 'apple',
originHeader,
cookieHeader,
}: SignInOptions = {}): Promise<Response> => {
const workspaceOrigin = buildWorkspaceOriginForSubdomain(workspaceSubdomain);
const loginTokenResponse = await postMetadataOperationWithHeaders(
getLoginTokenFromCredentialsQueryFactory({
email,
password,
origin: workspaceOrigin,
}),
{ originHeader, cookieHeader },
);
const loginToken =
loginTokenResponse.body.data?.getLoginTokenFromCredentials?.loginToken
?.token;
expect(loginToken).toBeDefined();
return await postMetadataOperationWithHeaders(
getAuthTokensFromLoginTokenQueryFactory({
loginToken,
origin: workspaceOrigin,
}),
{ originHeader, cookieHeader },
);
};
// Redacts the two dynamic parts (token value, expiry timestamp) so the rest of
// the set-cookie header can be snapshot: name, attribute list and order are
// deterministic, and the snapshot also pins the absence of Domain and Secure.
export const normalizeSessionCookieForSnapshot = (rawCookie: string): string =>
rawCookie
.replace(/=sess_[A-Za-z0-9_-]+/, '=sess_<redacted>')
.replace(/Expires=[^;]+/, 'Expires=<redacted>');
export const getSetCookieHeaders = (response: Response): string[] =>
Array.isArray(response.headers['set-cookie'])
? response.headers['set-cookie']
: [];
// Which of the two names the server issues depends on SERVER_URL and
// AUTH_COOKIE_SAME_SITE, so callers match on both by default and read the name
// back off the response rather than assuming the insecure deployment. Specs
// that assert one specific variant pass the name explicitly.
const SESSION_COOKIE_NAMES = [
USER_SESSION_SECURE_COOKIE_NAME,
USER_SESSION_COOKIE_NAME,
];
// startsWith keeps the plain and __Host- names distinct: the prefixed name
// does not start with the plain one.
export const extractSessionCookie = (
response: Response,
cookieName?: string,
):
| { rawCookie: string; cookieName: string; cookieHeader: string; sessionToken: string }
| undefined => {
const candidateNames =
cookieName === undefined ? SESSION_COOKIE_NAMES : [cookieName];
for (const candidateName of candidateNames) {
const rawCookie = getSetCookieHeaders(response).find((cookie) =>
cookie.startsWith(`${candidateName}=sess_`),
);
if (rawCookie === undefined) {
continue;
}
const sessionToken = rawCookie
.split(';')[0]
.slice(`${candidateName}=`.length);
return {
rawCookie,
cookieName: candidateName,
cookieHeader: `${candidateName}=${sessionToken}`,
sessionToken,
};
}
return undefined;
};
// A deletion cookie is the name with an empty value and an epoch expiry; the
// extractor above will not match it because the value lacks the sess_ prefix.
export const hasClearingCookie = (
response: Response,
cookieName?: string,
): boolean => {
const candidateNames =
cookieName === undefined ? SESSION_COOKIE_NAMES : [cookieName];
return getSetCookieHeaders(response).some((cookie) =>
candidateNames.some(
(candidateName) =>
cookie.startsWith(`${candidateName}=;`) &&
cookie.includes('Expires=Thu, 01 Jan 1970'),
),
);
};

View File

@@ -0,0 +1,95 @@
import { gql } from 'graphql-tag';
export const currentUserIdentityQueryFactory = () => {
return {
query: gql`
query CurrentUser {
currentUser {
id
email
}
}
`,
variables: {},
};
};
export const currentUserWorkspaceContextQueryFactory = () => {
return {
query: gql`
query CurrentUserWorkspaceContext {
currentUser {
id
email
currentWorkspace {
id
}
currentUserWorkspace {
id
}
}
}
`,
variables: {},
};
};
export const currentUserSessionsQueryFactory = () => {
return {
query: gql`
query CurrentUserSessions {
currentUserSessions {
id
workspaceId
authProvider
isImpersonating
isCurrent
lastActiveAt
expiresAt
}
}
`,
variables: {},
};
};
export const revokeUserSessionQueryFactory = ({
userSessionId,
}: {
userSessionId: string;
}) => {
return {
query: gql`
mutation RevokeUserSession($userSessionId: UUID!) {
revokeUserSession(userSessionId: $userSessionId)
}
`,
variables: { userSessionId },
};
};
export const revokeAllOtherUserSessionsQueryFactory = () => {
return {
query: gql`
mutation RevokeAllOtherUserSessions {
revokeAllOtherUserSessions
}
`,
variables: {},
};
};
export const signOutQueryFactory = ({
refreshToken,
}: {
refreshToken?: string;
} = {}) => {
return {
query: gql`
mutation SignOut($refreshToken: String) {
signOut(refreshToken: $refreshToken)
}
`,
variables: { refreshToken },
};
};

View File

@@ -0,0 +1,112 @@
import {
extractSessionCookie,
postMetadataOperationWithHeaders,
signInWithCookieCapture,
} from 'test/integration/graphql/suites/auth/user-sessions/utils/sign-in-with-cookie-capture.util';
import {
currentUserIdentityQueryFactory,
currentUserSessionsQueryFactory,
revokeAllOtherUserSessionsQueryFactory,
revokeUserSessionQueryFactory,
} from 'test/integration/graphql/suites/auth/user-sessions/utils/user-session-operations.util';
import { ALLOWED_ORIGIN } from 'test/integration/graphql/suites/auth/user-sessions/constants/session-origins.constants';
type UserSessionApiEntry = { id: string; isCurrent: boolean };
// Tim is seeded in both apple and yc, which is what makes this provable: one
// person, two workspaces. Almost nothing in Twenty is account-wide, so a
// workspace must not surface, or be able to revoke, the sessions this person
// holds in another workspace.
describe('workspace-scoped user sessions API (integration)', () => {
let appleCookieHeader: string;
let ycCookieHeader: string;
const signInTo = async (workspaceSubdomain: string): Promise<string> => {
const response = await signInWithCookieCapture({
workspaceSubdomain,
originHeader: ALLOWED_ORIGIN,
});
const sessionCookie = extractSessionCookie(response);
if (!sessionCookie) {
throw new Error(`Expected a session cookie from ${workspaceSubdomain}`);
}
return sessionCookie.cookieHeader;
};
const fetchSessions = async (
cookieHeader: string,
): Promise<UserSessionApiEntry[]> => {
const response = await postMetadataOperationWithHeaders(
currentUserSessionsQueryFactory(),
{ originHeader: ALLOWED_ORIGIN, cookieHeader },
);
expect(response.body.errors).toBeUndefined();
return response.body.data.currentUserSessions;
};
beforeAll(async () => {
appleCookieHeader = await signInTo('apple');
ycCookieHeader = await signInTo('yc');
});
it('should list only the sessions bound to the workspace the cookie belongs to', async () => {
const appleSessionIds = (await fetchSessions(appleCookieHeader)).map(
(session) => session.id,
);
const ycSessionIds = (await fetchSessions(ycCookieHeader)).map(
(session) => session.id,
);
expect(appleSessionIds.length).toBeGreaterThan(0);
expect(ycSessionIds.length).toBeGreaterThan(0);
expect(
appleSessionIds.filter((sessionId) => ycSessionIds.includes(sessionId)),
).toHaveLength(0);
});
it('should refuse to revoke a session belonging to another workspace', async () => {
const ycSessions = await fetchSessions(ycCookieHeader);
const ycSessionId = ycSessions[0].id;
const response = await postMetadataOperationWithHeaders(
revokeUserSessionQueryFactory({ userSessionId: ycSessionId }),
{ originHeader: ALLOWED_ORIGIN, cookieHeader: appleCookieHeader },
);
expect(response.body.errors).toBeDefined();
// Still usable: the refusal has to be a no-op, not a silent revocation.
const ycSessionsAfter = await fetchSessions(ycCookieHeader);
expect(ycSessionsAfter.map((session) => session.id)).toContain(ycSessionId);
});
it('should leave other workspaces signed in when revoking all other devices', async () => {
// A second apple sign-in guarantees there is something to revoke.
await signInTo('apple');
const response = await postMetadataOperationWithHeaders(
revokeAllOtherUserSessionsQueryFactory(),
{ originHeader: ALLOWED_ORIGIN, cookieHeader: appleCookieHeader },
);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.revokeAllOtherUserSessions).toBeGreaterThanOrEqual(
1,
);
const ycResponse = await postMetadataOperationWithHeaders(
currentUserIdentityQueryFactory(),
{ originHeader: ALLOWED_ORIGIN, cookieHeader: ycCookieHeader },
);
expect(ycResponse.body.errors).toBeUndefined();
expect(ycResponse.body.data.currentUser.email).toBe('tim@apple.dev');
});
});