Files
upstream-mirror e7da97194a
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 2s
snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
2026-09-17 23:28:13 +05:00

254 lines
7.2 KiB
TypeScript

import request from 'supertest';
import { USER_SESSION_COOKIE_NAME } from 'src/engine/core-modules/user-session/constants/user-session-cookie-name.constant';
const SERVER_URL = `http://localhost:${APP_PORT}`;
const buildAppleOrigin = (): string => {
const origin = new URL(SERVER_URL);
origin.hostname =
process.env.IS_MULTIWORKSPACE_ENABLED === 'true'
? `apple.${origin.hostname}`
: origin.hostname;
return origin.origin;
};
// The app listens on localhost while the seeded workspace resolves from the
// apple subdomain. Without a matching Host the server sees a cross-origin
// request and refuses to issue the cookie, which is the behaviour under test
// everywhere except here.
const asWorkspaceRequest = (
agent: request.Test,
originOverride?: string,
): request.Test => {
const host = new URL(origin).host;
return originOverride === undefined
? agent.set('Host', host).set('Origin', origin)
: agent.set('Host', host).set('Origin', originOverride);
};
const CURRENT_USER_QUERY = `
query CurrentUser {
currentUser {
id
email
}
}
`;
const SIGN_OUT_MUTATION = `
mutation SignOut {
signOut
}
`;
// A workspace object rather than currentUser: workspace types only exist in the
// schema once a workspace is bound, so a request that is allowed to continue
// unauthenticated fails with "Cannot query field" instead of an auth error.
const FIND_COMPANIES_QUERY = `
query FindCompanies {
companies(first: 1) {
edges {
node {
id
}
}
}
}
`;
const AUTH_ERROR_CODES = ['UNAUTHENTICATED', 'FORBIDDEN'];
const extractSessionCookie = (
setCookieHeader: string | string[] | undefined,
): string | undefined => {
const setCookies =
typeof setCookieHeader === 'string'
? [setCookieHeader]
: (setCookieHeader ?? []);
return setCookies
.find((cookie) => cookie.startsWith(`${USER_SESSION_COOKIE_NAME}=`))
?.split(';')[0];
};
const origin = buildAppleOrigin();
const signInAndGetSessionCookie = async (): Promise<string> => {
const loginResponse = await asWorkspaceRequest(
request(SERVER_URL).post('/metadata'),
)
.send({
query: `
mutation GetLoginTokenFromCredentials(
$email: String!
$password: String!
$origin: String!
) {
getLoginTokenFromCredentials(
email: $email
password: $password
origin: $origin
) {
loginToken {
token
}
}
}
`,
variables: {
email: 'tim@apple.dev',
password: 'tim@apple.dev',
origin,
},
})
.expect(200);
const loginToken =
loginResponse.body.data.getLoginTokenFromCredentials.loginToken.token;
const exchangeResponse = await asWorkspaceRequest(
request(SERVER_URL).post('/metadata'),
)
.send({
query: `
mutation GetAuthTokensFromLoginToken(
$loginToken: String!
$origin: String!
) {
getAuthTokensFromLoginToken(loginToken: $loginToken, origin: $origin) {
tokens {
accessOrWorkspaceAgnosticToken {
token
}
}
}
}
`,
variables: { loginToken, origin },
})
.expect(200);
const sessionCookie = extractSessionCookie(
exchangeResponse.headers['set-cookie'],
);
expect(sessionCookie).toBeDefined();
return sessionCookie as string;
};
describe('Cookie sessions (integration)', () => {
let sessionCookie: string;
beforeAll(async () => {
sessionCookie = await signInAndGetSessionCookie();
});
it('should set a session cookie when exchanging a login token', () => {
expect(sessionCookie).toContain(`${USER_SESSION_COOKIE_NAME}=sess_`);
});
it('should authenticate a request carrying only the session cookie', async () => {
const response = await asWorkspaceRequest(
request(SERVER_URL).post('/metadata'),
)
.set('Cookie', sessionCookie)
.send({ query: CURRENT_USER_QUERY })
.expect(200);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.currentUser.email).toBe('tim@apple.dev');
});
it('should reject a cookie-authenticated request from a foreign origin', async () => {
const response = await asWorkspaceRequest(
request(SERVER_URL).post('/metadata'),
'https://evil.example.org',
)
.set('Cookie', sessionCookie)
.send({ query: CURRENT_USER_QUERY });
expect(response.status).toBe(403);
expect(response.body.error).toBe('CSRF_ORIGIN_MISMATCH');
});
it('should reject a cookie-authenticated request sending no origin', async () => {
const response = await request(SERVER_URL)
.post('/metadata')
.set('Host', new URL(origin).host)
.set('Cookie', sessionCookie)
.send({ query: CURRENT_USER_QUERY });
expect(response.status).toBe(403);
expect(response.body.error).toBe('CSRF_ORIGIN_MISMATCH');
});
// Mints its own session rather than consuming the shared one, so revoking it
// cannot break the tests above whatever order they run in.
it('should stop authenticating the cookie once signed out', async () => {
const disposableSessionCookie = await signInAndGetSessionCookie();
const signOutResponse = await asWorkspaceRequest(
request(SERVER_URL).post('/metadata'),
)
.set('Cookie', disposableSessionCookie)
.send({ query: SIGN_OUT_MUTATION })
.expect(200);
expect(signOutResponse.body.errors).toBeUndefined();
const afterSignOut = await asWorkspaceRequest(
request(SERVER_URL).post('/metadata'),
)
.set('Cookie', disposableSessionCookie)
.send({ query: CURRENT_USER_QUERY });
expect(afterSignOut.body.data?.currentUser).toBeFalsy();
expect(afterSignOut.body.errors).toBeDefined();
expect(
afterSignOut.body.errors.map(
(graphQLError: { extensions?: { code?: string } }) =>
graphQLError.extensions?.code,
),
).toEqual(
expect.arrayContaining([expect.stringMatching(/UNAUTHENTICATED/)]),
);
});
it('should answer a revoked cookie with an auth error rather than a schema error', async () => {
const disposableSessionCookie = await signInAndGetSessionCookie();
await asWorkspaceRequest(request(SERVER_URL).post('/metadata'))
.set('Cookie', disposableSessionCookie)
.send({ query: SIGN_OUT_MUTATION })
.expect(200);
const response = await asWorkspaceRequest(
request(SERVER_URL).post('/graphql'),
)
.set('Cookie', disposableSessionCookie)
.send({ query: FIND_COMPANIES_QUERY });
expect(response.body.errors).toBeDefined();
const messages = response.body.errors.map(
(graphQLError: { message: string }) => graphQLError.message,
);
const codes = response.body.errors.map(
(graphQLError: { extensions?: { code?: string } }) =>
graphQLError.extensions?.code,
);
// The client only signs out on an auth code. A missing workspace schema
// surfaces as "Cannot query field", which it cannot act on.
expect(messages.join(' ')).not.toContain('Cannot query field');
expect(codes.some((code: string) => AUTH_ERROR_CODES.includes(code))).toBe(
true,
);
});
});