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 2s

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,213 @@
import * as fs from 'fs';
import * as path from 'path';
import * as process from 'process';
import { pascalToKebab } from 'twenty-shared/utils';
import { INTROSPECTION_QUERY } from './introspection-query';
import {
type Field,
type InputValue,
type IntrospectionResponse,
type TypeRef,
} from './introspection.interface';
const GRAPHQL_URL = 'http://localhost:3000/graphql';
const BEARER_TOKEN =
'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwiaWF0IjoxNzI2NDkyNTAyLCJleHAiOjEzMjQ1MDE2NTAyfQ.zM6TbfeOqYVH5Sgryc2zf02hd9uqUOSL1-iJlMgwzsI';
const TEST_OUTPUT_DIR = './test/integration/graphql/suites/object-generated';
const fetchGraphQLSchema = async (): Promise<IntrospectionResponse> => {
const headers = {
Authorization: BEARER_TOKEN,
'Content-Type': 'application/json',
};
const response = await fetch(GRAPHQL_URL, {
method: 'POST',
headers,
body: JSON.stringify({ query: INTROSPECTION_QUERY }),
});
if (!response.ok) {
throw new Error(`Failed to fetch schema: ${response.statusText}`);
}
return response.json();
};
const unwrapType = (typeInfo: TypeRef): any => {
while (typeInfo.ofType) {
typeInfo = typeInfo.ofType;
}
return typeInfo;
};
const hasRequiredArgs = (args: InputValue[]): boolean => {
return args.some((arg) => unwrapType(arg.type).kind === 'NON_NULL');
};
const generateTestContent = (
queryName: string,
fields: Field[],
): string | null => {
const fieldNames = fields
.filter((f) => ['SCALAR', 'ENUM'].includes(unwrapType(f.type).kind))
.map((f) => f.name);
if (fieldNames.length === 0) {
// oxlint-disable-next-line no-console
console.log(`Skipping ${queryName}: No usable fields found.`);
return null;
}
const fieldSelection = fieldNames.join('\n ');
const expectSelection = fieldNames
.map((f) => `expect(${queryName}).toHaveProperty('${f}');`)
.join('\n ');
return `import request from 'supertest';
const client = request(\`http://localhost:\${APP_PORT}\`);
describe('${queryName}Resolver (e2e)', () => {
it('should find many ${queryName}', () => {
const queryData = {
query: \`
query ${queryName} {
${queryName} {
edges {
node {
${fieldSelection}
}
}
}
}
\`,
};
return client
.post('/graphql')
.set('Authorization', \`Bearer \${APPLE_JANE_ADMIN_ACCESS_TOKEN}\`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeDefined();
expect(res.body.errors).toBeUndefined();
})
.expect((res) => {
const data = res.body.data.${queryName};
expect(data).toBeDefined();
expect(Array.isArray(data.edges)).toBe(true);
const edges = data.edges;
if (edges.length > 0) {
const ${queryName} = edges[0].node;
${expectSelection}
}
});
});
});
`;
};
const writeTestFile = (
queryName: string,
content: string | null,
force = false,
): string => {
if (!content) return 'skipped';
const fileName = `${pascalToKebab(queryName)}.integration-spec.ts`;
const filePath = path.join(TEST_OUTPUT_DIR, fileName);
if (fs.existsSync(filePath) && !force) {
return 'skipped';
}
fs.writeFileSync(filePath, content);
return force ? 'updated' : 'created';
};
const generateTests = async (force = false) => {
fs.mkdirSync(TEST_OUTPUT_DIR, { recursive: true });
const schemaData = await fetchGraphQLSchema();
const types = schemaData.data.__schema.types;
const queryTypeName = schemaData.data.__schema.queryType.name;
const queryType = types.find((t: any) => t.name === queryTypeName);
let createdCount = 0;
let updatedCount = 0;
let totalCount = 0;
if (!queryType?.fields) {
// oxlint-disable-next-line no-console
console.log('No query fields found.');
return;
}
for (const query of queryType.fields) {
const queryName = query.name;
if (hasRequiredArgs(query.args)) continue;
if (queryName.includes('Duplicates')) continue;
const queryReturnType = unwrapType(query.type);
if (
queryReturnType.kind === 'OBJECT' &&
queryReturnType.name.includes('Connection')
) {
totalCount++;
const connectionTypeInfo = types.find(
(f: any) => f.name === queryReturnType.name,
);
const edgeTypeInfo = connectionTypeInfo?.fields?.find(
(f: any) => f.name === 'edges',
);
if (edgeTypeInfo) {
const returnType = unwrapType(edgeTypeInfo.type);
const returnTypeInfo = types.find(
(t: any) => t.name === returnType.name,
);
const returnNodeTypeInfo = returnTypeInfo?.fields?.find(
(f: any) => f.name === 'node',
);
if (returnNodeTypeInfo) {
const nodeType = unwrapType(returnNodeTypeInfo.type);
const nodeTypeInfo = types.find((t: any) => t.name === nodeType.name);
if (!nodeTypeInfo?.fields) {
continue;
}
const content = generateTestContent(queryName, nodeTypeInfo?.fields);
const result = writeTestFile(queryName, content, force);
if (result === 'created') createdCount++;
if (result === 'updated') updatedCount++;
}
}
}
}
// oxlint-disable-next-line no-console
console.log(`Number of tests created: ${createdCount}/${totalCount}`);
if (force) {
// oxlint-disable-next-line no-console
console.log(`Number of tests updated: ${updatedCount}/${totalCount}`);
}
};
const forceArg = process.argv.includes('--force');
void generateTests(forceArg);

View File

@@ -0,0 +1,89 @@
export const INTROSPECTION_QUERY = `
query IntrospectionQuery {
__schema {
queryType { name }
mutationType { name }
subscriptionType { name }
types {
...FullType
}
directives {
name
description
locations
args {
...InputValue
}
}
}
}
fragment FullType on __Type {
kind
name
description
fields(includeDeprecated: true) {
name
description
args {
...InputValue
}
type {
...TypeRef
}
isDeprecated
deprecationReason
}
inputFields {
...InputValue
}
interfaces {
...TypeRef
}
enumValues(includeDeprecated: true) {
name
description
isDeprecated
deprecationReason
}
possibleTypes {
...TypeRef
}
}
fragment InputValue on __InputValue {
name
description
type { ...TypeRef }
defaultValue
}
fragment TypeRef on __Type {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
}
}
}
}
}
}
}
`;

View File

@@ -0,0 +1,60 @@
export interface IntrospectionResponse {
data: {
__schema: Schema;
};
}
export interface Schema {
queryType: { name: string };
mutationType: { name: string | null };
subscriptionType: { name: string | null };
types: GraphQLType[];
directives: Directive[];
}
export interface Directive {
name: string;
description: string | null;
locations: string[];
args: InputValue[];
}
export interface GraphQLType {
kind: string;
name: string;
description: string | null;
fields?: Field[];
inputFields?: InputValue[];
interfaces?: TypeRef[];
enumValues?: EnumValue[];
possibleTypes?: TypeRef[];
}
export interface Field {
name: string;
description: string | null;
args: InputValue[];
type: TypeRef;
isDeprecated: boolean;
deprecationReason: string | null;
}
export interface InputValue {
name: string;
description: string | null;
type: TypeRef;
defaultValue: string | null;
}
export interface TypeRef {
kind: string;
name: string | null;
ofType: TypeRef | null;
}
export interface EnumValue {
name: string;
description: string | null;
isDeprecated: boolean;
deprecationReason: string | null;
}

View File

@@ -0,0 +1,664 @@
import { randomUUID } from 'crypto';
import { gql } from 'graphql-tag';
import { type DataSource } from 'typeorm';
import { v5 } from 'uuid';
import { makeAdminPanelAPIRequestWithGuestRole } from 'test/integration/graphql/suites/admin-panel/utils/make-admin-panel-api-request-with-guest-role.util';
import { makeAdminPanelAPIRequest } from 'test/integration/twenty-config/utils/make-admin-panel-api-request.util';
import { WORKSPACE_SETUP_CHAT_THREAD_ID_NAMESPACE } from 'src/engine/metadata-modules/ai/ai-chat/constants/workspace-setup-chat-thread-id-namespace.constant';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
const GET_ADMIN_CHAT_THREADS = gql`
query GetAdminChatThreads(
$scope: AdminChatThreadScope
$hasErrorOnly: Boolean
$userNeverEngagedOnly: Boolean
$searchTerm: String
$sortBy: AdminChatThreadSortField
$sortDirection: AdminChatThreadSortDirection
$limit: Int
$offset: Int
) {
getAdminChatThreads(
scope: $scope
hasErrorOnly: $hasErrorOnly
userNeverEngagedOnly: $userNeverEngagedOnly
searchTerm: $searchTerm
sortBy: $sortBy
sortDirection: $sortDirection
limit: $limit
offset: $offset
) {
totalCount
hasMore
threads {
id
title
workspaceId
workspaceDisplayName
userWorkspaceId
userEmail
messageCount
userReplyCount
hasError
isOnboardingThread
deletedAt
createdAt
updatedAt
}
}
}
`;
const GET_ADMIN_CHAT_THREAD_MESSAGES = gql`
query GetAdminChatThreadMessages($threadId: UUID!) {
getAdminChatThreadMessages(threadId: $threadId) {
thread {
id
messageCount
conversationSize
}
messages {
id
role
isHidden
parts {
type
orderIndex
textContent
reasoningContent
toolName
toolCallId
toolInput
toolOutput
state
errorMessage
}
}
}
}
`;
type ThreadsResult = {
totalCount: number;
hasMore: boolean;
threads: {
id: string;
messageCount: number;
userReplyCount: number;
hasError: boolean;
isOnboardingThread: boolean;
userEmail: string | null;
}[];
};
describe('Admin panel global chat threads (integration)', () => {
let dataSource: DataSource;
let userWorkspaceId: string;
let userEmail: string;
let kickoffThreadId: string;
let deterministicThreadId: string;
let regularThreadId: string;
let answeredQuestionThreadId: string;
let pendingQuestionThreadId: string;
const seededThreadIds: string[] = [];
const seededMessageIds: string[] = [];
const seededPartIds: string[] = [];
const insertThread = async ({
id,
title,
lastStreamError,
}: {
id: string;
title: string;
lastStreamError?: object;
}): Promise<string> => {
await dataSource.query(
`INSERT INTO core."agentChatThread"
(id, "workspaceId", "userWorkspaceId", title, "lastStreamError")
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO UPDATE SET "lastStreamError" = EXCLUDED."lastStreamError"`,
[
id,
SEED_APPLE_WORKSPACE_ID,
userWorkspaceId,
title,
lastStreamError ? JSON.stringify(lastStreamError) : null,
],
);
seededThreadIds.push(id);
return id;
};
const insertMessage = async ({
threadId,
role,
isHidden = false,
createdAt,
}: {
threadId: string;
role: 'user' | 'assistant';
isHidden?: boolean;
createdAt: string;
}): Promise<string> => {
const id = randomUUID();
await dataSource.query(
`INSERT INTO core."agentMessage"
(id, "workspaceId", "threadId", role, "isHidden", "createdAt")
VALUES ($1, $2, $3, $4, $5, $6)`,
[id, SEED_APPLE_WORKSPACE_ID, threadId, role, isHidden, createdAt],
);
seededMessageIds.push(id);
return id;
};
const insertPart = async ({
messageId,
orderIndex,
type,
textContent,
reasoningContent,
toolName,
toolCallId,
toolInput,
toolOutput,
state,
}: {
messageId: string;
orderIndex: number;
type: string;
textContent?: string;
reasoningContent?: string;
toolName?: string;
toolCallId?: string;
toolInput?: object;
toolOutput?: object;
state?: string;
}): Promise<string> => {
const id = randomUUID();
await dataSource.query(
`INSERT INTO core."agentMessagePart"
(id, "workspaceId", "messageId", "orderIndex", type, "textContent",
"reasoningContent", "toolName", "toolCallId", "toolInput",
"toolOutput", state)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
[
id,
SEED_APPLE_WORKSPACE_ID,
messageId,
orderIndex,
type,
textContent ?? null,
reasoningContent ?? null,
toolName ?? null,
toolCallId ?? null,
toolInput ? JSON.stringify(toolInput) : null,
toolOutput ? JSON.stringify(toolOutput) : null,
state ?? null,
],
);
seededPartIds.push(id);
return id;
};
const fetchThreads = async (
variables: Record<string, unknown>,
): Promise<ThreadsResult> => {
const response = await makeAdminPanelAPIRequest({
query: GET_ADMIN_CHAT_THREADS,
variables,
});
expect(response.body.errors).toBeUndefined();
return response.body.data?.getAdminChatThreads;
};
beforeAll(async () => {
dataSource = global.testDataSource;
const [firstUserWorkspace] = await dataSource.query(
`SELECT "userWorkspace".id, "user".email
FROM core."userWorkspace" "userWorkspace"
JOIN core."user" "user" ON "user".id = "userWorkspace"."userId"
WHERE "userWorkspace"."workspaceId" = $1
AND "userWorkspace"."deletedAt" IS NULL
ORDER BY "userWorkspace"."createdAt" ASC
LIMIT 1`,
[SEED_APPLE_WORKSPACE_ID],
);
userWorkspaceId = firstUserWorkspace.id;
userEmail = firstUserWorkspace.email;
kickoffThreadId = await insertThread({
id: randomUUID(),
title: 'integration-onboarding-kickoff-thread',
});
const hiddenKickoffMessageId = await insertMessage({
threadId: kickoffThreadId,
role: 'user',
isHidden: true,
createdAt: '2026-01-01T00:00:00Z',
});
await insertPart({
messageId: hiddenKickoffMessageId,
orderIndex: 0,
type: 'text',
textContent: 'kickoff prompt with company context',
});
await insertMessage({
threadId: kickoffThreadId,
role: 'user',
createdAt: '2026-01-01T00:01:00Z',
});
const assistantMessageId = await insertMessage({
threadId: kickoffThreadId,
role: 'assistant',
createdAt: '2026-01-01T00:02:00Z',
});
await insertPart({
messageId: assistantMessageId,
orderIndex: 1,
type: 'tool-call',
toolName: 'create_many_object_metadata',
toolCallId: 'call-1',
toolInput: { objects: [{ nameSingular: 'listing' }] },
toolOutput: { success: true },
state: 'output-available',
});
await insertPart({
messageId: assistantMessageId,
orderIndex: 0,
type: 'reasoning',
reasoningContent: 'planning the data model',
});
deterministicThreadId = await insertThread({
id: v5(
`${SEED_APPLE_WORKSPACE_ID}:${userWorkspaceId}`,
WORKSPACE_SETUP_CHAT_THREAD_ID_NAMESPACE,
),
title: 'integration-onboarding-deterministic-thread',
});
regularThreadId = await insertThread({
id: randomUUID(),
title: 'integration-regular-thread',
lastStreamError: { message: 'stream failed' },
});
const regularAssistantMessageId = await insertMessage({
threadId: regularThreadId,
role: 'assistant',
createdAt: '2026-01-01T00:03:00Z',
});
await insertPart({
messageId: regularAssistantMessageId,
orderIndex: 0,
type: 'text',
textContent: 'assistant reply',
});
const questionItems = [
{
header: 'Email type',
question: 'Which mailbox should we sync?',
options: [{ label: 'Work' }, { label: 'Personal' }],
},
];
answeredQuestionThreadId = await insertThread({
id: randomUUID(),
title: 'integration-answered-question-thread',
});
await insertMessage({
threadId: answeredQuestionThreadId,
role: 'user',
isHidden: true,
createdAt: '2026-01-01T00:04:00Z',
});
const answeredQuestionMessageId = await insertMessage({
threadId: answeredQuestionThreadId,
role: 'assistant',
createdAt: '2026-01-01T00:05:00Z',
});
await insertPart({
messageId: answeredQuestionMessageId,
orderIndex: 0,
type: 'tool-ask_questions',
toolName: 'ask_questions',
toolCallId: 'call-answered-questions',
toolInput: { questions: questionItems },
toolOutput: {
success: true,
message: 'User answered the questions.',
result: {
questions: questionItems,
status: 'answered',
answers: [{ questionIndex: 0, selectedOptionIndices: [0] }],
},
},
state: 'output-available',
});
pendingQuestionThreadId = await insertThread({
id: randomUUID(),
title: 'integration-pending-question-thread',
});
await insertMessage({
threadId: pendingQuestionThreadId,
role: 'user',
isHidden: true,
createdAt: '2026-01-01T00:06:00Z',
});
const pendingQuestionMessageId = await insertMessage({
threadId: pendingQuestionThreadId,
role: 'assistant',
createdAt: '2026-01-01T00:07:00Z',
});
await insertPart({
messageId: pendingQuestionMessageId,
orderIndex: 0,
type: 'tool-ask_questions',
toolName: 'ask_questions',
toolCallId: 'call-pending-questions',
toolInput: { questions: questionItems },
toolOutput: {
success: true,
message: 'Questions presented to the user; awaiting their answer.',
result: { questions: questionItems, status: 'pending' },
},
state: 'output-available',
});
});
afterAll(async () => {
if (seededPartIds.length > 0) {
await dataSource.query(
`DELETE FROM core."agentMessagePart" WHERE id = ANY($1)`,
[seededPartIds],
);
}
if (seededMessageIds.length > 0) {
await dataSource.query(
`DELETE FROM core."agentMessage" WHERE id = ANY($1)`,
[seededMessageIds],
);
}
if (seededThreadIds.length > 0) {
await dataSource.query(
`DELETE FROM core."agentChatThread" WHERE id = ANY($1)`,
[seededThreadIds],
);
}
await dataSource.query(
`UPDATE core."workspace" SET "allowImpersonation" = true WHERE id = $1`,
[SEED_APPLE_WORKSPACE_ID],
);
});
describe('getAdminChatThreads', () => {
it('returns only onboarding threads for the ONBOARDING scope', async () => {
const result = await fetchThreads({ scope: 'ONBOARDING', limit: 100 });
const threadIds = result.threads.map((thread) => thread.id);
expect(threadIds).toContain(kickoffThreadId);
expect(threadIds).toContain(deterministicThreadId);
expect(threadIds).not.toContain(regularThreadId);
for (const thread of result.threads) {
expect(thread.isOnboardingThread).toBe(true);
}
});
it('defaults to every scope when none is given', async () => {
const result = await fetchThreads({ limit: 100 });
const threadIds = result.threads.map((thread) => thread.id);
expect(threadIds).toContain(regularThreadId);
expect(threadIds).toContain(kickoffThreadId);
});
it('returns all threads with the onboarding flag for the ALL scope', async () => {
const result = await fetchThreads({
scope: 'ALL',
searchTerm: regularThreadId,
});
expect(result.totalCount).toBe(1);
expect(result.threads).toHaveLength(1);
expect(result.threads[0]).toMatchObject({
id: regularThreadId,
isOnboardingThread: false,
hasError: true,
messageCount: 1,
userReplyCount: 0,
userEmail,
});
});
it('counts only visible messages and user replies', async () => {
const result = await fetchThreads({
scope: 'ALL',
searchTerm: kickoffThreadId,
});
expect(result.threads[0]).toMatchObject({
id: kickoffThreadId,
isOnboardingThread: true,
messageCount: 2,
userReplyCount: 1,
hasError: false,
});
});
it('filters threads with errors via hasErrorOnly', async () => {
const result = await fetchThreads({
scope: 'ALL',
hasErrorOnly: true,
limit: 100,
});
const threadIds = result.threads.map((thread) => thread.id);
expect(threadIds).toContain(regularThreadId);
expect(threadIds).not.toContain(kickoffThreadId);
});
it('filters threads without user replies via userNeverEngagedOnly', async () => {
const result = await fetchThreads({
scope: 'ONBOARDING',
userNeverEngagedOnly: true,
limit: 100,
});
const threadIds = result.threads.map((thread) => thread.id);
expect(threadIds).toContain(deterministicThreadId);
expect(threadIds).toContain(pendingQuestionThreadId);
expect(threadIds).not.toContain(kickoffThreadId);
expect(threadIds).not.toContain(answeredQuestionThreadId);
});
it('counts an answered question card as a user reply', async () => {
const result = await fetchThreads({
scope: 'ALL',
searchTerm: answeredQuestionThreadId,
});
expect(result.threads[0]).toMatchObject({
id: answeredQuestionThreadId,
messageCount: 1,
userReplyCount: 1,
});
});
it('does not count a pending question card as a user reply', async () => {
const result = await fetchThreads({
scope: 'ALL',
searchTerm: pendingQuestionThreadId,
});
expect(result.threads[0]).toMatchObject({
id: pendingQuestionThreadId,
messageCount: 1,
userReplyCount: 0,
});
});
it('sorts by message count', async () => {
const result = await fetchThreads({
scope: 'ONBOARDING',
sortBy: 'MESSAGE_COUNT',
sortDirection: 'ASC',
limit: 100,
});
const threadIds = result.threads.map((thread) => thread.id);
expect(threadIds.indexOf(deterministicThreadId)).toBeLessThan(
threadIds.indexOf(kickoffThreadId),
);
});
it('sorts by reply count, counting answered question cards', async () => {
const result = await fetchThreads({
scope: 'ALL',
sortBy: 'REPLY_COUNT',
sortDirection: 'DESC',
limit: 100,
});
const threadIds = result.threads.map((thread) => thread.id);
expect(threadIds.indexOf(answeredQuestionThreadId)).toBeLessThan(
threadIds.indexOf(pendingQuestionThreadId),
);
expect(threadIds.indexOf(kickoffThreadId)).toBeLessThan(
threadIds.indexOf(pendingQuestionThreadId),
);
});
it('paginates with totalCount and hasMore', async () => {
const result = await fetchThreads({ scope: 'ONBOARDING', limit: 1 });
expect(result.threads).toHaveLength(1);
expect(result.totalCount).toBeGreaterThanOrEqual(2);
expect(result.hasMore).toBe(true);
});
it('finds threads by user email via searchTerm', async () => {
const result = await fetchThreads({
scope: 'ALL',
searchTerm: userEmail,
limit: 100,
});
const threadIds = result.threads.map((thread) => thread.id);
expect(threadIds).toContain(kickoffThreadId);
expect(threadIds).toContain(regularThreadId);
});
it('excludes workspaces that disabled support access', async () => {
await dataSource.query(
`UPDATE core."workspace" SET "allowImpersonation" = false WHERE id = $1`,
[SEED_APPLE_WORKSPACE_ID],
);
try {
const result = await fetchThreads({
scope: 'ALL',
searchTerm: kickoffThreadId,
});
expect(result.totalCount).toBe(0);
expect(result.threads).toHaveLength(0);
} finally {
await dataSource.query(
`UPDATE core."workspace" SET "allowImpersonation" = true WHERE id = $1`,
[SEED_APPLE_WORKSPACE_ID],
);
}
});
it('rejects a caller without the SECURITY permission flag', async () => {
const response = await makeAdminPanelAPIRequestWithGuestRole({
query: GET_ADMIN_CHAT_THREADS,
variables: {},
});
expect(response.body.errors).toBeDefined();
expect(response.body.data?.getAdminChatThreads).toBeFalsy();
});
});
describe('getAdminChatThreadMessages', () => {
it('returns the hidden kickoff first with enriched ordered parts', async () => {
const response = await makeAdminPanelAPIRequest({
query: GET_ADMIN_CHAT_THREAD_MESSAGES,
variables: { threadId: kickoffThreadId },
});
expect(response.body.errors).toBeUndefined();
const result = response.body.data?.getAdminChatThreadMessages;
expect(result.thread.messageCount).toBe(2);
expect(result.messages).toHaveLength(3);
expect(result.messages[0]).toMatchObject({
role: 'USER',
isHidden: true,
});
expect(result.messages[0].parts[0].textContent).toBe(
'kickoff prompt with company context',
);
const assistantMessage = result.messages.find(
(message: { role: string }) => message.role === 'ASSISTANT',
);
expect(
assistantMessage.parts.map(
(part: { orderIndex: number }) => part.orderIndex,
),
).toEqual([0, 1]);
expect(assistantMessage.parts[1]).toMatchObject({
type: 'tool-call',
toolName: 'create_many_object_metadata',
toolCallId: 'call-1',
toolInput: { objects: [{ nameSingular: 'listing' }] },
toolOutput: { success: true },
state: 'output-available',
});
expect(assistantMessage.parts[0]).toMatchObject({
type: 'reasoning',
reasoningContent: 'planning the data model',
});
});
});
});

View File

@@ -0,0 +1,505 @@
import { randomUUID } from 'crypto';
import { gql } from 'graphql-tag';
import { type DataSource } from 'typeorm';
import { makeAdminPanelAPIRequestWithGuestRole } from 'test/integration/graphql/suites/admin-panel/utils/make-admin-panel-api-request-with-guest-role.util';
import { makeAdminPanelAPIRequest } from 'test/integration/twenty-config/utils/make-admin-panel-api-request.util';
import {
SEED_APPLE_WORKSPACE_ID,
SEED_YCOMBINATOR_WORKSPACE_ID,
} from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
// displayNames of the seeded workspaces (see seeder-workspaces.constant.ts).
// They drive the ASC ordering and the searchTerm-by-workspace-name assertions.
const APPLE_WORKSPACE_DISPLAY_NAME = 'Apple';
const YCOMBINATOR_WORKSPACE_DISPLAY_NAME = 'YCombinator';
const FIND_STATS = gql`
query FindAdminApplicationRegistrationStats($id: String!) {
findAdminApplicationRegistrationStats(id: $id) {
activeInstalls
suspendedInstalls
mostInstalledVersion
versionDistribution {
version
count
}
}
}
`;
const FIND_INSTALLED_WORKSPACES = gql`
query FindAdminApplicationRegistrationInstalledWorkspaces(
$input: FindApplicationRegistrationInstalledWorkspacesInput!
) {
findAdminApplicationRegistrationInstalledWorkspaces(input: $input) {
totalCount
hasMore
workspaces {
id
displayName
logo
version
}
}
}
`;
const FIND_ALL_REGISTRATIONS = gql`
query FindAllApplicationRegistrations(
$limit: Int
$offset: Int
$searchTerm: String
$isPreInstalledOnly: Boolean
) {
findAllApplicationRegistrations(
limit: $limit
offset: $offset
searchTerm: $searchTerm
isPreInstalledOnly: $isPreInstalledOnly
) {
totalCount
hasMore
registrations {
id
name
}
}
}
`;
const REGISTRATION_NAME = 'admin-panel-stats-integration-test-registration';
const DELETED_WORKSPACE_ONLY_VERSION = '9.9.9';
type SeededApplication = {
id: string;
workspaceId: string;
version: string;
};
describe('Admin panel application registration stats and installed workspaces (integration)', () => {
let dataSource: DataSource;
let applicationRegistrationId: string;
let deletedWorkspaceId: string;
const seededApplicationIds: string[] = [];
const insertApplication = async ({
workspaceId,
version,
}: {
workspaceId: string;
version: string;
}): Promise<SeededApplication> => {
const id = randomUUID();
await dataSource.query(
`INSERT INTO core."application"
(id, "universalIdentifier", name, version, "sourcePath",
"sourceType", "workspaceId", "applicationRegistrationId")
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
id,
randomUUID(),
'admin-panel-stats-integration-test-app',
version,
'',
'local',
workspaceId,
applicationRegistrationId,
],
);
seededApplicationIds.push(id);
return { id, workspaceId, version };
};
beforeAll(async () => {
dataSource = global.testDataSource;
applicationRegistrationId = randomUUID();
await dataSource.query(
`INSERT INTO core."applicationRegistration"
(id, "universalIdentifier", name, "oAuthClientId",
"oAuthRedirectUris", "oAuthScopes", "sourceType")
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
applicationRegistrationId,
randomUUID(),
REGISTRATION_NAME,
randomUUID(),
[],
[],
'local',
],
);
// Two installs on version 2.0.0 (one per seeded workspace) and one on
// 1.0.0, so the distribution is deterministic and 2.0.0 is the most
// installed version.
await insertApplication({
workspaceId: SEED_APPLE_WORKSPACE_ID,
version: '2.0.0',
});
await insertApplication({
workspaceId: SEED_YCOMBINATOR_WORKSPACE_ID,
version: '2.0.0',
});
await insertApplication({
workspaceId: SEED_YCOMBINATOR_WORKSPACE_ID,
version: '1.0.0',
});
deletedWorkspaceId = randomUUID();
const [{ workspaceCustomApplicationId }] = await dataSource.query(
`SELECT "workspaceCustomApplicationId" FROM core."workspace" WHERE id = $1`,
[SEED_APPLE_WORKSPACE_ID],
);
await dataSource.query(
`INSERT INTO core."workspace"
(id, "displayName", subdomain, "activationStatus",
"workspaceCustomApplicationId", "deletedAt")
VALUES ($1, $2, $3, $4, $5, now())`,
[
deletedWorkspaceId,
'admin-panel-stats-deleted-workspace',
`admin-panel-stats-deleted-${deletedWorkspaceId}`,
'PENDING_CREATION',
workspaceCustomApplicationId,
],
);
await insertApplication({
workspaceId: deletedWorkspaceId,
version: DELETED_WORKSPACE_ONLY_VERSION,
});
});
afterAll(async () => {
if (seededApplicationIds.length > 0) {
await dataSource.query(
`DELETE FROM core."application" WHERE id = ANY($1)`,
[seededApplicationIds],
);
}
await dataSource.query(
`DELETE FROM core."applicationRegistration" WHERE id = $1`,
[applicationRegistrationId],
);
await dataSource.query(`DELETE FROM core."workspace" WHERE id = $1`, [
deletedWorkspaceId,
]);
});
describe('findAdminApplicationRegistrationStats', () => {
it('returns active installs, version distribution and the most installed version', async () => {
const response = await makeAdminPanelAPIRequest({
query: FIND_STATS,
variables: { id: applicationRegistrationId },
});
expect(response.body.errors).toBeUndefined();
const stats = response.body.data?.findAdminApplicationRegistrationStats;
expect(stats).toBeDefined();
expect(stats.activeInstalls).toBe(3);
expect(stats.suspendedInstalls).toBe(0);
expect(stats.mostInstalledVersion).toBe('2.0.0');
const distributionByVersion = Object.fromEntries(
stats.versionDistribution.map(
(entry: { version: string; count: number }) => [
entry.version,
entry.count,
],
),
);
expect(distributionByVersion).toEqual({ '2.0.0': 2, '1.0.0': 1 });
// Distribution is ordered by count DESC, so the top entry matches
// mostInstalledVersion.
expect(stats.versionDistribution[0].version).toBe('2.0.0');
});
it('excludes installs on soft-deleted workspaces', async () => {
const response = await makeAdminPanelAPIRequest({
query: FIND_STATS,
variables: { id: applicationRegistrationId },
});
expect(response.body.errors).toBeUndefined();
const stats = response.body.data?.findAdminApplicationRegistrationStats;
expect(stats.activeInstalls).toBe(3);
expect(
stats.versionDistribution.map(
(entry: { version: string }) => entry.version,
),
).not.toContain(DELETED_WORKSPACE_ONLY_VERSION);
});
it('rejects a caller without the SECURITY permission flag', async () => {
const response = await makeAdminPanelAPIRequestWithGuestRole({
query: FIND_STATS,
variables: { id: applicationRegistrationId },
});
expect(response.body.errors).toBeDefined();
expect(
response.body.data?.findAdminApplicationRegistrationStats,
).toBeFalsy();
});
});
describe('findAdminApplicationRegistrationInstalledWorkspaces', () => {
it('returns installed workspaces ordered by display name with totalCount and hasMore', async () => {
const response = await makeAdminPanelAPIRequest({
query: FIND_INSTALLED_WORKSPACES,
variables: { input: { id: applicationRegistrationId, offset: 0 } },
});
expect(response.body.errors).toBeUndefined();
const result =
response.body.data?.findAdminApplicationRegistrationInstalledWorkspaces;
expect(result).toBeDefined();
expect(result.totalCount).toBe(3);
expect(result.hasMore).toBe(false);
expect(result.workspaces).toHaveLength(3);
// Ordered by workspace.displayName ASC — Apple before YCombinator.
expect(result.workspaces[0].displayName).toBe(
APPLE_WORKSPACE_DISPLAY_NAME,
);
expect(result.workspaces[0].version).toBe('2.0.0');
expect(result.workspaces[1].displayName).toBe(
YCOMBINATOR_WORKSPACE_DISPLAY_NAME,
);
expect(result.workspaces[2].displayName).toBe(
YCOMBINATOR_WORKSPACE_DISPLAY_NAME,
);
});
it('filters by workspace display name via searchTerm', async () => {
const response = await makeAdminPanelAPIRequest({
query: FIND_INSTALLED_WORKSPACES,
variables: {
input: {
id: applicationRegistrationId,
offset: 0,
searchTerm: APPLE_WORKSPACE_DISPLAY_NAME,
},
},
});
expect(response.body.errors).toBeUndefined();
const result =
response.body.data?.findAdminApplicationRegistrationInstalledWorkspaces;
expect(result.totalCount).toBe(1);
expect(result.hasMore).toBe(false);
expect(result.workspaces).toHaveLength(1);
expect(result.workspaces[0].displayName).toBe(
APPLE_WORKSPACE_DISPLAY_NAME,
);
});
it('returns an empty result instead of erroring when a search only matches installs on soft-deleted workspaces', async () => {
const response = await makeAdminPanelAPIRequest({
query: FIND_INSTALLED_WORKSPACES,
variables: {
input: {
id: applicationRegistrationId,
offset: 0,
searchTerm: DELETED_WORKSPACE_ONLY_VERSION,
},
},
});
expect(response.body.errors).toBeUndefined();
const result =
response.body.data?.findAdminApplicationRegistrationInstalledWorkspaces;
expect(result.totalCount).toBe(0);
expect(result.hasMore).toBe(false);
expect(result.workspaces).toHaveLength(0);
});
it('filters by application version via searchTerm', async () => {
const response = await makeAdminPanelAPIRequest({
query: FIND_INSTALLED_WORKSPACES,
variables: {
input: {
id: applicationRegistrationId,
offset: 0,
searchTerm: '1.0.0',
},
},
});
expect(response.body.errors).toBeUndefined();
const result =
response.body.data?.findAdminApplicationRegistrationInstalledWorkspaces;
expect(result.totalCount).toBe(1);
expect(result.workspaces).toHaveLength(1);
expect(result.workspaces[0].version).toBe('1.0.0');
expect(result.workspaces[0].displayName).toBe(
YCOMBINATOR_WORKSPACE_DISPLAY_NAME,
);
});
it('rejects a caller without the SECURITY permission flag', async () => {
const response = await makeAdminPanelAPIRequestWithGuestRole({
query: FIND_INSTALLED_WORKSPACES,
variables: { input: { id: applicationRegistrationId, offset: 0 } },
});
expect(response.body.errors).toBeDefined();
expect(
response.body.data?.findAdminApplicationRegistrationInstalledWorkspaces,
).toBeFalsy();
});
});
describe('findAllApplicationRegistrations', () => {
it('filters registrations by name via searchTerm', async () => {
const response = await makeAdminPanelAPIRequest({
query: FIND_ALL_REGISTRATIONS,
variables: { limit: 25, offset: 0, searchTerm: REGISTRATION_NAME },
});
expect(response.body.errors).toBeUndefined();
const result = response.body.data?.findAllApplicationRegistrations;
expect(result.totalCount).toBe(1);
expect(result.hasMore).toBe(false);
expect(result.registrations).toHaveLength(1);
expect(result.registrations[0].id).toBe(applicationRegistrationId);
expect(result.registrations[0].name).toBe(REGISTRATION_NAME);
});
it('returns no registrations when searchTerm matches nothing', async () => {
const response = await makeAdminPanelAPIRequest({
query: FIND_ALL_REGISTRATIONS,
variables: {
limit: 25,
offset: 0,
searchTerm: 'no-registration-matches-this-search-term',
},
});
expect(response.body.errors).toBeUndefined();
const result = response.body.data?.findAllApplicationRegistrations;
expect(result.totalCount).toBe(0);
expect(result.registrations).toHaveLength(0);
});
it('rejects a caller without the SECURITY permission flag', async () => {
const response = await makeAdminPanelAPIRequestWithGuestRole({
query: FIND_ALL_REGISTRATIONS,
variables: { limit: 25, offset: 0 },
});
expect(response.body.errors).toBeDefined();
expect(response.body.data?.findAllApplicationRegistrations).toBeFalsy();
});
});
describe('suspended workspace installs', () => {
let suspendedWorkspaceId: string;
let suspendedApplicationId: string;
beforeAll(async () => {
suspendedWorkspaceId = randomUUID();
const [{ workspaceCustomApplicationId }] = await dataSource.query(
`SELECT "workspaceCustomApplicationId" FROM core."workspace" WHERE id = $1`,
[SEED_APPLE_WORKSPACE_ID],
);
await dataSource.query(
`INSERT INTO core."workspace"
(id, "displayName", subdomain, "activationStatus", "defaultRoleId",
"databaseSchema", "workspaceCustomApplicationId")
VALUES ($1, $2, $3, 'SUSPENDED', $4, $5, $6)`,
[
suspendedWorkspaceId,
'admin-panel-stats-suspended-workspace',
`admin-panel-stats-suspended-${suspendedWorkspaceId}`,
randomUUID(),
'admin_panel_stats_suspended',
workspaceCustomApplicationId,
],
);
const { id } = await insertApplication({
workspaceId: suspendedWorkspaceId,
version: '2.0.0',
});
suspendedApplicationId = id;
});
afterAll(async () => {
await dataSource.query(`DELETE FROM core."application" WHERE id = $1`, [
suspendedApplicationId,
]);
await dataSource.query(`DELETE FROM core."workspace" WHERE id = $1`, [
suspendedWorkspaceId,
]);
});
it('counts them as installs and reports them separately', async () => {
const response = await makeAdminPanelAPIRequest({
query: FIND_STATS,
variables: { id: applicationRegistrationId },
});
expect(response.body.errors).toBeUndefined();
const stats = response.body.data?.findAdminApplicationRegistrationStats;
expect(stats.activeInstalls).toBe(4);
expect(stats.suspendedInstalls).toBe(1);
});
it('lists them alongside installs on active workspaces', async () => {
const response = await makeAdminPanelAPIRequest({
query: FIND_INSTALLED_WORKSPACES,
variables: {
input: {
id: applicationRegistrationId,
offset: 0,
searchTerm: 'admin-panel-stats-suspended-workspace',
},
},
});
expect(response.body.errors).toBeUndefined();
const result =
response.body.data?.findAdminApplicationRegistrationInstalledWorkspaces;
expect(result.totalCount).toBe(1);
expect(result.workspaces[0].id).toBe(suspendedWorkspaceId);
});
});
});

View File

@@ -0,0 +1,26 @@
import { type ASTNode, print } from 'graphql';
import request from 'supertest';
/* global APP_PORT, APPLE_PHIL_GUEST_ACCESS_TOKEN */
type GraphqlOperation = {
query: ASTNode;
variables?: Record<string, unknown>;
};
// Hits the admin GraphQL schema (/admin-panel) with a guest user that lacks the
// SECURITY permission flag, so it can assert the resolver-level guards reject it.
export const makeAdminPanelAPIRequestWithGuestRole = (
graphqlOperation: GraphqlOperation,
) => {
const client = request(`http://localhost:${APP_PORT}`);
return client
.post('/admin-panel')
.set('Authorization', `Bearer ${APPLE_PHIL_GUEST_ACCESS_TOKEN}`)
.send({
query: print(graphqlOperation.query),
variables: graphqlOperation.variables || {},
})
.expect(200);
};

View File

@@ -0,0 +1,415 @@
import { PERSON_GQL_FIELDS } from 'test/integration/constants/person-gql-fields.constants';
import {
TEST_PERSON_1_ID,
TEST_PERSON_2_ID,
TEST_PERSON_3_ID,
} from 'test/integration/constants/test-person-ids.constants';
import { createManyOperationFactory } from 'test/integration/graphql/utils/create-many-operation-factory.util';
import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util';
import { deleteManyOperationFactory } from 'test/integration/graphql/utils/delete-many-operation-factory.util';
import { deleteOneOperationFactory } from 'test/integration/graphql/utils/delete-one-operation-factory.util';
import { destroyManyOperationFactory } from 'test/integration/graphql/utils/destroy-many-operation-factory.util';
import { destroyOneOperationFactory } from 'test/integration/graphql/utils/destroy-one-operation-factory.util';
import { findManyOperationFactory } from 'test/integration/graphql/utils/find-many-operation-factory.util';
import { findOneOperationFactory } from 'test/integration/graphql/utils/find-one-operation-factory.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { updateManyOperationFactory } from 'test/integration/graphql/utils/update-many-operation-factory.util';
import { updateOneOperationFactory } from 'test/integration/graphql/utils/update-one-operation-factory.util';
import { deleteAllRecords } from 'test/integration/utils/delete-all-records';
import { generateRecordName } from 'test/integration/utils/generate-record-name';
describe('people resolvers (integration)', () => {
beforeAll(async () => {
await deleteAllRecords('person');
});
it('1. should create and return people', async () => {
const personJobTitle1 = generateRecordName(TEST_PERSON_1_ID);
const personJobTitle2 = generateRecordName(TEST_PERSON_2_ID);
const graphqlOperation = createManyOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
gqlFields: PERSON_GQL_FIELDS,
data: [
{
id: TEST_PERSON_1_ID,
jobTitle: personJobTitle1,
},
{
id: TEST_PERSON_2_ID,
jobTitle: personJobTitle2,
},
],
});
const response = await makeGraphqlAPIRequest(graphqlOperation);
expect(response.body.data.createPeople).toHaveLength(2);
// @ts-expect-error legacy noImplicitAny
response.body.data.createPeople.forEach((person) => {
expect(person).toHaveProperty('jobTitle');
expect([personJobTitle1, personJobTitle2]).toContain(person.jobTitle);
expect(person).toHaveProperty('id');
expect(person).toHaveProperty('jobTitle');
expect(person).toHaveProperty('avatarUrl');
expect(person).toHaveProperty('avatarFile');
expect(person).toHaveProperty('intro');
expect(person).toHaveProperty('searchVector');
expect(person).toHaveProperty('name');
expect(person).toHaveProperty('createdAt');
expect(person).toHaveProperty('deletedAt');
});
});
it('1b. should create and return one person', async () => {
const personJobTitle3 = generateRecordName(TEST_PERSON_3_ID);
const graphqlOperation = createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
data: {
id: TEST_PERSON_3_ID,
jobTitle: personJobTitle3,
},
});
const response = await makeGraphqlAPIRequest(graphqlOperation);
const createdPerson = response.body.data.createPerson;
expect(createdPerson).toHaveProperty('jobTitle');
expect(createdPerson.jobTitle).toEqual(personJobTitle3);
expect(createdPerson).toHaveProperty('id');
expect(createdPerson).toHaveProperty('jobTitle');
expect(createdPerson).toHaveProperty('avatarUrl');
expect(createdPerson).toHaveProperty('avatarFile');
expect(createdPerson).toHaveProperty('intro');
expect(createdPerson).toHaveProperty('searchVector');
expect(createdPerson).toHaveProperty('name');
expect(createdPerson).toHaveProperty('createdAt');
expect(createdPerson).toHaveProperty('deletedAt');
});
it('2. should find many people', async () => {
const graphqlOperation = findManyOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
gqlFields: PERSON_GQL_FIELDS,
});
const response = await makeGraphqlAPIRequest(graphqlOperation);
const data = response.body.data.people;
expect(data).toBeDefined();
expect(Array.isArray(data.edges)).toBe(true);
const edges = data.edges;
if (edges.length > 0) {
const person = edges[0].node;
expect(person).toHaveProperty('id');
expect(person).toHaveProperty('jobTitle');
expect(person).toHaveProperty('avatarUrl');
expect(person).toHaveProperty('avatarFile');
expect(person).toHaveProperty('intro');
expect(person).toHaveProperty('searchVector');
expect(person).toHaveProperty('name');
expect(person).toHaveProperty('createdAt');
expect(person).toHaveProperty('deletedAt');
}
});
it('2b. should find one person', async () => {
const graphqlOperation = findOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
filter: {
id: {
eq: TEST_PERSON_3_ID,
},
},
});
const response = await makeGraphqlAPIRequest(graphqlOperation);
const person = response.body.data.person;
expect(person).toHaveProperty('jobTitle');
expect(person).toHaveProperty('id');
expect(person).toHaveProperty('jobTitle');
expect(person).toHaveProperty('avatarUrl');
expect(person).toHaveProperty('avatarFile');
expect(person).toHaveProperty('intro');
expect(person).toHaveProperty('searchVector');
expect(person).toHaveProperty('name');
expect(person).toHaveProperty('createdAt');
expect(person).toHaveProperty('deletedAt');
});
it('3. should update many people', async () => {
const graphqlOperation = updateManyOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
gqlFields: PERSON_GQL_FIELDS,
data: {
jobTitle: 'Updated City',
},
filter: {
id: {
in: [TEST_PERSON_1_ID, TEST_PERSON_2_ID],
},
},
});
const response = await makeGraphqlAPIRequest(graphqlOperation);
const updatedPeople = response.body.data.updatePeople;
expect(updatedPeople).toHaveLength(2);
// @ts-expect-error legacy noImplicitAny
updatedPeople.forEach((person) => {
expect(person.jobTitle).toEqual('Updated City');
});
});
it('3b. should update one person', async () => {
const graphqlOperation = updateOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
data: {
jobTitle: 'New City',
},
recordId: TEST_PERSON_3_ID,
});
const response = await makeGraphqlAPIRequest(graphqlOperation);
const updatedPerson = response.body.data.updatePerson;
expect(updatedPerson.jobTitle).toEqual('New City');
});
it('4. should find many people with updated city', async () => {
const graphqlOperation = findManyOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
gqlFields: PERSON_GQL_FIELDS,
filter: {
jobTitle: {
eq: 'Updated City',
},
},
});
const response = await makeGraphqlAPIRequest(graphqlOperation);
expect(response.body.data.people.edges).toHaveLength(2);
});
it('4b. should find one person with updated city', async () => {
const graphqlOperation = findOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
filter: {
jobTitle: {
eq: 'New City',
},
},
});
const response = await makeGraphqlAPIRequest(graphqlOperation);
expect(response.body.data.person.jobTitle).toEqual('New City');
});
it('5. should delete many people', async () => {
const graphqlOperation = deleteManyOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
gqlFields: PERSON_GQL_FIELDS,
filter: {
id: {
in: [TEST_PERSON_1_ID, TEST_PERSON_2_ID],
},
},
});
const response = await makeGraphqlAPIRequest(graphqlOperation);
const deletePeople = response.body.data.deletePeople;
expect(deletePeople).toHaveLength(2);
// @ts-expect-error legacy noImplicitAny
deletePeople.forEach((person) => {
expect(person.deletedAt).toBeTruthy();
});
});
it('5b. should delete one person', async () => {
const graphqlOperation = deleteOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
recordId: TEST_PERSON_3_ID,
});
const response = await makeGraphqlAPIRequest(graphqlOperation);
expect(response.body.data.deletePerson.deletedAt).toBeTruthy();
});
it('6. should not find many people anymore', async () => {
const graphqlOperation = findManyOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
gqlFields: PERSON_GQL_FIELDS,
filter: {
id: {
in: [TEST_PERSON_1_ID, TEST_PERSON_2_ID],
},
},
});
const findPeopleResponse = await makeGraphqlAPIRequest(graphqlOperation);
expect(findPeopleResponse.body.data.people.edges).toHaveLength(0);
});
it('6b. should not find one person anymore', async () => {
const graphqlOperation = findOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
filter: {
id: {
eq: TEST_PERSON_3_ID,
},
},
});
const response = await makeGraphqlAPIRequest(graphqlOperation);
expect(response.body.data.person).toBeNull();
});
it('7. should find many deleted people with deletedAt filter', async () => {
const graphqlOperation = findManyOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
gqlFields: PERSON_GQL_FIELDS,
filter: {
id: {
in: [TEST_PERSON_1_ID, TEST_PERSON_2_ID],
},
not: {
deletedAt: {
is: 'NULL',
},
},
},
});
const response = await makeGraphqlAPIRequest(graphqlOperation);
expect(response.body.data.people.edges).toHaveLength(2);
});
it('7b. should find one deleted person with deletedAt filter', async () => {
const graphqlOperation = findOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
filter: {
id: {
eq: TEST_PERSON_3_ID,
},
not: {
deletedAt: {
is: 'NULL',
},
},
},
});
const response = await makeGraphqlAPIRequest(graphqlOperation);
expect(response.body.data.person.id).toEqual(TEST_PERSON_3_ID);
});
it('8. should destroy many people', async () => {
const graphqlOperation = destroyManyOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
gqlFields: PERSON_GQL_FIELDS,
filter: {
id: {
in: [TEST_PERSON_1_ID, TEST_PERSON_2_ID],
},
},
});
const response = await makeGraphqlAPIRequest(graphqlOperation);
expect(response.body.data.destroyPeople).toHaveLength(2);
});
it('8b. should destroy one person', async () => {
const graphqlOperation = destroyOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
recordId: TEST_PERSON_3_ID,
});
const destroyPeopleResponse = await makeGraphqlAPIRequest(graphqlOperation);
expect(destroyPeopleResponse.body.data.destroyPerson).toBeTruthy();
});
it('9. should not find many people anymore', async () => {
const graphqlOperation = findManyOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
gqlFields: PERSON_GQL_FIELDS,
filter: {
id: {
in: [TEST_PERSON_1_ID, TEST_PERSON_2_ID],
},
not: {
deletedAt: {
is: 'NULL',
},
},
},
});
const response = await makeGraphqlAPIRequest(graphqlOperation);
expect(response.body.data.people.edges).toHaveLength(0);
});
it('9b. should not find one person anymore', async () => {
const graphqlOperation = findOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
filter: {
id: {
eq: TEST_PERSON_3_ID,
},
not: {
deletedAt: {
is: 'NULL',
},
},
},
});
const response = await makeGraphqlAPIRequest(graphqlOperation);
expect(response.body.data.person).toBeNull();
});
});

View File

@@ -0,0 +1,237 @@
import { randomUUID } from 'crypto';
import { COMPANY_GQL_FIELDS } from 'test/integration/constants/company-gql-fields.constants';
import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util';
import { destroyOneOperationFactory } from 'test/integration/graphql/utils/destroy-one-operation-factory.util';
import { findManyOperationFactory } from 'test/integration/graphql/utils/find-many-operation-factory.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { updateOneOperationFactory } from 'test/integration/graphql/utils/update-one-operation-factory.util';
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { generateApplicationToken } from 'test/integration/metadata/suites/application/utils/generate-application-token.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { createOneRole } from 'test/integration/metadata/suites/role/utils/create-one-role.util';
import { type Manifest } from 'twenty-shared/application';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { RowLevelPermissionPredicateOperand } from 'twenty-shared/types';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
const TEST_APP_UNIVERSAL_IDENTIFIER = randomUUID();
const TEST_ROLE_UNIVERSAL_IDENTIFIER = randomUUID();
const TEST_OBJECT_PERMISSION_UNIVERSAL_IDENTIFIER = randomUUID();
const TEST_PREDICATE_UNIVERSAL_IDENTIFIER = randomUUID();
const VISIBLE_COMPANY_ID = randomUUID();
const HIDDEN_COMPANY_ID = randomUUID();
const VISIBLE_COMPANY_NAME = `Intersection Visible ${VISIBLE_COMPANY_ID}`;
const HIDDEN_COMPANY_NAME = `Intersection Hidden ${HIDDEN_COMPANY_ID}`;
const COMPANY_UNIVERSAL_IDENTIFIER =
STANDARD_OBJECTS.company.universalIdentifier;
const COMPANY_NAME_FIELD_UNIVERSAL_IDENTIFIER =
STANDARD_OBJECTS.company.fields.name.universalIdentifier;
// The application declares a role that is strictly narrower than the admin who
// holds the token: it may read but not update, and only sees companies whose
// name contains "Intersection Visible". The admin has neither bound, so every
// assertion below fails if the application's role is dropped.
const buildApplicationManifest = (): Manifest =>
buildBaseManifest({
appId: TEST_APP_UNIVERSAL_IDENTIFIER,
roleId: TEST_ROLE_UNIVERSAL_IDENTIFIER,
overrides: {
roles: [
{
universalIdentifier: TEST_ROLE_UNIVERSAL_IDENTIFIER,
label: 'Intersection Test Role',
description: 'Role narrower than the user acting through the app',
canUpdateAllSettings: false,
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: false,
objectPermissions: [
{
universalIdentifier: TEST_OBJECT_PERMISSION_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: COMPANY_UNIVERSAL_IDENTIFIER,
canReadObjectRecords: true,
canUpdateObjectRecords: false,
},
],
rowLevelPermissionPredicates: [
{
universalIdentifier: TEST_PREDICATE_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: COMPANY_UNIVERSAL_IDENTIFIER,
fieldUniversalIdentifier: COMPANY_NAME_FIELD_UNIVERSAL_IDENTIFIER,
operand: RowLevelPermissionPredicateOperand.CONTAINS,
value: 'Intersection Visible',
},
],
},
],
},
});
const findApplicationId = async (): Promise<string> => {
const rows = await globalThis.testDataSource.query(
`SELECT id FROM core."application"
WHERE "universalIdentifier" = $1 AND "workspaceId" = $2`,
[TEST_APP_UNIVERSAL_IDENTIFIER, SEED_APPLE_WORKSPACE_ID],
);
return rows[0]?.id;
};
const findApplicationDefaultRoleId = async (): Promise<string | null> => {
const rows = await globalThis.testDataSource.query(
`SELECT "defaultRoleId" FROM core."application"
WHERE "universalIdentifier" = $1 AND "workspaceId" = $2`,
[TEST_APP_UNIVERSAL_IDENTIFIER, SEED_APPLE_WORKSPACE_ID],
);
return rows[0]?.defaultRoleId ?? null;
};
const createCompany = async (id: string, name: string) =>
makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: COMPANY_GQL_FIELDS,
data: { id, name },
}),
);
const destroyCompany = async (id: string) =>
makeGraphqlAPIRequest(
destroyOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: 'id',
recordId: id,
}),
);
const findCompanyNames = async (token?: string): Promise<string[]> => {
const response = await makeGraphqlAPIRequest(
findManyOperationFactory({
objectMetadataSingularName: 'company',
objectMetadataPluralName: 'companies',
gqlFields: 'id name',
filter: { id: { in: [VISIBLE_COMPANY_ID, HIDDEN_COMPANY_ID] } },
}),
token,
);
expect(response.body.errors).toBeUndefined();
return response.body.data.companies.edges.map(
(edge: { node: { name: string } }) => edge.node.name,
);
};
describe('An application acting for a user is bound by both roles', () => {
let applicationAccessToken: string;
beforeAll(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_UNIVERSAL_IDENTIFIER,
name: 'Role Intersection Test Application',
description: 'App for testing application and user role intersection',
sourcePath: 'test-role-intersection',
});
// setupApplicationForSync leaves fake timers installed.
jest.useRealTimers();
const { errors } = await syncApplication({
manifest: buildApplicationManifest(),
expectToFail: false,
});
expect(errors).toBeUndefined();
const applicationId = await findApplicationId();
expect(applicationId).toBeTruthy();
expect(await findApplicationDefaultRoleId()).toBeTruthy();
await createCompany(VISIBLE_COMPANY_ID, VISIBLE_COMPANY_NAME);
await createCompany(HIDDEN_COMPANY_ID, HIDDEN_COMPANY_NAME);
// Minted with the admin token, so it carries that admin's userId and
// userWorkspaceId alongside the applicationId.
const { data } = await generateApplicationToken({
applicationId,
expectToFail: false,
});
applicationAccessToken =
data.generateApplicationToken.applicationAccessToken.token;
}, 120000);
afterAll(async () => {
await destroyCompany(VISIBLE_COMPANY_ID);
await destroyCompany(HIDDEN_COMPANY_ID);
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_UNIVERSAL_IDENTIFIER,
});
}, 120000);
it('should let the user see both companies when acting on their own', async () => {
const names = await findCompanyNames();
expect(names).toHaveLength(2);
expect(names).toEqual(
expect.arrayContaining([VISIBLE_COMPANY_NAME, HIDDEN_COMPANY_NAME]),
);
});
it('should apply the application row-level predicate even though the user has none', async () => {
const names = await findCompanyNames(applicationAccessToken);
expect(names).toEqual([VISIBLE_COMPANY_NAME]);
});
it('should refuse an update the application role forbids and the user role allows', async () => {
const response = await makeGraphqlAPIRequest(
updateOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: COMPANY_GQL_FIELDS,
recordId: VISIBLE_COMPANY_ID,
data: { name: `${VISIBLE_COMPANY_NAME} edited` },
}),
applicationAccessToken,
);
expect(response.body.errors).toBeDefined();
expect(response.body.data?.updateCompany).toBeFalsy();
});
it('should refuse a settings mutation the application role forbids', async () => {
const { errors } = await createOneRole({
expectToFail: true,
token: applicationAccessToken,
input: {
label: `Should Never Exist ${randomUUID()}`,
description: 'Created through an application whose role forbids it',
icon: 'IconLock',
},
});
expect(errors).toBeDefined();
});
it('should let the same update through when the user acts on their own', async () => {
const response = await makeGraphqlAPIRequest(
updateOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: COMPANY_GQL_FIELDS,
recordId: VISIBLE_COMPANY_ID,
data: { name: VISIBLE_COMPANY_NAME },
}),
);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.updateCompany.id).toBe(VISIBLE_COMPANY_ID);
});
});

View File

@@ -0,0 +1,93 @@
import request from 'supertest';
const SERVER_URL = `http://localhost:${APP_PORT}`;
const client = request(SERVER_URL);
const ORIGIN = new URL(SERVER_URL);
ORIGIN.hostname =
process.env.IS_MULTIWORKSPACE_ENABLED === 'true'
? `apple.${ORIGIN.hostname}`
: ORIGIN.hostname;
const auth = {
email: 'tim@apple.dev',
password: 'tim@apple.dev',
};
describe('AuthResolve (integration)', () => {
let loginToken: string;
it('should getLoginTokenFromCredentials with email and password', () => {
const queryData = {
query: `
mutation GetLoginTokenFromCredentials {
getLoginTokenFromCredentials(email: "${auth.email}", password: "${auth.password}", origin: "${ORIGIN.toString()}") {
loginToken {
token
expiresAt
}
}
}
`,
};
return client
.post('/metadata')
.set('Host', ORIGIN.host)
.set('Origin', ORIGIN.origin)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeDefined();
expect(res.body.errors).toBeUndefined();
})
.expect((res) => {
const data = res.body.data.getLoginTokenFromCredentials;
expect(data).toBeDefined();
expect(data.loginToken).toBeDefined();
loginToken = data.loginToken.token;
});
});
it('should getAuthTokensFromLoginToken with login token', () => {
const queryData = {
query: `
mutation GetAuthTokensFromLoginToken {
getAuthTokensFromLoginToken(loginToken: "${loginToken}", origin: "${ORIGIN.toString()}") {
tokens {
accessOrWorkspaceAgnosticToken {
token
}
}
}
}
`,
};
return client
.post('/metadata')
.set('Host', ORIGIN.host)
.set('Origin', ORIGIN.origin)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeDefined();
expect(res.body.errors).toBeUndefined();
})
.expect((res) => {
const data = res.body.data.getAuthTokensFromLoginToken;
expect(data).toBeDefined();
expect(data.tokens).toBeDefined();
const accessToken = data.tokens.accessOrWorkspaceAgnosticToken;
expect(accessToken).toBeDefined();
expect(accessToken.token).toBeDefined();
});
});
});

View File

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

View File

@@ -0,0 +1,81 @@
import { buildAppleWorkspaceOrigin } from 'test/integration/graphql/utils/build-apple-workspace-origin.util';
import { getLoginTokenFromCredentialsQueryFactory } from 'test/integration/graphql/utils/get-login-token-from-credentials.query-factory.util';
import { signUpInWorkspaceOperationFactory } from 'test/integration/graphql/utils/sign-up-in-workspace-operation-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 { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
import { USER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util';
const STORED_ADDRESS = 'tim@apple.dev';
const IDENTITY_PROVIDER_ADDRESS = 'Tim@Apple.Dev';
const PASSWORD = 'tim@apple.dev';
const countLiveUsersWithAddress = async (address: string): Promise<number> => {
const rows = await global.testDataSource.query(
'SELECT 1 FROM core."user" WHERE lower("email") = lower($1) AND "deletedAt" IS NULL',
[address],
);
return rows.length;
};
describe('user identity is case-insensitive on email (integration)', () => {
it('resolves the already-registered user when the address casing differs', async () => {
const userRepository = getCoreRepository<UserEntity>(UserEntity);
const user = await userRepository.findOne({
where: { email: IDENTITY_PROVIDER_ADDRESS },
});
expect(user?.id).toBe(USER_DATA_SEED_IDS.TIM);
expect(user?.email).toBe(STORED_ADDRESS);
});
it('issues a login token when signing in with a differently-cased address', async () => {
const response = await makeMetadataAPIRequest(
getLoginTokenFromCredentialsQueryFactory({
email: IDENTITY_PROVIDER_ADDRESS,
password: PASSWORD,
origin: buildAppleWorkspaceOrigin(),
}),
null,
).expect(200);
expect(response.body.errors).toBeUndefined();
expect(
response.body.data.getLoginTokenFromCredentials.loginToken.token,
).toBeDefined();
});
it('signs the existing user into the workspace instead of creating a second row', async () => {
expect(await countLiveUsersWithAddress(STORED_ADDRESS)).toBe(1);
const response = await makeMetadataAPIRequest(
signUpInWorkspaceOperationFactory({
email: IDENTITY_PROVIDER_ADDRESS,
password: PASSWORD,
workspaceId: SEED_APPLE_WORKSPACE_ID,
}),
null,
);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.signUpInWorkspace.workspace.id).toBe(
SEED_APPLE_WORKSPACE_ID,
);
expect(await countLiveUsersWithAddress(STORED_ADDRESS)).toBe(1);
});
it('refuses a second account whose address differs only by casing', async () => {
await expect(
global.testDataSource.query(
'INSERT INTO core."user" ("firstName", "lastName", "email") VALUES ($1, $2, $3)',
['Case', 'Clash', IDENTITY_PROVIDER_ADDRESS],
),
).rejects.toThrow(/UQ_USER_EMAIL/);
expect(await countLiveUsersWithAddress(STORED_ADDRESS)).toBe(1);
});
});

View File

@@ -0,0 +1,29 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`JWT Asymmetric Signing - new ES256 + kid implementation (integration) rejects a token signed by a revoked kid (publicKey present, revokedAt set) 1`] = `
{
"extensions": {
"code": "UNAUTHENTICATED",
"subCode": "UNAUTHENTICATED",
"userFriendlyMessage": {
"id": Any<String>,
"message": "You must be authenticated to perform this action.",
},
},
"message": "Token invalid.",
}
`;
exports[`JWT Asymmetric Signing - new ES256 + kid implementation (integration) rejects a token whose kid was never registered without leaking a 500 1`] = `
{
"extensions": {
"code": "UNAUTHENTICATED",
"subCode": "UNAUTHENTICATED",
"userFriendlyMessage": {
"id": Any<String>,
"message": "You must be authenticated to perform this action.",
},
},
"message": "Token invalid.",
}
`;

View File

@@ -0,0 +1,266 @@
import { randomUUID } from 'crypto';
import { gql } from 'graphql-tag';
import * as jwt from 'jsonwebtoken';
import { decodeJwtCompleteOrThrow } from 'test/integration/graphql/utils/decode-jwt-complete-or-throw.util';
import { deleteUser } from 'test/integration/graphql/utils/delete-user.util';
import { getAuthTokensFromLoginToken } from 'test/integration/graphql/utils/get-auth-tokens-from-login-token.util';
import { getCurrentUser } from 'test/integration/graphql/utils/get-current-user.util';
import { signUp } from 'test/integration/graphql/utils/sign-up.util';
import { signUpInNewWorkspace } from 'test/integration/graphql/utils/sign-up-in-new-workspace.util';
import { makeAdminPanelAPIRequest } from 'test/integration/twenty-config/utils/make-admin-panel-api-request.util';
import { type AccessTokenJwtPayload } from 'src/engine/core-modules/auth/types/access-token-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import {
PREVIOUS_PRIVATE_KEY_PEM,
PREVIOUS_PUBLIC_KEY_PEM,
} from './jwt-key-rotation.fixture';
const buildAccessTokenPayload = (payload: AccessTokenJwtPayload) => ({
sub: payload.sub,
userId: payload.userId,
workspaceId: payload.workspaceId,
workspaceMemberId: payload.workspaceMemberId,
userWorkspaceId: payload.userWorkspaceId,
authProvider: payload.authProvider,
isImpersonating: false,
type: JwtTokenTypeEnum.ACCESS,
});
const GET_SIGNING_KEYS = gql`
query GetSigningKeys {
getSigningKeys {
signingKeys {
id
publicKey
isCurrent
createdAt
revokedAt
verifyCountInWindow
}
legacyVerifyCountInWindow
verifyWindowDays
}
}
`;
const REVOKE_SIGNING_KEY = gql`
mutation RevokeSigningKey($id: UUID!) {
revokeSigningKey(id: $id) {
id
isCurrent
revokedAt
}
}
`;
describe('Admin panel signing keys (integration)', () => {
let sharedAccessToken: string;
let sharedAccessPayload: AccessTokenJwtPayload;
beforeAll(async () => {
const uniqueEmail = `admin-signing-keys-${randomUUID()}@example.com`;
const { data: signUpData } = await signUp({
input: { email: uniqueEmail, password: 'Test123!@#' },
expectToFail: false,
});
const workspaceAgnosticToken =
signUpData.signUp.tokens.accessOrWorkspaceAgnosticToken.token;
await global.testDataSource.query(
'UPDATE core."user" SET "isEmailVerified" = true WHERE email = $1',
[uniqueEmail],
);
const { data: workspaceData } = await signUpInNewWorkspace({
accessToken: workspaceAgnosticToken,
expectToFail: false,
});
const subdomainUrl =
workspaceData.signUpInNewWorkspace.workspace.workspaceUrls.subdomainUrl;
const loginToken = workspaceData.signUpInNewWorkspace.loginToken.token;
const { data: tokensData } = await getAuthTokensFromLoginToken({
loginToken,
origin: subdomainUrl,
expectToFail: false,
});
sharedAccessToken =
tokensData.getAuthTokensFromLoginToken.tokens
.accessOrWorkspaceAgnosticToken.token;
sharedAccessPayload = jwt.decode(
sharedAccessToken,
) as AccessTokenJwtPayload;
});
afterAll(async () => {
try {
await deleteUser({
accessToken: sharedAccessToken,
expectToFail: false,
});
} catch {
/* */
}
});
it('exposes signing keys with current marker and a 7-day window', async () => {
const response = await makeAdminPanelAPIRequest({
query: GET_SIGNING_KEYS,
});
expect(response.body.errors).toBeUndefined();
const payload = response.body.data?.getSigningKeys;
expect(payload).toBeDefined();
expect(payload.verifyWindowDays).toBe(7);
expect(Array.isArray(payload.signingKeys)).toBe(true);
expect(payload.signingKeys.length).toBeGreaterThan(0);
const currentKeys = payload.signingKeys.filter(
(signingKey: { isCurrent: boolean }) => signingKey.isCurrent === true,
);
expect(currentKeys).toHaveLength(1);
expect(currentKeys[0].revokedAt).toBeNull();
expect(typeof currentKeys[0].publicKey).toBe('string');
expect(typeof payload.legacyVerifyCountInWindow).toBe('number');
});
it('revokes a non-current signing key, keeps the current key active, and rejects tokens signed with the revoked kid', async () => {
const seededRow = await global.testDataSource.query(
`SELECT "id" FROM core."signingKey" WHERE "isCurrent" = true LIMIT 1`,
);
const seededCurrentKid: string = seededRow[0].id;
const obsoleteKid = randomUUID();
await global.testDataSource.query(
`INSERT INTO core."signingKey" ("id", "publicKey", "privateKey", "isCurrent")
VALUES ($1, $2, NULL, false)`,
[obsoleteKid, PREVIOUS_PUBLIC_KEY_PEM],
);
const tokenSignedByObsoleteKey = jwt.sign(
buildAccessTokenPayload(sharedAccessPayload),
PREVIOUS_PRIVATE_KEY_PEM,
{ algorithm: 'ES256', keyid: obsoleteKid, expiresIn: '5m' },
);
const { data: userBeforeRevoke, errors: userBeforeRevokeErrors } =
await getCurrentUser({
accessToken: tokenSignedByObsoleteKey,
expectToFail: false,
});
expect(userBeforeRevokeErrors).toBeUndefined();
expect(userBeforeRevoke?.currentUser?.id).toBe(sharedAccessPayload.userId);
const response = await makeAdminPanelAPIRequest({
query: REVOKE_SIGNING_KEY,
variables: { id: obsoleteKid },
});
expect(response.body.errors).toBeUndefined();
expect(response.body.data?.revokeSigningKey?.id).toBe(obsoleteKid);
expect(response.body.data?.revokeSigningKey?.isCurrent).toBe(false);
expect(response.body.data?.revokeSigningKey?.revokedAt).not.toBeNull();
const stillCurrentRows = await global.testDataSource.query(
`SELECT "id", "isCurrent", "revokedAt" FROM core."signingKey"
WHERE "id" = $1`,
[seededCurrentKid],
);
expect(stillCurrentRows[0].isCurrent).toBe(true);
expect(stillCurrentRows[0].revokedAt).toBeNull();
const { data: userAfterRevoke, errors: userAfterRevokeErrors } =
await getCurrentUser({
accessToken: tokenSignedByObsoleteKey,
expectToFail: true,
});
expect(userAfterRevoke?.currentUser).toBeFalsy();
expect(userAfterRevokeErrors).toBeDefined();
await global.testDataSource.query(
`DELETE FROM core."signingKey" WHERE "id" = $1`,
[obsoleteKid],
);
});
it('revokes the current signing key and the next sign mints a new current key', async () => {
const before = await global.testDataSource.query(
`SELECT "id" FROM core."signingKey" WHERE "isCurrent" = true LIMIT 1`,
);
const previousCurrentKid: string = before[0].id;
const response = await makeAdminPanelAPIRequest({
query: REVOKE_SIGNING_KEY,
variables: { id: previousCurrentKid },
});
expect(response.body.errors).toBeUndefined();
expect(response.body.data?.revokeSigningKey?.isCurrent).toBe(false);
expect(response.body.data?.revokeSigningKey?.revokedAt).not.toBeNull();
const uniqueEmail = `signing-key-revoke-${randomUUID()}@example.com`;
const { data: signUpData } = await signUp({
input: { email: uniqueEmail, password: 'Test123!@#' },
expectToFail: false,
});
const workspaceAgnosticToken =
signUpData.signUp.tokens.accessOrWorkspaceAgnosticToken.token;
await global.testDataSource.query(
'UPDATE core."user" SET "isEmailVerified" = true WHERE email = $1',
[uniqueEmail],
);
const { data: workspaceData } = await signUpInNewWorkspace({
accessToken: workspaceAgnosticToken,
expectToFail: false,
});
const subdomainUrl =
workspaceData.signUpInNewWorkspace.workspace.workspaceUrls.subdomainUrl;
const loginToken = workspaceData.signUpInNewWorkspace.loginToken.token;
const { data: tokensData } = await getAuthTokensFromLoginToken({
loginToken,
origin: subdomainUrl,
expectToFail: false,
});
const accessToken =
tokensData.getAuthTokensFromLoginToken.tokens
.accessOrWorkspaceAgnosticToken.token;
const decoded = decodeJwtCompleteOrThrow(accessToken);
expect(decoded.header.alg).toBe('ES256');
expect(decoded.header.kid).not.toBe(previousCurrentKid);
const { errors } = await getCurrentUser({
accessToken,
expectToFail: false,
});
expect(errors).toBeUndefined();
try {
await deleteUser({ accessToken, expectToFail: false });
} catch {
/* */
}
});
});

View File

@@ -0,0 +1,336 @@
import { randomUUID } from 'crypto';
import { isNonEmptyString } from '@sniptt/guards';
import * as jwt from 'jsonwebtoken';
import { decodeJwtCompleteOrThrow } from 'test/integration/graphql/utils/decode-jwt-complete-or-throw.util';
import { deleteUser } from 'test/integration/graphql/utils/delete-user.util';
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { findManyApplications } from 'test/integration/graphql/utils/find-many-applications.util';
import { generateApiKeyToken } from 'test/integration/graphql/utils/generate-api-key-token.util';
import { getAuthTokensFromLoginToken } from 'test/integration/graphql/utils/get-auth-tokens-from-login-token.util';
import { getCurrentUser } from 'test/integration/graphql/utils/get-current-user.util';
import { renewApplicationToken } from 'test/integration/graphql/utils/renew-application-token.util';
import { renewToken } from 'test/integration/graphql/utils/renew-token.util';
import { signUp } from 'test/integration/graphql/utils/sign-up.util';
import { signUpInNewWorkspace } from 'test/integration/graphql/utils/sign-up-in-new-workspace.util';
import { generateApplicationToken } from 'test/integration/metadata/suites/application/utils/generate-application-token.util';
import { type AccessTokenJwtPayload } from 'src/engine/core-modules/auth/types/access-token-jwt-payload.type';
import { type ApplicationAccessTokenJwtPayload } from 'src/engine/core-modules/auth/types/application-access-token-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { API_KEY_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/api-key-data-seeds.constant';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
import {
PREVIOUS_KID,
PREVIOUS_PRIVATE_KEY_PEM,
PREVIOUS_PUBLIC_KEY_PEM,
REVOKED_KID,
} from './jwt-key-rotation.fixture';
const buildAccessTokenPayload = (payload: AccessTokenJwtPayload) => ({
sub: payload.sub,
userId: payload.userId,
workspaceId: payload.workspaceId,
workspaceMemberId: payload.workspaceMemberId,
userWorkspaceId: payload.userWorkspaceId,
authProvider: payload.authProvider,
isImpersonating: false,
type: JwtTokenTypeEnum.ACCESS,
});
describe('JWT Asymmetric Signing - new ES256 + kid implementation (integration)', () => {
let sharedAccessToken: string;
let sharedRefreshToken: string;
let sharedAccessPayload: AccessTokenJwtPayload;
let currentKid: string;
beforeAll(async () => {
const uniqueEmail = `jwt-asymmetric-${randomUUID()}@example.com`;
const { data: signUpData } = await signUp({
input: { email: uniqueEmail, password: 'Test123!@#' },
expectToFail: false,
});
const workspaceAgnosticToken =
signUpData.signUp.tokens.accessOrWorkspaceAgnosticToken.token;
await global.testDataSource.query(
'UPDATE core."user" SET "isEmailVerified" = true WHERE email = $1',
[uniqueEmail],
);
const { data: workspaceData } = await signUpInNewWorkspace({
accessToken: workspaceAgnosticToken,
expectToFail: false,
});
const subdomainUrl =
workspaceData.signUpInNewWorkspace.workspace.workspaceUrls.subdomainUrl;
const loginToken = workspaceData.signUpInNewWorkspace.loginToken.token;
const { data: tokensData } = await getAuthTokensFromLoginToken({
loginToken,
origin: subdomainUrl,
expectToFail: false,
});
sharedAccessToken =
tokensData.getAuthTokensFromLoginToken.tokens
.accessOrWorkspaceAgnosticToken.token;
sharedRefreshToken =
tokensData.getAuthTokensFromLoginToken.tokens.refreshToken.token;
sharedAccessPayload = jwt.decode(
sharedAccessToken,
) as AccessTokenJwtPayload;
currentKid = decodeJwtCompleteOrThrow(sharedAccessToken).header
.kid as string;
});
afterAll(async () => {
if (isNonEmptyString(sharedAccessToken)) {
try {
await deleteUser({
accessToken: sharedAccessToken,
expectToFail: false,
});
} catch {
/* */
}
}
await global.testDataSource.query(
`DELETE FROM core."signingKey" WHERE "id" = ANY($1::uuid[])`,
[[PREVIOUS_KID, REVOKED_KID]],
);
});
it('auto-generates an ES256 signing key on first boot and signs ACCESS tokens with kid + isCurrent row', async () => {
const decoded = decodeJwtCompleteOrThrow(sharedAccessToken);
expect(decoded.header.alg).toBe('ES256');
expect(isNonEmptyString(decoded.header.kid)).toBe(true);
const rows = await global.testDataSource.query(
`SELECT "id", "publicKey", "privateKey", "isCurrent", "revokedAt"
FROM core."signingKey" WHERE "id" = $1`,
[currentKid],
);
expect(rows).toHaveLength(1);
expect(rows[0].isCurrent).toBe(true);
expect(rows[0].revokedAt).toBeNull();
expect(isNonEmptyString(rows[0].publicKey)).toBe(true);
expect(isNonEmptyString(rows[0].privateKey)).toBe(true);
expect(rows[0].publicKey).toMatch(
/^-----BEGIN PUBLIC KEY-----[\s\S]+-----END PUBLIC KEY-----\s*$/,
);
const { data, errors } = await getCurrentUser({
accessToken: sharedAccessToken,
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(data?.currentUser).toBeDefined();
});
it('verifies a token signed by a previously rotated-out kid (privateKey null, public key still present)', async () => {
await global.testDataSource.query(
`INSERT INTO core."signingKey" ("id", "publicKey", "privateKey", "isCurrent")
VALUES ($1, $2, NULL, false)
ON CONFLICT ("id") DO NOTHING`,
[PREVIOUS_KID, PREVIOUS_PUBLIC_KEY_PEM],
);
const tokenSignedByPreviousKey = jwt.sign(
buildAccessTokenPayload(sharedAccessPayload),
PREVIOUS_PRIVATE_KEY_PEM,
{ algorithm: 'ES256', keyid: PREVIOUS_KID, expiresIn: '5m' },
);
const decoded = decodeJwtCompleteOrThrow(tokenSignedByPreviousKey);
expect(decoded.header.alg).toBe('ES256');
expect(decoded.header.kid).toBe(PREVIOUS_KID);
expect(decoded.header.kid).not.toBe(currentKid);
const { data, errors } = await getCurrentUser({
accessToken: tokenSignedByPreviousKey,
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(data?.currentUser?.id).toBe(sharedAccessPayload.userId);
});
it('rejects a token signed by a revoked kid (publicKey present, revokedAt set)', async () => {
await global.testDataSource.query(
`INSERT INTO core."signingKey" ("id", "publicKey", "privateKey", "isCurrent", "revokedAt")
VALUES ($1, $2, NULL, false, NOW())
ON CONFLICT ("id") DO UPDATE SET "revokedAt" = NOW(), "isCurrent" = false`,
[REVOKED_KID, PREVIOUS_PUBLIC_KEY_PEM],
);
const tokenSignedByRevokedKey = jwt.sign(
buildAccessTokenPayload(sharedAccessPayload),
PREVIOUS_PRIVATE_KEY_PEM,
{ algorithm: 'ES256', keyid: REVOKED_KID, expiresIn: '5m' },
);
const { data, errors } = await getCurrentUser({
accessToken: tokenSignedByRevokedKey,
expectToFail: true,
});
expect(data?.currentUser).toBeFalsy();
expectOneNotInternalServerErrorSnapshot({ errors });
});
it('rejects a token whose kid was never registered without leaking a 500', async () => {
const unknownKid = '00000000-0000-4000-8000-000000000099';
const tokenSignedByOrphanKey = jwt.sign(
buildAccessTokenPayload(sharedAccessPayload),
PREVIOUS_PRIVATE_KEY_PEM,
{ algorithm: 'ES256', keyid: unknownKid, expiresIn: '5m' },
);
const { data, errors } = await getCurrentUser({
accessToken: tokenSignedByOrphanKey,
expectToFail: true,
});
expect(data?.currentUser).toBeFalsy();
expectOneNotInternalServerErrorSnapshot({ errors });
});
it('round-trips a new ES256 REFRESH token through renewToken', async () => {
const response = await renewToken(sharedRefreshToken);
expect(response.body.errors).toBeUndefined();
expect(
isNonEmptyString(
response.body.data?.renewToken.tokens.accessOrWorkspaceAgnosticToken
.token,
),
).toBe(true);
expect(
isNonEmptyString(
response.body.data?.renewToken.tokens.refreshToken.token,
),
).toBe(true);
});
});
describe('JWT Asymmetric Signing - seeded-workspace tokens (integration)', () => {
const seededApiKeyId = API_KEY_DATA_SEED_IDS.ID_1;
const seededWorkspaceId = SEED_APPLE_WORKSPACE_ID;
let seededCurrentKid: string;
let seededApplicationId: string;
beforeAll(async () => {
const [{ id: currentKidRow }] = await global.testDataSource.query(
`SELECT "id" FROM core."signingKey" WHERE "isCurrent" = true LIMIT 1`,
);
seededCurrentKid = currentKidRow;
const { data: applicationsData } = await findManyApplications({
expectToFail: false,
});
const firstApplication = applicationsData.findManyApplications[0];
expect(firstApplication).toBeDefined();
seededApplicationId = firstApplication.id;
});
it('signs new API_KEY tokens with ES256 + kid and authenticates against the GraphQL API', async () => {
const response = await generateApiKeyToken({
apiKeyId: seededApiKeyId,
accessToken: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
expect(response.body.errors).toBeUndefined();
const apiKeyToken: string =
response.body.data?.generateApiKeyToken.token ?? '';
expect(isNonEmptyString(apiKeyToken)).toBe(true);
const decoded = decodeJwtCompleteOrThrow(apiKeyToken);
expect(decoded.header.alg).toBe('ES256');
expect(decoded.header.kid).toBe(seededCurrentKid);
const { data, errors } = await findManyApplications({
accessToken: apiKeyToken,
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(data?.findManyApplications).toBeDefined();
});
it('signs new APPLICATION_ACCESS + APPLICATION_REFRESH tokens with ES256 + kid via generateApplicationToken', async () => {
const { data, errors } = await generateApplicationToken({
applicationId: seededApplicationId,
expectToFail: false,
});
expect(errors).toBeUndefined();
const { applicationAccessToken, applicationRefreshToken } =
data.generateApplicationToken;
const decodedAccess = decodeJwtCompleteOrThrow(
applicationAccessToken.token,
);
const decodedRefresh = decodeJwtCompleteOrThrow(
applicationRefreshToken.token,
);
expect(decodedAccess.header.alg).toBe('ES256');
expect(decodedAccess.header.kid).toBe(seededCurrentKid);
expect(decodedRefresh.header.alg).toBe('ES256');
expect(decodedRefresh.header.kid).toBe(seededCurrentKid);
const accessPayload = jwt.decode(
applicationAccessToken.token,
) as ApplicationAccessTokenJwtPayload;
expect(accessPayload.type).toBe(JwtTokenTypeEnum.APPLICATION_ACCESS);
expect(accessPayload.workspaceId).toBe(seededWorkspaceId);
expect(accessPayload.applicationId).toBe(seededApplicationId);
});
it('round-trips a new ES256 APPLICATION_REFRESH token through renewApplicationToken', async () => {
const { data } = await generateApplicationToken({
applicationId: seededApplicationId,
expectToFail: false,
});
const response = await renewApplicationToken({
applicationRefreshToken:
data.generateApplicationToken.applicationRefreshToken.token,
accessToken: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
expect(response.body.errors).toBeUndefined();
const renewed = response.body.data?.renewApplicationToken;
expect(isNonEmptyString(renewed?.applicationAccessToken.token)).toBe(true);
expect(isNonEmptyString(renewed?.applicationRefreshToken.token)).toBe(true);
expect(
decodeJwtCompleteOrThrow(renewed.applicationAccessToken.token).header.alg,
).toBe('ES256');
});
});

View File

@@ -0,0 +1,13 @@
export const PREVIOUS_PRIVATE_KEY_PEM = `-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgc39GS4ubhdkNWp7L
b8sC0ROUVBGNO8DvltFB9yHCDLWhRANCAASnreXZnMdvXRwuymcxYO0puFCnIQ9n
xySPRmivfklUKwm00ZMXX3AxStxeqp0vr6qOu54JpPdRFfdeYp5E53OF
-----END PRIVATE KEY-----`;
export const PREVIOUS_PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEp63l2ZzHb10cLspnMWDtKbhQpyEP
Z8ckj0Zor35JVCsJtNGTF19wMUrcXqqdL6+qjrueCaT3URX3XmKeROdzhQ==
-----END PUBLIC KEY-----`;
export const PREVIOUS_KID = '00000000-0000-4000-8000-000000000001';
export const REVOKED_KID = '00000000-0000-4000-8000-000000000002';

View File

@@ -0,0 +1,323 @@
import { randomUUID } from 'crypto';
import { isNonEmptyString } from '@sniptt/guards';
import * as jwt from 'jsonwebtoken';
import { decodeJwtCompleteOrThrow } from 'test/integration/graphql/utils/decode-jwt-complete-or-throw.util';
import { deleteUser } from 'test/integration/graphql/utils/delete-user.util';
import { findManyApplications } from 'test/integration/graphql/utils/find-many-applications.util';
import { forgeLegacyHs256Token } from 'test/integration/graphql/utils/forge-legacy-hs256-token.util';
import { getAuthTokensFromLoginToken } from 'test/integration/graphql/utils/get-auth-tokens-from-login-token.util';
import { getCurrentUser } from 'test/integration/graphql/utils/get-current-user.util';
import { renewApplicationToken } from 'test/integration/graphql/utils/renew-application-token.util';
import { renewToken } from 'test/integration/graphql/utils/renew-token.util';
import { signUp } from 'test/integration/graphql/utils/sign-up.util';
import { signUpInNewWorkspace } from 'test/integration/graphql/utils/sign-up-in-new-workspace.util';
import { type AccessTokenJwtPayload } from 'src/engine/core-modules/auth/types/access-token-jwt-payload.type';
import { type ApplicationRefreshTokenJwtPayload } from 'src/engine/core-modules/auth/types/application-refresh-token-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { type LoginTokenJwtPayload } from 'src/engine/core-modules/auth/types/login-token-jwt-payload.type';
import { type RefreshTokenJwtPayload } from 'src/engine/core-modules/auth/types/refresh-token-jwt-payload.type';
import { type WorkspaceAgnosticTokenJwtPayload } from 'src/engine/core-modules/auth/types/workspace-agnostic-token-jwt-payload.type';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { API_KEY_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/api-key-data-seeds.constant';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
const buildAccessTokenPayload = (payload: AccessTokenJwtPayload) => ({
sub: payload.sub,
userId: payload.userId,
workspaceId: payload.workspaceId,
workspaceMemberId: payload.workspaceMemberId,
userWorkspaceId: payload.userWorkspaceId,
authProvider: payload.authProvider,
isImpersonating: false,
type: JwtTokenTypeEnum.ACCESS,
});
describe('JWT Legacy HS256 no-kid fallback (integration)', () => {
let sharedAccessToken: string;
let sharedAccessPayload: AccessTokenJwtPayload;
let sharedRefreshPayload: RefreshTokenJwtPayload;
let sharedLoginPayload: LoginTokenJwtPayload;
let sharedWorkspaceAgnosticPayload: WorkspaceAgnosticTokenJwtPayload;
let sharedSubdomainUrl: string;
beforeAll(async () => {
const uniqueEmail = `jwt-legacy-${randomUUID()}@example.com`;
const { data: signUpData } = await signUp({
input: { email: uniqueEmail, password: 'Test123!@#' },
expectToFail: false,
});
const workspaceAgnosticToken =
signUpData.signUp.tokens.accessOrWorkspaceAgnosticToken.token;
sharedWorkspaceAgnosticPayload = jwt.decode(
workspaceAgnosticToken,
) as WorkspaceAgnosticTokenJwtPayload;
await global.testDataSource.query(
'UPDATE core."user" SET "isEmailVerified" = true WHERE email = $1',
[uniqueEmail],
);
const { data: workspaceData } = await signUpInNewWorkspace({
accessToken: workspaceAgnosticToken,
expectToFail: false,
});
sharedSubdomainUrl =
workspaceData.signUpInNewWorkspace.workspace.workspaceUrls.subdomainUrl;
const loginToken = workspaceData.signUpInNewWorkspace.loginToken.token;
sharedLoginPayload = jwt.decode(loginToken) as LoginTokenJwtPayload;
const { data: tokensData } = await getAuthTokensFromLoginToken({
loginToken,
origin: sharedSubdomainUrl,
expectToFail: false,
});
sharedAccessToken =
tokensData.getAuthTokensFromLoginToken.tokens
.accessOrWorkspaceAgnosticToken.token;
sharedAccessPayload = jwt.decode(
sharedAccessToken,
) as AccessTokenJwtPayload;
sharedRefreshPayload = jwt.decode(
tokensData.getAuthTokensFromLoginToken.tokens.refreshToken.token,
) as RefreshTokenJwtPayload;
});
afterAll(async () => {
if (isNonEmptyString(sharedAccessToken)) {
try {
await deleteUser({
accessToken: sharedAccessToken,
expectToFail: false,
});
} catch {
/* */
}
}
});
it('verifies a hand-crafted no-kid HS256 ACCESS token via the legacy fallback', async () => {
const legacyHs256Token = forgeLegacyHs256Token(
buildAccessTokenPayload(sharedAccessPayload),
sharedAccessPayload.workspaceId,
);
const decoded = decodeJwtCompleteOrThrow(legacyHs256Token);
expect(decoded.header.alg).toBe('HS256');
expect(decoded.header.kid).toBeUndefined();
const { data, errors } = await getCurrentUser({
accessToken: legacyHs256Token,
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(data?.currentUser?.id).toBe(sharedAccessPayload.userId);
});
it('verifies a hand-crafted no-kid HS256 LOGIN token via the legacy fallback (round-trip through getAuthTokensFromLoginToken)', async () => {
const forgedLoginPayload: LoginTokenJwtPayload = {
sub: sharedLoginPayload.sub,
type: JwtTokenTypeEnum.LOGIN,
workspaceId: sharedLoginPayload.workspaceId,
authProvider:
sharedLoginPayload.authProvider ?? AuthProviderEnum.Password,
};
const forgedToken = forgeLegacyHs256Token(
forgedLoginPayload,
sharedLoginPayload.workspaceId,
);
const decoded = decodeJwtCompleteOrThrow(forgedToken);
expect(decoded.header.alg).toBe('HS256');
expect(decoded.header.kid).toBeUndefined();
const { data, errors } = await getAuthTokensFromLoginToken({
loginToken: forgedToken,
origin: sharedSubdomainUrl,
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(
isNonEmptyString(
data?.getAuthTokensFromLoginToken.tokens.accessOrWorkspaceAgnosticToken
.token,
),
).toBe(true);
});
it('verifies a hand-crafted no-kid HS256 WORKSPACE_AGNOSTIC token via the legacy fallback (round-trip through signUpInNewWorkspace)', async () => {
const forgedAgnosticPayload: WorkspaceAgnosticTokenJwtPayload = {
sub: sharedWorkspaceAgnosticPayload.sub,
userId: sharedWorkspaceAgnosticPayload.userId,
type: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC,
authProvider:
sharedWorkspaceAgnosticPayload.authProvider ??
AuthProviderEnum.Password,
};
const forgedToken = forgeLegacyHs256Token(
forgedAgnosticPayload,
sharedWorkspaceAgnosticPayload.userId,
);
const decoded = decodeJwtCompleteOrThrow(forgedToken);
expect(decoded.header.alg).toBe('HS256');
expect(decoded.header.kid).toBeUndefined();
const { data, errors } = await signUpInNewWorkspace({
accessToken: forgedToken,
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(isNonEmptyString(data?.signUpInNewWorkspace.workspace.id)).toBe(
true,
);
});
it('verifies a hand-crafted no-kid HS256 REFRESH token via the legacy fallback (round-trip through renewToken)', async () => {
expect(isNonEmptyString(sharedRefreshPayload.jti)).toBe(true);
const forgedRefreshPayload = {
sub: sharedRefreshPayload.sub,
type: JwtTokenTypeEnum.REFRESH,
userId: sharedRefreshPayload.userId,
workspaceId: sharedRefreshPayload.workspaceId,
authProvider:
sharedRefreshPayload.authProvider ?? AuthProviderEnum.Password,
targetedTokenType:
sharedRefreshPayload.targetedTokenType ?? JwtTokenTypeEnum.ACCESS,
};
const forgedToken = forgeLegacyHs256Token(
forgedRefreshPayload,
sharedRefreshPayload.workspaceId ?? sharedRefreshPayload.userId,
{ expiresIn: '5m', jwtid: sharedRefreshPayload.jti },
);
const decoded = decodeJwtCompleteOrThrow(forgedToken);
expect(decoded.header.alg).toBe('HS256');
expect(decoded.header.kid).toBeUndefined();
const response = await renewToken(forgedToken);
expect(response.body.errors).toBeUndefined();
expect(
isNonEmptyString(
response.body.data?.renewToken.tokens.accessOrWorkspaceAgnosticToken
.token,
),
).toBe(true);
});
});
describe('JWT Legacy HS256 no-kid fallback - seeded-workspace tokens (integration)', () => {
const seededApiKeyId = API_KEY_DATA_SEED_IDS.ID_1;
const seededWorkspaceId = SEED_APPLE_WORKSPACE_ID;
let seededApplicationId: string;
beforeAll(async () => {
const { data: applicationsData } = await findManyApplications({
expectToFail: false,
});
const firstApplication = applicationsData.findManyApplications[0];
expect(firstApplication).toBeDefined();
seededApplicationId = firstApplication.id;
});
it('verifies the seeded legacy HS256 no-kid API_KEY token via the legacy fallback', async () => {
const decoded = decodeJwtCompleteOrThrow(API_KEY_ACCESS_TOKEN);
expect(decoded.header.alg).toBe('HS256');
expect(decoded.header.kid).toBeUndefined();
const { data, errors } = await findManyApplications({
accessToken: API_KEY_ACCESS_TOKEN,
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(data?.findManyApplications).toBeDefined();
});
it('verifies a hand-crafted no-kid HS256 API_KEY token via the legacy fallback', async () => {
const forgedPayload = {
sub: seededWorkspaceId,
type: JwtTokenTypeEnum.API_KEY,
workspaceId: seededWorkspaceId,
};
const forgedToken = forgeLegacyHs256Token(
forgedPayload,
seededWorkspaceId,
{
expiresIn: '5m',
jwtid: seededApiKeyId,
},
);
const decoded = decodeJwtCompleteOrThrow(forgedToken);
expect(decoded.header.alg).toBe('HS256');
expect(decoded.header.kid).toBeUndefined();
const { data, errors } = await findManyApplications({
accessToken: forgedToken,
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(data?.findManyApplications).toBeDefined();
});
it('verifies a hand-crafted no-kid HS256 APPLICATION_REFRESH token via the legacy fallback (round-trip through renewApplicationToken)', async () => {
const forgedPayload: ApplicationRefreshTokenJwtPayload = {
sub: seededApplicationId,
type: JwtTokenTypeEnum.APPLICATION_REFRESH,
workspaceId: seededWorkspaceId,
applicationId: seededApplicationId,
};
const forgedToken = forgeLegacyHs256Token(
forgedPayload as unknown as Record<string, unknown> & {
type: JwtTokenTypeEnum;
},
seededWorkspaceId,
);
const decoded = decodeJwtCompleteOrThrow(forgedToken);
expect(decoded.header.alg).toBe('HS256');
expect(decoded.header.kid).toBeUndefined();
const response = await renewApplicationToken({
applicationRefreshToken: forgedToken,
accessToken: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
expect(response.body.errors).toBeUndefined();
const renewed = response.body.data?.renewApplicationToken;
expect(isNonEmptyString(renewed?.applicationAccessToken.token)).toBe(true);
expect(isNonEmptyString(renewed?.applicationRefreshToken.token)).toBe(true);
});
});

View File

@@ -0,0 +1,105 @@
/* @license Enterprise */
import { isNonEmptyString } from '@sniptt/guards';
import { decodeJwtCompleteOrThrow } from 'test/integration/graphql/utils/decode-jwt-complete-or-throw.util';
import { findManyApplications } from 'test/integration/graphql/utils/find-many-applications.util';
import { generateApiKeyToken } from 'test/integration/graphql/utils/generate-api-key-token.util';
import { deleteConfigVariable } from 'test/integration/twenty-config/utils/delete-config-variable.util';
import { updateConfigVariable } from 'test/integration/twenty-config/utils/update-config-variable.util';
import { expectEventually } from 'test/integration/utils/expect-eventually.util';
import { RotateSigningKeysCronJob } from 'src/engine/core-modules/jwt/crons/jobs/rotate-signing-keys.cron.job';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { type MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { getQueueToken } from 'src/engine/core-modules/message-queue/utils/get-queue-token.util';
import { API_KEY_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/api-key-data-seeds.constant';
const SIGNING_KEY_ROTATION_DAYS_KEY = 'SIGNING_KEY_ROTATION_DAYS';
describe('RotateSigningKeysCronJob (integration)', () => {
const seededApiKeyId = API_KEY_DATA_SEED_IDS.ID_1;
let cronQueue: MessageQueueService;
beforeAll(() => {
cronQueue = global.app.get<MessageQueueService>(
getQueueToken(MessageQueue.cronQueue),
);
});
afterAll(async () => {
await deleteConfigVariable({
input: { key: SIGNING_KEY_ROTATION_DAYS_KEY },
}).catch(() => {});
});
it('rotates the current JWT signing key when the cron command runs and keeps verifying tokens signed by the previous key', async () => {
const initialTokenResponse = await generateApiKeyToken({
apiKeyId: seededApiKeyId,
accessToken: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
expect(initialTokenResponse.body.errors).toBeUndefined();
const initialApiKeyToken: string =
initialTokenResponse.body.data?.generateApiKeyToken.token ?? '';
expect(isNonEmptyString(initialApiKeyToken)).toBe(true);
const initialKid = decodeJwtCompleteOrThrow(initialApiKeyToken).header
.kid as string;
expect(isNonEmptyString(initialKid)).toBe(true);
const initialAuthCall = await findManyApplications({
accessToken: initialApiKeyToken,
expectToFail: false,
});
expect(initialAuthCall.errors).toBeUndefined();
expect(initialAuthCall.data?.findManyApplications).toBeDefined();
await updateConfigVariable({
input: { key: SIGNING_KEY_ROTATION_DAYS_KEY, value: 0 },
});
await cronQueue.add(RotateSigningKeysCronJob.name, {});
let rotatedApiKeyToken = '';
await expectEventually(async () => {
const rotatedTokenResponse = await generateApiKeyToken({
apiKeyId: seededApiKeyId,
accessToken: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
expect(rotatedTokenResponse.body.errors).toBeUndefined();
rotatedApiKeyToken =
rotatedTokenResponse.body.data?.generateApiKeyToken.token ?? '';
expect(isNonEmptyString(rotatedApiKeyToken)).toBe(true);
const rotatedKid = decodeJwtCompleteOrThrow(rotatedApiKeyToken).header
.kid as string;
expect(isNonEmptyString(rotatedKid)).toBe(true);
expect(rotatedKid).not.toBe(initialKid);
});
const callWithPreviousToken = await findManyApplications({
accessToken: initialApiKeyToken,
expectToFail: false,
});
expect(callWithPreviousToken.errors).toBeUndefined();
expect(callWithPreviousToken.data?.findManyApplications).toBeDefined();
const callWithRotatedToken = await findManyApplications({
accessToken: rotatedApiKeyToken,
expectToFail: false,
});
expect(callWithRotatedToken.errors).toBeUndefined();
expect(callWithRotatedToken.data?.findManyApplications).toBeDefined();
});
});

View File

@@ -0,0 +1,25 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`signUpInWorkspace into a non-active workspace (integration) rejects a brand-new user joining a suspended workspace with the workspace-readiness message 1`] = `
{
"extensions": {
"code": "FORBIDDEN",
"subCode": "FORBIDDEN_EXCEPTION",
"userFriendlyMessage": "Workspace is not ready to welcome new members",
},
"message": "Workspace is not ready to welcome new members",
"name": "ForbiddenError",
}
`;
exports[`signUpInWorkspace into a non-active workspace (integration) rejects an existing user joining a suspended workspace with the workspace-readiness message 1`] = `
{
"extensions": {
"code": "FORBIDDEN",
"subCode": "FORBIDDEN_EXCEPTION",
"userFriendlyMessage": "Workspace is not ready to welcome new members",
},
"message": "Workspace is not ready to welcome new members",
"name": "ForbiddenError",
}
`;

View File

@@ -0,0 +1,13 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`signUpInWorkspace with an expired personal invitation (integration) denies access when the personal invitation is expired 1`] = `
{
"extensions": {
"code": "FORBIDDEN",
"subCode": "FORBIDDEN_EXCEPTION",
"userFriendlyMessage": "User does not have access to this workspace",
},
"message": "User does not have access to this workspace",
"name": "ForbiddenError",
}
`;

View File

@@ -0,0 +1,36 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Successful User Sign Up (integration) should fail to sign up with duplicate email 1`] = `
{
"extensions": {
"code": "FORBIDDEN",
"subCode": "USER_ALREADY_EXISTS",
"userFriendlyMessage": "User already exists",
},
"message": "User already exists",
"name": "ForbiddenError",
}
`;
exports[`Successful User Sign Up (integration) should fail to sign up with invalid email 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"userFriendlyMessage": "An error occurred.",
},
"message": "email must be an email",
"name": "UserInputError",
}
`;
exports[`Successful User Sign Up (integration) should fail to sign up with weak password 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_INPUT",
"userFriendlyMessage": "Password too weak",
},
"message": "Password too weak",
"name": "UserInputError",
}
`;

View File

@@ -0,0 +1,53 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Successful User Sign Up (integration) should sign up, delete and signup same new user successfully 1`] = `
{
"canAccessFullAdminPanel": false,
"canImpersonate": false,
"createdAt": Any<String>,
"currentUserWorkspace": null,
"currentWorkspace": null,
"deletedAt": null,
"disabled": false,
"email": "test-123@example.com",
"firstName": "",
"id": Any<String>,
"isEmailVerified": false,
"lastName": "",
"locale": "en",
"updatedAt": Any<String>,
}
`;
exports[`Successful User Sign Up (integration) should sign up, delete and signup same new user successfully 2`] = `
{
"extensions": {
"code": "UNAUTHENTICATED",
"subCode": "USER_NOT_FOUND",
"userFriendlyMessage": {
"id": Any<String>,
"message": "User not found.",
},
},
"message": "User not found",
}
`;
exports[`Successful User Sign Up (integration) should sign up, delete and signup same new user successfully 3`] = `
{
"canAccessFullAdminPanel": false,
"canImpersonate": false,
"createdAt": Any<String>,
"currentUserWorkspace": null,
"currentWorkspace": null,
"deletedAt": null,
"disabled": false,
"email": "test-123@example.com",
"firstName": "",
"id": Any<String>,
"isEmailVerified": false,
"lastName": "",
"locale": "en",
"updatedAt": Any<String>,
}
`;

View File

@@ -0,0 +1,59 @@
import request from 'supertest';
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { signUpOperationFactory } from 'test/integration/graphql/utils/sign-up-operation-factory.util';
import { signUp } from 'test/integration/graphql/utils/sign-up.util';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
const client = request(`http://localhost:${APP_PORT}`);
describe('signUpInWorkspace into a non-active workspace (integration)', () => {
const existingUserEmail = `existing-non-active-${Date.now()}@example.com`;
const password = 'Test123!@#';
beforeAll(async () => {
await signUp({
input: { email: existingUserEmail, password },
expectToFail: false,
});
await testDataSource.query(
'UPDATE core.workspace SET "activationStatus" = $1 WHERE id = $2',
[WorkspaceActivationStatus.SUSPENDED, SEED_APPLE_WORKSPACE_ID],
);
});
afterAll(async () => {
await testDataSource.query(
'UPDATE core.workspace SET "activationStatus" = $1 WHERE id = $2',
[WorkspaceActivationStatus.ACTIVE, SEED_APPLE_WORKSPACE_ID],
);
await testDataSource.query('DELETE FROM core."user" WHERE email = $1', [
existingUserEmail,
]);
});
it('rejects an existing user joining a suspended workspace with the workspace-readiness message', async () => {
const response = await client.post('/metadata').send(
signUpOperationFactory({
email: existingUserEmail,
password,
}),
);
expectOneNotInternalServerErrorSnapshot({ errors: response.body.errors });
});
it('rejects a brand-new user joining a suspended workspace with the workspace-readiness message', async () => {
const response = await client.post('/metadata').send(
signUpOperationFactory({
email: `new-non-active-${Date.now()}@example.com`,
password,
}),
);
expectOneNotInternalServerErrorSnapshot({ errors: response.body.errors });
});
});

View File

@@ -0,0 +1,40 @@
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import {
deleteWorkspaceInvitationsByEmail,
seedWorkspaceInvitation,
} from 'test/integration/graphql/utils/seed-workspace-invitation.util';
import { signUpInWorkspaceOperationFactory } from 'test/integration/graphql/utils/sign-up-in-workspace-operation-factory.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
const ONE_HOUR_IN_MS = 60 * 60 * 1000;
describe('signUpInWorkspace with an expired personal invitation (integration)', () => {
const email = `expired-invite-signup-${Date.now()}@example.com`;
const token = `expired-invite-signup-token-${Date.now()}`;
beforeAll(() =>
seedWorkspaceInvitation({
email,
value: token,
expiresAt: new Date(Date.now() - ONE_HOUR_IN_MS),
}),
);
afterAll(() => deleteWorkspaceInvitationsByEmail({ email }));
it('denies access when the personal invitation is expired', async () => {
const response = await makeMetadataAPIRequest(
signUpInWorkspaceOperationFactory({
email,
workspaceId: SEED_APPLE_WORKSPACE_ID,
workspacePersonalInviteToken: token,
}),
undefined,
);
expect(response.body.data?.signUpInWorkspace).toBeFalsy();
expectOneNotInternalServerErrorSnapshot({ errors: response.body.errors });
});
});

View File

@@ -0,0 +1,60 @@
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { signUp } from 'test/integration/graphql/utils/sign-up.util';
describe('Successful User Sign Up (integration)', () => {
it('should fail to sign up with invalid email', async () => {
const { errors } = await signUp({
input: {
email: 'invalid-email',
password: 'Test123!@#',
},
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({
errors,
});
});
it('should fail to sign up with duplicate email', async () => {
const testEmail = `test-duplicate@example.com`;
const { data: firstSignUp } = await signUp({
input: {
email: testEmail,
password: 'Test123!@#',
},
expectToFail: false,
});
expect(
firstSignUp.signUp.tokens.accessOrWorkspaceAgnosticToken.token,
).toBeDefined();
const { errors } = await signUp({
input: {
email: testEmail,
password: 'AnotherPassword123!',
},
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({
errors,
});
});
it('should fail to sign up with weak password', async () => {
const { errors } = await signUp({
input: {
email: `test-123@example.com`,
password: '123',
},
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({
errors,
});
});
});

View File

@@ -0,0 +1,38 @@
import { randomUUID } from 'crypto';
import { deleteUser } from 'test/integration/graphql/utils/delete-user.util';
import { getOnboardingStatus } from 'test/integration/graphql/utils/get-onboarding-status.util';
import { signUpInWorkspaceAndGetAccessToken } from 'test/integration/graphql/utils/sign-up-in-workspace-and-get-access-token.util';
import { isDefined } from 'twenty-shared/utils';
import { OnboardingStatus } from 'src/engine/core-modules/onboarding/enums/onboarding-status.enum';
describe('Onboarding status when signing up in an existing workspace (integration)', () => {
let createdUserAccessToken: string | undefined;
afterEach(async () => {
if (isDefined(createdUserAccessToken)) {
await deleteUser({
accessToken: createdUserAccessToken,
expectToFail: false,
});
createdUserAccessToken = undefined;
}
});
it('should start with the connect-account step for a new user joining through an invite', async () => {
createdUserAccessToken = await signUpInWorkspaceAndGetAccessToken(
`invited-onboarding-status-${randomUUID()}@example.com`,
);
const {
data: { currentUser },
} = await getOnboardingStatus({
accessToken: createdUserAccessToken,
expectToFail: false,
});
expect(currentUser.onboardingStatus).toBe(OnboardingStatus.SYNC_EMAIL);
});
});

View File

@@ -0,0 +1,72 @@
import { deleteUser } from 'test/integration/graphql/utils/delete-user.util';
import { getAuthTokensFromLoginToken } from 'test/integration/graphql/utils/get-auth-tokens-from-login-token.util';
import {
deleteWorkspaceInvitationsByEmail,
seedWorkspaceInvitation,
} from 'test/integration/graphql/utils/seed-workspace-invitation.util';
import { signUpInWorkspaceOperationFactory } from 'test/integration/graphql/utils/sign-up-in-workspace-operation-factory.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
const ONE_HOUR_IN_MS = 60 * 60 * 1000;
describe('signUpInWorkspace with a valid personal invitation (integration)', () => {
const email = `valid-invite-signup-${Date.now()}@example.com`;
const token = `valid-invite-signup-token-${Date.now()}`;
let accessToken: string | undefined;
beforeAll(() =>
seedWorkspaceInvitation({
email,
value: token,
expiresAt: new Date(Date.now() + ONE_HOUR_IN_MS),
}),
);
afterAll(async () => {
if (accessToken) {
await deleteUser({ accessToken, expectToFail: false });
}
await deleteWorkspaceInvitationsByEmail({ email });
});
it('grants access when the personal invitation is still valid', async () => {
const response = await makeMetadataAPIRequest(
signUpInWorkspaceOperationFactory({
email,
workspaceId: SEED_APPLE_WORKSPACE_ID,
workspacePersonalInviteToken: token,
}),
undefined,
);
expect(response.body.errors).toBeUndefined();
const signUpPayload = response.body.data.signUpInWorkspace;
expect(signUpPayload.workspace.id).toBe(SEED_APPLE_WORKSPACE_ID);
expect(signUpPayload.loginToken.token).toBeDefined();
await testDataSource.query(
'UPDATE core."user" SET "isEmailVerified" = true WHERE email = $1',
[email],
);
const {
data: { getAuthTokensFromLoginToken: authTokensData },
} = await getAuthTokensFromLoginToken({
loginToken: signUpPayload.loginToken.token,
origin:
signUpPayload.workspace.workspaceUrls?.subdomainUrl ??
'http://localhost:3001',
expectToFail: false,
});
accessToken = authTokensData.tokens.accessOrWorkspaceAgnosticToken.token;
expect(accessToken).toBeDefined();
});
});

View File

@@ -0,0 +1,95 @@
import { deleteUser } from 'test/integration/graphql/utils/delete-user.util';
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { getCurrentUser } from 'test/integration/graphql/utils/get-current-user.util';
import { signUp } from 'test/integration/graphql/utils/sign-up.util';
import { extractRecordIdsAndDatesAsExpectAny } from 'test/utils/extract-record-ids-and-dates-as-expect-any';
import { isDefined } from 'twenty-shared/utils';
import { type SignUpInput } from 'src/engine/core-modules/auth/dto/sign-up.input';
describe('Successful User Sign Up (integration)', () => {
let createdUserAccessToken: string | undefined;
afterEach(async () => {
if (isDefined(createdUserAccessToken)) {
await deleteUser({
accessToken: createdUserAccessToken,
expectToFail: false,
});
createdUserAccessToken = undefined;
}
});
it('should sign up, delete and signup same new user successfully', async () => {
const input: SignUpInput = {
email: `test-123@example.com`,
password: 'Test123!@#',
};
const { data: firstSignUpData } = await signUp({
input,
expectToFail: false,
});
createdUserAccessToken =
firstSignUpData.signUp.tokens.accessOrWorkspaceAgnosticToken.token;
expect(
firstSignUpData.signUp.tokens.accessOrWorkspaceAgnosticToken.token,
).toBeDefined();
expect(firstSignUpData.signUp.tokens.refreshToken.token).toBeDefined();
expect(
firstSignUpData.signUp.availableWorkspaces.availableWorkspacesForSignIn,
).toEqual([]);
expect(
firstSignUpData.signUp.availableWorkspaces.availableWorkspacesForSignUp,
).toEqual([]);
const {
data: { currentUser: currentUserAfterSignUp },
} = await getCurrentUser({
accessToken: createdUserAccessToken,
expectToFail: false,
});
expect(currentUserAfterSignUp.deletedAt).toBeNull();
expect(currentUserAfterSignUp).toMatchSnapshot(
extractRecordIdsAndDatesAsExpectAny({ ...currentUserAfterSignUp }),
);
await deleteUser({
accessToken: createdUserAccessToken,
expectToFail: false,
});
const { errors: getCurrentUserAfterDeleteErrors } = await getCurrentUser({
accessToken: createdUserAccessToken,
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({
errors: getCurrentUserAfterDeleteErrors,
});
const { data: secondSignUpData } = await signUp({
input,
expectToFail: false,
});
createdUserAccessToken =
secondSignUpData.signUp.tokens.accessOrWorkspaceAgnosticToken.token;
const {
data: { currentUser: currentUserAfterSecondSignUp },
} = await getCurrentUser({
accessToken: createdUserAccessToken,
expectToFail: false,
});
expect(currentUserAfterSecondSignUp.deletedAt).toBeNull();
expect(currentUserAfterSecondSignUp).toMatchSnapshot(
extractRecordIdsAndDatesAsExpectAny({ ...currentUserAfterSecondSignUp }),
);
});
});

View File

@@ -0,0 +1,171 @@
import crypto from 'crypto';
import { type DataSource } from 'typeorm';
import { getAuthTokensFromSsoExchangeToken } from 'test/integration/graphql/utils/get-auth-tokens-from-sso-exchange-token.util';
import { AppTokenType } from 'src/engine/core-modules/app-token/app-token.entity';
import { AuthExceptionCode } from 'src/engine/core-modules/auth/auth.exception';
import { USER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
const hashToken = (token: string) =>
crypto.createHash('sha256').update(token).digest('hex');
const expectUniformInvalidTokenError = (errors: { message: string }[]) => {
expect(errors).toHaveLength(1);
expect(errors[0]).toMatchObject({
message: 'Invalid SSO exchange token',
extensions: { subCode: AuthExceptionCode.INVALID_INPUT },
});
};
describe('SSO exchange token redemption (integration)', () => {
let dataSource: DataSource;
const seedSsoExchangeToken = async ({
expiresAt = new Date(Date.now() + 5 * 60 * 1000),
type = AppTokenType.SsoExchangeToken,
revokedAt = null,
}: {
expiresAt?: Date;
type?: AppTokenType;
revokedAt?: Date | null;
} = {}): Promise<string> => {
const plainToken = crypto.randomBytes(32).toString('hex');
await dataSource.query(
`INSERT INTO core."appToken" ("userId", "type", "value", "expiresAt", "revokedAt", "context")
VALUES ($1, $2, $3, $4, $5, $6)`,
[
USER_DATA_SEED_IDS.JANE,
type,
hashToken(plainToken),
expiresAt,
revokedAt,
JSON.stringify({ authProvider: AuthProviderEnum.Google }),
],
);
return plainToken;
};
const countRemainingRows = async (
plainToken: string,
type: AppTokenType = AppTokenType.SsoExchangeToken,
): Promise<number> => {
const rows = await dataSource.query(
`SELECT 1 FROM core."appToken" WHERE "value" = $1 AND "type" = $2`,
[hashToken(plainToken), type],
);
return rows.length;
};
beforeAll(() => {
dataSource = global.testDataSource;
});
it('should exchange a valid token for a token pair and consume it', async () => {
const plainToken = await seedSsoExchangeToken();
const { data } = await getAuthTokensFromSsoExchangeToken({
ssoExchangeToken: plainToken,
expectToFail: false,
});
expect(
data.getAuthTokensFromSSOExchangeToken.tokens
.accessOrWorkspaceAgnosticToken.token,
).toBeDefined();
expect(
data.getAuthTokensFromSSOExchangeToken.tokens.refreshToken.token,
).toBeDefined();
expect(await countRemainingRows(plainToken)).toBe(0);
});
it('should reject a second redemption of the same token', async () => {
const plainToken = await seedSsoExchangeToken();
await getAuthTokensFromSsoExchangeToken({
ssoExchangeToken: plainToken,
expectToFail: false,
});
const { errors } = await getAuthTokensFromSsoExchangeToken({
ssoExchangeToken: plainToken,
expectToFail: true,
});
expectUniformInvalidTokenError(errors);
});
it('should reject an expired token', async () => {
const plainToken = await seedSsoExchangeToken({
expiresAt: new Date(Date.now() - 1000),
});
const { errors } = await getAuthTokensFromSsoExchangeToken({
ssoExchangeToken: plainToken,
expectToFail: true,
});
expectUniformInvalidTokenError(errors);
expect(await countRemainingRows(plainToken)).toBe(0);
});
it('should reject a revoked token without consuming it', async () => {
const plainToken = await seedSsoExchangeToken({
revokedAt: new Date(),
});
const { errors } = await getAuthTokensFromSsoExchangeToken({
ssoExchangeToken: plainToken,
expectToFail: true,
});
expectUniformInvalidTokenError(errors);
expect(await countRemainingRows(plainToken)).toBe(1);
});
it('should not redeem an app token of another type sharing the same value', async () => {
const plainToken = await seedSsoExchangeToken({
type: AppTokenType.EmailVerificationToken,
});
const { errors } = await getAuthTokensFromSsoExchangeToken({
ssoExchangeToken: plainToken,
expectToFail: true,
});
expectUniformInvalidTokenError(errors);
expect(
await countRemainingRows(plainToken, AppTokenType.EmailVerificationToken),
).toBe(1);
});
it('should reject an unknown token', async () => {
const { errors } = await getAuthTokensFromSsoExchangeToken({
ssoExchangeToken: crypto.randomBytes(32).toString('hex'),
expectToFail: true,
});
expectUniformInvalidTokenError(errors);
});
it('should let exactly one of several concurrent redemptions succeed', async () => {
const plainToken = await seedSsoExchangeToken();
const responses = await Promise.all(
Array.from({ length: 5 }, () =>
getAuthTokensFromSsoExchangeToken({ ssoExchangeToken: plainToken }),
),
);
const succeeded = responses.filter(
(response) => response.data?.getAuthTokensFromSSOExchangeToken,
);
expect(succeeded).toHaveLength(1);
expect(await countRemainingRows(plainToken)).toBe(0);
});
});

View File

@@ -0,0 +1,117 @@
import { authenticator } from 'otplib';
import { initiateOtpProvisioningForAuthenticatedUser } from 'test/integration/graphql/utils/initiate-otp-provisioning-for-authenticated-user.util';
import { verifyTwoFactorAuthenticationMethod } from 'test/integration/graphql/utils/verify-two-factor-authentication-method.util';
import { USER_WORKSPACE_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-user-workspaces.util';
const TOTP_STEP_DURATION_MS = 30_000;
const MINIMUM_STEP_REMAINING_MS = 5_000;
const generateTokenAtEpoch = (secret: string, epochMs: number): string =>
authenticator.clone({ epoch: epochMs }).generate(secret);
// A previous-step token generated right before a step boundary would already be
// two steps old once the server validates it, so wait out the boundary first
const waitUntilSafelyInsideTotpStep = async (): Promise<void> => {
const millisecondsRemainingInStep =
TOTP_STEP_DURATION_MS - (Date.now() % TOTP_STEP_DURATION_MS);
if (millisecondsRemainingInStep < MINIMUM_STEP_REMAINING_MS) {
await new Promise((resolve) =>
setTimeout(resolve, millisecondsRemainingInStep + 100),
);
}
};
const deleteJonyTwoFactorAuthenticationMethods = async (): Promise<void> => {
await global.testDataSource.query(
`DELETE FROM core."twoFactorAuthenticationMethod" WHERE "userWorkspaceId" = $1`,
[USER_WORKSPACE_DATA_SEED_IDS.JONY],
);
};
describe('Two-factor authentication TOTP verification (integration)', () => {
let secret: string;
beforeAll(async () => {
await deleteJonyTwoFactorAuthenticationMethods();
const { data, errors } = await initiateOtpProvisioningForAuthenticatedUser({
accessToken: APPLE_JONY_MEMBER_ACCESS_TOKEN,
expectToFail: false,
});
expect(errors).toBeUndefined();
const uri = data.initiateOTPProvisioningForAuthenticatedUser.uri;
expect(uri).toMatch(/^otpauth:\/\/totp\//);
const secretFromUri = uri.match(/[?&]secret=([^&]+)/)?.[1];
if (secretFromUri === undefined || secretFromUri === '') {
throw new Error('Expected the otpauth URI to contain a secret');
}
secret = secretFromUri;
});
afterAll(async () => {
await deleteJonyTwoFactorAuthenticationMethods();
});
it('should reject a code that is more than one step old', async () => {
const staleToken = generateTokenAtEpoch(
secret,
Date.now() - 2 * TOTP_STEP_DURATION_MS,
);
const { data, errors } = await verifyTwoFactorAuthenticationMethod({
otp: staleToken,
accessToken: APPLE_JONY_MEMBER_ACCESS_TOKEN,
expectToFail: true,
});
expect(errors).toBeDefined();
expect(
data?.verifyTwoFactorAuthenticationMethodForAuthenticatedUser,
).toBeFalsy();
});
it('should accept the current code', async () => {
await waitUntilSafelyInsideTotpStep();
const currentToken = generateTokenAtEpoch(secret, Date.now());
const { data, errors } = await verifyTwoFactorAuthenticationMethod({
otp: currentToken,
accessToken: APPLE_JONY_MEMBER_ACCESS_TOKEN,
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(
data.verifyTwoFactorAuthenticationMethodForAuthenticatedUser.success,
).toBe(true);
});
it('should accept the code from the previous step', async () => {
await waitUntilSafelyInsideTotpStep();
const previousStepToken = generateTokenAtEpoch(
secret,
Date.now() - TOTP_STEP_DURATION_MS,
);
const { data, errors } = await verifyTwoFactorAuthenticationMethod({
otp: previousStepToken,
accessToken: APPLE_JONY_MEMBER_ACCESS_TOKEN,
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(
data.verifyTwoFactorAuthenticationMethodForAuthenticatedUser.success,
).toBe(true);
});
});

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');
});
});

View File

@@ -0,0 +1,230 @@
import gql from 'graphql-tag';
import request from 'supertest';
import { createManyOperationFactory } from 'test/integration/graphql/utils/create-many-operation-factory.util';
import { deleteManyOperationFactory } from 'test/integration/graphql/utils/delete-many-operation-factory.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { v4 as uuidv4 } from 'uuid';
const client = request(`http://localhost:${APP_PORT}`);
const TEST_COMPANY_AIRBNB_ID = '20202020-aaaa-4000-8000-000000000001';
const TEST_COMPANY_STRIPE_ID = '20202020-aaaa-4000-8000-000000000002';
const TEST_PERSON_AIRBNB_1_ID = '20202020-bbbb-4000-8000-000000000001';
const TEST_PERSON_AIRBNB_2_ID = '20202020-bbbb-4000-8000-000000000002';
const TEST_PERSON_STRIPE_1_ID = '20202020-bbbb-4000-8000-000000000003';
const SHARED_JOB_TITLE = 'chart-test-relation-traversal';
const ALL_TEST_PERSON_IDS = [
TEST_PERSON_AIRBNB_1_ID,
TEST_PERSON_AIRBNB_2_ID,
TEST_PERSON_STRIPE_1_ID,
];
const ALL_TEST_COMPANY_IDS = [TEST_COMPANY_AIRBNB_ID, TEST_COMPANY_STRIPE_ID];
describe('BarChartData with relation-traversal filter (e2e)', () => {
let personObjectMetadataId: string | null = null;
let personIdFieldMetadataId: string | null = null;
let personJobTitleFieldMetadataId: string | null = null;
let personCompanyFieldMetadataId: string | null = null;
let companyNameFieldMetadataId: string | null = null;
const lookupMetadataIds = async () => {
const objectsResponse = await makeMetadataAPIRequest({
query: gql`
query Objects($filter: ObjectFilter!, $paging: CursorPaging!) {
objects(filter: $filter, paging: $paging) {
edges {
node {
id
nameSingular
fieldsList {
id
name
}
}
}
}
}
`,
variables: { paging: { first: 1000 }, filter: {} },
});
expect(objectsResponse.body.errors).toBeUndefined();
const objects: Array<{
id: string;
nameSingular: string;
fieldsList: Array<{ id: string; name: string }>;
}> = objectsResponse.body.data.objects.edges.map(
(edge: { node: unknown }) => edge.node,
);
const personObject = objects.find((o) => o.nameSingular === 'person');
const companyObject = objects.find((o) => o.nameSingular === 'company');
personObjectMetadataId = personObject?.id ?? null;
personIdFieldMetadataId =
personObject?.fieldsList.find((f) => f.name === 'id')?.id ?? null;
personJobTitleFieldMetadataId =
personObject?.fieldsList.find((f) => f.name === 'jobTitle')?.id ?? null;
personCompanyFieldMetadataId =
personObject?.fieldsList.find((f) => f.name === 'company')?.id ?? null;
companyNameFieldMetadataId =
companyObject?.fieldsList.find((f) => f.name === 'name')?.id ?? null;
if (
!personObjectMetadataId ||
!personIdFieldMetadataId ||
!personJobTitleFieldMetadataId ||
!personCompanyFieldMetadataId ||
!companyNameFieldMetadataId
) {
throw new Error('Failed to resolve required metadata ids for chart test');
}
};
const seedTestRecords = async () => {
await makeGraphqlAPIRequest(
createManyOperationFactory({
objectMetadataSingularName: 'company',
objectMetadataPluralName: 'companies',
gqlFields: 'id',
data: [
{ id: TEST_COMPANY_AIRBNB_ID, name: 'AirbnbChartTest' },
{ id: TEST_COMPANY_STRIPE_ID, name: 'StripeChartTest' },
],
upsert: true,
}),
);
await makeGraphqlAPIRequest(
createManyOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
gqlFields: 'id',
data: [
{
id: TEST_PERSON_AIRBNB_1_ID,
companyId: TEST_COMPANY_AIRBNB_ID,
jobTitle: SHARED_JOB_TITLE,
},
{
id: TEST_PERSON_AIRBNB_2_ID,
companyId: TEST_COMPANY_AIRBNB_ID,
jobTitle: SHARED_JOB_TITLE,
},
{
id: TEST_PERSON_STRIPE_1_ID,
companyId: TEST_COMPANY_STRIPE_ID,
jobTitle: SHARED_JOB_TITLE,
},
],
upsert: true,
}),
);
};
const queryBarChartCount = async (extraRecordFilters: object[] = []) => {
const filterGroupId = uuidv4();
const allRecordFilters = [
{
id: uuidv4(),
type: 'TEXT',
operand: 'CONTAINS',
value: SHARED_JOB_TITLE,
fieldMetadataId: personJobTitleFieldMetadataId,
recordFilterGroupId: filterGroupId,
},
...extraRecordFilters.map((filter) => ({
...filter,
recordFilterGroupId: filterGroupId,
})),
];
const response = await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({
query: `
query BarChartData($input: BarChartDataInput!) {
barChartData(input: $input) {
data
indexBy
keys
}
}
`,
variables: {
input: {
objectMetadataId: personObjectMetadataId,
configuration: {
configurationType: 'BAR_CHART',
layout: 'VERTICAL',
aggregateFieldMetadataId: personIdFieldMetadataId,
aggregateOperation: 'COUNT',
primaryAxisGroupByFieldMetadataId: personJobTitleFieldMetadataId,
primaryAxisOrderBy: 'VALUE_DESC',
filter: {
recordFilters: allRecordFilters,
recordFilterGroups: [
{ id: filterGroupId, logicalOperator: 'AND' },
],
},
},
},
},
});
expect(response.body.errors).toBeUndefined();
const data: Array<Record<string, string | number>> =
response.body.data.barChartData.data;
const row = data.find((entry) => entry.jobTitle === SHARED_JOB_TITLE);
return typeof row?.id === 'number' ? row.id : 0;
};
beforeAll(async () => {
await lookupMetadataIds();
await seedTestRecords();
});
afterAll(async () => {
await makeGraphqlAPIRequest(
deleteManyOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
gqlFields: 'id',
filter: { id: { in: ALL_TEST_PERSON_IDS } },
}),
);
await makeGraphqlAPIRequest(
deleteManyOperationFactory({
objectMetadataSingularName: 'company',
objectMetadataPluralName: 'companies',
gqlFields: 'id',
filter: { id: { in: ALL_TEST_COMPANY_IDS } },
}),
);
});
it('should count all 3 test people without a relation-traversal filter', async () => {
const count = await queryBarChartCount();
expect(count).toBe(3);
});
it('should apply a one-hop relation-traversal filter and only count Airbnb people', async () => {
const count = await queryBarChartCount([
{
id: uuidv4(),
type: 'TEXT',
operand: 'CONTAINS',
value: 'AirbnbChartTest',
fieldMetadataId: personCompanyFieldMetadataId,
relationTargetFieldMetadataId: companyNameFieldMetadataId,
},
]);
expect(count).toBe(2);
});
});

View File

@@ -0,0 +1,269 @@
import { PERSON_GQL_FIELDS } from 'test/integration/constants/person-gql-fields.constants';
import {
TEST_PERSON_1_ID,
TEST_PERSON_2_ID,
TEST_PERSON_3_ID,
TEST_PERSON_4_ID,
TEST_PERSON_5_ID,
} from 'test/integration/constants/test-person-ids.constants';
import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util';
import { findManyOperationFactory } from 'test/integration/graphql/utils/find-many-operation-factory.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { deleteAllRecords } from 'test/integration/utils/delete-all-records';
describe('GraphQL People Pagination with Composite Field Sorting', () => {
beforeAll(async () => {
await deleteAllRecords('person');
const testPeople = [
{
id: TEST_PERSON_1_ID,
firstName: 'Alice',
lastName: 'Brown',
},
{
id: TEST_PERSON_2_ID,
firstName: 'Alice',
lastName: 'Smith',
},
{
id: TEST_PERSON_3_ID,
firstName: 'Bob',
lastName: 'Johnson',
},
{
id: TEST_PERSON_4_ID,
firstName: 'Bob',
lastName: 'Williams',
},
{
id: TEST_PERSON_5_ID,
firstName: 'Charlie',
lastName: 'Davis',
},
];
for (const person of testPeople) {
const graphqlOperation = createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: PERSON_GQL_FIELDS,
data: {
id: person.id,
name: {
firstName: person.firstName,
lastName: person.lastName,
},
},
});
await makeGraphqlAPIRequest(graphqlOperation).expect(200);
}
});
it('should support pagination with fullName composite field in ascending order', async () => {
const firstPageOperation = findManyOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
gqlFields: PERSON_GQL_FIELDS,
orderBy: {
name: {
firstName: 'AscNullsLast',
lastName: 'AscNullsLast',
},
},
first: 2,
});
const firstPageResponse =
await makeGraphqlAPIRequest(firstPageOperation).expect(200);
const firstPagePeople = firstPageResponse.body.data.people.edges.map(
(edge: any) => edge.node,
);
const firstPageCursor =
firstPageResponse.body.data.people.pageInfo.endCursor;
expect(firstPagePeople).toHaveLength(2);
expect(firstPageResponse.body.data.people.pageInfo.hasNextPage).toBe(true);
expect(firstPageResponse.body.data.people.pageInfo.hasPreviousPage).toBe(
false,
);
expect(firstPagePeople[0].name.firstName).toBe('Alice');
expect(firstPagePeople[0].name.lastName).toBe('Brown');
expect(firstPagePeople[1].name.firstName).toBe('Alice');
expect(firstPagePeople[1].name.lastName).toBe('Smith');
const secondPageOperation = findManyOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
gqlFields: PERSON_GQL_FIELDS,
orderBy: {
name: {
firstName: 'AscNullsLast',
lastName: 'AscNullsLast',
},
},
first: 2,
after: firstPageCursor,
});
const secondPageResponse =
await makeGraphqlAPIRequest(secondPageOperation).expect(200);
const secondPagePeople = secondPageResponse.body.data.people.edges.map(
(edge: any) => edge.node,
);
expect(secondPagePeople).toHaveLength(2);
expect(secondPageResponse.body.data.people.pageInfo.hasPreviousPage).toBe(
true,
);
expect(secondPagePeople[0].name.firstName).toBe('Bob');
expect(secondPagePeople[0].name.lastName).toBe('Johnson');
expect(secondPagePeople[1].name.firstName).toBe('Bob');
expect(secondPagePeople[1].name.lastName).toBe('Williams');
const firstPageIds = firstPagePeople.map((p: { id: string }) => p.id);
const secondPageIds = secondPagePeople.map((p: { id: string }) => p.id);
const intersection = firstPageIds.filter((id: string) =>
secondPageIds.includes(id),
);
expect(intersection).toHaveLength(0);
const thirdPageOperation = findManyOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
gqlFields: PERSON_GQL_FIELDS,
orderBy: {
name: {
firstName: 'AscNullsLast',
lastName: 'AscNullsLast',
},
},
first: 2,
after: secondPageResponse.body.data.people.pageInfo.endCursor,
});
const thirdPageResponse =
await makeGraphqlAPIRequest(thirdPageOperation).expect(200);
const thirdPagePeople = thirdPageResponse.body.data.people.edges.map(
(edge: any) => edge.node,
);
expect(thirdPagePeople).toHaveLength(1);
expect(thirdPagePeople[0].name.firstName).toBe('Charlie');
expect(thirdPagePeople[0].name.lastName).toBe('Davis');
expect(thirdPageResponse.body.data.people.pageInfo.hasNextPage).toBe(false);
});
it('should support cursor-based pagination with fullName in descending order', async () => {
const firstPageOperation = findManyOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
gqlFields: PERSON_GQL_FIELDS,
orderBy: {
name: {
firstName: 'DescNullsLast',
lastName: 'DescNullsLast',
},
},
first: 2,
});
const firstPageResponse =
await makeGraphqlAPIRequest(firstPageOperation).expect(200);
const firstPagePeople = firstPageResponse.body.data.people.edges.map(
(edge: any) => edge.node,
);
expect(firstPagePeople).toHaveLength(2);
expect(firstPagePeople[0].name.firstName).toBe('Charlie');
expect(firstPagePeople[0].name.lastName).toBe('Davis');
expect(firstPagePeople[1].name.firstName).toBe('Bob');
expect(firstPagePeople[1].name.lastName).toBe('Williams');
const secondPageOperation = findManyOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
gqlFields: PERSON_GQL_FIELDS,
orderBy: {
name: {
firstName: 'DescNullsLast',
lastName: 'DescNullsLast',
},
},
first: 2,
after: firstPageResponse.body.data.people.pageInfo.endCursor,
});
const secondPageResponse =
await makeGraphqlAPIRequest(secondPageOperation).expect(200);
const secondPagePeople = secondPageResponse.body.data.people.edges.map(
(edge: any) => edge.node,
);
expect(secondPagePeople).toHaveLength(2);
expect(secondPagePeople[0].name.firstName).toBe('Bob');
expect(secondPagePeople[0].name.lastName).toBe('Johnson');
expect(secondPagePeople[1].name.firstName).toBe('Alice');
expect(secondPagePeople[1].name.lastName).toBe('Smith');
});
it('should support backward pagination with fullName composite field in ascending order', async () => {
const allPeopleOperation = findManyOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
gqlFields: PERSON_GQL_FIELDS,
orderBy: {
name: {
firstName: 'AscNullsLast',
lastName: 'AscNullsLast',
},
},
});
const allPeopleResponse =
await makeGraphqlAPIRequest(allPeopleOperation).expect(200);
const allPeople = allPeopleResponse.body.data.people.edges.map(
(edge: any) => edge.node,
);
const lastPersonCursor =
allPeopleResponse.body.data.people.pageInfo.endCursor;
const backwardPageOperation = findManyOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
gqlFields: PERSON_GQL_FIELDS,
orderBy: {
name: {
firstName: 'AscNullsLast',
lastName: 'AscNullsLast',
},
},
last: 2,
before: lastPersonCursor,
});
const backwardPageResponse = await makeGraphqlAPIRequest(
backwardPageOperation,
).expect(200);
const backwardPagePeople = backwardPageResponse.body.data.people.edges.map(
(edge: any) => edge.node,
);
expect(backwardPagePeople).toHaveLength(2);
expect(backwardPagePeople[0].id).toBe(allPeople.at(-3)?.id);
expect(backwardPagePeople[1].id).toBe(allPeople.at(-2)?.id);
});
});

View File

@@ -0,0 +1,46 @@
import gql from 'graphql-tag';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
const NON_EXISTENT_CONNECTED_ACCOUNT_ID =
'00000000-0000-4000-8000-000000000000';
const CREATE_CALENDAR_EVENT_MUTATION = gql`
mutation CreateCalendarEvent($input: CreateCalendarEventInput!) {
createCalendarEvent(input: $input) {
success
error
iCalUid
conferenceLink
}
}
`;
// Creating an actual event hits Google/Microsoft and cannot be integration-tested
// without real OAuth + provider mocking. The auth + ownership + validation paths,
// however, fail closed before any provider call — so we assert the mutation is
// wired into the metadata schema and returns a structured failure (never a 500)
// for an account that does not exist.
describe('createCalendarEvent (metadata API) (e2e)', () => {
it('fails closed with a structured error when the connected account does not exist', async () => {
const response = await makeMetadataAPIRequest({
query: CREATE_CALENDAR_EVENT_MUTATION,
variables: {
input: {
connectedAccountId: NON_EXISTENT_CONNECTED_ACCOUNT_ID,
title: 'Integration test event',
startsAt: '2026-07-01T15:00:00Z',
endsAt: '2026-07-01T16:00:00Z',
},
},
});
expect(response.body.errors).toBeUndefined();
const result = response.body.data.createCalendarEvent;
expect(result.success).toBe(false);
expect(result.error).toContain('not found');
expect(result.iCalUid).toBeNull();
expect(result.conferenceLink).toBeNull();
});
});

View File

@@ -0,0 +1,592 @@
import { randomUUID } from 'crypto';
import { createManyOperationFactory } from 'test/integration/graphql/utils/create-many-operation-factory.util';
import { findManyOperationFactory } from 'test/integration/graphql/utils/find-many-operation-factory.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { deleteAllRecords } from 'test/integration/utils/delete-all-records';
const PAGE_SIZE = 3;
const DATED_CLOSE_DATES = [
'2026-01-01T00:00:00.000Z',
'2026-01-02T00:00:00.000Z',
'2026-01-03T00:00:00.000Z',
'2026-01-04T00:00:00.000Z',
'2026-01-05T00:00:00.000Z',
'2026-01-06T00:00:00.000Z',
];
const NULL_CLOSE_DATE_COUNT = 4;
const TOTAL_COUNT = DATED_CLOSE_DATES.length + NULL_CLOSE_DATE_COUNT;
const datedOpportunityIds = DATED_CLOSE_DATES.map(() => randomUUID());
const nullCloseDateOpportunityIds = Array.from(
{ length: NULL_CLOSE_DATE_COUNT },
() => randomUUID(),
);
type GraphqlResponse = {
body: {
errors?: { message: string }[];
// oxlint-disable-next-line typescript/no-explicit-any
data: Record<string, any>;
};
};
type RecordConnection = {
edges: { node: { id: string } }[];
pageInfo: {
hasNextPage: boolean;
hasPreviousPage: boolean;
startCursor: string;
endCursor: string;
};
};
type PaginateAllParams = {
orderBy: object;
objectMetadataSingularName?: string;
objectMetadataPluralName?: string;
filter?: object;
gqlFields?: string;
first?: number;
};
const paginateForward = async ({
orderBy,
objectMetadataSingularName = 'opportunity',
objectMetadataPluralName = 'opportunities',
filter,
gqlFields = 'id',
first = PAGE_SIZE,
}: PaginateAllParams) => {
const ids: string[] = [];
let after: string | undefined = undefined;
let pages = 0;
// Cap the loop far above the expected page count so a paging bug cannot hang the suite
for (let iteration = 0; iteration < 20; iteration++) {
const response: GraphqlResponse = await makeGraphqlAPIRequest(
findManyOperationFactory({
objectMetadataSingularName,
objectMetadataPluralName,
gqlFields,
filter,
orderBy,
first,
after,
}),
).expect(200);
expect(response.body.errors).toBeUndefined();
const connection: RecordConnection =
response.body.data[objectMetadataPluralName];
ids.push(...connection.edges.map((edge) => edge.node.id));
pages++;
if (!connection.pageInfo.hasNextPage) {
break;
}
after = connection.pageInfo.endCursor;
}
return { ids, pages };
};
const paginateBackwardFrom = async ({
orderBy,
startingBefore,
gqlFields = 'id',
}: PaginateAllParams & { startingBefore: string }) => {
const ids: string[] = [];
let before: string | undefined = startingBefore;
for (let iteration = 0; iteration < 20; iteration++) {
const response: GraphqlResponse = await makeGraphqlAPIRequest(
findManyOperationFactory({
objectMetadataSingularName: 'opportunity',
objectMetadataPluralName: 'opportunities',
gqlFields,
orderBy,
last: PAGE_SIZE,
before,
}),
).expect(200);
expect(response.body.errors).toBeUndefined();
const connection: RecordConnection = response.body.data.opportunities;
ids.unshift(...connection.edges.map((edge) => edge.node.id));
if (!connection.pageInfo.hasPreviousPage) {
break;
}
before = connection.pageInfo.startCursor;
}
return { ids };
};
describe('Cursor pagination exhaustiveness with orderBy (issue #24333)', () => {
beforeAll(async () => {
await deleteAllRecords('opportunity');
await makeGraphqlAPIRequest(
createManyOperationFactory({
objectMetadataSingularName: 'opportunity',
objectMetadataPluralName: 'opportunities',
gqlFields: 'id',
data: [
...DATED_CLOSE_DATES.map((closeDate, index) => ({
id: datedOpportunityIds[index],
name: `Dated opportunity ${index + 1}`,
closeDate,
})),
...nullCloseDateOpportunityIds.map((id, index) => ({
id,
name: `Undated opportunity ${index + 1}`,
})),
],
}),
).expect(200);
});
it('should return every record when paginating ordered by id (baseline)', async () => {
const { ids } = await paginateForward({
orderBy: { id: 'AscNullsLast' },
});
expect(new Set(ids).size).toBe(TOTAL_COUNT);
});
it('should return every record when the orderBy field is not in the selection set', async () => {
const { ids, pages } = await paginateForward({
orderBy: { closeDate: 'AscNullsFirst' },
gqlFields: 'id',
});
expect(ids).toHaveLength(TOTAL_COUNT);
expect(new Set(ids).size).toBe(TOTAL_COUNT);
expect(pages).toBe(Math.ceil(TOTAL_COUNT / PAGE_SIZE));
});
it.each(['AscNullsLast', 'DescNullsLast'])(
'should paginate across the trailing NULL block with %s',
async (direction) => {
const { ids } = await paginateForward({
orderBy: { closeDate: direction },
});
expect(ids).toHaveLength(TOTAL_COUNT);
expect(new Set(ids).size).toBe(TOTAL_COUNT);
// The NULL block sorts last: its records must all be there, at the end
expect(new Set(ids.slice(DATED_CLOSE_DATES.length))).toEqual(
new Set(nullCloseDateOpportunityIds),
);
},
);
it.each(['AscNullsFirst', 'DescNullsFirst'])(
'should paginate across the leading NULL block with %s',
async (direction) => {
const { ids } = await paginateForward({
orderBy: { closeDate: direction },
});
expect(ids).toHaveLength(TOTAL_COUNT);
expect(new Set(ids).size).toBe(TOTAL_COUNT);
expect(new Set(ids.slice(0, NULL_CLOSE_DATE_COUNT))).toEqual(
new Set(nullCloseDateOpportunityIds),
);
},
);
it('should keep the requested global order across pages', async () => {
const { ids } = await paginateForward({
orderBy: { closeDate: 'AscNullsLast' },
});
expect(ids.slice(0, DATED_CLOSE_DATES.length)).toEqual(datedOpportunityIds);
});
it('should walk backward across the NULL boundary with before cursors', async () => {
// Reach the last page forward, then walk back from its end cursor: the
// backward scan re-crosses the null/non-null boundary in reverse
const forwardResponse = await makeGraphqlAPIRequest(
findManyOperationFactory({
objectMetadataSingularName: 'opportunity',
objectMetadataPluralName: 'opportunities',
gqlFields: 'id',
orderBy: { closeDate: 'AscNullsLast' },
first: TOTAL_COUNT,
}),
).expect(200);
const forwardConnection = forwardResponse.body.data.opportunities;
const forwardIds = forwardConnection.edges.map(
(edge: { node: { id: string } }) => edge.node.id,
);
expect(forwardIds).toHaveLength(TOTAL_COUNT);
const { ids } = await paginateBackwardFrom({
orderBy: { closeDate: 'AscNullsLast' },
startingBefore: forwardConnection.pageInfo.endCursor,
});
// Everything before the last record, in the same order as the forward scan
expect(ids).toEqual(forwardIds.slice(0, -1));
});
it('should reject a cursor that does not carry the orderBy field value', async () => {
const staleCursor = Buffer.from(
JSON.stringify({ id: datedOpportunityIds[0] }),
).toString('base64');
const response: GraphqlResponse = await makeGraphqlAPIRequest(
findManyOperationFactory({
objectMetadataSingularName: 'opportunity',
objectMetadataPluralName: 'opportunities',
gqlFields: 'id',
orderBy: { closeDate: 'AscNullsLast' },
first: PAGE_SIZE,
after: staleCursor,
}),
).expect(200);
expect(response.body.errors).toBeDefined();
expect(response.body.errors?.[0].message).toContain(
'Cursor is missing the value for orderBy field "closeDate"',
);
});
it('should paginate exhaustively when a filter is combined with the cursor', async () => {
const { ids } = await paginateForward({
filter: { closeDate: { gte: DATED_CLOSE_DATES[2] } },
orderBy: { closeDate: 'AscNullsLast' },
first: 2,
});
expect(ids).toEqual(datedOpportunityIds.slice(2));
});
// Canonical lowercase UUID strings sort the same way in JS and in Postgres,
// so the expected order can be computed with a plain string sort
it('should honor an explicit descending id ordering across pages', async () => {
const { ids } = await paginateForward({
orderBy: { id: 'DescNullsLast' },
});
expect(ids).toEqual(
[...datedOpportunityIds, ...nullCloseDateOpportunityIds].sort().reverse(),
);
});
it('should tie-break with an explicit descending id inside the NULL block', async () => {
const { ids } = await paginateForward({
orderBy: [{ closeDate: 'AscNullsLast' }, { id: 'DescNullsLast' }],
});
expect(ids).toEqual([
...datedOpportunityIds,
...[...nullCloseDateOpportunityIds].sort().reverse(),
]);
});
it('should keep the first direction when the same field is ordered twice', async () => {
const { ids } = await paginateForward({
orderBy: [{ closeDate: 'AscNullsLast' }, { closeDate: 'DescNullsFirst' }],
});
expect(ids.slice(0, DATED_CLOSE_DATES.length)).toEqual(datedOpportunityIds);
expect(new Set(ids.slice(DATED_CLOSE_DATES.length))).toEqual(
new Set(nullCloseDateOpportunityIds),
);
});
});
describe('Cursor pagination with composite orderBy not in the selection set', () => {
const personIds = Array.from({ length: 5 }, () => randomUUID());
const firstNames = ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve'];
beforeAll(async () => {
await deleteAllRecords('person');
await makeGraphqlAPIRequest(
createManyOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
gqlFields: 'id',
data: personIds.map((id, index) => ({
id,
name: {
firstName: firstNames[index],
lastName: 'Test',
},
})),
}),
).expect(200);
});
it('should return every record when only id is selected', async () => {
const { ids } = await paginateForward({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
orderBy: { name: { firstName: 'AscNullsLast' } },
first: 2,
});
// Global order must hold across pages: Alice..Eve
expect(ids).toEqual(personIds);
});
});
describe('Cursor pagination ordered by a nullable foreign key', () => {
const companyIds = [randomUUID(), randomUUID()];
const withCompanyOpportunityIds = [randomUUID(), randomUUID(), randomUUID()];
const withoutCompanyOpportunityIds = [
randomUUID(),
randomUUID(),
randomUUID(),
];
const fkTotalCount =
withCompanyOpportunityIds.length + withoutCompanyOpportunityIds.length;
beforeAll(async () => {
await deleteAllRecords('opportunity');
await deleteAllRecords('company');
await makeGraphqlAPIRequest(
createManyOperationFactory({
objectMetadataSingularName: 'company',
objectMetadataPluralName: 'companies',
gqlFields: 'id',
data: companyIds.map((id, index) => ({
id,
name: `Cursor FK company ${index + 1}`,
})),
}),
).expect(200);
await makeGraphqlAPIRequest(
createManyOperationFactory({
objectMetadataSingularName: 'opportunity',
objectMetadataPluralName: 'opportunities',
gqlFields: 'id',
data: [
...withCompanyOpportunityIds.map((id, index) => ({
id,
name: `FK opportunity ${index + 1}`,
companyId: companyIds[index % companyIds.length],
})),
...withoutCompanyOpportunityIds.map((id, index) => ({
id,
name: `FK-less opportunity ${index + 1}`,
})),
],
}),
).expect(200);
});
it('should return every record when ordering by the foreign key column', async () => {
const { ids } = await paginateForward({
orderBy: { companyId: 'AscNullsLast' },
first: 2,
});
expect(ids).toHaveLength(fkTotalCount);
expect(new Set(ids).size).toBe(fkTotalCount);
expect(new Set(ids.slice(withCompanyOpportunityIds.length))).toEqual(
new Set(withoutCompanyOpportunityIds),
);
});
});
describe('Cursor pagination ordered by a nullable composite sub-field', () => {
const withAmountOpportunityIds = [
randomUUID(),
randomUUID(),
randomUUID(),
randomUUID(),
];
const withoutAmountOpportunityIds = [
randomUUID(),
randomUUID(),
randomUUID(),
];
const amountTotalCount =
withAmountOpportunityIds.length + withoutAmountOpportunityIds.length;
beforeAll(async () => {
await deleteAllRecords('opportunity');
await makeGraphqlAPIRequest(
createManyOperationFactory({
objectMetadataSingularName: 'opportunity',
objectMetadataPluralName: 'opportunities',
gqlFields: 'id',
data: [
...withAmountOpportunityIds.map((id, index) => ({
id,
name: `Amount opportunity ${index + 1}`,
amount: {
amountMicros: (index + 1) * 1_000_000,
currencyCode: 'USD',
},
})),
...withoutAmountOpportunityIds.map((id, index) => ({
id,
name: `Amount-less opportunity ${index + 1}`,
})),
],
}),
).expect(200);
});
it('should paginate across the NULL block of a currency sub-field', async () => {
const { ids } = await paginateForward({
orderBy: { amount: { amountMicros: 'AscNullsLast' } },
first: 2,
});
expect(ids).toHaveLength(amountTotalCount);
expect(new Set(ids).size).toBe(amountTotalCount);
// Non-null amounts keep their micros order across pages
expect(ids.slice(0, withAmountOpportunityIds.length)).toEqual(
withAmountOpportunityIds,
);
expect(new Set(ids.slice(withAmountOpportunityIds.length))).toEqual(
new Set(withoutAmountOpportunityIds),
);
});
});
describe('Cursor pagination with duplicate sort values', () => {
const duplicatedCloseDates = [
'2026-02-01T00:00:00.000Z',
'2026-02-01T00:00:00.000Z',
'2026-02-02T00:00:00.000Z',
'2026-02-02T00:00:00.000Z',
'2026-02-03T00:00:00.000Z',
'2026-02-03T00:00:00.000Z',
];
const duplicateDatedOpportunityIds = duplicatedCloseDates.map(() =>
randomUUID(),
);
const duplicateNullOpportunityIds = [randomUUID(), randomUUID()];
const duplicatesTotalCount =
duplicateDatedOpportunityIds.length + duplicateNullOpportunityIds.length;
beforeAll(async () => {
await deleteAllRecords('opportunity');
await makeGraphqlAPIRequest(
createManyOperationFactory({
objectMetadataSingularName: 'opportunity',
objectMetadataPluralName: 'opportunities',
gqlFields: 'id',
data: [
...duplicatedCloseDates.map((closeDate, index) => ({
id: duplicateDatedOpportunityIds[index],
name: `Duplicate dated opportunity ${index + 1}`,
closeDate,
})),
...duplicateNullOpportunityIds.map((id, index) => ({
id,
name: `Duplicate undated opportunity ${index + 1}`,
})),
],
}),
).expect(200);
});
it('should neither skip nor repeat records when the sort value has duplicates', async () => {
// Page size 2 lands page boundaries inside the duplicate groups
const { ids } = await paginateForward({
orderBy: { closeDate: 'AscNullsLast' },
first: 2,
});
expect(ids).toHaveLength(duplicatesTotalCount);
expect(new Set(ids).size).toBe(duplicatesTotalCount);
expect(new Set(ids.slice(duplicatedCloseDates.length))).toEqual(
new Set(duplicateNullOpportunityIds),
);
});
});
// Empty TEXT values are stored as SQL NULL (write-side normalization) and
// presented as '' by the API: the scan's NULL block holds every empty row, and
// cursors read the raw SQL values so the continuation follows the scan exactly
describe('Cursor pagination ordered by a TEXT field with empty values', () => {
const namedCompanyIds = {
ALPHA: '20202020-eeee-4000-8000-000000000001',
BETA: '20202020-eeee-4000-8000-000000000002',
};
const emptyNameCompanyIds = [
'20202020-eeee-4000-8000-000000000003',
'20202020-eeee-4000-8000-000000000004',
'20202020-eeee-4000-8000-000000000005',
'20202020-eeee-4000-8000-000000000006',
];
const allCompanyIds = [
...Object.values(namedCompanyIds),
...emptyNameCompanyIds,
];
beforeAll(async () => {
await makeGraphqlAPIRequest(
createManyOperationFactory({
objectMetadataSingularName: 'company',
objectMetadataPluralName: 'companies',
gqlFields: 'id',
data: [
{ id: namedCompanyIds.ALPHA, name: 'Alpha Corp' },
{ id: namedCompanyIds.BETA, name: 'Beta Inc' },
// One explicitly empty, the others without the field: both store NULL
{ id: emptyNameCompanyIds[0], name: '' },
...emptyNameCompanyIds.slice(1).map((id) => ({ id })),
],
upsert: true,
}),
).expect(200);
});
it('should paginate the empty block exhaustively with AscNullsLast', async () => {
const { ids } = await paginateForward({
objectMetadataSingularName: 'company',
objectMetadataPluralName: 'companies',
filter: { id: { in: allCompanyIds } },
orderBy: { name: 'AscNullsLast' },
first: 2,
});
expect(ids).toEqual([
namedCompanyIds.ALPHA,
namedCompanyIds.BETA,
...emptyNameCompanyIds,
]);
});
it('should paginate the empty block exhaustively with DescNullsLast', async () => {
const { ids } = await paginateForward({
objectMetadataSingularName: 'company',
objectMetadataPluralName: 'companies',
filter: { id: { in: allCompanyIds } },
orderBy: { name: 'DescNullsLast' },
first: 2,
});
expect(ids).toEqual([
namedCompanyIds.BETA,
namedCompanyIds.ALPHA,
...emptyNameCompanyIds,
]);
});
});

View File

@@ -0,0 +1,177 @@
import gql from 'graphql-tag';
import request from 'supertest';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
// End-to-end coverage for the self-serve DPA generator. The generate mutation
// renders the PDF with @react-pdf/renderer on the server, so this also guards
// against the embedded-font regression ("unsupported number" on non-ASCII
// glyphs) — the input below intentionally contains accents and an em dash.
describe('DPA resolver (integration)', () => {
const createdAgreementIds: string[] = [];
afterAll(async () => {
for (const id of createdAgreementIds) {
await global.testDataSource
.query('DELETE FROM core."dpaAgreement" WHERE id = $1', [id])
.catch(() => {});
}
});
describe('dpaPreview query', () => {
it('resolves the deployment document with no unresolved merge fields', async () => {
const response = await makeGraphqlAPIRequest({
query: gql`
query DpaPreview {
dpaPreview {
title
region
processorEntity
templateVersion
sccSectionActive
blocks {
kind
text
}
}
}
`,
});
expect(response.status).toBe(200);
expect(response.body.errors).toBeUndefined();
const preview = response.body.data.dpaPreview;
expect(preview).toBeDefined();
expect(['EU', 'US']).toContain(preview.region);
expect(preview.processorEntity).toBeTruthy();
expect(preview.templateVersion).toBeTruthy();
expect(preview.blocks.length).toBeGreaterThan(0);
const joined = preview.blocks
.map((block: { text: string }) => block.text)
.join('\n');
expect(joined).not.toMatch(/\{\{[^}]+\}\}/);
});
});
describe('generateSignedDpa mutation', () => {
it('generates a signed PDF, returns a download URL and persists the record', async () => {
const input = {
customerLegalEntityName: 'Société Générale — Genève',
signatoryName: 'José Peña',
signatoryTitle: 'Directeur Général',
};
const response = await makeGraphqlAPIRequest({
query: gql`
mutation GenerateSignedDpa($input: GenerateSignedDpaInput!) {
generateSignedDpa(input: $input) {
downloadUrl
agreement {
id
type
region
processorEntity
templateVersion
customerLegalEntityName
signatoryName
signatoryTitle
}
}
}
`,
variables: { input },
});
expect(response.status).toBe(200);
// No errors here means renderToBuffer succeeded with the embedded font.
expect(response.body.errors).toBeUndefined();
const result = response.body.data.generateSignedDpa;
expect(result).toBeDefined();
expect(typeof result.downloadUrl).toBe('string');
expect(result.downloadUrl).toContain('/file/');
expect(result.agreement.id).toBeDefined();
expect(result.agreement.type).toBe('SIGNED');
expect(result.agreement.customerLegalEntityName).toBe(
input.customerLegalEntityName,
);
expect(result.agreement.templateVersion).toBeTruthy();
createdAgreementIds.push(result.agreement.id);
// The stored copy is downloadable and is a real PDF.
const downloadUrl = new URL(result.downloadUrl);
const downloadResponse = await request(
`http://localhost:${APP_PORT}`,
).get(`${downloadUrl.pathname}${downloadUrl.search}`);
expect(downloadResponse.status).toBe(200);
const body = downloadResponse.body;
const header = Buffer.isBuffer(body)
? body.subarray(0, 5).toString()
: '';
// Local storage streams the bytes; S3 would 30x-redirect. Only assert the
// PDF magic bytes when we actually received the file body.
if (header !== '') {
expect(header).toBe('%PDF-');
}
});
it('rejects blank execution fields via server-side validation', async () => {
const response = await makeGraphqlAPIRequest({
query: gql`
mutation GenerateSignedDpa($input: GenerateSignedDpaInput!) {
generateSignedDpa(input: $input) {
downloadUrl
}
}
`,
variables: {
input: {
customerLegalEntityName: 'Acme GmbH',
signatoryName: '',
signatoryTitle: 'Head of Legal',
},
},
});
expect(response.body.errors).toBeDefined();
});
});
describe('dpaAgreements query', () => {
it('lists executed copies with a re-download URL', async () => {
const response = await makeGraphqlAPIRequest({
query: gql`
query DpaAgreements {
dpaAgreements {
id
type
templateVersion
downloadUrl
}
}
`,
});
expect(response.status).toBe(200);
expect(response.body.errors).toBeUndefined();
expect(Array.isArray(response.body.data.dpaAgreements)).toBe(true);
const signed = response.body.data.dpaAgreements.find(
(agreement: { id: string }) =>
createdAgreementIds.includes(agreement.id),
);
expect(signed).toBeDefined();
expect(signed.type).toBe('SIGNED');
expect(typeof signed.downloadUrl).toBe('string');
});
});
});

View File

@@ -0,0 +1,648 @@
import process from 'process';
import {
type ClickHouseClient,
ClickHouseLogLevel,
createClient,
} from '@clickhouse/client';
import request from 'supertest';
import { formatDateTimeForClickHouse } from 'src/database/clickhouse/utils/format-date-time-for-clickhouse.util';
import { UsageOperationType } from 'src/engine/core-modules/usage/enums/usage-operation-type.enum';
import { UsageResourceType } from 'src/engine/core-modules/usage/enums/usage-resource-type.enum';
import { UsageUnit } from 'src/engine/core-modules/usage/enums/usage-unit.enum';
const client = request(`http://localhost:${APP_PORT}`);
describe('Event Logs (integration)', () => {
let clickHouseClient: ClickHouseClient;
const testWorkspaceId = '20202020-1c25-4d02-bf25-6aeccf7ea419';
const testUserWorkspaceId = '20202020-3957-45c9-be39-337dc4d9100a';
const otherUserWorkspaceId = '20202020-0000-4000-8000-0000000000ff';
beforeAll(async () => {
jest.useRealTimers();
clickHouseClient = createClient({
url: process.env.CLICKHOUSE_URL,
clickhouse_settings: {
allow_experimental_json_type: 1,
},
log: { level: ClickHouseLogLevel.OFF },
});
await seedTestData();
});
afterAll(async () => {
await cleanupTestData();
if (clickHouseClient) {
await clickHouseClient.close();
}
});
const seedTestData = async () => {
const now = new Date();
const pageviewRecords = Array.from({ length: 25 }, (_, i) => ({
workspaceId: testWorkspaceId,
userId: testUserWorkspaceId,
name: i % 2 === 0 ? 'settings/profile' : 'objects/companies',
timestamp: formatDateTimeForClickHouse(
new Date(now.getTime() - i * 60000),
),
properties: { path: `/settings/${i}` },
}));
const workspaceEventRecords = Array.from({ length: 15 }, (_, i) => ({
workspaceId: testWorkspaceId,
userId: testUserWorkspaceId,
event:
i % 3 === 0
? 'user.login'
: i % 3 === 1
? 'user.logout'
: 'settings.updated',
timestamp: formatDateTimeForClickHouse(
new Date(now.getTime() - i * 120000),
),
properties: { action: `action_${i}` },
}));
const objectEventRecords = Array.from({ length: 20 }, (_, i) => ({
workspaceId: testWorkspaceId,
userId: testUserWorkspaceId,
event: i % 2 === 0 ? 'company.created' : 'company.updated',
timestamp: formatDateTimeForClickHouse(
new Date(now.getTime() - i * 90000),
),
properties: { field: `field_${i}` },
recordId: `record-${i}`,
objectMetadataId: i % 2 === 0 ? 'object-meta-1' : 'object-meta-2',
isCustom: i % 4 === 0,
}));
const usageEventRecords = Array.from({ length: 8 }, (_, i) => ({
workspaceId: testWorkspaceId,
userWorkspaceId: i < 5 ? testUserWorkspaceId : otherUserWorkspaceId,
resourceType: UsageResourceType.AI,
operationType: UsageOperationType.AI_CHAT_TOKEN,
quantity: (i + 1) * 100,
unit: UsageUnit.TOKEN,
creditsUsedMicro: (i + 1) * 500,
resourceId: `agent-${i}`,
resourceContext: 'gpt-4o',
metadata: {},
timestamp: formatDateTimeForClickHouse(
new Date(now.getTime() - i * 60000),
),
}));
const applicationLogRecords = Array.from({ length: 8 }, (_, i) => ({
workspaceId: testWorkspaceId,
applicationId: 'app-1',
logicFunctionId: 'fn-1',
logicFunctionName: i % 2 === 0 ? 'sendEmail' : 'syncContacts',
executionId: `exec-${i}`,
level: i % 2 === 0 ? 'INFO' : 'ERROR',
message: `log line ${i}`,
timestamp: formatDateTimeForClickHouse(
new Date(now.getTime() - i * 60000),
),
}));
await clickHouseClient.insert({
table: 'pageview',
values: pageviewRecords,
format: 'JSONEachRow',
});
await clickHouseClient.insert({
table: 'workspaceEvent',
values: workspaceEventRecords,
format: 'JSONEachRow',
});
await clickHouseClient.insert({
table: 'objectEvent',
values: objectEventRecords,
format: 'JSONEachRow',
});
await clickHouseClient.insert({
table: 'usageEvent',
values: usageEventRecords,
format: 'JSONEachRow',
});
await clickHouseClient.insert({
table: 'applicationLog',
values: applicationLogRecords,
format: 'JSONEachRow',
});
// Wait for ClickHouse async inserts to complete
await new Promise((resolve) => setTimeout(resolve, 2000));
};
const cleanupTestData = async () => {
try {
await clickHouseClient.command({
query: `ALTER TABLE pageview DELETE WHERE workspaceId = '${testWorkspaceId}'`,
});
await clickHouseClient.command({
query: `ALTER TABLE workspaceEvent DELETE WHERE workspaceId = '${testWorkspaceId}'`,
});
await clickHouseClient.command({
query: `ALTER TABLE objectEvent DELETE WHERE workspaceId = '${testWorkspaceId}'`,
});
await clickHouseClient.command({
query: `ALTER TABLE usageEvent DELETE WHERE workspaceId = '${testWorkspaceId}'`,
});
await clickHouseClient.command({
query: `ALTER TABLE applicationLog DELETE WHERE workspaceId = '${testWorkspaceId}'`,
});
} catch {
// Ignore cleanup errors
}
};
const makeEventLogsQuery = (
input: Record<string, unknown>,
token = APPLE_JANE_ADMIN_ACCESS_TOKEN,
) => {
return client
.post('/metadata')
.set('Authorization', `Bearer ${token}`)
.send({
query: `
query EventLogs($input: EventLogQueryInput!) {
eventLogs(input: $input) {
records {
event
timestamp
userId
properties
recordId
objectMetadataId
isCustom
}
totalCount
pageInfo {
endCursor
hasNextPage
}
}
}
`,
variables: { input },
});
};
describe('querying different tables', () => {
it('should query PAGEVIEW table', async () => {
const response = await makeEventLogsQuery({
table: 'PAGEVIEW',
first: 10,
});
expect(response.status).toBe(200);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.eventLogs).toBeDefined();
expect(response.body.data.eventLogs.records.length).toBeGreaterThan(0);
expect(response.body.data.eventLogs.totalCount).toBeGreaterThanOrEqual(
response.body.data.eventLogs.records.length,
);
});
it('should query WORKSPACE_EVENT table', async () => {
const response = await makeEventLogsQuery({
table: 'WORKSPACE_EVENT',
first: 10,
});
expect(response.status).toBe(200);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.eventLogs).toBeDefined();
expect(response.body.data.eventLogs.records.length).toBeGreaterThan(0);
});
it('should query OBJECT_EVENT table', async () => {
const response = await makeEventLogsQuery({
table: 'OBJECT_EVENT',
first: 10,
});
expect(response.status).toBe(200);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.eventLogs).toBeDefined();
expect(response.body.data.eventLogs.records.length).toBeGreaterThan(0);
const record = response.body.data.eventLogs.records[0];
expect(record.recordId).toBeDefined();
expect(record.objectMetadataId).toBeDefined();
});
it('should query USAGE_EVENT table and fold columns into properties', async () => {
const response = await makeEventLogsQuery({
table: 'USAGE_EVENT',
first: 10,
});
expect(response.status).toBe(200);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.eventLogs.records.length).toBeGreaterThan(0);
const record = response.body.data.eventLogs.records[0];
expect(typeof record.event).toBe('string');
expect(record.event.length).toBeGreaterThan(0);
expect(record.userId).toBeDefined();
expect(record.properties.operationType).toBeDefined();
expect(record.properties.quantity).toBeDefined();
expect(record.properties.unit).toBeDefined();
expect(record.properties.creditsUsedMicro).toBeDefined();
});
it('should query APPLICATION_LOG table and fold level/message into properties', async () => {
const response = await makeEventLogsQuery({
table: 'APPLICATION_LOG',
first: 10,
});
expect(response.status).toBe(200);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.eventLogs.records.length).toBeGreaterThan(0);
const record = response.body.data.eventLogs.records[0];
expect(typeof record.event).toBe('string');
expect(record.event.length).toBeGreaterThan(0);
expect(record.properties.level).toBeDefined();
expect(record.properties.message).toBeDefined();
expect(record.properties.executionId).toBeDefined();
});
});
describe('pagination', () => {
it('should return hasNextPage=true when more records exist', async () => {
const response = await makeEventLogsQuery({
table: 'PAGEVIEW',
first: 5,
});
expect(response.status).toBe(200);
expect(response.body.data.eventLogs.records.length).toBe(5);
expect(response.body.data.eventLogs.pageInfo.hasNextPage).toBe(true);
expect(response.body.data.eventLogs.pageInfo.endCursor).toBeDefined();
});
it('should fetch next page using cursor', async () => {
const firstPage = await makeEventLogsQuery({
table: 'PAGEVIEW',
first: 5,
});
expect(firstPage.body.data.eventLogs.pageInfo.hasNextPage).toBe(true);
const cursor = firstPage.body.data.eventLogs.pageInfo.endCursor;
const secondPage = await makeEventLogsQuery({
table: 'PAGEVIEW',
first: 5,
after: cursor,
});
expect(secondPage.status).toBe(200);
expect(secondPage.body.data.eventLogs.records.length).toBeGreaterThan(0);
const firstPageTimestamps = firstPage.body.data.eventLogs.records.map(
(r: { timestamp: string }) => r.timestamp,
);
const secondPageTimestamps = secondPage.body.data.eventLogs.records.map(
(r: { timestamp: string }) => r.timestamp,
);
const lastFirstPage = new Date(
firstPageTimestamps[firstPageTimestamps.length - 1],
);
const firstSecondPage = new Date(secondPageTimestamps[0]);
expect(lastFirstPage.getTime()).toBeGreaterThanOrEqual(
firstSecondPage.getTime(),
);
});
it('should return correct totalCount regardless of page size', async () => {
const smallPage = await makeEventLogsQuery({
table: 'PAGEVIEW',
first: 3,
});
const largePage = await makeEventLogsQuery({
table: 'PAGEVIEW',
first: 100,
});
expect(smallPage.body.data.eventLogs.totalCount).toBe(
largePage.body.data.eventLogs.totalCount,
);
});
});
describe('filters', () => {
describe('eventType filter', () => {
it('should filter by event type (partial match)', async () => {
const response = await makeEventLogsQuery({
table: 'PAGEVIEW',
first: 50,
filters: {
eventType: 'settings',
},
});
expect(response.status).toBe(200);
expect(response.body.data.eventLogs.records.length).toBeGreaterThan(0);
response.body.data.eventLogs.records.forEach(
(record: { event: string }) => {
expect(record.event.toLowerCase()).toContain('settings');
},
);
});
it('should filter by event type case-insensitively', async () => {
const lowerCase = await makeEventLogsQuery({
table: 'WORKSPACE_EVENT',
first: 50,
filters: { eventType: 'login' },
});
const upperCase = await makeEventLogsQuery({
table: 'WORKSPACE_EVENT',
first: 50,
filters: { eventType: 'LOGIN' },
});
expect(lowerCase.body.data.eventLogs.totalCount).toBe(
upperCase.body.data.eventLogs.totalCount,
);
});
});
describe('dateRange filter', () => {
it('should filter by start date', async () => {
const now = new Date();
const tenMinutesAgo = new Date(now.getTime() - 10 * 60 * 1000);
const response = await makeEventLogsQuery({
table: 'PAGEVIEW',
first: 50,
filters: {
dateRange: {
start: tenMinutesAgo.toISOString(),
},
},
});
expect(response.status).toBe(200);
response.body.data.eventLogs.records.forEach(
(record: { timestamp: string }) => {
expect(new Date(record.timestamp).getTime()).toBeGreaterThanOrEqual(
tenMinutesAgo.getTime(),
);
},
);
});
it('should filter by end date', async () => {
const now = new Date();
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
const response = await makeEventLogsQuery({
table: 'PAGEVIEW',
first: 50,
filters: {
dateRange: {
end: fiveMinutesAgo.toISOString(),
},
},
});
expect(response.status).toBe(200);
response.body.data.eventLogs.records.forEach(
(record: { timestamp: string }) => {
expect(new Date(record.timestamp).getTime()).toBeLessThanOrEqual(
fiveMinutesAgo.getTime(),
);
},
);
});
it('should filter by date range (start and end)', async () => {
const now = new Date();
const twentyMinutesAgo = new Date(now.getTime() - 20 * 60 * 1000);
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
const response = await makeEventLogsQuery({
table: 'PAGEVIEW',
first: 50,
filters: {
dateRange: {
start: twentyMinutesAgo.toISOString(),
end: fiveMinutesAgo.toISOString(),
},
},
});
expect(response.status).toBe(200);
response.body.data.eventLogs.records.forEach(
(record: { timestamp: string }) => {
const timestamp = new Date(record.timestamp).getTime();
expect(timestamp).toBeGreaterThanOrEqual(
twentyMinutesAgo.getTime(),
);
expect(timestamp).toBeLessThanOrEqual(fiveMinutesAgo.getTime());
},
);
});
});
describe('object event specific filters', () => {
it('should filter by recordId', async () => {
const response = await makeEventLogsQuery({
table: 'OBJECT_EVENT',
first: 50,
filters: {
recordId: 'record-0',
},
});
expect(response.status).toBe(200);
if (response.body.data.eventLogs.records.length > 0) {
response.body.data.eventLogs.records.forEach(
(record: { recordId: string }) => {
expect(record.recordId).toBe('record-0');
},
);
}
});
it('should filter by objectMetadataId', async () => {
const response = await makeEventLogsQuery({
table: 'OBJECT_EVENT',
first: 50,
filters: {
objectMetadataId: 'object-meta-1',
},
});
expect(response.status).toBe(200);
if (response.body.data.eventLogs.records.length > 0) {
response.body.data.eventLogs.records.forEach(
(record: { objectMetadataId: string }) => {
expect(record.objectMetadataId).toBe('object-meta-1');
},
);
}
});
});
describe('usage event specific filters', () => {
it('should filter usage events by userWorkspaceId', async () => {
const response = await makeEventLogsQuery({
table: 'USAGE_EVENT',
first: 50,
filters: {
userWorkspaceId: testUserWorkspaceId,
},
});
expect(response.status).toBe(200);
expect(response.body.data.eventLogs.records.length).toBeGreaterThan(0);
response.body.data.eventLogs.records.forEach(
(record: { userId: string }) => {
expect(record.userId).toBe(testUserWorkspaceId);
},
);
});
});
describe('combined filters', () => {
it('should apply multiple filters together', async () => {
const now = new Date();
const thirtyMinutesAgo = new Date(now.getTime() - 30 * 60 * 1000);
const response = await makeEventLogsQuery({
table: 'WORKSPACE_EVENT',
first: 50,
filters: {
eventType: 'login',
dateRange: {
start: thirtyMinutesAgo.toISOString(),
},
},
});
expect(response.status).toBe(200);
response.body.data.eventLogs.records.forEach(
(record: { event: string; timestamp: string }) => {
expect(record.event.toLowerCase()).toContain('login');
expect(new Date(record.timestamp).getTime()).toBeGreaterThanOrEqual(
thirtyMinutesAgo.getTime(),
);
},
);
});
});
});
describe('record structure', () => {
it('should return properly structured pageview records', async () => {
const response = await makeEventLogsQuery({
table: 'PAGEVIEW',
first: 1,
});
expect(response.status).toBe(200);
const record = response.body.data.eventLogs.records[0];
expect(record).toHaveProperty('event');
expect(record).toHaveProperty('timestamp');
expect(record).toHaveProperty('userId');
expect(record).toHaveProperty('properties');
expect(typeof record.event).toBe('string');
expect(typeof record.timestamp).toBe('string');
});
it('should return properly structured object event records', async () => {
const response = await makeEventLogsQuery({
table: 'OBJECT_EVENT',
first: 1,
});
expect(response.status).toBe(200);
const record = response.body.data.eventLogs.records[0];
expect(record).toHaveProperty('event');
expect(record).toHaveProperty('timestamp');
expect(record).toHaveProperty('recordId');
expect(record).toHaveProperty('objectMetadataId');
expect(record).toHaveProperty('isCustom');
});
});
describe('permissions', () => {
it('should deny access to member role', async () => {
const response = await makeEventLogsQuery(
{ table: 'PAGEVIEW', first: 10 },
APPLE_JONY_MEMBER_ACCESS_TOKEN,
);
expect(response.status).toBe(200);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].extensions.code).toBe('FORBIDDEN');
});
it('should deny access without authentication', async () => {
const response = await client.post('/metadata').send({
query: `
query EventLogs($input: EventLogQueryInput!) {
eventLogs(input: $input) {
records { event }
totalCount
pageInfo { hasNextPage }
}
}
`,
variables: { input: { table: 'PAGEVIEW', first: 10 } },
});
expect(response.status).toBe(200);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].extensions.code).toBe('UNAUTHENTICATED');
});
});
describe('error handling', () => {
it('should reject invalid table name', async () => {
const response = await makeEventLogsQuery({
table: 'INVALID_TABLE' as 'PAGEVIEW',
first: 10,
});
// Invalid enum values are rejected by GraphQL validation before reaching the resolver
expect(response.status).toBe(400);
expect(response.body.errors).toBeDefined();
});
});
});

View File

@@ -0,0 +1,119 @@
import process from 'process';
import {
type ClickHouseClient,
ClickHouseLogLevel,
createClient,
} from '@clickhouse/client';
import gql from 'graphql-tag';
import { OBJECT_RECORD_CREATED_EVENT } from 'src/engine/core-modules/event-logs/emit/events/object-event/object-record-created';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
import { PERSON_GQL_FIELDS } from 'test/integration/constants/person-gql-fields.constants';
import { createManyOperationFactory } from 'test/integration/graphql/utils/create-many-operation-factory.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { deleteAllRecords } from 'test/integration/utils/delete-all-records';
// End-to-end write path: creating a record fires an objectEvent through
// entityEventsToDbQueue -> the consumer -> the ClickHouse sink.
describe('Object event write (integration)', () => {
let clickHouseClient: ClickHouseClient;
let personObjectMetadataId: string;
const testWorkspaceId = SEED_APPLE_WORKSPACE_ID;
beforeAll(async () => {
jest.useRealTimers();
clickHouseClient = createClient({
url: process.env.CLICKHOUSE_URL,
log: { level: ClickHouseLogLevel.OFF },
});
await deleteAllRecords('person');
const response = await makeMetadataAPIRequest({
query: gql`
query {
objects(paging: { first: 1000 }) {
edges {
node {
id
nameSingular
}
}
}
}
`,
});
personObjectMetadataId = response.body.data.objects.edges.find(
(edge: { node: { id: string; nameSingular: string } }) =>
edge.node.nameSingular === 'person',
).node.id;
});
afterAll(async () => {
await deleteAllRecords('person');
if (clickHouseClient) {
await clickHouseClient.close();
}
});
const queryObjectEventsByRecordId = (recordId: string) =>
clickHouseClient
.query({
query: `
SELECT event, recordId, objectMetadataId, workspaceId
FROM objectEvent
WHERE recordId = '${recordId}' AND workspaceId = '${testWorkspaceId}'
`,
format: 'JSONEachRow',
})
.then((result) =>
result.json<{
event: string;
recordId: string;
objectMetadataId: string;
workspaceId: string;
}>(),
);
it('writes an objectEvent row to ClickHouse when a record is created', async () => {
const createResponse = await makeGraphqlAPIRequest(
createManyOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
gqlFields: PERSON_GQL_FIELDS,
data: [{ name: { firstName: 'Event', lastName: 'Logged' } }],
}),
);
expect(createResponse.body.errors).toBeUndefined();
const recordId = createResponse.body.data.createPeople[0].id;
expect(recordId).toBeDefined();
let rows: Awaited<ReturnType<typeof queryObjectEventsByRecordId>> = [];
for (let attempt = 0; attempt < 20 && rows.length === 0; attempt++) {
rows = await queryObjectEventsByRecordId(recordId);
if (rows.length === 0) {
await new Promise((resolve) => setTimeout(resolve, 500));
}
}
expect(rows.length).toBeGreaterThanOrEqual(1);
expect(rows[0].event).toBe(OBJECT_RECORD_CREATED_EVENT);
expect(rows[0].recordId).toBe(recordId);
expect(rows[0].objectMetadataId).toBe(personObjectMetadataId);
await clickHouseClient.command({
query: `ALTER TABLE objectEvent DELETE WHERE recordId = '${recordId}'`,
});
});
});

View File

@@ -0,0 +1,81 @@
import process from 'process';
import {
type ClickHouseClient,
ClickHouseLogLevel,
createClient,
} from '@clickhouse/client';
import request from 'supertest';
import { CUSTOM_DOMAIN_ACTIVATED_EVENT } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/custom-domain/custom-domain-activated';
import { type GenericTrackEvent } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/track';
describe('ClickHouse Event Registration (integration)', () => {
let clickHouseClient: ClickHouseClient;
beforeAll(async () => {
jest.useRealTimers();
clickHouseClient = createClient({
url: process.env.CLICKHOUSE_URL,
log: { level: ClickHouseLogLevel.OFF },
});
await clickHouseClient.query({
query: 'TRUNCATE TABLE workspaceEvent',
format: 'JSONEachRow',
});
});
afterAll(async () => {
if (clickHouseClient) {
await clickHouseClient.close();
}
});
it('should register events in ClickHouse when sending an event', async () => {
const mutation = `
mutation TrackAnalytics($type: AnalyticsType!, $event: String, $name: String, $properties: JSON) {
trackAnalytics(type: $type, event: $event, name: $name, properties: $properties) {
success
}
}
`;
const variables = {
type: 'TRACK',
event: CUSTOM_DOMAIN_ACTIVATED_EVENT,
properties: {},
};
const response = await request(`http://localhost:${APP_PORT}`)
.post('/metadata')
.send({
query: mutation,
variables,
});
expect(response.status).toBe(200);
expect(response.body.data.trackAnalytics.success).toBe(true);
const queryResult = await clickHouseClient.query({
query: `
SELECT *
FROM workspaceEvent
WHERE event = '${CUSTOM_DOMAIN_ACTIVATED_EVENT}' AND timestamp >= now() - INTERVAL 1 SECOND
`,
format: 'JSONEachRow',
});
const rows = await queryResult.json<GenericTrackEvent>();
expect(rows.length).toEqual(1);
expect(rows[0].properties).toEqual(variables.properties);
expect(rows[0].event).toEqual(variables.event);
// workspaceId and userId are empty/undefined for unauthenticated requests
expect(rows[0].workspaceId ?? '').toEqual('');
expect(rows[0].userId ?? '').toEqual('');
expect(rows[0].timestamp).toHaveLength(23);
});
});

View File

@@ -0,0 +1,62 @@
import {
deleteWorkspaceInvitationsByEmail,
findWorkspaceInvitationsByEmail,
seedWorkspaceInvitation,
} from 'test/integration/graphql/utils/seed-workspace-invitation.util';
import { sendInvitationsOperationFactory } from 'test/integration/graphql/utils/send-invitations-operation-factory.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
const ONE_HOUR_IN_MS = 60 * 60 * 1000;
describe('sendInvitations expired invitation handling (integration)', () => {
const sendInvitations = (email: string) =>
makeMetadataAPIRequest(
sendInvitationsOperationFactory({ emails: [email] }),
);
it('re-invites an email whose only existing invitation is expired', async () => {
const email = `expired-invite-resend-${Date.now()}@example.com`;
const staleToken = `expired-invite-resend-token-${Date.now()}`;
await seedWorkspaceInvitation({
email,
value: staleToken,
expiresAt: new Date(Date.now() - ONE_HOUR_IN_MS),
});
try {
const response = await sendInvitations(email);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.sendInvitations.success).toBe(true);
const remainingTokens = await findWorkspaceInvitationsByEmail({ email });
expect(remainingTokens).toHaveLength(1);
expect(remainingTokens[0].value).not.toBe(staleToken);
expect(new Date(remainingTokens[0].expiresAt).getTime()).toBeGreaterThan(
Date.now(),
);
} finally {
await deleteWorkspaceInvitationsByEmail({ email });
}
});
it('still reports a valid invitation as already existing', async () => {
const email = `valid-invite-resend-${Date.now()}@example.com`;
await seedWorkspaceInvitation({
email,
value: `valid-invite-resend-token-${Date.now()}`,
expiresAt: new Date(Date.now() + ONE_HOUR_IN_MS),
});
try {
const response = await sendInvitations(email);
expect(response.body.data.sendInvitations.success).toBe(false);
} finally {
await deleteWorkspaceInvitationsByEmail({ email });
}
});
});

View File

@@ -0,0 +1,332 @@
import gql from 'graphql-tag';
import request from 'supertest';
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { FieldMetadataType, FileFolder } from 'twenty-shared/types';
const createFileUploadMutation = gql`
mutation CreateFileUpload(
$filename: String!
$size: Float!
$fileFolder: FileFolder!
$fieldMetadataId: String
) {
createFileUpload(
filename: $filename
size: $size
fileFolder: $fileFolder
fieldMetadataId: $fieldMetadataId
) {
fileId
uploadUrl
contentType
expiresAt
}
}
`;
const completeFileUploadMutation = gql`
mutation CompleteFileUpload($fileId: String!) {
completeFileUpload(fileId: $fileId) {
id
path
size
createdAt
url
}
}
`;
const deleteFileMutation = gql`
mutation DeleteFile($fileId: UUID!) {
deleteFile(fileId: $fileId) {
id
}
}
`;
describe('direct file upload (createFileUpload / completeFileUpload)', () => {
let createdObjectMetadataId: string;
let createdFieldMetadataId: string;
const uploadedFileIds: string[] = [];
const createFileUpload = async (variables: Record<string, unknown>) => {
return makeMetadataAPIRequest({
query: createFileUploadMutation,
variables,
});
};
const completeFileUpload = async (fileId: string) => {
return makeMetadataAPIRequest({
query: completeFileUploadMutation,
variables: { fileId },
});
};
const putFileToUploadUrl = async (
uploadUrl: string,
contentType: string,
content: Buffer,
) => {
// Integration tests run on the local storage driver, so the upload url
// targets the server's streaming endpoint: replay it against the test app.
const { pathname, search } = new URL(uploadUrl);
return request(global.app.getHttpServer())
.put(`${pathname}${search}`)
.set('Content-Type', contentType)
.send(content);
};
beforeAll(async () => {
jest.useRealTimers();
const {
data: {
createOneObject: { id: objectMetadataId },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'directUploadTestObject',
namePlural: 'directUploadTestObjects',
labelSingular: 'Direct Upload Test Object',
labelPlural: 'Direct Upload Test Objects',
icon: 'IconFile',
},
});
createdObjectMetadataId = objectMetadataId;
const {
data: {
createOneField: { id: fieldMetadataId },
},
} = await createOneFieldMetadata({
input: {
name: 'filesField',
label: 'Files Field',
type: FieldMetadataType.FILES,
objectMetadataId: createdObjectMetadataId,
settings: { maxNumberOfValues: 5 },
},
gqlFields: `
id
`,
});
createdFieldMetadataId = fieldMetadataId;
});
afterAll(async () => {
for (const fileId of uploadedFileIds) {
try {
await makeMetadataAPIRequest({
query: deleteFileMutation,
variables: { fileId },
});
} catch {
// Cleanup is best-effort: a failed deletion must not prevent the
// object metadata teardown below.
}
}
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
input: { idToDelete: createdObjectMetadataId },
});
jest.useFakeTimers();
});
it('should upload a file end to end: initiate, PUT bytes, complete', async () => {
const testFileContent = Buffer.from('direct upload test content');
const createResponse = await createFileUpload({
filename: 'direct-upload.txt',
size: testFileContent.length,
fileFolder: 'FilesField',
fieldMetadataId: createdFieldMetadataId,
});
expect(createResponse.status).toBe(200);
expect(createResponse.body.errors).toBeUndefined();
const uploadTarget = createResponse.body.data.createFileUpload;
expect(uploadTarget.fileId).toBeDefined();
expect(uploadTarget.uploadUrl).toContain(
`/file-upload/${uploadTarget.fileId}?token=`,
);
expect(uploadTarget.contentType).toBe('application/octet-stream');
expect(new Date(uploadTarget.expiresAt).getTime()).toBeGreaterThan(
Date.now(),
);
uploadedFileIds.push(uploadTarget.fileId);
const putResponse = await putFileToUploadUrl(
uploadTarget.uploadUrl,
uploadTarget.contentType,
testFileContent,
);
expect(putResponse.status).toBe(204);
const completeResponse = await completeFileUpload(uploadTarget.fileId);
expect(completeResponse.status).toBe(200);
expect(completeResponse.body.errors).toBeUndefined();
const completedFile = completeResponse.body.data.completeFileUpload;
expect(completedFile.id).toBe(uploadTarget.fileId);
expect(completedFile.path).toContain(FileFolder.FilesField);
expect(completedFile.size).toBe(testFileContent.length);
expect(completedFile.url).toContain(
`/file/${FileFolder.FilesField}/${uploadTarget.fileId}?token=`,
);
// The confirmed file is downloadable through the regular file endpoint
const { pathname, search } = new URL(completedFile.url);
const downloadResponse = await request(global.app.getHttpServer()).get(
`${pathname}${search}`,
);
expect(downloadResponse.status).toBe(200);
expect(downloadResponse.text).toBe(testFileContent.toString());
});
it('should refuse to complete an upload whose bytes never reached storage', async () => {
const createResponse = await createFileUpload({
filename: 'never-uploaded.txt',
size: 100,
fileFolder: 'FilesField',
fieldMetadataId: createdFieldMetadataId,
});
const uploadTarget = createResponse.body.data.createFileUpload;
uploadedFileIds.push(uploadTarget.fileId);
const completeResponse = await completeFileUpload(uploadTarget.fileId);
expect(completeResponse.body.errors).toBeDefined();
expect(completeResponse.body.errors[0].message).toContain(
'has not been uploaded',
);
});
it('should reject completing an upload whose content contradicts its extension', async () => {
const spoofedContent = Buffer.from('this is not really a png image');
const createResponse = await createFileUpload({
filename: 'actually-text.png',
size: spoofedContent.length,
fileFolder: 'FilesField',
fieldMetadataId: createdFieldMetadataId,
});
const uploadTarget = createResponse.body.data.createFileUpload;
uploadedFileIds.push(uploadTarget.fileId);
const putResponse = await putFileToUploadUrl(
uploadTarget.uploadUrl,
uploadTarget.contentType,
spoofedContent,
);
expect(putResponse.status).toBe(204);
const completeResponse = await completeFileUpload(uploadTarget.fileId);
expect(completeResponse.body.errors).toBeDefined();
expect(completeResponse.body.errors[0].message).toContain(
'does not match its extension',
);
const retryResponse = await completeFileUpload(uploadTarget.fileId);
expect(retryResponse.body.errors).toBeDefined();
expect(retryResponse.body.data?.completeFileUpload ?? null).toBeNull();
});
it('should refuse a PUT larger than the declared size', async () => {
const declaredSize = 10;
const oversizedContent = Buffer.from(
'this content is longer than ten bytes',
);
const createResponse = await createFileUpload({
filename: 'oversized.txt',
size: declaredSize,
fileFolder: 'FilesField',
fieldMetadataId: createdFieldMetadataId,
});
const uploadTarget = createResponse.body.data.createFileUpload;
uploadedFileIds.push(uploadTarget.fileId);
const putResponse = await putFileToUploadUrl(
uploadTarget.uploadUrl,
uploadTarget.contentType,
oversizedContent,
);
expect(putResponse.status).toBe(413);
});
it('should refuse a PUT without a valid upload token', async () => {
const createResponse = await createFileUpload({
filename: 'bad-token.txt',
size: 10,
fileFolder: 'FilesField',
fieldMetadataId: createdFieldMetadataId,
});
const uploadTarget = createResponse.body.data.createFileUpload;
uploadedFileIds.push(uploadTarget.fileId);
const putResponse = await request(global.app.getHttpServer())
.put(`/file-upload/${uploadTarget.fileId}?token=not-a-valid-token`)
.set('Content-Type', 'application/octet-stream')
.send(Buffer.from('0123456789'));
expect(putResponse.status).toBe(403);
});
it('should reject file folders without direct upload support', async () => {
const createResponse = await createFileUpload({
filename: 'picture.png',
size: 10,
fileFolder: 'CorePicture',
});
expect(createResponse.body.errors).toBeDefined();
});
it('should reject a size above the direct upload maximum', async () => {
const createResponse = await createFileUpload({
filename: 'huge.bin',
size: 5 * 1024 * 1024 * 1024,
fileFolder: 'FilesField',
fieldMetadataId: createdFieldMetadataId,
});
expect(createResponse.body.errors).toBeDefined();
});
});

View File

@@ -0,0 +1,345 @@
import gql from 'graphql-tag';
import request from 'supertest';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { uploadFileWithDirectUpload } from 'test/integration/graphql/utils/upload-file-with-direct-upload.util';
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { FieldMetadataType } from 'twenty-shared/types';
const deleteFileMutation = gql`
mutation DeleteFile($fileId: UUID!) {
deleteFile(fileId: $fileId) {
id
}
}
`;
const createRecordsQuery = gql`
mutation CreateRecords(
$data: [FileDownloadTestObjectCreateInput!]!
$upsert: Boolean
) {
createFileDownloadTestObjects(data: $data, upsert: $upsert) {
id
name
filesField {
fileId
label
extension
url
}
}
}
`;
const deleteRecordsQuery = gql`
mutation DeleteRecords($filter: FileDownloadTestObjectFilterInput!) {
deleteFileDownloadTestObjects(filter: $filter) {
id
}
}
`;
type UploadedFile = {
id: string;
contentType: string;
content: string;
};
const deleteFile = async (fileId: string): Promise<void> => {
await makeMetadataAPIRequest({
query: deleteFileMutation,
variables: { fileId },
});
};
describe('file-by-id.controller - GET /file/:fileFolder/:id', () => {
let createdObjectMetadataId = '';
let createdFieldMetadataId = '';
let uploadedFiles: UploadedFile[] = [];
const uploadFile = async (
filename: string,
content: string,
contentType: string,
): Promise<UploadedFile> => {
const uploadedFile = await uploadFileWithDirectUpload({
filename,
content: Buffer.from(content),
fileFolder: 'FilesField',
fieldMetadataId: createdFieldMetadataId,
});
return {
id: uploadedFile.id,
contentType,
content,
};
};
beforeAll(async () => {
jest.useRealTimers();
const {
data: {
createOneObject: { id: objectMetadataId },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'fileDownloadTestObject',
namePlural: 'fileDownloadTestObjects',
labelSingular: 'File Download Test Object',
labelPlural: 'File Download Test Objects',
icon: 'IconFile',
},
});
createdObjectMetadataId = objectMetadataId;
const {
data: { createOneField: createdFieldMetadata },
} = await createOneFieldMetadata({
input: {
name: 'filesField',
label: 'Files Field',
type: FieldMetadataType.FILES,
objectMetadataId: createdObjectMetadataId,
settings: { maxNumberOfValues: 5 },
},
gqlFields: `
id
name
label
type
`,
});
createdFieldMetadataId = createdFieldMetadata.id;
});
afterEach(async () => {
for (const file of uploadedFiles) {
await deleteFile(file.id);
}
uploadedFiles = [];
});
afterAll(async () => {
jest.useFakeTimers();
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
input: { idToDelete: createdObjectMetadataId },
});
});
it('should download file successfully with valid url', async () => {
const testFileContent = 'This is test file content for download';
const textFile = await uploadFile(
'download-test.txt',
testFileContent,
'text/plain',
);
uploadedFiles.push(textFile);
const createResponse = await makeGraphqlAPIRequest({
query: createRecordsQuery,
variables: {
data: [
{
name: 'Record for download test',
filesField: [
{
fileId: textFile.id,
label: 'download-test.txt',
},
],
},
],
upsert: false,
},
});
expect(createResponse.body.errors).toBeUndefined();
const createdRecord =
createResponse.body.data.createFileDownloadTestObjects[0];
const fileUrl = createdRecord.filesField[0].url;
const fileId = createdRecord.filesField[0].fileId;
expect(fileUrl).toBeDefined();
expect(fileId).toBe(textFile.id);
const urlPath = new URL(fileUrl).pathname + new URL(fileUrl).search;
const downloadResponse = await request(global.app.getHttpServer()).get(
urlPath,
);
expect(downloadResponse.status).toBe(200);
expect(downloadResponse.text).toBe(testFileContent);
await makeGraphqlAPIRequest({
query: deleteRecordsQuery,
variables: {
filter: { id: { eq: createdRecord.id } },
},
});
});
it('should download image file successfully with valid url', async () => {
const testFileContent = 'This is test file content for download';
const textFile = await uploadFile(
'test-file.txt',
testFileContent,
'text/plain',
);
uploadedFiles.push(textFile);
const createResponse = await makeGraphqlAPIRequest({
query: createRecordsQuery,
variables: {
data: [
{
name: 'Record with image',
filesField: [
{
fileId: textFile.id,
label: 'test-file.txt',
},
],
},
],
upsert: false,
},
});
expect(createResponse.body.errors).toBeUndefined();
const createdRecord =
createResponse.body.data.createFileDownloadTestObjects[0];
const fileUrl = createdRecord.filesField[0].url;
expect(fileUrl).toBeDefined();
expect(createdRecord.filesField[0].extension).toBe('.txt');
const urlPath = new URL(fileUrl).pathname + new URL(fileUrl).search;
const downloadResponse = await request(global.app.getHttpServer()).get(
urlPath,
);
expect(downloadResponse.status).toBe(200);
expect(downloadResponse.text).toBe(testFileContent);
await makeGraphqlAPIRequest({
query: deleteRecordsQuery,
variables: {
filter: { id: { eq: createdRecord.id } },
},
});
});
it('should return 403 when token is missing', async () => {
const textFile = await uploadFile(
'unauthorized-test.txt',
'content',
'text/plain',
);
uploadedFiles.push(textFile);
const createResponse = await makeGraphqlAPIRequest({
query: createRecordsQuery,
variables: {
data: [
{
name: 'Record for unauthorized test',
filesField: [
{
fileId: textFile.id,
label: 'unauthorized-test.txt',
},
],
},
],
upsert: false,
},
});
const createdRecord =
createResponse.body.data.createFileDownloadTestObjects[0];
const fileId = createdRecord.filesField[0].fileId;
const downloadResponse = await request(global.app.getHttpServer()).get(
`/file/files-field/${fileId}`,
);
expect(downloadResponse.status).toBe(403);
await makeGraphqlAPIRequest({
query: deleteRecordsQuery,
variables: {
filter: { id: { eq: createdRecord.id } },
},
});
});
it('should return 403 when url is invalid', async () => {
const textFile = await uploadFile(
'invalid-token-test.txt',
'content',
'text/plain',
);
uploadedFiles.push(textFile);
const createResponse = await makeGraphqlAPIRequest({
query: createRecordsQuery,
variables: {
data: [
{
name: 'Record for invalid token test',
filesField: [
{
fileId: textFile.id,
label: 'invalid-token-test.txt',
},
],
},
],
upsert: false,
},
});
const createdRecord =
createResponse.body.data.createFileDownloadTestObjects[0];
const fileId = createdRecord.filesField[0].fileId;
const downloadResponse = await request(global.app.getHttpServer())
.get(`/file/files-field/${fileId}`)
.query({ token: 'invalid-token-12345' });
expect(downloadResponse.status).toBe(403);
await makeGraphqlAPIRequest({
query: deleteRecordsQuery,
variables: {
filter: { id: { eq: createdRecord.id } },
},
});
});
});

View File

@@ -0,0 +1,552 @@
import gql from 'graphql-tag';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { uploadFileWithDirectUpload } from 'test/integration/graphql/utils/upload-file-with-direct-upload.util';
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { FieldMetadataType } from 'twenty-shared/types';
const deleteFileMutation = gql`
mutation DeleteFile($fileId: UUID!) {
deleteFile(fileId: $fileId) {
id
}
}
`;
const createRecordsQuery = gql`
mutation CreateRecords(
$data: [FileSyncTestObjectCreateInput!]!
$upsert: Boolean
) {
createFileSyncTestObjects(data: $data, upsert: $upsert) {
id
name
filesField {
fileId
label
extension
url
}
}
}
`;
const updateRecordQuery = gql`
mutation UpdateFileSyncTestObject(
$fileSyncTestObjectId: UUID!
$data: FileSyncTestObjectUpdateInput!
) {
updateFileSyncTestObject(id: $fileSyncTestObjectId, data: $data) {
id
name
filesField {
fileId
label
extension
url
}
}
}
`;
const deleteRecordsQuery = gql`
mutation DeleteRecords($filter: FileSyncTestObjectFilterInput!) {
deleteFileSyncTestObjects(filter: $filter) {
id
}
}
`;
type UploadedFile = {
id: string;
contentType: string;
};
const deleteFile = async (fileId: string): Promise<void> => {
await makeMetadataAPIRequest({
query: deleteFileMutation,
variables: { fileId },
});
};
describe('fileFieldSync - FILES field <> files sync', () => {
let createdObjectMetadataId = '';
let createdFieldMetadataId = '';
let uploadedFiles: UploadedFile[] = [];
const uploadFile = async (
filename: string,
content: string,
contentType: string,
): Promise<UploadedFile> => {
const uploadedFile = await uploadFileWithDirectUpload({
filename,
content: Buffer.from(content),
fileFolder: 'FilesField',
fieldMetadataId: createdFieldMetadataId,
});
return {
id: uploadedFile.id,
contentType,
};
};
const checkFileExistsInDB = async (fileId: string): Promise<boolean> => {
const result = await global.testDataSource.query(
'SELECT id FROM core."file" WHERE id = $1 AND "deletedAt" IS NULL',
[fileId],
);
return result.length > 0;
};
const checkFileIsInPermanentStorage = async (
fileId: string,
): Promise<boolean> => {
const result = await global.testDataSource.query(
'SELECT settings FROM core."file" WHERE id = $1',
[fileId],
);
if (result.length === 0) {
return false;
}
return result[0].settings.isTemporaryFile === false;
};
const checkFileIsTemporary = async (fileId: string): Promise<boolean> => {
const result = await global.testDataSource.query(
'SELECT settings FROM core."file" WHERE id = $1',
[fileId],
);
if (result.length === 0) {
return false;
}
return result[0].settings.isTemporaryFile === true;
};
beforeAll(async () => {
jest.useRealTimers();
const {
data: {
createOneObject: { id: objectMetadataId },
},
} = await createOneObjectMetadata({
input: {
nameSingular: 'fileSyncTestObject',
namePlural: 'fileSyncTestObjects',
labelSingular: 'File Sync Test Object',
labelPlural: 'File Sync Test Objects',
icon: 'IconFile',
},
});
createdObjectMetadataId = objectMetadataId;
const {
data: {
createOneField: { id: fieldMetadataId },
},
} = await createOneFieldMetadata({
input: {
name: 'filesField',
label: 'Files Field',
type: FieldMetadataType.FILES,
objectMetadataId: createdObjectMetadataId,
settings: { maxNumberOfValues: 5 },
},
gqlFields: `
id
name
label
type
`,
});
createdFieldMetadataId = fieldMetadataId;
});
afterEach(async () => {
for (const file of uploadedFiles) {
await deleteFile(file.id);
}
uploadedFiles = [];
});
afterAll(async () => {
jest.useFakeTimers();
await updateOneObjectMetadata({
expectToFail: false,
input: {
idToUpdate: createdObjectMetadataId,
updatePayload: {
isActive: false,
},
},
});
await deleteOneObjectMetadata({
input: { idToDelete: createdObjectMetadataId },
});
});
it('createMany without upsert - files sync successfully', async () => {
const file1 = await uploadFile(
'test-file1.txt',
'fake text content',
'text/plain',
);
const file2 = await uploadFile(
'test-text.txt',
'fake text content',
'text/plain',
);
uploadedFiles.push(file1, file2);
expect(await checkFileExistsInDB(file1.id)).toBe(true);
expect(await checkFileExistsInDB(file2.id)).toBe(true);
expect(await checkFileIsTemporary(file1.id)).toBe(true);
expect(await checkFileIsTemporary(file2.id)).toBe(true);
const response = await makeGraphqlAPIRequest({
query: createRecordsQuery,
variables: {
data: [
{
name: 'Record with image',
filesField: [
{
fileId: file1.id,
label: 'test-file1.txt',
},
{
fileId: file2.id,
label: 'test-file2.txt',
},
],
},
],
upsert: false,
},
});
expect(response.body.errors).toBeUndefined();
expect(response.body.data).toBeDefined();
const createdRecord = response.body.data.createFileSyncTestObjects[0];
expect(createdRecord.filesField).toHaveLength(2);
expect(createdRecord.filesField[0].fileId).toBe(file1.id);
expect(createdRecord.filesField[0].extension).toBe('.txt');
expect(createdRecord.filesField[1].fileId).toBe(file2.id);
expect(createdRecord.filesField[1].extension).toBe('.txt');
expect(createdRecord.filesField[0].url).toBeDefined();
expect(createdRecord.filesField[1].url).toBeDefined();
expect(await checkFileExistsInDB(file1.id)).toBe(true);
expect(await checkFileExistsInDB(file2.id)).toBe(true);
expect(await checkFileIsInPermanentStorage(file1.id)).toBe(true);
expect(await checkFileIsInPermanentStorage(file2.id)).toBe(true);
await makeGraphqlAPIRequest({
query: deleteRecordsQuery,
variables: {
filter: { id: { eq: createdRecord.id } },
},
});
});
it('createMany with upsert - files sync successfully', async () => {
const file1 = await uploadFile(
'test-file1.txt',
'fake text content',
'text/plain',
);
const file2 = await uploadFile(
'test-file2.txt',
'fake text content',
'text/plain',
);
const file3 = await uploadFile(
'test-file3.txt',
'fake text content',
'text/plain',
);
uploadedFiles.push(file1, file2, file3);
expect(await checkFileExistsInDB(file1.id)).toBe(true);
expect(await checkFileExistsInDB(file2.id)).toBe(true);
expect(await checkFileExistsInDB(file3.id)).toBe(true);
expect(await checkFileIsTemporary(file1.id)).toBe(true);
expect(await checkFileIsTemporary(file2.id)).toBe(true);
expect(await checkFileIsTemporary(file3.id)).toBe(true);
const createResponse = await makeGraphqlAPIRequest({
query: createRecordsQuery,
variables: {
data: [
{
name: 'Record to upsert',
filesField: [
{
fileId: file1.id,
label: 'test-file1.txt',
},
{
fileId: file2.id,
label: 'test-file2.txt',
},
],
},
],
upsert: false,
},
});
const createdRecord = createResponse.body.data.createFileSyncTestObjects[0];
const recordId = createdRecord.id;
expect(createdRecord.filesField[0].extension).toBe('.txt');
const upsertResponse = await makeGraphqlAPIRequest({
query: createRecordsQuery,
variables: {
data: [
{
id: recordId,
name: 'Record updated via upsert',
filesField: [
{
fileId: file1.id,
label: 'updated-label-test-file1.txt',
},
{
fileId: file2.id,
label: 'test-file2.txt',
},
{
fileId: file3.id,
label: 'new-test-file3.txt',
},
],
},
],
upsert: true,
},
});
expect(upsertResponse.body.errors).toBeUndefined();
const updatedRecord = upsertResponse.body.data.createFileSyncTestObjects[0];
expect(updatedRecord.id).toBe(recordId);
expect(updatedRecord.name).toBe('Record updated via upsert');
expect(updatedRecord.filesField).toHaveLength(3);
expect(updatedRecord.filesField[1].fileId).toBe(file2.id);
expect(updatedRecord.filesField[1].label).toBe('test-file2.txt');
expect(updatedRecord.filesField[1].extension).toBe('.txt');
expect(updatedRecord.filesField[1].url).toBeDefined();
expect(updatedRecord.filesField[2].fileId).toBe(file3.id);
expect(updatedRecord.filesField[2].label).toBe('new-test-file3.txt');
expect(updatedRecord.filesField[2].extension).toBe('.txt');
expect(updatedRecord.filesField[2].url).toBeDefined();
expect(updatedRecord.filesField[0].fileId).toBe(file1.id);
expect(updatedRecord.filesField[0].label).toBe(
'updated-label-test-file1.txt',
);
expect(updatedRecord.filesField[0].extension).toBe('.txt');
expect(updatedRecord.filesField[0].url).toBeDefined();
expect(await checkFileIsInPermanentStorage(file1.id)).toBe(true);
expect(await checkFileIsInPermanentStorage(file2.id)).toBe(true);
expect(await checkFileIsInPermanentStorage(file3.id)).toBe(true);
await makeGraphqlAPIRequest({
query: deleteRecordsQuery,
variables: {
filter: { id: { eq: recordId } },
},
});
});
it('updateOne - files sync successfully', async () => {
const file1 = await uploadFile(
'test-file1.txt',
'fake text content',
'text/plain',
);
const file2 = await uploadFile(
'test-file2.txt',
'fake text content',
'text/plain',
);
uploadedFiles.push(file1, file2);
expect(await checkFileExistsInDB(file1.id)).toBe(true);
expect(await checkFileExistsInDB(file2.id)).toBe(true);
expect(await checkFileIsTemporary(file1.id)).toBe(true);
expect(await checkFileIsTemporary(file2.id)).toBe(true);
const createResponse = await makeGraphqlAPIRequest({
query: createRecordsQuery,
variables: {
data: [
{
name: 'Record for updateOne test',
filesField: [
{
fileId: file1.id,
label: 'test-file1.txt',
},
],
},
],
upsert: false,
},
});
expect(await checkFileIsInPermanentStorage(file1.id)).toBe(true);
const createdRecord = createResponse.body.data.createFileSyncTestObjects[0];
const recordId = createdRecord.id;
expect(createdRecord.filesField[0].extension).toBe('.txt');
const updateResponse = await makeGraphqlAPIRequest({
query: updateRecordQuery,
variables: {
fileSyncTestObjectId: recordId,
data: {
name: 'Record updated via updateOne',
filesField: [
{
fileId: file1.id,
label: 'added-text.txt',
},
{
fileId: file2.id,
label: 'test-file2.txt',
},
],
},
},
});
expect(updateResponse.body.errors).toBeUndefined();
const updatedRecord = updateResponse.body.data.updateFileSyncTestObject;
expect(updatedRecord.id).toBe(recordId);
expect(updatedRecord.name).toBe('Record updated via updateOne');
expect(updatedRecord.filesField).toHaveLength(2);
expect(updatedRecord.filesField[1].fileId).toBe(file2.id);
expect(updatedRecord.filesField[1].label).toBe('test-file2.txt');
expect(updatedRecord.filesField[1].url).toBeDefined();
expect(updatedRecord.filesField[0].fileId).toBe(file1.id);
expect(updatedRecord.filesField[0].label).toBe('added-text.txt');
expect(updatedRecord.filesField[0].url).toBeDefined();
expect(await checkFileExistsInDB(file1.id)).toBe(true);
expect(await checkFileExistsInDB(file2.id)).toBe(true);
expect(await checkFileIsInPermanentStorage(file1.id)).toBe(true);
expect(await checkFileIsInPermanentStorage(file2.id)).toBe(true);
await makeGraphqlAPIRequest({
query: deleteRecordsQuery,
variables: {
filter: { id: { eq: recordId } },
},
});
});
it('updateOne with removeFiles - verifies files can be removed', async () => {
const file1 = await uploadFile(
'test-file1.txt',
'fake text content',
'text/plain',
);
const file2 = await uploadFile(
'test-file2.txt',
'fake text content',
'text/plain',
);
uploadedFiles.push(file1, file2);
expect(await checkFileExistsInDB(file1.id)).toBe(true);
expect(await checkFileExistsInDB(file2.id)).toBe(true);
expect(await checkFileIsTemporary(file1.id)).toBe(true);
expect(await checkFileIsTemporary(file2.id)).toBe(true);
const createResponse = await makeGraphqlAPIRequest({
query: createRecordsQuery,
variables: {
data: [
{
name: 'Record for file deletion test',
filesField: [
{
fileId: file1.id,
label: 'test-file1.txt',
},
{
fileId: file2.id,
label: 'text-to-keep.txt',
},
],
},
],
upsert: false,
},
});
expect(await checkFileIsInPermanentStorage(file1.id)).toBe(true);
expect(await checkFileIsInPermanentStorage(file2.id)).toBe(true);
const createdRecord = createResponse.body.data.createFileSyncTestObjects[0];
const recordId = createdRecord.id;
const updateResponse = await makeGraphqlAPIRequest({
query: updateRecordQuery,
variables: {
fileSyncTestObjectId: recordId,
data: {
filesField: [
{
fileId: file2.id,
label: 'text-to-keep.txt',
},
],
},
},
});
expect(updateResponse.body.errors).toBeUndefined();
const updatedRecord = updateResponse.body.data.updateFileSyncTestObject;
expect(updatedRecord.filesField).toHaveLength(1);
expect(updatedRecord.filesField[0].fileId).toBe(file2.id);
expect(await checkFileExistsInDB(file1.id)).toBe(false);
expect(await checkFileExistsInDB(file2.id)).toBe(true);
expect(await checkFileIsInPermanentStorage(file2.id)).toBe(true);
await makeGraphqlAPIRequest({
query: deleteRecordsQuery,
variables: {
filter: { id: { eq: recordId } },
},
});
});
});

View File

@@ -0,0 +1,71 @@
import { generateILikeFiltersForCompositeFields } from 'twenty-shared/utils';
import { createManyOperationFactory } from 'test/integration/graphql/utils/create-many-operation-factory.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { deleteAllRecords } from 'test/integration/utils/delete-all-records';
import { findRecordNodesByFilter } from 'test/integration/utils/find-records-by-filter.util';
type PersonNode = {
name: { firstName: string; lastName: string };
};
const PERSON_GQL_FIELDS = `
id
name {
firstName
lastName
}
`;
const JOHN_DOE_ID = '20202020-8f4a-4a3f-9c1b-000000000001';
const JANE_SMITH_ID = '20202020-8f4a-4a3f-9c1b-000000000002';
const JOHN_SMITH_ID = '20202020-8f4a-4a3f-9c1b-000000000003';
const findPeopleMatching = async (filterString: string) => {
const nodes = await findRecordNodesByFilter<PersonNode>(
'person',
'people',
PERSON_GQL_FIELDS,
{
or: generateILikeFiltersForCompositeFields(filterString, 'name', [
'firstName',
'lastName',
]),
},
);
return nodes.map((node) => `${node.name.firstName} ${node.name.lastName}`);
};
describe('filter by composite name field (integration)', () => {
beforeAll(async () => {
await deleteAllRecords('person');
const graphqlOperation = createManyOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
gqlFields: PERSON_GQL_FIELDS,
data: [
{ id: JOHN_DOE_ID, name: { firstName: 'John', lastName: 'Doe' } },
{ id: JANE_SMITH_ID, name: { firstName: 'Jane', lastName: 'Smith' } },
{ id: JOHN_SMITH_ID, name: { firstName: 'John', lastName: 'Smith' } },
],
});
const response = await makeGraphqlAPIRequest(graphqlOperation);
expect(response.body.data.createPeople).toHaveLength(3);
});
afterAll(async () => {
await deleteAllRecords('person');
});
it('should only return people matching every token when filtering on last and first name', async () => {
expect(await findPeopleMatching('Doe John')).toEqual(['John Doe']);
});
it('should return people matching a single token when filtering on last name only', async () => {
expect(await findPeopleMatching('Doe')).toEqual(['John Doe']);
});
});

View File

@@ -0,0 +1,460 @@
import gql from 'graphql-tag';
import { createManyOperationFactory } from 'test/integration/graphql/utils/create-many-operation-factory.util';
import { deleteManyOperationFactory } from 'test/integration/graphql/utils/delete-many-operation-factory.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
const TEST_COMPANY_IDS = {
AIRBNB: '20202020-cccc-4000-8000-000000000001',
STRIPE: '20202020-cccc-4000-8000-000000000002',
NOTION: '20202020-cccc-4000-8000-000000000003',
};
const TEST_PERSON_IDS = {
AIRBNB_ENGINEER: '20202020-dddd-4000-8000-000000000001',
AIRBNB_DESIGNER: '20202020-dddd-4000-8000-000000000002',
STRIPE_ENGINEER: '20202020-dddd-4000-8000-000000000003',
NOTION_ENGINEER: '20202020-dddd-4000-8000-000000000004',
UNAFFILIATED: '20202020-dddd-4000-8000-000000000005',
};
const TEST_ROCKET_IDS = {
FALCON: '20202020-cccc-4000-8000-100000000001',
STARSHIP: '20202020-cccc-4000-8000-100000000002',
};
const TEST_PET_IDS = {
FALCON_PET: '20202020-dddd-4000-8000-100000000001',
STARSHIP_PET: '20202020-dddd-4000-8000-100000000002',
};
const ALL_TEST_PERSON_IDS = Object.values(TEST_PERSON_IDS);
const ALL_TEST_PET_IDS = Object.values(TEST_PET_IDS);
describe('Filter by relation field (e2e)', () => {
beforeAll(async () => {
const createCompanies = createManyOperationFactory({
objectMetadataSingularName: 'company',
objectMetadataPluralName: 'companies',
gqlFields: 'id name',
data: [
{
id: TEST_COMPANY_IDS.AIRBNB,
name: 'Airbnb',
annualRevenue: {
amountMicros: 50_000_000_000_000,
currencyCode: 'USD',
},
},
{
id: TEST_COMPANY_IDS.STRIPE,
name: 'Stripe',
annualRevenue: {
amountMicros: 10_000_000_000_000,
currencyCode: 'USD',
},
},
{
id: TEST_COMPANY_IDS.NOTION,
name: 'Notion',
annualRevenue: {
amountMicros: 5_000_000_000_000,
currencyCode: 'USD',
},
},
],
upsert: true,
});
await makeGraphqlAPIRequest(createCompanies);
const createPeople = createManyOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
gqlFields: 'id',
data: [
{
id: TEST_PERSON_IDS.AIRBNB_ENGINEER,
companyId: TEST_COMPANY_IDS.AIRBNB,
jobTitle: 'Engineer',
},
{
id: TEST_PERSON_IDS.AIRBNB_DESIGNER,
companyId: TEST_COMPANY_IDS.AIRBNB,
jobTitle: 'Designer',
},
{
id: TEST_PERSON_IDS.STRIPE_ENGINEER,
companyId: TEST_COMPANY_IDS.STRIPE,
jobTitle: 'Engineer',
},
{
id: TEST_PERSON_IDS.NOTION_ENGINEER,
companyId: TEST_COMPANY_IDS.NOTION,
jobTitle: 'Engineer',
},
{
id: TEST_PERSON_IDS.UNAFFILIATED,
companyId: null,
jobTitle: 'Engineer',
},
],
upsert: true,
});
await makeGraphqlAPIRequest(createPeople);
const createRockets = createManyOperationFactory({
objectMetadataSingularName: 'rocket',
objectMetadataPluralName: 'rockets',
gqlFields: 'id name',
data: [
{ id: TEST_ROCKET_IDS.FALCON, name: 'FilterFalcon' },
{ id: TEST_ROCKET_IDS.STARSHIP, name: 'FilterStarship' },
],
upsert: true,
});
await makeGraphqlAPIRequest(createRockets);
const createPets = createManyOperationFactory({
objectMetadataSingularName: 'pet',
objectMetadataPluralName: 'pets',
gqlFields: 'id',
data: [
{
id: TEST_PET_IDS.FALCON_PET,
name: 'FilterFalconPet',
polymorphicOwnerRocketId: TEST_ROCKET_IDS.FALCON,
},
{
id: TEST_PET_IDS.STARSHIP_PET,
name: 'FilterStarshipPet',
polymorphicOwnerRocketId: TEST_ROCKET_IDS.STARSHIP,
},
],
upsert: true,
});
await makeGraphqlAPIRequest(createPets);
});
it('should filter people by company name (exact match)', async () => {
const queryData = {
query: gql`
query People($filter: PersonFilterInput) {
people(filter: $filter, first: 10) {
edges {
node {
id
}
}
}
}
`,
variables: {
filter: {
and: [
{ id: { in: ALL_TEST_PERSON_IDS } },
{ company: { name: { eq: 'Airbnb' } } },
],
},
},
};
const response = await makeGraphqlAPIRequest(queryData);
expect(response.body.errors).toBeUndefined();
expect(response.body.data).toBeDefined();
const ids = response.body.data.people.edges.map(
(edge: { node: { id: string } }) => edge.node.id,
);
expect(ids.sort()).toEqual(
[TEST_PERSON_IDS.AIRBNB_ENGINEER, TEST_PERSON_IDS.AIRBNB_DESIGNER].sort(),
);
});
it('should filter people by company name with like operator', async () => {
const queryData = {
query: gql`
query People($filter: PersonFilterInput) {
people(filter: $filter, first: 10) {
edges {
node {
id
}
}
}
}
`,
variables: {
filter: {
and: [
{ id: { in: ALL_TEST_PERSON_IDS } },
{ company: { name: { like: '%irbnb%' } } },
],
},
},
};
const response = await makeGraphqlAPIRequest(queryData);
expect(response.body.errors).toBeUndefined();
const ids = response.body.data.people.edges.map(
(edge: { node: { id: string } }) => edge.node.id,
);
expect(ids.sort()).toEqual(
[TEST_PERSON_IDS.AIRBNB_ENGINEER, TEST_PERSON_IDS.AIRBNB_DESIGNER].sort(),
);
});
it('should combine a relation filter with a scalar filter on the root object', async () => {
const queryData = {
query: gql`
query People($filter: PersonFilterInput) {
people(filter: $filter, first: 10) {
edges {
node {
id
}
}
}
}
`,
variables: {
filter: {
and: [
{ id: { in: ALL_TEST_PERSON_IDS } },
{ company: { name: { eq: 'Airbnb' } } },
{ jobTitle: { eq: 'Designer' } },
],
},
},
};
const response = await makeGraphqlAPIRequest(queryData);
expect(response.body.errors).toBeUndefined();
const ids = response.body.data.people.edges.map(
(edge: { node: { id: string } }) => edge.node.id,
);
expect(ids).toEqual([TEST_PERSON_IDS.AIRBNB_DESIGNER]);
});
it('should combine a relation filter with an order-by on the same relation (join dedupe)', async () => {
const queryData = {
query: gql`
query People(
$filter: PersonFilterInput
$orderBy: [PersonOrderByInput]
) {
people(filter: $filter, orderBy: $orderBy, first: 10) {
edges {
node {
id
company {
name
}
}
}
}
}
`,
variables: {
filter: {
and: [
{ id: { in: ALL_TEST_PERSON_IDS } },
{ company: { name: { like: '%i%' } } },
],
},
orderBy: [{ company: { name: 'AscNullsLast' } }],
},
};
const response = await makeGraphqlAPIRequest(queryData);
expect(response.body.errors).toBeUndefined();
const companyNames = response.body.data.people.edges.map(
(edge: { node: { company: { name: string } | null } }) =>
edge.node.company?.name ?? null,
);
// Airbnb, Notion, Stripe all contain "i"; ascending order
expect(companyNames).toEqual(['Airbnb', 'Airbnb', 'Notion', 'Stripe']);
});
it('should filter on a composite sub-field of the related object without tripping the depth cap', async () => {
const queryData = {
query: gql`
query People($filter: PersonFilterInput) {
people(filter: $filter, first: 10) {
edges {
node {
id
}
}
}
}
`,
variables: {
filter: {
and: [
{ id: { in: ALL_TEST_PERSON_IDS } },
{
company: {
annualRevenue: {
amountMicros: { gte: 20_000_000_000_000 },
},
},
},
],
},
},
};
const response = await makeGraphqlAPIRequest(queryData);
expect(response.body.errors).toBeUndefined();
const ids = response.body.data.people.edges.map(
(edge: { node: { id: string } }) => edge.node.id,
);
// Only Airbnb (50M) is >= 20M; Stripe (10M) and Notion (5M) excluded.
expect(ids.sort()).toEqual(
[TEST_PERSON_IDS.AIRBNB_ENGINEER, TEST_PERSON_IDS.AIRBNB_DESIGNER].sort(),
);
});
it('should reject relation filters nested deeper than one hop', async () => {
const queryData = {
query: gql`
query People($filter: PersonFilterInput) {
people(filter: $filter, first: 10) {
edges {
node {
id
}
}
}
}
`,
variables: {
filter: {
company: {
accountOwner: {
name: { firstName: { eq: 'Anything' } },
},
},
},
},
};
const response = await makeGraphqlAPIRequest(queryData);
expect(response.body.errors).toBeDefined();
expect(response.body.errors.length).toBeGreaterThan(0);
});
it('should not widen the root query when a relation block contains a deletedAt filter', async () => {
// A deletedAt nested inside a relation block belongs to the related
// entity — it must not call `withDeleted()` on the root builder and
// surface soft-deleted root rows.
const liveId = '20202020-dddd-4000-8000-000000000098';
const softDeletedId = '20202020-dddd-4000-8000-000000000099';
await makeGraphqlAPIRequest(
createManyOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
gqlFields: 'id',
data: [
{ id: liveId, companyId: TEST_COMPANY_IDS.AIRBNB },
{ id: softDeletedId, companyId: TEST_COMPANY_IDS.AIRBNB },
],
upsert: true,
}),
);
await makeGraphqlAPIRequest(
deleteManyOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
gqlFields: 'id',
filter: { id: { in: [softDeletedId] } },
}),
);
const queryData = {
query: gql`
query People($filter: PersonFilterInput) {
people(filter: $filter, first: 10) {
edges {
node {
id
}
}
}
}
`,
variables: {
filter: {
and: [
{ id: { in: [liveId, softDeletedId] } },
{ company: { deletedAt: { is: 'NULL' } } },
],
},
},
};
const response = await makeGraphqlAPIRequest(queryData);
expect(response.body.errors).toBeUndefined();
const ids = response.body.data.people.edges.map(
(edge: { node: { id: string } }) => edge.node.id,
);
expect(ids).toEqual([liveId]);
});
it('should filter pets by a MORPH_RELATION target field (rocket name)', async () => {
const queryData = {
query: gql`
query Pets($filter: PetFilterInput) {
pets(filter: $filter, first: 10) {
edges {
node {
id
}
}
}
}
`,
variables: {
filter: {
and: [
{ id: { in: ALL_TEST_PET_IDS } },
{ polymorphicOwnerRocket: { name: { eq: 'FilterFalcon' } } },
],
},
},
};
const response = await makeGraphqlAPIRequest(queryData);
expect(response.body.errors).toBeUndefined();
const ids = response.body.data.pets.edges.map(
(edge: { node: { id: string } }) => edge.node.id,
);
expect(ids).toEqual([TEST_PET_IDS.FALCON_PET]);
});
});

View File

@@ -0,0 +1,135 @@
import { groupByOperationFactory } from 'test/integration/graphql/utils/group-by-operation-factory.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import {
type CompanyNameRlsRoleSetup,
cleanupCompanyNameRlsRole,
setupCompanyNameRlsRole,
} from 'test/integration/graphql/utils/setup-company-name-rls-role.util';
import {
type RlsCompanyRelationRecords,
cleanupRlsCompanyRelationRecords,
setupRlsCompanyRelationRecords,
} from 'test/integration/graphql/utils/setup-rls-company-relation-records.util';
import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test';
const SEEDED_PEOPLE_COUNT = 3;
const PEOPLE_LINKED_TO_VISIBLE_COMPANY_COUNT = 1;
const PEOPLE_LINKED_TO_HIDDEN_COMPANY_OR_NO_COMPANY_COUNT = 2;
const RECORDS_CREATED_AT = '2019-06-15T10:00:00.000Z';
const RECORDS_WINDOW_FILTER = {
and: [
{ createdAt: { gte: '2019-06-15T00:00:00.000Z' } },
{ createdAt: { lte: '2019-06-15T23:59:59.999Z' } },
],
};
describe('group-by on a relation respects row-level permission predicates', () => {
let rlsRole: CompanyNameRlsRoleSetup;
let records: RlsCompanyRelationRecords;
beforeAll(async () => {
rlsRole = await setupCompanyNameRlsRole({
label: 'RLS GroupBy Relation Test Role',
description: 'Role for testing RLS on relation group-by',
});
records = await setupRlsCompanyRelationRecords({
companyNamePrefix: 'RLS GroupBy Relation',
createdAt: RECORDS_CREATED_AT,
});
});
afterAll(async () => {
await cleanupRlsCompanyRelationRecords(records);
await cleanupCompanyNameRlsRole(rlsRole);
});
it('does not expose hidden related dimension values and folds hidden-linked records into the null group', async () => {
const response = await makeGraphqlAPIRequest(
groupByOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
groupBy: [{ company: { name: true } }],
filter: RECORDS_WINDOW_FILTER,
}),
APPLE_JONY_MEMBER_ACCESS_TOKEN,
);
expect(response.body.errors).toBeUndefined();
const groups = response.body.data.peopleGroupBy;
jestExpectToBeDefined(groups);
const dimensionValues = groups.flatMap(
(group: { groupByDimensionValues: unknown[] }) =>
group.groupByDimensionValues,
);
expect(dimensionValues).toContain(records.visibleCompanyName);
expect(dimensionValues).not.toContain(records.hiddenCompanyName);
const peopleCountAcrossAllGroups = groups.reduce(
(sum: number, group: { totalCount: number }) => sum + group.totalCount,
0,
);
expect(peopleCountAcrossAllGroups).toBe(SEEDED_PEOPLE_COUNT);
const visibleCompanyGroup = groups.find(
(group: { groupByDimensionValues: unknown[] }) =>
group.groupByDimensionValues.includes(records.visibleCompanyName),
);
expect(visibleCompanyGroup?.totalCount).toBe(
PEOPLE_LINKED_TO_VISIBLE_COMPANY_COUNT,
);
const groupWithoutVisibleCompanyName = groups.find(
(group: { groupByDimensionValues: unknown[] }) =>
!group.groupByDimensionValues.includes(records.visibleCompanyName),
);
expect(groupWithoutVisibleCompanyName?.totalCount).toBe(
PEOPLE_LINKED_TO_HIDDEN_COMPANY_OR_NO_COMPANY_COUNT,
);
});
it('sorts records linked to a hidden related record as null when ordering records within groups by that relation', async () => {
const response = await makeGraphqlAPIRequest(
groupByOperationFactory({
objectMetadataSingularName: 'person',
objectMetadataPluralName: 'people',
groupBy: [{ jobTitle: true }],
filter: RECORDS_WINDOW_FILTER,
orderByForRecords: [{ company: { name: 'AscNullsLast' } }],
offsetForRecords: 1,
gqlFields: 'edges { node { id } }',
}),
APPLE_JONY_MEMBER_ACCESS_TOKEN,
);
expect(response.body.errors).toBeUndefined();
const groups = response.body.data.peopleGroupBy;
jestExpectToBeDefined(groups);
expect(groups).toHaveLength(1);
const recordIdsAfterSkippingFirstRankedRecord = groups[0].edges.map(
(edge: { node: { id: string } }) => edge.node.id,
);
expect(recordIdsAfterSkippingFirstRankedRecord).not.toContain(
records.personWithVisibleCompanyId,
);
expect(recordIdsAfterSkippingFirstRankedRecord).toEqual(
expect.arrayContaining([
records.personWithHiddenCompanyId,
records.personWithoutCompanyId,
]),
);
expect(recordIdsAfterSkippingFirstRankedRecord).toHaveLength(2);
});
});

View File

@@ -0,0 +1,697 @@
import { randomUUID } from 'crypto';
import gql from 'graphql-tag';
import { COMPANY_GQL_FIELDS } from 'test/integration/constants/company-gql-fields.constants';
import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util';
import { destroyOneOperationFactory } from 'test/integration/graphql/utils/destroy-one-operation-factory.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
const OPPORTUNITY_GQL_FIELDS = `
id
stage
amount {
amountMicros
}
companyId
createdAt
closeDate
`;
// used not to mix records with the seeded ones
const FILTER_2020 = {
and: [
{
createdAt: {
gte: '2020-01-01T00:00:00.000Z',
},
},
{
createdAt: {
lte: '2020-03-03T23:59:59.999Z',
},
},
],
};
describe('basic group-by with records', () => {
const testOpportunityId1 = randomUUID();
const testOpportunityId2 = randomUUID();
const testOpportunityId3 = randomUUID();
const testOpportunityId4 = randomUUID();
const testCompanyId1 = randomUUID();
const testCompanyId2 = randomUUID();
const COMPANY_1_EMPLOYEES = 10;
const COMPANY_2_EMPLOYEES = 20;
beforeAll(async () => {
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: COMPANY_GQL_FIELDS,
data: {
id: testCompanyId1,
name: 'Company 1',
employees: COMPANY_1_EMPLOYEES,
createdAt: '2020-02-05T08:00:00.000Z',
},
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: COMPANY_GQL_FIELDS,
data: {
id: testCompanyId2,
name: 'Company 2',
employees: COMPANY_2_EMPLOYEES,
createdAt: '2020-02-05T08:00:00.000Z',
},
}),
);
// Create test opportunities with different stages and dates
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'opportunity',
gqlFields: OPPORTUNITY_GQL_FIELDS,
data: {
id: testOpportunityId1,
stage: 'NEW',
name: 'Opportunity 1',
amount: { amountMicros: 1000000000000 }, // 1000
companyId: testCompanyId1,
closeDate: '2025-02-05T08:00:00.000Z', // Wednesday
createdAt: '2020-02-05T08:00:00.000Z',
},
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'opportunity',
gqlFields: OPPORTUNITY_GQL_FIELDS,
data: {
id: testOpportunityId2,
stage: 'NEW',
name: 'Opportunity 2',
amount: { amountMicros: 2000000000000 }, // 2000
companyId: testCompanyId1,
closeDate: '2025-02-06T08:00:00.000Z', // Thursday
createdAt: '2020-02-05T08:00:00.000Z',
},
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'opportunity',
gqlFields: OPPORTUNITY_GQL_FIELDS,
data: {
id: testOpportunityId3,
stage: 'NEW',
name: 'Opportunity 3',
amount: { amountMicros: 3000000000000 }, // 3000
companyId: testCompanyId2,
closeDate: '2025-02-06T08:00:00.000Z', // Thursday
createdAt: '2020-02-05T08:00:00.000Z',
},
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'opportunity',
gqlFields: OPPORTUNITY_GQL_FIELDS,
data: {
id: testOpportunityId4,
stage: 'SCREENING',
name: 'Opportunity 4',
amount: { amountMicros: 4000000000000 }, // 4000
companyId: testCompanyId2,
closeDate: '2025-02-06T08:00:00.000Z', // Thursday
createdAt: '2020-02-05T08:00:00.000Z',
},
}),
);
});
afterAll(async () => {
for (const id of [
testOpportunityId1,
testOpportunityId2,
testOpportunityId3,
testOpportunityId4,
]) {
await makeGraphqlAPIRequest(
destroyOneOperationFactory({
objectMetadataSingularName: 'opportunity',
gqlFields: 'id',
recordId: id,
}),
);
}
for (const id of [testCompanyId1, testCompanyId2]) {
await makeGraphqlAPIRequest(
destroyOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: 'id',
recordId: id,
}),
);
}
});
it('groups by stage and createdAt with records', async () => {
const response = await makeGraphqlAPIRequest({
query: gql`
query OpportunitiesGroupBy(
$groupBy: [OpportunityGroupByInput!]!
$filter: OpportunityFilterInput
$limit: Int
) {
opportunitiesGroupBy(
groupBy: $groupBy
filter: $filter
limit: $limit
) {
minCloseDate
groupByDimensionValues
sumAmountAmountMicros
__typename
edges {
node {
stage
closeDate
name
amount {
amountMicros
}
company {
id
employees
}
}
}
}
}
`,
variables: {
groupBy: [
{
closeDate: {
granularity: 'DAY_OF_THE_WEEK',
},
},
{
stage: true,
},
],
orderByForRecords: {
name: 'AscNullsFirst',
},
filter: FILTER_2020,
limit: 3,
},
});
expect(response.body.errors).toBeUndefined();
expect(response.body.data).toBeDefined();
const groups = response.body.data.opportunitiesGroupBy;
expect(groups).toBeDefined();
expect(groups).toHaveLength(3);
groups.forEach((group: any) => {
expect(group.groupByDimensionValues).toBeDefined();
expect(Array.isArray(group.groupByDimensionValues)).toBe(true);
expect(group.edges).toBeDefined();
expect(Array.isArray(group.edges)).toBe(true);
});
const wednesdayNewGroup = groups.find(
(group: any) =>
group.groupByDimensionValues.includes('Wednesday') &&
group.groupByDimensionValues.includes('NEW'),
);
expect(wednesdayNewGroup).toBeDefined();
expect(wednesdayNewGroup.edges).toHaveLength(1);
expect(wednesdayNewGroup.edges[0].node.name).toBe('Opportunity 1');
expect(wednesdayNewGroup.edges[0].node.stage).toBe('NEW');
expect(wednesdayNewGroup.edges[0].node.amount.amountMicros).toBe(
1000000000000,
);
expect(wednesdayNewGroup.sumAmountAmountMicros).toBe(1000000000000);
const thursdayNewGroup = groups.find(
(group: any) =>
group.groupByDimensionValues.includes('Thursday') &&
group.groupByDimensionValues.includes('NEW'),
);
expect(thursdayNewGroup).toBeDefined();
expect(thursdayNewGroup.edges).toHaveLength(2);
expect(thursdayNewGroup.sumAmountAmountMicros).toBe(5000000000000);
const opportunity2Edge = thursdayNewGroup.edges.find(
(edge: any) => edge.node.name === 'Opportunity 2',
).node;
const opportunity3Edge = thursdayNewGroup.edges.find(
(edge: any) => edge.node.name === 'Opportunity 3',
).node;
expect(opportunity2Edge.amount.amountMicros).toBe(2000000000000);
expect(opportunity2Edge.stage).toBe('NEW');
expect(opportunity2Edge.name).toBe('Opportunity 2');
expect(opportunity2Edge.company.id).toBe(testCompanyId1);
expect(opportunity2Edge.company.employees).toBe(COMPANY_1_EMPLOYEES);
expect(opportunity3Edge.amount.amountMicros).toBe(3000000000000);
expect(opportunity3Edge.stage).toBe('NEW');
expect(opportunity3Edge.name).toBe('Opportunity 3');
expect(opportunity3Edge.company.id).toBe(testCompanyId2);
expect(opportunity3Edge.company.employees).toBe(COMPANY_2_EMPLOYEES);
const thursdayScreeningGroup = groups.find(
(group: any) =>
group.groupByDimensionValues.includes('Thursday') &&
group.groupByDimensionValues.includes('SCREENING'),
);
expect(thursdayScreeningGroup).toBeDefined();
expect(thursdayScreeningGroup.edges).toHaveLength(1);
const opportunity4Edge = thursdayScreeningGroup.edges[0].node;
expect(opportunity4Edge.amount.amountMicros).toBe(4000000000000);
expect(opportunity4Edge.stage).toBe('SCREENING');
expect(opportunity4Edge.name).toBe('Opportunity 4');
expect(opportunity4Edge.company.id).toBe(testCompanyId2);
expect(opportunity4Edge.company.employees).toBe(COMPANY_2_EMPLOYEES);
expect(thursdayScreeningGroup.sumAmountAmountMicros).toBe(4000000000000);
});
it('groups by stage and createdAt with records and filters', async () => {
const response = await makeGraphqlAPIRequest({
query: gql`
query OpportunitiesGroupBy(
$groupBy: [OpportunityGroupByInput!]!
$filter: OpportunityFilterInput
$limit: Int
) {
opportunitiesGroupBy(
groupBy: $groupBy
filter: $filter
limit: $limit
) {
minCloseDate
groupByDimensionValues
sumAmountAmountMicros
__typename
edges {
node {
stage
createdAt
amount {
amountMicros
}
}
}
}
}
`,
variables: {
groupBy: [
{
closeDate: {
granularity: 'DAY_OF_THE_WEEK',
},
},
{
stage: true,
},
],
filter: {
and: [
...FILTER_2020.and,
{
stage: {
eq: 'NEW',
},
},
],
},
limit: 2,
},
});
expect(response.body.errors).toBeUndefined();
expect(response.body.data).toBeDefined();
const groups = response.body.data.opportunitiesGroupBy;
expect(groups).toHaveLength(2);
const wednesdayNewGroup = groups.find(
(group: any) =>
group.groupByDimensionValues.includes('Wednesday') &&
group.groupByDimensionValues.includes('NEW'),
);
expect(wednesdayNewGroup.groupByDimensionValues).toHaveLength(2);
expect(wednesdayNewGroup.groupByDimensionValues).toContain('NEW');
expect(wednesdayNewGroup.groupByDimensionValues).toContain('Wednesday');
expect(wednesdayNewGroup.edges).toHaveLength(1);
expect(wednesdayNewGroup.edges[0].node.stage).toBe('NEW');
expect(wednesdayNewGroup.edges[0].node.amount.amountMicros).toBe(
1000000000000,
);
const thursdayNewGroup = groups.find(
(group: any) =>
group.groupByDimensionValues.includes('Thursday') &&
group.groupByDimensionValues.includes('NEW'),
);
expect(thursdayNewGroup.groupByDimensionValues).toHaveLength(2);
expect(thursdayNewGroup.groupByDimensionValues).toContain('NEW');
expect(thursdayNewGroup.groupByDimensionValues).toContain('Thursday');
expect(thursdayNewGroup.edges).toHaveLength(2);
});
it('groups companies by employees with relations', async () => {
const response = await makeGraphqlAPIRequest({
query: gql`
query CompaniesGroupBy(
$groupBy: [CompanyGroupByInput!]!
$filter: CompanyFilterInput
$limit: Int
) {
companiesGroupBy(groupBy: $groupBy, filter: $filter, limit: $limit) {
groupByDimensionValues
__typename
edges {
node {
name
opportunities {
edges {
node {
name
stage
}
}
}
}
}
}
}
`,
variables: {
groupBy: [
{
employees: true,
},
],
filter: FILTER_2020,
limit: 2,
},
});
expect(response.body.errors).toBeUndefined();
expect(response.body.data).toBeDefined();
const groups = response.body.data.companiesGroupBy;
expect(groups).toHaveLength(2);
const company1Group = groups.find((group: any) =>
group.groupByDimensionValues.includes(COMPANY_1_EMPLOYEES),
);
expect(company1Group).toBeDefined();
expect(company1Group.edges).toHaveLength(1);
expect(company1Group.edges[0].node.name).toBe('Company 1');
expect(company1Group.edges[0].node.opportunities.edges).toHaveLength(2);
const opportunity1Edge =
company1Group.edges[0].node.opportunities.edges.find(
(edge: any) => edge.node.name === 'Opportunity 1',
).node;
expect(opportunity1Edge.name).toBe('Opportunity 1');
expect(opportunity1Edge.stage).toBe('NEW');
const opportunity2Edge =
company1Group.edges[0].node.opportunities.edges.find(
(edge: any) => edge.node.name === 'Opportunity 2',
).node;
expect(opportunity2Edge.name).toBe('Opportunity 2');
expect(opportunity2Edge.stage).toBe('NEW');
const company2Group = groups.find((group: any) =>
group.groupByDimensionValues.includes(COMPANY_2_EMPLOYEES),
);
expect(company2Group).toBeDefined();
expect(company2Group.edges).toHaveLength(1);
expect(company2Group.edges[0].node.name).toBe('Company 2');
expect(company2Group.edges[0].node.opportunities.edges).toHaveLength(2);
const opportunity3Edge =
company2Group.edges[0].node.opportunities.edges.find(
(edge: any) => edge.node.name === 'Opportunity 3',
).node;
expect(opportunity3Edge.name).toBe('Opportunity 3');
expect(opportunity3Edge.stage).toBe('NEW');
const opportunity4Edge =
company2Group.edges[0].node.opportunities.edges.find(
(edge: any) => edge.node.name === 'Opportunity 4',
).node;
expect(opportunity4Edge.name).toBe('Opportunity 4');
expect(opportunity4Edge.stage).toBe('SCREENING');
});
describe('order by for records', () => {
const getQueryWithOrderByForRecords = (orderByForRecords: string) => {
return {
query: gql`
query OpportunitiesGroupBy(
$groupBy: [OpportunityGroupByInput!]!
$filter: OpportunityFilterInput
$orderByForRecords: [OpportunityOrderByInput!]
$limit: Int
) {
opportunitiesGroupBy(
groupBy: $groupBy
filter: $filter
orderByForRecords: $orderByForRecords
limit: $limit
) {
groupByDimensionValues
__typename
edges {
node {
stage
name
}
}
}
}
`,
variables: {
groupBy: [
{
closeDate: {
granularity: 'DAY_OF_THE_WEEK',
},
},
{
stage: true,
},
],
orderByForRecords: [
{
name: orderByForRecords,
},
],
filter: FILTER_2020,
limit: 20,
},
};
};
it('sorts by name in ascending order', async () => {
const response = await makeGraphqlAPIRequest(
getQueryWithOrderByForRecords('AscNullsFirst'),
);
expect(response.body.errors).toBeUndefined();
expect(response.body.data).toBeDefined();
const groups = response.body.data.opportunitiesGroupBy;
const thursdayNewGroup = groups.find(
(group: any) =>
group.groupByDimensionValues.includes('Thursday') &&
group.groupByDimensionValues.includes('NEW'),
);
expect(thursdayNewGroup).toBeDefined();
expect(thursdayNewGroup.edges).toHaveLength(2);
expect(thursdayNewGroup.edges[0].node.name).toBe('Opportunity 2');
expect(thursdayNewGroup.edges[1].node.name).toBe('Opportunity 3');
});
it('sorts by name in descending order', async () => {
const response = await makeGraphqlAPIRequest(
getQueryWithOrderByForRecords('DescNullsFirst'),
);
expect(response.body.errors).toBeUndefined();
expect(response.body.data).toBeDefined();
const groups = response.body.data.opportunitiesGroupBy;
const thursdayNewGroup = groups.find(
(group: any) =>
group.groupByDimensionValues.includes('Thursday') &&
group.groupByDimensionValues.includes('NEW'),
);
expect(thursdayNewGroup).toBeDefined();
expect(thursdayNewGroup.edges).toHaveLength(2);
expect(thursdayNewGroup.edges[0].node.name).toBe('Opportunity 3');
expect(thursdayNewGroup.edges[1].node.name).toBe('Opportunity 2');
});
it('sorts by relation field (company name) in ascending order', async () => {
const response = await makeGraphqlAPIRequest({
query: gql`
query OpportunitiesGroupBy(
$groupBy: [OpportunityGroupByInput!]!
$filter: OpportunityFilterInput
$orderByForRecords: [OpportunityOrderByInput!]
$limit: Int
) {
opportunitiesGroupBy(
groupBy: $groupBy
filter: $filter
orderByForRecords: $orderByForRecords
limit: $limit
) {
groupByDimensionValues
__typename
edges {
node {
stage
name
company {
id
name
}
}
}
}
}
`,
variables: {
groupBy: [
{
stage: true,
},
],
orderByForRecords: [
{
company: {
name: 'AscNullsFirst',
},
},
],
filter: FILTER_2020,
limit: 20,
},
});
expect(response.body.errors).toBeUndefined();
expect(response.body.data).toBeDefined();
const groups = response.body.data.opportunitiesGroupBy;
const newGroup = groups.find((group: any) =>
group.groupByDimensionValues.includes('NEW'),
);
expect(newGroup).toBeDefined();
expect(newGroup.edges).toHaveLength(3);
// Opp 1 and 2 belong to Company 1, Opp 3 to Company 2
// Ascending by company name: Company 1 < Company 2
expect(newGroup.edges[0].node.company.name).toBe('Company 1');
expect(newGroup.edges[1].node.company.name).toBe('Company 1');
expect(newGroup.edges[2].node.company.name).toBe('Company 2');
});
it('sorts by relation field (company name) in descending order', async () => {
const response = await makeGraphqlAPIRequest({
query: gql`
query OpportunitiesGroupBy(
$groupBy: [OpportunityGroupByInput!]!
$filter: OpportunityFilterInput
$orderByForRecords: [OpportunityOrderByInput!]
$limit: Int
) {
opportunitiesGroupBy(
groupBy: $groupBy
filter: $filter
orderByForRecords: $orderByForRecords
limit: $limit
) {
groupByDimensionValues
__typename
edges {
node {
stage
name
company {
id
name
}
}
}
}
}
`,
variables: {
groupBy: [
{
stage: true,
},
],
orderByForRecords: [
{
company: {
name: 'DescNullsLast',
},
},
],
filter: FILTER_2020,
limit: 20,
},
});
expect(response.body.errors).toBeUndefined();
expect(response.body.data).toBeDefined();
const groups = response.body.data.opportunitiesGroupBy;
const newGroup = groups.find((group: any) =>
group.groupByDimensionValues.includes('NEW'),
);
expect(newGroup).toBeDefined();
expect(newGroup.edges).toHaveLength(3);
expect(newGroup.edges[0].node.company.name).toBe('Company 2');
expect(newGroup.edges[1].node.company.name).toBe('Company 1');
expect(newGroup.edges[2].node.company.name).toBe('Company 1');
});
});
});

View File

@@ -0,0 +1,122 @@
import { randomUUID } from 'crypto';
import gql from 'graphql-tag';
import { COMPANY_GQL_FIELDS } from 'test/integration/constants/company-gql-fields.constants';
import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util';
import { destroyOneOperationFactory } from 'test/integration/graphql/utils/destroy-one-operation-factory.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import {
type CompanyNameRlsRoleSetup,
cleanupCompanyNameRlsRole,
setupCompanyNameRlsRole,
} from 'test/integration/graphql/utils/setup-company-name-rls-role.util';
const FILTER_2020 = {
and: [
{ createdAt: { gte: '2020-01-01T00:00:00.000Z' } },
{ createdAt: { lte: '2020-03-03T23:59:59.999Z' } },
],
};
const COMPANIES_CREATED_AT = '2020-02-05T08:00:00.000Z';
const VISIBLE_COMPANY_NAME = 'RLS Visible Company';
const HIDDEN_COMPANY_NAME = 'RLS Hidden Company';
describe('group-by with records respects row-level permission predicates', () => {
const testCompanyId1 = randomUUID();
const testCompanyId2 = randomUUID();
let rlsRole: CompanyNameRlsRoleSetup;
beforeAll(async () => {
rlsRole = await setupCompanyNameRlsRole({
label: 'RLS GroupBy Test Role',
description: 'Role for testing RLS in group-by with records',
});
for (const { id, name } of [
{ id: testCompanyId1, name: VISIBLE_COMPANY_NAME },
{ id: testCompanyId2, name: HIDDEN_COMPANY_NAME },
]) {
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: COMPANY_GQL_FIELDS,
data: {
id,
name,
employees: 99,
createdAt: COMPANIES_CREATED_AT,
},
}),
);
}
});
afterAll(async () => {
for (const recordId of [testCompanyId1, testCompanyId2]) {
await makeGraphqlAPIRequest(
destroyOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: 'id',
recordId,
}),
);
}
await cleanupCompanyNameRlsRole(rlsRole);
});
it('filters records in group-by results based on RLS predicates', async () => {
const response = await makeGraphqlAPIRequest(
{
query: gql`
query CompaniesGroupBy(
$groupBy: [CompanyGroupByInput!]!
$filter: CompanyFilterInput
$limit: Int
) {
companiesGroupBy(
groupBy: $groupBy
filter: $filter
limit: $limit
) {
groupByDimensionValues
edges {
node {
name
employees
}
}
}
}
`,
variables: {
groupBy: [{ employees: true }],
filter: FILTER_2020,
limit: 10,
},
},
APPLE_JONY_MEMBER_ACCESS_TOKEN,
);
expect(response.body.errors).toBeUndefined();
expect(response.body.data).toBeDefined();
const groups = response.body.data.companiesGroupBy;
const allRecords = groups.flatMap(
(group: { edges: { node: { name: string } }[] }) =>
group.edges.map((edge: { node: { name: string } }) => edge.node),
);
const visibleRecords = allRecords.filter(
(record: { name: string }) => record.name === VISIBLE_COMPANY_NAME,
);
const hiddenRecords = allRecords.filter(
(record: { name: string }) => record.name === HIDDEN_COMPANY_NAME,
);
expect(visibleRecords).toHaveLength(1);
expect(hiddenRecords).toHaveLength(0);
});
});

View File

@@ -0,0 +1,166 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`blocklist scope hooks refuses a member-scoped entry targeting another workspace member 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "BAD_REQUEST",
"userFriendlyMessage": "Cannot manage a blocklist entry of another workspace member.",
},
"message": "A workspace-member-scoped blocklist entry must target its own workspace member",
}
`;
exports[`blocklist scope hooks refuses a member-scoped entry with no workspace member 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "BAD_REQUEST",
"userFriendlyMessage": "Cannot manage a blocklist entry of another workspace member.",
},
"message": "A workspace-member-scoped blocklist entry must target its own workspace member",
}
`;
exports[`blocklist scope hooks refuses a scope change on an existing entry 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "BAD_REQUEST",
"userFriendlyMessage": "Blocklist scope cannot be updated.",
},
"message": "Blocklist scope cannot be updated",
}
`;
exports[`blocklist scope hooks refuses a second workspace-scoped entry with the same handle 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "BAD_REQUEST",
"userFriendlyMessage": "Blocklist handle already exists.",
},
"message": "Blocklist handle already exists",
}
`;
exports[`blocklist scope hooks refuses a workspace-scoped entry created without the workspace permission 1`] = `
{
"extensions": {
"code": "FORBIDDEN",
"subCode": "PERMISSION_DENIED",
"userFriendlyMessage": "User does not have permission.",
},
"message": "Entity performing the request does not have permission",
}
`;
exports[`blocklist scope hooks refuses a workspace-scoped entry that also targets a workspace member 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "BAD_REQUEST",
"userFriendlyMessage": "A workspace-wide blocklist entry cannot target a workspace member.",
},
"message": "A workspace-scoped blocklist entry cannot target a workspace member",
}
`;
exports[`blocklist scope hooks refuses deletion and restoration of a workspace-scoped entry without the workspace permission 1`] = `
{
"extensions": {
"code": "FORBIDDEN",
"subCode": "PERMISSION_DENIED",
"userFriendlyMessage": "User does not have permission.",
},
"message": "Entity performing the request does not have permission",
}
`;
exports[`blocklist scope hooks refuses deletion and restoration of a workspace-scoped entry without the workspace permission 2`] = `
{
"extensions": {
"code": "FORBIDDEN",
"subCode": "PERMISSION_DENIED",
"userFriendlyMessage": "User does not have permission.",
},
"message": "Entity performing the request does not have permission",
}
`;
exports[`blocklist scope hooks refuses every bulk mutation of blocklist entries 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "BAD_REQUEST",
"userFriendlyMessage": "Bulk deletion of blocklist entries is not allowed.",
},
"message": "Method not allowed.",
}
`;
exports[`blocklist scope hooks refuses every bulk mutation of blocklist entries 2`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "BAD_REQUEST",
"userFriendlyMessage": "Bulk deletion of blocklist entries is not allowed.",
},
"message": "Method not allowed.",
}
`;
exports[`blocklist scope hooks refuses every bulk mutation of blocklist entries 3`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "BAD_REQUEST",
"userFriendlyMessage": "Bulk restoration of blocklist entries is not allowed.",
},
"message": "Method not allowed.",
}
`;
exports[`blocklist scope hooks refuses every bulk mutation of blocklist entries 4`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "BAD_REQUEST",
"userFriendlyMessage": "Bulk update of blocklist entries is not allowed.",
},
"message": "Method not allowed.",
}
`;
exports[`blocklist scope hooks refuses every bulk mutation of blocklist entries 5`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_QUERY_INPUT",
"userFriendlyMessage": "This type of record cannot be merged.",
},
"message": "Merge is only available for objects with duplicate criteria. Object 'blocklist' does not have duplicate criteria defined.",
}
`;
exports[`blocklist scope hooks refuses to null out the workspace member of a member-scoped entry 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "BAD_REQUEST",
"userFriendlyMessage": "Workspace member cannot be updated.",
},
"message": "Workspace member cannot be updated",
}
`;
exports[`blocklist scope hooks refuses to overwrite a workspace-scoped entry through an upsert create 1`] = `
{
"extensions": {
"code": "FORBIDDEN",
"subCode": "PERMISSION_DENIED",
"userFriendlyMessage": "User does not have permission.",
},
"message": "Entity performing the request does not have permission",
}
`;

View File

@@ -0,0 +1,423 @@
import { randomUUID } from 'node:crypto';
import gql from 'graphql-tag';
import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util';
import { createOneOperation } from 'test/integration/graphql/utils/create-one-operation.util';
import { deleteManyOperationFactory } from 'test/integration/graphql/utils/delete-many-operation-factory.util';
import { deleteOneOperationFactory } from 'test/integration/graphql/utils/delete-one-operation-factory.util';
import { destroyManyOperationFactory } from 'test/integration/graphql/utils/destroy-many-operation-factory.util';
import { destroyOneOperationFactory } from 'test/integration/graphql/utils/destroy-one-operation-factory.util';
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { makeGraphqlAPIRequestWithMemberRole } from 'test/integration/graphql/utils/make-graphql-api-request-with-member-role.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { restoreManyOperationFactory } from 'test/integration/graphql/utils/restore-many-operation-factory.util';
import { restoreOneOperationFactory } from 'test/integration/graphql/utils/restore-one-operation-factory.util';
import { updateManyOperationFactory } from 'test/integration/graphql/utils/update-many-operation-factory.util';
import { updateOneOperationFactory } from 'test/integration/graphql/utils/update-one-operation-factory.util';
import { BlocklistScope } from 'twenty-shared/types';
import { WORKSPACE_MEMBER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/workspace-member-data-seeds.constant';
const BLOCKLIST_GQL_FIELDS = `
id
handle
scope
workspaceMemberId
deletedAt
`;
const CREATE_BLOCKLISTS_WITH_UPSERT = gql`
mutation CreateBlocklists($data: [BlocklistCreateInput!]!, $upsert: Boolean) {
createBlocklists(data: $data, upsert: $upsert) {
id
handle
scope
workspaceMemberId
}
}
`;
const MERGE_BLOCKLISTS = gql`
mutation MergeBlocklists($ids: [UUID!]!, $conflictPriorityIndex: Int!) {
mergeBlocklists(ids: $ids, conflictPriorityIndex: $conflictPriorityIndex) {
id
}
}
`;
const uniqueEmailHandle = (prefix: string) =>
`${prefix}-${randomUUID()}@blocklist.test`;
const uniqueDomainHandle = () => `@${randomUUID()}.test`;
describe('blocklist scope hooks', () => {
const blocklistIds: string[] = [];
const trackedId = () => {
const id = randomUUID();
blocklistIds.push(id);
return id;
};
const createAsAdmin = (input: Record<string, unknown>) =>
createOneOperation({
objectMetadataSingularName: 'blocklist',
gqlFields: BLOCKLIST_GQL_FIELDS,
input,
});
const createAsMember = (input: Record<string, unknown>) =>
makeGraphqlAPIRequestWithMemberRole(
createOneOperationFactory({
objectMetadataSingularName: 'blocklist',
gqlFields: BLOCKLIST_GQL_FIELDS,
data: input,
}),
);
const upsertAsMember = (data: Record<string, unknown>) =>
makeGraphqlAPIRequestWithMemberRole({
query: CREATE_BLOCKLISTS_WITH_UPSERT,
variables: { data: [data], upsert: true },
});
const upsertAsAdmin = (data: Record<string, unknown>) =>
makeGraphqlAPIRequest({
query: CREATE_BLOCKLISTS_WITH_UPSERT,
variables: { data: [data], upsert: true },
});
const deleteAsAdmin = (recordId: string) =>
makeGraphqlAPIRequest(
deleteOneOperationFactory({
objectMetadataSingularName: 'blocklist',
gqlFields: BLOCKLIST_GQL_FIELDS,
recordId,
}),
);
const restoreOne = (
recordId: string,
request: typeof makeGraphqlAPIRequest = makeGraphqlAPIRequest,
) =>
request(
restoreOneOperationFactory({
objectMetadataSingularName: 'blocklist',
gqlFields: BLOCKLIST_GQL_FIELDS,
recordId,
}),
);
afterAll(async () => {
for (const blocklistId of blocklistIds) {
await makeGraphqlAPIRequest(
destroyOneOperationFactory({
objectMetadataSingularName: 'blocklist',
gqlFields: 'id',
recordId: blocklistId,
}),
);
}
});
it('lets an admin create a workspace-scoped entry with no workspace member', async () => {
const { data, errors } = await createAsAdmin({
id: trackedId(),
handle: uniqueDomainHandle(),
scope: BlocklistScope.WORKSPACE,
});
expect(errors).toBeUndefined();
expect(data.createOneResponse.scope).toBe(BlocklistScope.WORKSPACE);
expect(data.createOneResponse.workspaceMemberId).toBeNull();
});
it('lets a member create their own member-scoped entry', async () => {
const response = await createAsMember({
id: trackedId(),
handle: uniqueEmailHandle('my-own-entry'),
scope: BlocklistScope.WORKSPACE_MEMBER,
workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.JONY,
});
expect(response.body.errors).toBeUndefined();
expect(response.body.data.createBlocklist.scope).toBe(
BlocklistScope.WORKSPACE_MEMBER,
);
});
it('refuses a workspace-scoped entry that also targets a workspace member', async () => {
const { errors } = await createAsAdmin({
id: randomUUID(),
handle: uniqueDomainHandle(),
scope: BlocklistScope.WORKSPACE,
workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.JANE,
});
expectOneNotInternalServerErrorSnapshot({ errors });
});
it('refuses a workspace-scoped entry created without the workspace permission', async () => {
const response = await createAsMember({
id: randomUUID(),
handle: uniqueDomainHandle(),
scope: BlocklistScope.WORKSPACE,
});
expectOneNotInternalServerErrorSnapshot({ errors: response.body.errors });
});
it('refuses a member-scoped entry targeting another workspace member', async () => {
const response = await createAsMember({
id: randomUUID(),
handle: uniqueEmailHandle('someone-elses-entry'),
scope: BlocklistScope.WORKSPACE_MEMBER,
workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.JANE,
});
expectOneNotInternalServerErrorSnapshot({ errors: response.body.errors });
});
it('refuses a member-scoped entry with no workspace member', async () => {
const response = await createAsMember({
id: randomUUID(),
handle: uniqueEmailHandle('ownerless'),
scope: BlocklistScope.WORKSPACE_MEMBER,
});
expectOneNotInternalServerErrorSnapshot({ errors: response.body.errors });
});
it('refuses to overwrite a workspace-scoped entry through an upsert create', async () => {
const id = trackedId();
await createAsAdmin({
id,
handle: uniqueDomainHandle(),
scope: BlocklistScope.WORKSPACE,
});
const hijackResponse = await upsertAsMember({
id,
handle: uniqueEmailHandle('hijacked'),
scope: BlocklistScope.WORKSPACE_MEMBER,
workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.JONY,
});
expectOneNotInternalServerErrorSnapshot({
errors: hijackResponse.body.errors,
});
const renamedHandle = uniqueDomainHandle();
const adminResponse = await upsertAsAdmin({
id,
handle: renamedHandle,
scope: BlocklistScope.WORKSPACE,
});
expect(adminResponse.body.errors).toBeUndefined();
expect(adminResponse.body.data.createBlocklists[0].handle).toBe(
renamedHandle,
);
const idempotentResponse = await upsertAsAdmin({
id,
handle: renamedHandle,
scope: BlocklistScope.WORKSPACE,
});
expect(idempotentResponse.body.errors).toBeUndefined();
expect(idempotentResponse.body.data.createBlocklists[0].handle).toBe(
renamedHandle,
);
});
it('refuses a scope change on an existing entry', async () => {
const id = trackedId();
await createAsAdmin({
id,
handle: uniqueEmailHandle('immutable-scope'),
scope: BlocklistScope.WORKSPACE_MEMBER,
workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.JANE,
});
const response = await makeGraphqlAPIRequest(
updateOneOperationFactory({
objectMetadataSingularName: 'blocklist',
gqlFields: BLOCKLIST_GQL_FIELDS,
recordId: id,
data: { scope: BlocklistScope.WORKSPACE },
}),
);
expectOneNotInternalServerErrorSnapshot({ errors: response.body.errors });
});
it('refuses to null out the workspace member of a member-scoped entry', async () => {
const id = trackedId();
await createAsAdmin({
id,
handle: uniqueEmailHandle('immutable-owner'),
scope: BlocklistScope.WORKSPACE_MEMBER,
workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.JANE,
});
const response = await makeGraphqlAPIRequest(
updateOneOperationFactory({
objectMetadataSingularName: 'blocklist',
gqlFields: BLOCKLIST_GQL_FIELDS,
recordId: id,
data: { workspaceMemberId: null },
}),
);
expectOneNotInternalServerErrorSnapshot({ errors: response.body.errors });
});
it('refuses deletion and restoration of a workspace-scoped entry without the workspace permission', async () => {
const id = trackedId();
await createAsAdmin({
id,
handle: uniqueDomainHandle(),
scope: BlocklistScope.WORKSPACE,
});
const deleteAsMemberResponse = await makeGraphqlAPIRequestWithMemberRole(
deleteOneOperationFactory({
objectMetadataSingularName: 'blocklist',
gqlFields: BLOCKLIST_GQL_FIELDS,
recordId: id,
}),
);
expectOneNotInternalServerErrorSnapshot({
errors: deleteAsMemberResponse.body.errors,
});
const deleteAsAdminResponse = await deleteAsAdmin(id);
expect(deleteAsAdminResponse.body.errors).toBeUndefined();
expect(
deleteAsAdminResponse.body.data.deleteBlocklist.deletedAt,
).not.toBeNull();
const restoreAsMemberResponse = await restoreOne(
id,
makeGraphqlAPIRequestWithMemberRole,
);
expectOneNotInternalServerErrorSnapshot({
errors: restoreAsMemberResponse.body.errors,
});
});
it('refuses to restore an entry whose handle was taken again while it was deleted', async () => {
const id = trackedId();
const handle = uniqueDomainHandle();
await createAsAdmin({ id, handle, scope: BlocklistScope.WORKSPACE });
const deleteResponse = await deleteAsAdmin(id);
expect(deleteResponse.body.errors).toBeUndefined();
const { errors: recreateErrors } = await createAsAdmin({
id: trackedId(),
handle,
scope: BlocklistScope.WORKSPACE,
});
expect(recreateErrors).toBeUndefined();
const restoreResponse = await restoreOne(id);
expect(restoreResponse.body.errors).toBeDefined();
expect(restoreResponse.body.errors[0].message).toMatch(/already exists/i);
});
it('refuses every bulk mutation of blocklist entries', async () => {
const filter = { scope: { eq: BlocklistScope.WORKSPACE } };
const mergeSourceIds = [trackedId(), trackedId()];
for (const mergeSourceId of mergeSourceIds) {
const { errors: mergeSourceErrors } = await createAsAdmin({
id: mergeSourceId,
handle: uniqueDomainHandle(),
scope: BlocklistScope.WORKSPACE,
});
expect(mergeSourceErrors).toBeUndefined();
}
const bulkResponses = [
await makeGraphqlAPIRequest(
deleteManyOperationFactory({
objectMetadataSingularName: 'blocklist',
objectMetadataPluralName: 'blocklists',
gqlFields: BLOCKLIST_GQL_FIELDS,
filter,
}),
),
await makeGraphqlAPIRequest(
destroyManyOperationFactory({
objectMetadataSingularName: 'blocklist',
objectMetadataPluralName: 'blocklists',
gqlFields: BLOCKLIST_GQL_FIELDS,
filter,
}),
),
await makeGraphqlAPIRequest(
restoreManyOperationFactory({
objectMetadataSingularName: 'blocklist',
objectMetadataPluralName: 'blocklists',
gqlFields: BLOCKLIST_GQL_FIELDS,
filter,
}),
),
await makeGraphqlAPIRequest(
updateManyOperationFactory({
objectMetadataSingularName: 'blocklist',
objectMetadataPluralName: 'blocklists',
gqlFields: BLOCKLIST_GQL_FIELDS,
filter,
data: { handle: uniqueDomainHandle() },
}),
),
await makeGraphqlAPIRequest({
query: MERGE_BLOCKLISTS,
variables: { ids: mergeSourceIds, conflictPriorityIndex: 0 },
}),
];
for (const bulkResponse of bulkResponses) {
expectOneNotInternalServerErrorSnapshot({
errors: bulkResponse.body.errors,
});
}
});
it('refuses a second workspace-scoped entry with the same handle', async () => {
const handle = uniqueDomainHandle();
await createAsAdmin({
id: trackedId(),
handle,
scope: BlocklistScope.WORKSPACE,
});
const { errors } = await createAsAdmin({
id: randomUUID(),
handle,
scope: BlocklistScope.WORKSPACE,
});
expectOneNotInternalServerErrorSnapshot({ errors });
});
});

View File

@@ -0,0 +1,477 @@
import { randomUUID } from 'crypto';
import { createOneOperation } from 'test/integration/graphql/utils/create-one-operation.util';
import { deleteManyOperationFactory } from 'test/integration/graphql/utils/delete-many-operation-factory.util';
import { deleteOneOperationFactory } from 'test/integration/graphql/utils/delete-one-operation-factory.util';
import { destroyManyOperationFactory } from 'test/integration/graphql/utils/destroy-many-operation-factory.util';
import { destroyOneOperationFactory } from 'test/integration/graphql/utils/destroy-one-operation-factory.util';
import { findManyOperationFactory } from 'test/integration/graphql/utils/find-many-operation-factory.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { restoreManyOperationFactory } from 'test/integration/graphql/utils/restore-many-operation-factory.util';
import { restoreOneOperationFactory } from 'test/integration/graphql/utils/restore-one-operation-factory.util';
const NOTE_GQL_FIELDS = `
id
title
deletedAt
`;
const NOTE_TARGET_GQL_FIELDS = `
id
noteId
deletedAt
`;
describe('noteTargets hooks on note actions', () => {
const noteIds: string[] = [];
const noteTargetIds: string[] = [];
afterAll(async () => {
if (noteIds.length > 0) {
const destroyNotesOperation = destroyManyOperationFactory({
objectMetadataSingularName: 'note',
objectMetadataPluralName: 'notes',
gqlFields: 'id',
filter: { id: { in: noteIds } },
});
await makeGraphqlAPIRequest(destroyNotesOperation);
}
if (noteTargetIds.length > 0) {
const destroyNoteTargetsOperation = destroyManyOperationFactory({
objectMetadataSingularName: 'noteTarget',
objectMetadataPluralName: 'noteTargets',
gqlFields: 'id',
filter: { id: { in: noteTargetIds } },
});
await makeGraphqlAPIRequest(destroyNoteTargetsOperation);
}
});
it('deleteOne note should soft delete related noteTargets', async () => {
const noteId = randomUUID();
const noteTargetId = randomUUID();
noteIds.push(noteId);
noteTargetIds.push(noteTargetId);
await createOneOperation({
objectMetadataSingularName: 'note',
gqlFields: NOTE_GQL_FIELDS,
input: { id: noteId, title: 'Test Note for DeleteOne' },
});
await createOneOperation({
objectMetadataSingularName: 'noteTarget',
gqlFields: NOTE_TARGET_GQL_FIELDS,
input: { id: noteTargetId, noteId },
});
const deleteNoteOperation = deleteOneOperationFactory({
objectMetadataSingularName: 'note',
gqlFields: NOTE_GQL_FIELDS,
recordId: noteId,
});
const deleteResponse = await makeGraphqlAPIRequest(deleteNoteOperation);
expect(deleteResponse.body.data.deleteNote).toBeDefined();
expect(deleteResponse.body.data.deleteNote.deletedAt).not.toBeNull();
const findNoteTargetsOperation = findManyOperationFactory({
objectMetadataSingularName: 'noteTarget',
objectMetadataPluralName: 'noteTargets',
gqlFields: NOTE_TARGET_GQL_FIELDS,
filter: {
id: { eq: noteTargetId },
not: { deletedAt: { is: 'NULL' } },
},
});
const noteTargetResponse = await makeGraphqlAPIRequest(
findNoteTargetsOperation,
);
expect(noteTargetResponse.body.data.noteTargets.edges).toHaveLength(1);
expect(
noteTargetResponse.body.data.noteTargets.edges[0].node.deletedAt,
).not.toBeNull();
});
it('deleteOne note should leave a noteTarget detached earlier at its own deletion time', async () => {
const noteId = randomUUID();
const detachedNoteTargetId = randomUUID();
const attachedNoteTargetId = randomUUID();
noteIds.push(noteId);
noteTargetIds.push(detachedNoteTargetId, attachedNoteTargetId);
await createOneOperation({
objectMetadataSingularName: 'note',
gqlFields: NOTE_GQL_FIELDS,
input: { id: noteId, title: 'Test Note with a detached target' },
});
await Promise.all([
createOneOperation({
objectMetadataSingularName: 'noteTarget',
gqlFields: NOTE_TARGET_GQL_FIELDS,
input: { id: detachedNoteTargetId, noteId },
}),
createOneOperation({
objectMetadataSingularName: 'noteTarget',
gqlFields: NOTE_TARGET_GQL_FIELDS,
input: { id: attachedNoteTargetId, noteId },
}),
]);
const detachResponse = await makeGraphqlAPIRequest(
deleteOneOperationFactory({
objectMetadataSingularName: 'noteTarget',
gqlFields: NOTE_TARGET_GQL_FIELDS,
recordId: detachedNoteTargetId,
}),
);
const detachedAt = detachResponse.body.data.deleteNoteTarget.deletedAt;
expect(detachedAt).not.toBeNull();
const deleteResponse = await makeGraphqlAPIRequest(
deleteOneOperationFactory({
objectMetadataSingularName: 'note',
gqlFields: NOTE_GQL_FIELDS,
recordId: noteId,
}),
);
expect(deleteResponse.body.data.deleteNote.deletedAt).not.toBeNull();
const noteTargetsResponse = await makeGraphqlAPIRequest(
findManyOperationFactory({
objectMetadataSingularName: 'noteTarget',
objectMetadataPluralName: 'noteTargets',
gqlFields: NOTE_TARGET_GQL_FIELDS,
filter: {
id: { in: [detachedNoteTargetId, attachedNoteTargetId] },
not: { deletedAt: { is: 'NULL' } },
},
}),
);
const deletedAtByNoteTargetId = Object.fromEntries(
noteTargetsResponse.body.data.noteTargets.edges.map(
({ node }: { node: { id: string; deletedAt: string } }) => [
node.id,
node.deletedAt,
],
),
);
expect(deletedAtByNoteTargetId[detachedNoteTargetId]).toBe(detachedAt);
expect(deletedAtByNoteTargetId[attachedNoteTargetId]).not.toBeNull();
expect(
new Date(deletedAtByNoteTargetId[attachedNoteTargetId]).getTime(),
).toBeGreaterThan(new Date(detachedAt).getTime());
});
it('deleteMany notes should soft delete related noteTargets', async () => {
const noteId1 = randomUUID();
const noteId2 = randomUUID();
const noteTargetId1 = randomUUID();
const noteTargetId2 = randomUUID();
noteIds.push(noteId1, noteId2);
noteTargetIds.push(noteTargetId1, noteTargetId2);
await Promise.all([
createOneOperation({
objectMetadataSingularName: 'note',
gqlFields: NOTE_GQL_FIELDS,
input: { id: noteId1, title: 'Test Note 1 for DeleteMany' },
}),
createOneOperation({
objectMetadataSingularName: 'note',
gqlFields: NOTE_GQL_FIELDS,
input: { id: noteId2, title: 'Test Note 2 for DeleteMany' },
}),
]);
await Promise.all([
createOneOperation({
objectMetadataSingularName: 'noteTarget',
gqlFields: NOTE_TARGET_GQL_FIELDS,
input: { id: noteTargetId1, noteId: noteId1 },
}),
createOneOperation({
objectMetadataSingularName: 'noteTarget',
gqlFields: NOTE_TARGET_GQL_FIELDS,
input: { id: noteTargetId2, noteId: noteId2 },
}),
]);
const deleteNotesOperation = deleteManyOperationFactory({
objectMetadataSingularName: 'note',
objectMetadataPluralName: 'notes',
gqlFields: NOTE_GQL_FIELDS,
filter: { id: { in: [noteId1, noteId2] } },
});
const deleteResponse = await makeGraphqlAPIRequest(deleteNotesOperation);
expect(deleteResponse.body.data.deleteNotes).toHaveLength(2);
const findNoteTargetsOperation = findManyOperationFactory({
objectMetadataSingularName: 'noteTarget',
objectMetadataPluralName: 'noteTargets',
gqlFields: NOTE_TARGET_GQL_FIELDS,
filter: {
id: { in: [noteTargetId1, noteTargetId2] },
not: { deletedAt: { is: 'NULL' } },
},
});
const noteTargetResponse = await makeGraphqlAPIRequest(
findNoteTargetsOperation,
);
expect(noteTargetResponse.body.data.noteTargets.edges).toHaveLength(2);
expect(
noteTargetResponse.body.data.noteTargets.edges[0].node.deletedAt,
).not.toBeNull();
expect(
noteTargetResponse.body.data.noteTargets.edges[1].node.deletedAt,
).not.toBeNull();
});
it('restoreOne note should restore related noteTargets', async () => {
const noteId = randomUUID();
const noteTargetId = randomUUID();
noteIds.push(noteId);
noteTargetIds.push(noteTargetId);
await createOneOperation({
objectMetadataSingularName: 'note',
gqlFields: NOTE_GQL_FIELDS,
input: { id: noteId, title: 'Test Note for RestoreOne' },
});
await createOneOperation({
objectMetadataSingularName: 'noteTarget',
gqlFields: NOTE_TARGET_GQL_FIELDS,
input: { id: noteTargetId, noteId },
});
const deleteNoteOperation = deleteOneOperationFactory({
objectMetadataSingularName: 'note',
gqlFields: NOTE_GQL_FIELDS,
recordId: noteId,
});
await makeGraphqlAPIRequest(deleteNoteOperation);
const restoreNoteOperation = restoreOneOperationFactory({
objectMetadataSingularName: 'note',
gqlFields: NOTE_GQL_FIELDS,
recordId: noteId,
});
const restoreResponse = await makeGraphqlAPIRequest(restoreNoteOperation);
expect(restoreResponse.body.data.restoreNote).toBeDefined();
expect(restoreResponse.body.data.restoreNote.deletedAt).toBeNull();
const findNoteTargetsOperation = findManyOperationFactory({
objectMetadataSingularName: 'noteTarget',
objectMetadataPluralName: 'noteTargets',
gqlFields: NOTE_TARGET_GQL_FIELDS,
filter: { id: { eq: noteTargetId } },
});
const noteTargetResponse = await makeGraphqlAPIRequest(
findNoteTargetsOperation,
);
expect(noteTargetResponse.body.data.noteTargets.edges).toHaveLength(1);
expect(
noteTargetResponse.body.data.noteTargets.edges[0].node.deletedAt,
).toBeNull();
});
it('restoreMany notes should restore related noteTargets', async () => {
const noteId1 = randomUUID();
const noteId2 = randomUUID();
const noteTargetId1 = randomUUID();
const noteTargetId2 = randomUUID();
noteIds.push(noteId1, noteId2);
noteTargetIds.push(noteTargetId1, noteTargetId2);
await Promise.all([
createOneOperation({
objectMetadataSingularName: 'note',
gqlFields: NOTE_GQL_FIELDS,
input: { id: noteId1, title: 'Test Note 1 for RestoreMany' },
}),
createOneOperation({
objectMetadataSingularName: 'note',
gqlFields: NOTE_GQL_FIELDS,
input: { id: noteId2, title: 'Test Note 2 for RestoreMany' },
}),
]);
await Promise.all([
createOneOperation({
objectMetadataSingularName: 'noteTarget',
gqlFields: NOTE_TARGET_GQL_FIELDS,
input: { id: noteTargetId1, noteId: noteId1 },
}),
createOneOperation({
objectMetadataSingularName: 'noteTarget',
gqlFields: NOTE_TARGET_GQL_FIELDS,
input: { id: noteTargetId2, noteId: noteId2 },
}),
]);
const deleteNotesOperation = deleteManyOperationFactory({
objectMetadataSingularName: 'note',
objectMetadataPluralName: 'notes',
gqlFields: NOTE_GQL_FIELDS,
filter: { id: { in: [noteId1, noteId2] } },
});
await makeGraphqlAPIRequest(deleteNotesOperation);
const restoreNotesOperation = restoreManyOperationFactory({
objectMetadataSingularName: 'note',
objectMetadataPluralName: 'notes',
gqlFields: NOTE_GQL_FIELDS,
filter: { id: { in: [noteId1, noteId2] } },
});
const restoreResponse = await makeGraphqlAPIRequest(restoreNotesOperation);
expect(restoreResponse.body.data.restoreNotes).toHaveLength(2);
const findNoteTargetsOperation = findManyOperationFactory({
objectMetadataSingularName: 'noteTarget',
objectMetadataPluralName: 'noteTargets',
gqlFields: NOTE_TARGET_GQL_FIELDS,
filter: { id: { in: [noteTargetId1, noteTargetId2] } },
});
const noteTargetResponse = await makeGraphqlAPIRequest(
findNoteTargetsOperation,
);
expect(noteTargetResponse.body.data.noteTargets.edges).toHaveLength(2);
expect(
noteTargetResponse.body.data.noteTargets.edges[0].node.deletedAt,
).toBeNull();
expect(
noteTargetResponse.body.data.noteTargets.edges[1].node.deletedAt,
).toBeNull();
});
it('destroyOne note should destroy related noteTargets', async () => {
const noteId = randomUUID();
const noteTargetId = randomUUID();
noteIds.push(noteId);
await createOneOperation({
objectMetadataSingularName: 'note',
gqlFields: NOTE_GQL_FIELDS,
input: { id: noteId, title: 'Test Note for DestroyOne' },
});
await createOneOperation({
objectMetadataSingularName: 'noteTarget',
gqlFields: NOTE_TARGET_GQL_FIELDS,
input: { id: noteTargetId, noteId },
});
const destroyNoteOperation = destroyOneOperationFactory({
objectMetadataSingularName: 'note',
gqlFields: 'id',
recordId: noteId,
});
const destroyResponse = await makeGraphqlAPIRequest(destroyNoteOperation);
expect(destroyResponse.body.data.destroyNote).toBeDefined();
const findNoteTargetsOperation = findManyOperationFactory({
objectMetadataSingularName: 'noteTarget',
objectMetadataPluralName: 'noteTargets',
gqlFields: NOTE_TARGET_GQL_FIELDS,
filter: { id: { eq: noteTargetId } },
});
const noteTargetResponse = await makeGraphqlAPIRequest(
findNoteTargetsOperation,
);
expect(noteTargetResponse.body.data.noteTargets.edges).toHaveLength(0);
});
it('destroyMany notes should destroy related noteTargets', async () => {
const noteId1 = randomUUID();
const noteId2 = randomUUID();
const noteTargetId1 = randomUUID();
const noteTargetId2 = randomUUID();
noteIds.push(noteId1, noteId2);
await Promise.all([
createOneOperation({
objectMetadataSingularName: 'note',
gqlFields: NOTE_GQL_FIELDS,
input: { id: noteId1, title: 'Test Note 1 for DestroyMany' },
}),
createOneOperation({
objectMetadataSingularName: 'note',
gqlFields: NOTE_GQL_FIELDS,
input: { id: noteId2, title: 'Test Note 2 for DestroyMany' },
}),
]);
await Promise.all([
createOneOperation({
objectMetadataSingularName: 'noteTarget',
gqlFields: NOTE_TARGET_GQL_FIELDS,
input: { id: noteTargetId1, noteId: noteId1 },
}),
createOneOperation({
objectMetadataSingularName: 'noteTarget',
gqlFields: NOTE_TARGET_GQL_FIELDS,
input: { id: noteTargetId2, noteId: noteId2 },
}),
]);
const destroyNotesOperation = destroyManyOperationFactory({
objectMetadataSingularName: 'note',
objectMetadataPluralName: 'notes',
gqlFields: 'id',
filter: { id: { in: [noteId1, noteId2] } },
});
const destroyResponse = await makeGraphqlAPIRequest(destroyNotesOperation);
expect(destroyResponse.body.data.destroyNotes).toHaveLength(2);
const findNoteTargetsOperation = findManyOperationFactory({
objectMetadataSingularName: 'noteTarget',
objectMetadataPluralName: 'noteTargets',
gqlFields: NOTE_TARGET_GQL_FIELDS,
filter: { id: { in: [noteTargetId1, noteTargetId2] } },
});
const noteTargetResponse = await makeGraphqlAPIRequest(
findNoteTargetsOperation,
);
expect(noteTargetResponse.body.data.noteTargets.edges).toHaveLength(0);
});
});

View File

@@ -0,0 +1,402 @@
import { randomUUID } from 'crypto';
import { createOneOperation } from 'test/integration/graphql/utils/create-one-operation.util';
import { deleteManyOperationFactory } from 'test/integration/graphql/utils/delete-many-operation-factory.util';
import { deleteOneOperationFactory } from 'test/integration/graphql/utils/delete-one-operation-factory.util';
import { destroyManyOperationFactory } from 'test/integration/graphql/utils/destroy-many-operation-factory.util';
import { destroyOneOperationFactory } from 'test/integration/graphql/utils/destroy-one-operation-factory.util';
import { findManyOperationFactory } from 'test/integration/graphql/utils/find-many-operation-factory.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { restoreManyOperationFactory } from 'test/integration/graphql/utils/restore-many-operation-factory.util';
import { restoreOneOperationFactory } from 'test/integration/graphql/utils/restore-one-operation-factory.util';
const TASK_GQL_FIELDS = `
id
title
deletedAt
`;
const TASK_TARGET_GQL_FIELDS = `
id
taskId
deletedAt
`;
describe('taskTargets hooks on task actions', () => {
const taskIds: string[] = [];
const taskTargetIds: string[] = [];
afterAll(async () => {
if (taskIds.length > 0) {
const destroyTasksOperation = destroyManyOperationFactory({
objectMetadataSingularName: 'task',
objectMetadataPluralName: 'tasks',
gqlFields: 'id',
filter: { id: { in: taskIds } },
});
await makeGraphqlAPIRequest(destroyTasksOperation);
}
if (taskTargetIds.length > 0) {
const destroyTaskTargetsOperation = destroyManyOperationFactory({
objectMetadataSingularName: 'taskTarget',
objectMetadataPluralName: 'taskTargets',
gqlFields: 'id',
filter: { id: { in: taskTargetIds } },
});
await makeGraphqlAPIRequest(destroyTaskTargetsOperation);
}
});
it('deleteOne task should soft delete related taskTargets', async () => {
const taskId = randomUUID();
const taskTargetId = randomUUID();
taskIds.push(taskId);
taskTargetIds.push(taskTargetId);
await createOneOperation({
objectMetadataSingularName: 'task',
gqlFields: TASK_GQL_FIELDS,
input: { id: taskId, title: 'Test Task for DeleteOne' },
});
await createOneOperation({
objectMetadataSingularName: 'taskTarget',
gqlFields: TASK_TARGET_GQL_FIELDS,
input: { id: taskTargetId, taskId },
});
const deleteTaskOperation = deleteOneOperationFactory({
objectMetadataSingularName: 'task',
gqlFields: TASK_GQL_FIELDS,
recordId: taskId,
});
const deleteResponse = await makeGraphqlAPIRequest(deleteTaskOperation);
expect(deleteResponse.body.data.deleteTask).toBeDefined();
expect(deleteResponse.body.data.deleteTask.deletedAt).not.toBeNull();
const findTaskTargetsOperation = findManyOperationFactory({
objectMetadataSingularName: 'taskTarget',
objectMetadataPluralName: 'taskTargets',
gqlFields: TASK_TARGET_GQL_FIELDS,
filter: {
id: { eq: taskTargetId },
not: { deletedAt: { is: 'NULL' } },
},
});
const taskTargetResponse = await makeGraphqlAPIRequest(
findTaskTargetsOperation,
);
expect(taskTargetResponse.body.data.taskTargets.edges).toHaveLength(1);
expect(
taskTargetResponse.body.data.taskTargets.edges[0].node.deletedAt,
).not.toBeNull();
});
it('deleteMany tasks should soft delete related taskTargets', async () => {
const taskId1 = randomUUID();
const taskId2 = randomUUID();
const taskTargetId1 = randomUUID();
const taskTargetId2 = randomUUID();
taskIds.push(taskId1, taskId2);
taskTargetIds.push(taskTargetId1, taskTargetId2);
await Promise.all([
createOneOperation({
objectMetadataSingularName: 'task',
gqlFields: TASK_GQL_FIELDS,
input: { id: taskId1, title: 'Test Task 1 for DeleteMany' },
}),
createOneOperation({
objectMetadataSingularName: 'task',
gqlFields: TASK_GQL_FIELDS,
input: { id: taskId2, title: 'Test Task 2 for DeleteMany' },
}),
]);
await Promise.all([
createOneOperation({
objectMetadataSingularName: 'taskTarget',
gqlFields: TASK_TARGET_GQL_FIELDS,
input: { id: taskTargetId1, taskId: taskId1 },
}),
createOneOperation({
objectMetadataSingularName: 'taskTarget',
gqlFields: TASK_TARGET_GQL_FIELDS,
input: { id: taskTargetId2, taskId: taskId2 },
}),
]);
const deleteTasksOperation = deleteManyOperationFactory({
objectMetadataSingularName: 'task',
objectMetadataPluralName: 'tasks',
gqlFields: TASK_GQL_FIELDS,
filter: { id: { in: [taskId1, taskId2] } },
});
const deleteResponse = await makeGraphqlAPIRequest(deleteTasksOperation);
expect(deleteResponse.body.data.deleteTasks).toHaveLength(2);
const findTaskTargetsOperation = findManyOperationFactory({
objectMetadataSingularName: 'taskTarget',
objectMetadataPluralName: 'taskTargets',
gqlFields: TASK_TARGET_GQL_FIELDS,
filter: {
id: { in: [taskTargetId1, taskTargetId2] },
not: { deletedAt: { is: 'NULL' } },
},
});
const taskTargetResponse = await makeGraphqlAPIRequest(
findTaskTargetsOperation,
);
expect(taskTargetResponse.body.data.taskTargets.edges).toHaveLength(2);
expect(
taskTargetResponse.body.data.taskTargets.edges[0].node.deletedAt,
).not.toBeNull();
expect(
taskTargetResponse.body.data.taskTargets.edges[1].node.deletedAt,
).not.toBeNull();
});
it('restoreOne task should restore related taskTargets', async () => {
const taskId = randomUUID();
const taskTargetId = randomUUID();
taskIds.push(taskId);
taskTargetIds.push(taskTargetId);
await createOneOperation({
objectMetadataSingularName: 'task',
gqlFields: TASK_GQL_FIELDS,
input: { id: taskId, title: 'Test Task for RestoreOne' },
});
await createOneOperation({
objectMetadataSingularName: 'taskTarget',
gqlFields: TASK_TARGET_GQL_FIELDS,
input: { id: taskTargetId, taskId },
});
const deleteTaskOperation = deleteOneOperationFactory({
objectMetadataSingularName: 'task',
gqlFields: TASK_GQL_FIELDS,
recordId: taskId,
});
await makeGraphqlAPIRequest(deleteTaskOperation);
const restoreTaskOperation = restoreOneOperationFactory({
objectMetadataSingularName: 'task',
gqlFields: TASK_GQL_FIELDS,
recordId: taskId,
});
const restoreResponse = await makeGraphqlAPIRequest(restoreTaskOperation);
expect(restoreResponse.body.data.restoreTask).toBeDefined();
expect(restoreResponse.body.data.restoreTask.deletedAt).toBeNull();
const findTaskTargetsOperation = findManyOperationFactory({
objectMetadataSingularName: 'taskTarget',
objectMetadataPluralName: 'taskTargets',
gqlFields: TASK_TARGET_GQL_FIELDS,
filter: { id: { eq: taskTargetId } },
});
const taskTargetResponse = await makeGraphqlAPIRequest(
findTaskTargetsOperation,
);
expect(taskTargetResponse.body.data.taskTargets.edges).toHaveLength(1);
expect(
taskTargetResponse.body.data.taskTargets.edges[0].node.deletedAt,
).toBeNull();
});
it('restoreMany tasks should restore related taskTargets', async () => {
const taskId1 = randomUUID();
const taskId2 = randomUUID();
const taskTargetId1 = randomUUID();
const taskTargetId2 = randomUUID();
taskIds.push(taskId1, taskId2);
taskTargetIds.push(taskTargetId1, taskTargetId2);
await Promise.all([
createOneOperation({
objectMetadataSingularName: 'task',
gqlFields: TASK_GQL_FIELDS,
input: { id: taskId1, title: 'Test Task 1 for RestoreMany' },
}),
createOneOperation({
objectMetadataSingularName: 'task',
gqlFields: TASK_GQL_FIELDS,
input: { id: taskId2, title: 'Test Task 2 for RestoreMany' },
}),
]);
await Promise.all([
createOneOperation({
objectMetadataSingularName: 'taskTarget',
gqlFields: TASK_TARGET_GQL_FIELDS,
input: { id: taskTargetId1, taskId: taskId1 },
}),
createOneOperation({
objectMetadataSingularName: 'taskTarget',
gqlFields: TASK_TARGET_GQL_FIELDS,
input: { id: taskTargetId2, taskId: taskId2 },
}),
]);
const deleteTasksOperation = deleteManyOperationFactory({
objectMetadataSingularName: 'task',
objectMetadataPluralName: 'tasks',
gqlFields: TASK_GQL_FIELDS,
filter: { id: { in: [taskId1, taskId2] } },
});
await makeGraphqlAPIRequest(deleteTasksOperation);
const restoreTasksOperation = restoreManyOperationFactory({
objectMetadataSingularName: 'task',
objectMetadataPluralName: 'tasks',
gqlFields: TASK_GQL_FIELDS,
filter: { id: { in: [taskId1, taskId2] } },
});
const restoreResponse = await makeGraphqlAPIRequest(restoreTasksOperation);
expect(restoreResponse.body.data.restoreTasks).toHaveLength(2);
const findTaskTargetsOperation = findManyOperationFactory({
objectMetadataSingularName: 'taskTarget',
objectMetadataPluralName: 'taskTargets',
gqlFields: TASK_TARGET_GQL_FIELDS,
filter: { id: { in: [taskTargetId1, taskTargetId2] } },
});
const taskTargetResponse = await makeGraphqlAPIRequest(
findTaskTargetsOperation,
);
expect(taskTargetResponse.body.data.taskTargets.edges).toHaveLength(2);
expect(
taskTargetResponse.body.data.taskTargets.edges[0].node.deletedAt,
).toBeNull();
expect(
taskTargetResponse.body.data.taskTargets.edges[1].node.deletedAt,
).toBeNull();
});
it('destroyOne task should destroy related taskTargets', async () => {
const taskId = randomUUID();
const taskTargetId = randomUUID();
taskIds.push(taskId);
await createOneOperation({
objectMetadataSingularName: 'task',
gqlFields: TASK_GQL_FIELDS,
input: { id: taskId, title: 'Test Task for DestroyOne' },
});
await createOneOperation({
objectMetadataSingularName: 'taskTarget',
gqlFields: TASK_TARGET_GQL_FIELDS,
input: { id: taskTargetId, taskId },
});
const destroyTaskOperation = destroyOneOperationFactory({
objectMetadataSingularName: 'task',
gqlFields: 'id',
recordId: taskId,
});
const destroyResponse = await makeGraphqlAPIRequest(destroyTaskOperation);
expect(destroyResponse.body.data.destroyTask).toBeDefined();
const findTaskTargetsOperation = findManyOperationFactory({
objectMetadataSingularName: 'taskTarget',
objectMetadataPluralName: 'taskTargets',
gqlFields: TASK_TARGET_GQL_FIELDS,
filter: { id: { eq: taskTargetId } },
});
const taskTargetResponse = await makeGraphqlAPIRequest(
findTaskTargetsOperation,
);
expect(taskTargetResponse.body.data.taskTargets.edges).toHaveLength(0);
});
it('destroyMany tasks should destroy related taskTargets', async () => {
const taskId1 = randomUUID();
const taskId2 = randomUUID();
const taskTargetId1 = randomUUID();
const taskTargetId2 = randomUUID();
taskIds.push(taskId1, taskId2);
await Promise.all([
createOneOperation({
objectMetadataSingularName: 'task',
gqlFields: TASK_GQL_FIELDS,
input: { id: taskId1, title: 'Test Task 1 for DestroyMany' },
}),
createOneOperation({
objectMetadataSingularName: 'task',
gqlFields: TASK_GQL_FIELDS,
input: { id: taskId2, title: 'Test Task 2 for DestroyMany' },
}),
]);
await Promise.all([
createOneOperation({
objectMetadataSingularName: 'taskTarget',
gqlFields: TASK_TARGET_GQL_FIELDS,
input: { id: taskTargetId1, taskId: taskId1 },
}),
createOneOperation({
objectMetadataSingularName: 'taskTarget',
gqlFields: TASK_TARGET_GQL_FIELDS,
input: { id: taskTargetId2, taskId: taskId2 },
}),
]);
const destroyTasksOperation = destroyManyOperationFactory({
objectMetadataSingularName: 'task',
objectMetadataPluralName: 'tasks',
gqlFields: 'id',
filter: { id: { in: [taskId1, taskId2] } },
});
const destroyResponse = await makeGraphqlAPIRequest(destroyTasksOperation);
expect(destroyResponse.body.data.destroyTasks).toHaveLength(2);
const findTaskTargetsOperation = findManyOperationFactory({
objectMetadataSingularName: 'taskTarget',
objectMetadataPluralName: 'taskTargets',
gqlFields: TASK_TARGET_GQL_FIELDS,
filter: { id: { in: [taskTargetId1, taskTargetId2] } },
});
const taskTargetResponse = await makeGraphqlAPIRequest(
findTaskTargetsOperation,
);
expect(taskTargetResponse.body.data.taskTargets.edges).toHaveLength(0);
});
});

View File

@@ -0,0 +1,15 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Impersonation - access token validation denial (integration) rejects an invalid impersonation access token on a protected query 1`] = `
{
"extensions": {
"code": "FORBIDDEN",
"subCode": "FORBIDDEN_EXCEPTION",
"userFriendlyMessage": {
"id": Any<String>,
"message": "You do not have permission to impersonate users in this workspace.",
},
},
"message": "Impersonation not allowed",
}
`;

View File

@@ -0,0 +1,13 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Impersonation - self-impersonation denial (integration) rejects a user impersonating themselves 1`] = `
{
"extensions": {
"code": "FORBIDDEN",
"subCode": "FORBIDDEN_EXCEPTION",
"userFriendlyMessage": "You do not have permission to perform this action.",
},
"message": "User cannot impersonate themselves",
"name": "ForbiddenError",
}
`;

View File

@@ -0,0 +1,13 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Impersonation - unauthenticated request denial (integration) rejects an impersonation request with no authentication 1`] = `
{
"extensions": {
"code": "UNAUTHENTICATED",
"subCode": "UNAUTHENTICATED",
"userFriendlyMessage": "You must be authenticated to perform this action.",
},
"message": "Missing authentication token",
"name": "AuthenticationError",
}
`;

View File

@@ -0,0 +1,29 @@
import gql from 'graphql-tag';
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
// Exercises the per-request JWT validation checkpoint (jwt.auth.strategy):
// an impersonation access token whose impersonator is not authorized to
// impersonate the target must be rejected on every protected query.
describe('Impersonation - access token validation denial (integration)', () => {
it('rejects an invalid impersonation access token on a protected query', async () => {
const query = gql`
query People {
people {
edges {
node {
id
}
}
}
}
`;
const response = await makeGraphqlAPIRequest(
{ query },
APPLE_SARAH_IMPERSONATE_TIM_INVALID_ACCESS_TOKEN,
);
expectOneNotInternalServerErrorSnapshot({ errors: response.body.errors });
});
});

View File

@@ -0,0 +1,18 @@
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { impersonate } from 'test/integration/graphql/utils/impersonate.util';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
import { USER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util';
describe('Impersonation - self-impersonation denial (integration)', () => {
it('rejects a user impersonating themselves', async () => {
const { errors } = await impersonate({
userId: USER_DATA_SEED_IDS.JANE,
workspaceId: SEED_APPLE_WORKSPACE_ID,
accessToken: APPLE_JANE_ADMIN_ACCESS_TOKEN,
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({ errors });
});
});

View File

@@ -0,0 +1,32 @@
import request from 'supertest';
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
import { USER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util';
const SERVER_URL = `http://localhost:${APP_PORT}`;
describe('Impersonation - unauthenticated request denial (integration)', () => {
it('rejects an impersonation request with no authentication', async () => {
const response = await request(SERVER_URL)
.post('/metadata')
.send({
query: `
mutation Impersonate($userId: UUID!, $workspaceId: UUID!) {
impersonate(userId: $userId, workspaceId: $workspaceId) {
loginToken {
token
}
}
}
`,
variables: {
userId: USER_DATA_SEED_IDS.JONY,
workspaceId: SEED_APPLE_WORKSPACE_ID,
},
})
.expect(200);
expectOneNotInternalServerErrorSnapshot({ errors: response.body.errors });
});
});

View File

@@ -0,0 +1,25 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Server-level impersonation - authorization denials (integration) rejects a non-admin with only the workspace impersonate permission from impersonating across workspaces 1`] = `
{
"extensions": {
"code": "FORBIDDEN",
"subCode": "FORBIDDEN_EXCEPTION",
"userFriendlyMessage": "Server-level impersonation is not enabled for this user or workspace.",
},
"message": "Server level impersonation not allowed",
"name": "ForbiddenError",
}
`;
exports[`Server-level impersonation - authorization denials (integration) rejects cross-workspace impersonation when the impersonator has no verified 2FA 1`] = `
{
"extensions": {
"code": "FORBIDDEN",
"subCode": "TWO_FACTOR_AUTHENTICATION_PROVISION_REQUIRED",
"userFriendlyMessage": "Set up two-factor authentication before impersonating users in another workspace.",
},
"message": "Two-factor authentication is required for server-level impersonation. Please enable 2FA in your workspace settings before attempting to impersonate users.",
"name": "ForbiddenError",
}
`;

View File

@@ -0,0 +1,40 @@
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { getAccessTokenForCredentials } from 'test/integration/graphql/utils/get-access-token-for-credentials.util';
import { impersonate } from 'test/integration/graphql/utils/impersonate.util';
import { SEED_YCOMBINATOR_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
import { USER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util';
// Jony has the server-level canImpersonate capability but no verified 2FA
// method. Outside development, server-level (cross-workspace) impersonation
// requires verified 2FA, so the attempt to impersonate Tim in the YCombinator
// workspace must be denied at the token-generation checkpoint.
const IMPERSONATOR_WITHOUT_2FA_ACCESS_TOKEN = APPLE_JONY_MEMBER_ACCESS_TOKEN;
describe('Server-level impersonation - authorization denials (integration)', () => {
it('rejects cross-workspace impersonation when the impersonator has no verified 2FA', async () => {
const { errors } = await impersonate({
userId: USER_DATA_SEED_IDS.TIM,
workspaceId: SEED_YCOMBINATOR_WORKSPACE_ID,
accessToken: IMPERSONATOR_WITHOUT_2FA_ACCESS_TOKEN,
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({ errors });
});
it('rejects a non-admin with only the workspace impersonate permission from impersonating across workspaces', async () => {
const scottAccessToken = await getAccessTokenForCredentials({
email: 'scott.forstall@apple.dev',
});
const { errors } = await impersonate({
userId: USER_DATA_SEED_IDS.JONY,
workspaceId: SEED_YCOMBINATOR_WORKSPACE_ID,
accessToken: scottAccessToken,
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({ errors });
});
});

View File

@@ -0,0 +1,58 @@
import { getAuthTokensFromLoginToken } from 'test/integration/graphql/utils/get-auth-tokens-from-login-token.util';
import { getCurrentUser } from 'test/integration/graphql/utils/get-current-user.util';
import { impersonate } from 'test/integration/graphql/utils/impersonate.util';
import { SEED_YCOMBINATOR_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
import { USER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util';
// Jane (authenticated in Apple) can impersonate across workspaces because she
// has the server-level canImpersonate capability and a seeded verified 2FA
// method. The target, Tim, is impersonated in the YCombinator workspace.
const SERVER_IMPERSONATOR_ACCESS_TOKEN = APPLE_JANE_ADMIN_ACCESS_TOKEN;
describe('Server-level impersonation - successful flow (integration)', () => {
it('lets a server admin with verified 2FA impersonate a user in another workspace', async () => {
const { data: impersonateData, errors: impersonateErrors } =
await impersonate({
userId: USER_DATA_SEED_IDS.TIM,
workspaceId: SEED_YCOMBINATOR_WORKSPACE_ID,
accessToken: SERVER_IMPERSONATOR_ACCESS_TOKEN,
expectToFail: false,
});
expect(impersonateErrors).toBeUndefined();
const loginToken = impersonateData.impersonate.loginToken.token;
const origin = impersonateData.impersonate.workspace.workspaceUrls
.subdomainUrl as string;
expect(loginToken).toBeDefined();
expect(impersonateData.impersonate.workspace.id).toBe(
SEED_YCOMBINATOR_WORKSPACE_ID,
);
const { data: tokensData, errors: tokensErrors } =
await getAuthTokensFromLoginToken({
loginToken,
origin,
expectToFail: false,
});
expect(tokensErrors).toBeUndefined();
const impersonationAccessToken =
tokensData.getAuthTokensFromLoginToken.tokens
.accessOrWorkspaceAgnosticToken.token;
expect(impersonationAccessToken).toBeDefined();
const { data: currentUserData, errors: currentUserErrors } =
await getCurrentUser({
accessToken: impersonationAccessToken,
expectToFail: false,
});
expect(currentUserErrors).toBeUndefined();
expect(currentUserData.currentUser.id).toBe(USER_DATA_SEED_IDS.TIM);
});
});

View File

@@ -0,0 +1,25 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Workspace-level impersonation - authorization denials (integration) rejects a non-admin with the impersonate permission impersonating an admin 1`] = `
{
"extensions": {
"code": "FORBIDDEN",
"subCode": "FORBIDDEN_EXCEPTION",
"userFriendlyMessage": "Only administrators can impersonate users with admin privileges.",
},
"message": "Cannot impersonate a user with admin privileges. Only administrators can impersonate other administrators.",
"name": "ForbiddenError",
}
`;
exports[`Workspace-level impersonation - authorization denials (integration) rejects a user without the impersonate permission 1`] = `
{
"extensions": {
"code": "FORBIDDEN",
"subCode": "PERMISSION_DENIED",
"userFriendlyMessage": "User does not have permission.",
},
"message": "Entity performing the request does not have permission",
"name": "ForbiddenError",
}
`;

View File

@@ -0,0 +1,44 @@
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { getAccessTokenForCredentials } from 'test/integration/graphql/utils/get-access-token-for-credentials.util';
import { impersonate } from 'test/integration/graphql/utils/impersonate.util';
import { signUpInWorkspaceAndGetAccessToken } from 'test/integration/graphql/utils/sign-up-in-workspace-and-get-access-token.util';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
import { USER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util';
describe('Workspace-level impersonation - authorization denials (integration)', () => {
it('rejects a user without the impersonate permission', async () => {
const memberWithoutPermissionAccessToken =
await signUpInWorkspaceAndGetAccessToken(
'impersonation-no-permission@apple.dev',
);
const { errors } = await impersonate({
userId: USER_DATA_SEED_IDS.JONY,
workspaceId: SEED_APPLE_WORKSPACE_ID,
accessToken: memberWithoutPermissionAccessToken,
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({ errors });
});
// Scott is a seeded non-admin Apple member (canImpersonate /
// canAccessFullAdminPanel both false) whose role grants only the IMPERSONATE
// permission flag. He passes the impersonate permission guard but must still
// be blocked from impersonating an admin (Jony) by the escalation check.
it('rejects a non-admin with the impersonate permission impersonating an admin', async () => {
const scottAccessToken = await getAccessTokenForCredentials({
email: 'scott.forstall@apple.dev',
});
const { errors } = await impersonate({
userId: USER_DATA_SEED_IDS.JONY,
workspaceId: SEED_APPLE_WORKSPACE_ID,
accessToken: scottAccessToken,
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({ errors });
});
});

View File

@@ -0,0 +1,57 @@
import { getAuthTokensFromLoginToken } from 'test/integration/graphql/utils/get-auth-tokens-from-login-token.util';
import { getCurrentUser } from 'test/integration/graphql/utils/get-current-user.util';
import { impersonate } from 'test/integration/graphql/utils/impersonate.util';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
import { USER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util';
// Jane holds the Apple admin role (so she has the IMPERSONATE workspace
// permission). Scott is a non-admin Apple member (canImpersonate /
// canAccessFullAdminPanel both false), so this exercises the member-only
// workspace impersonation path: an admin impersonating a regular member, with
// the admin-target escalation guard correctly not triggering.
const IMPERSONATOR_ACCESS_TOKEN = APPLE_JANE_ADMIN_ACCESS_TOKEN;
describe('Workspace-level impersonation - successful flow (integration)', () => {
it('lets a workspace admin impersonate a member and obtain a usable access token', async () => {
const { data: impersonateData, errors: impersonateErrors } =
await impersonate({
userId: USER_DATA_SEED_IDS.SCOTT,
workspaceId: SEED_APPLE_WORKSPACE_ID,
accessToken: IMPERSONATOR_ACCESS_TOKEN,
expectToFail: false,
});
expect(impersonateErrors).toBeUndefined();
const loginToken = impersonateData.impersonate.loginToken.token;
const origin = impersonateData.impersonate.workspace.workspaceUrls
.subdomainUrl as string;
expect(loginToken).toBeDefined();
const { data: tokensData, errors: tokensErrors } =
await getAuthTokensFromLoginToken({
loginToken,
origin,
expectToFail: false,
});
expect(tokensErrors).toBeUndefined();
const impersonationAccessToken =
tokensData.getAuthTokensFromLoginToken.tokens
.accessOrWorkspaceAgnosticToken.token;
expect(impersonationAccessToken).toBeDefined();
const { data: currentUserData, errors: currentUserErrors } =
await getCurrentUser({
accessToken: impersonationAccessToken,
expectToFail: false,
});
expect(currentUserErrors).toBeUndefined();
expect(currentUserData.currentUser.id).toBe(USER_DATA_SEED_IDS.SCOTT);
});
});

View File

@@ -0,0 +1,67 @@
import { joinColumnNameForManyToOneMorphRelationField1 } from 'test/integration/graphql/suites/inputs-validation/utils/setup-test-objects-with-all-field-types.util';
export const TEST_OBJECT_GQL_FIELDS = `
id
position
manyToOneRelationFieldId
manyToOneRelationField {
id
}
${joinColumnNameForManyToOneMorphRelationField1}
${joinColumnNameForManyToOneMorphRelationField1.replace('Id', '')}{
id
}
uuidField
textField
phonesField {
primaryPhoneNumber
primaryPhoneCallingCode
primaryPhoneCountryCode
additionalPhones {
number
callingCode
countryCode
}
}
emailsField {
primaryEmail
additionalEmails
}
dateTimeField
dateField
booleanField
numberField
linksField {
primaryLinkUrl
primaryLinkLabel
secondaryLinks {
label
url
}
}
currencyField {
amountMicros
currencyCode
}
fullNameField {
firstName
lastName
}
ratingField
selectField
multiSelectField
addressField {
addressStreet1
addressStreet2
addressCity
addressState
addressCountry
addressPostcode
}
rawJsonField
arrayField
richTextField {
blocknote
markdown
}
`;

View File

@@ -0,0 +1,5 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Create input validation - ADDRESS Gql create input - failure ADDRESS - should fail with : {"addressField":"not-an-address"} 1`] = `"Invalid object value 'not-an-address' for field "addressField""`;
exports[`Create input validation - ADDRESS Rest create input - failure ADDRESS - should fail with : {"addressField":"not-an-address"} 1`] = `"["Invalid object value 'not-an-address' for field \\"addressField\\""]"`;

View File

@@ -0,0 +1,9 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Create input validation - ARRAY Gql create input - failure ARRAY - should fail with : {"arrayField":1} 1`] = `"Invalid value 1 for field "arrayField - Array values need to be string""`;
exports[`Create input validation - ARRAY Gql create input - failure ARRAY - should fail with : {"arrayField":true} 1`] = `"Invalid value true for field "arrayField - Array values need to be string""`;
exports[`Create input validation - ARRAY Rest create input - failure ARRAY - should fail with : {"arrayField":1} 1`] = `"["Invalid value 1 for field \\"arrayField - Array values need to be string\\""]"`;
exports[`Create input validation - ARRAY Rest create input - failure ARRAY - should fail with : {"arrayField":true} 1`] = `"["Invalid value true for field \\"arrayField - Array values need to be string\\""]"`;

View File

@@ -0,0 +1,5 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Create input validation - CURRENCY Gql create input - failure CURRENCY - should fail with : {"currencyField":"not-a-currency"} 1`] = `"Invalid object value 'not-a-currency' for field "currencyField""`;
exports[`Create input validation - CURRENCY Rest create input - failure CURRENCY - should fail with : {"currencyField":"not-a-currency"} 1`] = `"["Invalid object value 'not-a-currency' for field \\"currencyField\\""]"`;

View File

@@ -0,0 +1,5 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Create input validation - DATE Gql create input - failure DATE - should fail with : {"dateField":"malformed-date"} 1`] = `"Invalid value 'malformed-date' for date field "dateField". Expected format: 'YYYY-MM-DD'"`;
exports[`Create input validation - DATE Rest create input - failure DATE - should fail with : {"dateField":"malformed-date"} 1`] = `"["Invalid value 'malformed-date' for date field \\"dateField\\". Expected format: 'YYYY-MM-DD'"]"`;

View File

@@ -0,0 +1,5 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Create input validation - DATE_TIME Gql create input - failure DATE_TIME - should fail with : {"dateTimeField":"malformed-date-time"} 1`] = `"Invalid value 'malformed-date-time' for date-time field "dateTimeField". Expected format: 'YYYY-MM-DDTHH:mm:ssZ'"`;
exports[`Create input validation - DATE_TIME Rest create input - failure DATE_TIME - should fail with : {"dateTimeField":"malformed-date-time"} 1`] = `"["Invalid value 'malformed-date-time' for date-time field \\"dateTimeField\\". Expected format: 'YYYY-MM-DDTHH:mm:ssZ'"]"`;

View File

@@ -0,0 +1,29 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Create input validation - EMAILS Gql create input - failure EMAILS - should fail with : {"emailsField":"not-an-email"} 1`] = `"Invalid object value 'not-an-email' for field "emailsField""`;
exports[`Create input validation - EMAILS Gql create input - failure EMAILS - should fail with : {"emailsField":{"additionalEmails":"not-an-email"}} 1`] = `"Invalid string value 'not-an-email' for email field "emailsField.additionalEmails""`;
exports[`Create input validation - EMAILS Gql create input - failure EMAILS - should fail with : {"emailsField":{"additionalEmails":["not-an-email","additional@email.com"]}} 1`] = `"Invalid string value 'not-an-email' for email field "emailsField.additionalEmails""`;
exports[`Create input validation - EMAILS Gql create input - failure EMAILS - should fail with : {"emailsField":{"additionalEmails":["not-an-email"]}} 1`] = `"Invalid string value 'not-an-email' for email field "emailsField.additionalEmails""`;
exports[`Create input validation - EMAILS Gql create input - failure EMAILS - should fail with : {"emailsField":{"primaryEmail":"email@email.com","additionalEmails":["not-an-email"]}} 1`] = `"Invalid string value 'not-an-email' for email field "emailsField.additionalEmails""`;
exports[`Create input validation - EMAILS Gql create input - failure EMAILS - should fail with : {"emailsField":{"primaryEmail":"not-an-email","additionalEmails":["additional@email.com"]}} 1`] = `"Invalid string value 'not-an-email' for email field "emailsField.primaryEmail""`;
exports[`Create input validation - EMAILS Gql create input - failure EMAILS - should fail with : {"emailsField":{"primaryEmail":"not-an-email"}} 1`] = `"Invalid string value 'not-an-email' for email field "emailsField.primaryEmail""`;
exports[`Create input validation - EMAILS Rest create input - failure EMAILS - should fail with : {"emailsField":"not-an-email"} 1`] = `"["Invalid object value 'not-an-email' for field \\"emailsField\\""]"`;
exports[`Create input validation - EMAILS Rest create input - failure EMAILS - should fail with : {"emailsField":{"additionalEmails":"not-an-email"}} 1`] = `"["Invalid string value 'not-an-email' for email field \\"emailsField.additionalEmails\\""]"`;
exports[`Create input validation - EMAILS Rest create input - failure EMAILS - should fail with : {"emailsField":{"additionalEmails":["not-an-email","additional@email.com"]}} 1`] = `"["Invalid string value 'not-an-email' for email field \\"emailsField.additionalEmails\\""]"`;
exports[`Create input validation - EMAILS Rest create input - failure EMAILS - should fail with : {"emailsField":{"additionalEmails":["not-an-email"]}} 1`] = `"["Invalid string value 'not-an-email' for email field \\"emailsField.additionalEmails\\""]"`;
exports[`Create input validation - EMAILS Rest create input - failure EMAILS - should fail with : {"emailsField":{"primaryEmail":"email@email.com","additionalEmails":["not-an-email"]}} 1`] = `"["Invalid string value 'not-an-email' for email field \\"emailsField.additionalEmails\\""]"`;
exports[`Create input validation - EMAILS Rest create input - failure EMAILS - should fail with : {"emailsField":{"primaryEmail":"not-an-email","additionalEmails":["additional@email.com"]}} 1`] = `"["Invalid string value 'not-an-email' for email field \\"emailsField.primaryEmail\\""]"`;
exports[`Create input validation - EMAILS Rest create input - failure EMAILS - should fail with : {"emailsField":{"primaryEmail":"not-an-email"}} 1`] = `"["Invalid string value 'not-an-email' for email field \\"emailsField.primaryEmail\\""]"`;

View File

@@ -0,0 +1,31 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Create input validation - FILES Gql create input - failure FILES - should fail with : {"filesField":"not-an-addFiles-property"} 1`] = `"Invalid value "'not-an-addFiles-property'" for FILES field "filesField" - It should be an array of objects with "fileId" and "label" properties."`;
exports[`Create input validation - FILES Gql create input - failure FILES - should fail with : {"filesField":[{"addFiles":[{"fileId":"550e8400-e29b-41d4-a716-446655440000","label":12345}]}]} 1`] = `"Invalid value "[ { addFiles: [ [Object] ] } ]" for FILES field "filesField" - 0.fileId: Invalid input: expected string, received undefined, 0.label: Invalid input: expected string, received undefined, 0: Unrecognized key: "addFiles""`;
exports[`Create input validation - FILES Gql create input - failure FILES - should fail with : {"filesField":{"addFiles":[{"fileId":"550e8400-e29b-41d4-a716-446655440000","label":"Document.pdf","extension":"not-allowed-in-input"}]}} 1`] = `
"Invalid value "{
addFiles: [
{
fileId: '550e8400-e29b-41d4-a716-446655440000',
label: 'Document.pdf',
extension: 'not-allowed-in-input'
}
]
}" for FILES field "filesField" - : Invalid input: expected array, received object"
`;
exports[`Create input validation - FILES Gql create input - failure FILES - should fail with : {"filesField":{"addFiles":[{"fileId":"not-a-uuid","label":"Document.pdf"}]}} 1`] = `"Invalid value "{ addFiles: [ { fileId: 'not-a-uuid', label: 'Document.pdf' } ] }" for FILES field "filesField" - : Invalid input: expected array, received object"`;
exports[`Create input validation - FILES Gql create input - failure FILES - should fail with : {"filesField":{"addFiles":[{"invalidField":"test"}]}} 1`] = `"Invalid value "{ addFiles: [ { invalidField: 'test' } ] }" for FILES field "filesField" - : Invalid input: expected array, received object"`;
exports[`Create input validation - FILES Rest create input - failure FILES - should fail with : {"filesField":"not-an-addFiles-property"} 1`] = `"["Invalid value \\"'not-an-addFiles-property'\\" for FILES field \\"filesField\\" - It should be an array of objects with \\"fileId\\" and \\"label\\" properties."]"`;
exports[`Create input validation - FILES Rest create input - failure FILES - should fail with : {"filesField":[{"addFiles":[{"fileId":"550e8400-e29b-41d4-a716-446655440000","label":12345}]}]} 1`] = `"["Invalid value \\"[ { addFiles: [ [Object] ] } ]\\" for FILES field \\"filesField\\" - 0.fileId: Invalid input: expected string, received undefined, 0.label: Invalid input: expected string, received undefined, 0: Unrecognized key: \\"addFiles\\""]"`;
exports[`Create input validation - FILES Rest create input - failure FILES - should fail with : {"filesField":{"addFiles":[{"fileId":"550e8400-e29b-41d4-a716-446655440000","label":"Document.pdf","extension":"not-allowed-in-input"}]}} 1`] = `"["Invalid value \\"{\\n addFiles: [\\n {\\n fileId: '550e8400-e29b-41d4-a716-446655440000',\\n label: 'Document.pdf',\\n extension: 'not-allowed-in-input'\\n }\\n ]\\n}\\" for FILES field \\"filesField\\" - : Invalid input: expected array, received object"]"`;
exports[`Create input validation - FILES Rest create input - failure FILES - should fail with : {"filesField":{"addFiles":[{"fileId":"not-a-uuid","label":"Document.pdf"}]}} 1`] = `"["Invalid value \\"{ addFiles: [ { fileId: 'not-a-uuid', label: 'Document.pdf' } ] }\\" for FILES field \\"filesField\\" - : Invalid input: expected array, received object"]"`;
exports[`Create input validation - FILES Rest create input - failure FILES - should fail with : {"filesField":{"addFiles":[{"invalidField":"test"}]}} 1`] = `"["Invalid value \\"{ addFiles: [ { invalidField: 'test' } ] }\\" for FILES field \\"filesField\\" - : Invalid input: expected array, received object"]"`;

View File

@@ -0,0 +1,5 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Create input validation - FULL_NAME Gql create input - failure FULL_NAME - should fail with : {"fullNameField":"not-a-full-name"} 1`] = `"Invalid object value 'not-a-full-name' for field "fullNameField""`;
exports[`Create input validation - FULL_NAME Rest create input - failure FULL_NAME - should fail with : {"fullNameField":"not-a-full-name"} 1`] = `"["Invalid object value 'not-a-full-name' for field \\"fullNameField\\""]"`;

View File

@@ -0,0 +1,5 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Create input validation - LINKS Gql create input - failure LINKS - should fail with : {"linksField":"not-a-link"} 1`] = `"Invalid object value 'not-a-link' for field "linksField""`;
exports[`Create input validation - LINKS Rest create input - failure LINKS - should fail with : {"linksField":"not-a-link"} 1`] = `"["Invalid object value 'not-a-link' for field \\"linksField\\""]"`;

View File

@@ -0,0 +1,11 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Create input validation - MORPH_RELATION Gql create input - failure MORPH_RELATION - should fail with : {"manyToOneMorphRelationFieldApiInputValidationTargetTestObject1Id":"not-a-morph-relation"} 1`] = `"Invalid UUID value 'not-a-morph-relation' for field "manyToOneMorphRelationFieldApiInputValidationTargetTestObject1Id""`;
exports[`Create input validation - MORPH_RELATION Gql create input - failure MORPH_RELATION - should fail with : {"manyToOneMorphRelationFieldApiInputValidationTargetTestObject1Id":[]} 1`] = `"Invalid UUID value [] for field "manyToOneMorphRelationFieldApiInputValidationTargetTestObject1Id""`;
exports[`Create input validation - MORPH_RELATION Gql create input - failure MORPH_RELATION - should fail with : {"manyToOneMorphRelationFieldApiInputValidationTargetTestObject1Id":{}} 1`] = `"Invalid UUID value {} for field "manyToOneMorphRelationFieldApiInputValidationTargetTestObject1Id""`;
exports[`Create input validation - MORPH_RELATION Gql create input - failure MORPH_RELATION - should fail with : {"manyToOneMorphRelationFieldApiInputValidationTargetTestObject1Id":1} 1`] = `"Invalid UUID value 1 for field "manyToOneMorphRelationFieldApiInputValidationTargetTestObject1Id""`;
exports[`Create input validation - MORPH_RELATION Gql create input - failure MORPH_RELATION - should fail with : {"manyToOneMorphRelationFieldApiInputValidationTargetTestObject1Id":true} 1`] = `"Invalid UUID value true for field "manyToOneMorphRelationFieldApiInputValidationTargetTestObject1Id""`;

View File

@@ -0,0 +1,5 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Create input validation - MULTI_SELECT Gql create input - failure MULTI_SELECT - should fail with : {"multiSelectField":"not-a-select-option"} 1`] = `"Invalid value 'not-a-select-option' for multi select field "multiSelectField""`;
exports[`Create input validation - MULTI_SELECT Rest create input - failure MULTI_SELECT - should fail with : {"multiSelectField":"not-a-select-option"} 1`] = `"["Invalid value 'not-a-select-option' for multi select field \\"multiSelectField\\""]"`;

View File

@@ -0,0 +1,5 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Create input validation - NUMBER Gql create input - failure NUMBER - should fail with : {"numberField":"string"} 1`] = `"Invalid number value 'string' for field "numberField""`;
exports[`Create input validation - NUMBER Rest create input - failure NUMBER - should fail with : {"numberField":"string"} 1`] = `"["Invalid number value 'string' for field \\"numberField\\""]"`;

View File

@@ -0,0 +1,5 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Create input validation - PHONES Gql create input - failure PHONES - should fail with : {"phonesField":"not-a-phone"} 1`] = `"Invalid object value 'not-a-phone' for field "phonesField""`;
exports[`Create input validation - PHONES Rest create input - failure PHONES - should fail with : {"phonesField":"not-a-phone"} 1`] = `"["Invalid object value 'not-a-phone' for field \\"phonesField\\""]"`;

View File

@@ -0,0 +1,9 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Create input validation - POSITION Gql create input - failure POSITION - should fail with : {"position":"not-a-position"} 1`] = `"Invalid position value 'not-a-position' for field "position""`;
exports[`Create input validation - POSITION Gql create input - failure POSITION - should fail with : {"position":null} 1`] = `"Invalid position value null for field "position""`;
exports[`Create input validation - POSITION Rest create input - failure POSITION - should fail with : {"position":"not-a-position"} 1`] = `"["Invalid position value 'not-a-position' for field \\"position\\""]"`;
exports[`Create input validation - POSITION Rest create input - failure POSITION - should fail with : {"position":null} 1`] = `"["Invalid position value null for field \\"position\\""]"`;

View File

@@ -0,0 +1,5 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Create input validation - RATING Gql create input - failure RATING - should fail with : {"ratingField":"not-a-rating"} 1`] = `"Invalid value \"not-a-rating\" for field \"ratingField\". Valid values are: RATING_1, RATING_2, RATING_3, RATING_4, RATING_5"`;
exports[`Create input validation - RATING Rest create input - failure RATING - should fail with : {"ratingField":"not-a-rating"} 1`] = `"[\"Invalid value \\\"not-a-rating\\\" for field \\\"ratingField\\\". Valid values are: RATING_1, RATING_2, RATING_3, RATING_4, RATING_5\"]"`;

View File

@@ -0,0 +1,5 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Create input validation - RAW_JSON Gql create input - failure RAW_JSON - should fail with : {"rawJsonField":"not-a-stringified-json"} 1`] = `"Invalid object value 'not-a-stringified-json' for field "rawJsonField""`;
exports[`Create input validation - RAW_JSON Rest create input - failure RAW_JSON - should fail with : {"rawJsonField":"not-a-stringified-json"} 1`] = `"["Invalid object value 'not-a-stringified-json' for field \\"rawJsonField\\""]"`;

Some files were not shown because too many files have changed in this diff Show More