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,244 @@
import { randomUUID } from 'node:crypto';
import request from 'supertest';
import { deleteRecordsByIds } from 'test/integration/utils/delete-records-by-ids';
const TOOL_NAMES = {
createCompany: 'create_one_company',
createOpportunity: 'create_one_opportunity',
findManyCompanies: 'find_many_companies',
findOneCompany: 'find_one_company',
findOneOpportunity: 'find_one_opportunity',
} as const;
type McpToolCallResult = {
content?: Array<{ type: string; text: string }>;
isError?: boolean;
};
type DatabaseToolPayload<TResult> = {
success: boolean;
message: string;
result: TResult;
warnings?: string[];
error?: string;
};
type CreatedRecord = { id: string };
type FindRecordsResult = {
records: Array<Record<string, unknown>>;
count: string | number;
hasNextPage: boolean;
};
const baseUrl = `http://localhost:${APP_PORT}`;
const endpoint = '/mcp';
const postMcp = (body: Record<string, unknown>) =>
request(baseUrl)
.post(endpoint)
.set('Authorization', `Bearer ${API_KEY_ACCESS_TOKEN}`)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.send(JSON.stringify(body));
const callMcpTool = async (
name: string,
args: Record<string, unknown>,
id: string = `call-${randomUUID()}`,
): Promise<McpToolCallResult> => {
const res = await postMcp({
jsonrpc: '2.0',
method: 'tools/call',
id,
params: { name, arguments: args },
}).expect(200);
expect(res.body.id).toBe(id);
expect(res.body.jsonrpc).toBe('2.0');
expect(res.body.error).toBeUndefined();
return res.body.result as McpToolCallResult;
};
const parseToolPayload = <T>(result: McpToolCallResult): T => {
expect(result.isError).toBe(false);
expect(result.content?.[0]?.type).toBe('text');
const raw = result.content?.[0]?.text;
expect(raw).toBeDefined();
return JSON.parse(raw as string) as T;
};
const executeWorkspaceTool = async <TResult>(
toolName: string,
args: Record<string, unknown>,
): Promise<DatabaseToolPayload<TResult>> => {
const mcpResult = await callMcpTool('execute_tool', {
toolName,
arguments: args,
});
const payload = parseToolPayload<DatabaseToolPayload<TResult>>(mcpResult);
expect(payload.success).toBe(true);
expect(payload.result).toBeDefined();
return payload;
};
describe('MCP find tools relation selection (integration)', () => {
let companyId: string;
let opportunityAId: string;
let opportunityBId: string;
const companyName = `mcp-relation-select-company-${randomUUID()}`;
const opportunityAName = `mcp-relation-select-opportunity-a-${randomUUID()}`;
const opportunityBName = `mcp-relation-select-opportunity-b-${randomUUID()}`;
beforeAll(async () => {
const company = await executeWorkspaceTool<CreatedRecord>(
TOOL_NAMES.createCompany,
{ name: companyName },
);
companyId = company.result.id;
const opportunityA = await executeWorkspaceTool<CreatedRecord>(
TOOL_NAMES.createOpportunity,
{ name: opportunityAName, companyId },
);
opportunityAId = opportunityA.result.id;
const opportunityB = await executeWorkspaceTool<CreatedRecord>(
TOOL_NAMES.createOpportunity,
{ name: opportunityBName, companyId },
);
opportunityBId = opportunityB.result.id;
});
afterAll(async () => {
await deleteRecordsByIds(
'opportunity',
[opportunityAId, opportunityBId].filter(Boolean),
);
await deleteRecordsByIds('company', [companyId].filter(Boolean));
});
it('should hydrate a one-to-many relation selected by name in find_many', async () => {
const payload = await executeWorkspaceTool<FindRecordsResult>(
TOOL_NAMES.findManyCompanies,
{
id: { eq: companyId },
select: ['id', 'name', 'opportunities'],
limit: 1,
},
);
expect(payload.warnings).toBeUndefined();
expect(payload.result.records).toHaveLength(1);
const record = payload.result.records[0];
expect(record.id).toBe(companyId);
expect(record.name).toBe(companyName);
const opportunities = record.opportunities as Array<
Record<string, unknown>
>;
expect(Array.isArray(opportunities)).toBe(true);
expect(opportunities).toHaveLength(2);
expect(opportunities).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: opportunityAId,
name: opportunityAName,
}),
expect.objectContaining({
id: opportunityBId,
name: opportunityBName,
}),
]),
);
});
it('should hydrate a one-to-many relation selected by name in find_one', async () => {
const payload = await executeWorkspaceTool<FindRecordsResult>(
TOOL_NAMES.findOneCompany,
{
id: companyId,
select: ['id', 'name', 'opportunities'],
},
);
const record = payload.result.records[0];
const opportunities = record.opportunities as Array<
Record<string, unknown>
>;
expect(opportunities).toHaveLength(2);
});
it('should hydrate a many-to-one relation selected by name as a single record', async () => {
const payload = await executeWorkspaceTool<FindRecordsResult>(
TOOL_NAMES.findOneOpportunity,
{
id: opportunityAId,
select: ['id', 'name', 'company'],
},
);
const record = payload.result.records[0];
expect(record.id).toBe(opportunityAId);
expect(record.company).toEqual(
expect.objectContaining({
id: companyId,
name: companyName,
}),
);
});
it('should hydrate relation fields in wildcard selection', async () => {
const payload = await executeWorkspaceTool<FindRecordsResult>(
TOOL_NAMES.findOneCompany,
{
id: companyId,
select: ['*'],
},
);
const record = payload.result.records[0];
expect(record.name).toBe(companyName);
const opportunities = record.opportunities as Array<
Record<string, unknown>
>;
expect(opportunities).toHaveLength(2);
});
it('should suggest the relation field for a near-miss selection', async () => {
const payload = await executeWorkspaceTool<FindRecordsResult>(
TOOL_NAMES.findManyCompanies,
{
id: { eq: companyId },
select: ['name', 'opportunitiez'],
limit: 1,
},
);
expect(payload.warnings).toEqual([
expect.stringContaining("'opportunities'"),
]);
expect(payload.result.records[0]).not.toHaveProperty('opportunities');
});
});

View File

@@ -0,0 +1,291 @@
import { gql } from 'graphql-tag';
import request from 'supertest';
import { generateApiKeyToken } from 'test/integration/graphql/utils/generate-api-key-token.util';
import { createOneRole } from 'test/integration/metadata/suites/role/utils/create-one-role.util';
import { deleteOneRole } from 'test/integration/metadata/suites/role/utils/delete-one-role.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test';
import { ToolCategory } from 'twenty-shared/ai';
/**
* Contract tests for the MCP tool catalog.
*
* 1. Catalog contract: every category advertised by get_tool_catalog must be
* dispatchable end to end through execute_tool. Dispatchable means the
* registry resolves and executes the tool: a structured business failure
* (e.g. a search tool rejecting empty arguments) still proves dispatch,
* only "not found" / "not available" outputs do not. Scales automatically:
* a newly registered provider is covered the moment it appears in the
* catalog, with no new test code.
* 2. Permission gating: the catalog is role-dependent. An API key bound to a
* role without settings permissions must not see settings-gated tools
* (e.g. the ROLE category), while an admin-bound key must.
*/
const baseUrl = `http://localhost:${APP_PORT}`;
const postMcp = (body: object, bearer: string) =>
request(baseUrl)
.post('/mcp')
.set('Authorization', `Bearer ${bearer}`)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.send(JSON.stringify(body));
const callMcpTool = async (
bearer: string,
toolName: string,
toolArguments: object,
) => {
const response = await postMcp(
{
jsonrpc: '2.0',
method: 'tools/call',
params: { name: toolName, arguments: toolArguments },
id: '1',
},
bearer,
).expect(200);
return response.body.result as {
content: { type: string; text: string }[];
isError: boolean;
};
};
const getToolCatalog = async (
bearer: string,
): Promise<Record<string, { name: string; description: string }[]>> => {
const result = await callMcpTool(bearer, 'get_tool_catalog', {});
expect(result.isError).toBe(false);
return JSON.parse(result.content[0].text).catalog;
};
const READ_ONLY_TOOL_NAME_PATTERN = /^(find_|list_|get_|search_)/;
// Dispatch-layer failures come from execute_tool gating or the registry
// (unknown or unavailable tool), not from the executed tool itself. Their
// exact wording is pinned by the "should report unknown tools as dispatch
// failures" control test below, so drift fails loudly instead of silently
// weakening the catalog contract.
const DISPATCH_FAILURE_MESSAGE_PATTERN =
/^Tool ".+" (not found|is not available)$/;
const isDispatchFailure = (result: {
content: { type: string; text: string }[];
}): boolean => {
let output: { success?: boolean; message?: string };
try {
output = JSON.parse(result.content[0].text);
} catch {
return false;
}
return (
output.success === false &&
DISPATCH_FAILURE_MESSAGE_PATTERN.test(output.message ?? '')
);
};
// Deliberate exceptions to the "every advertised category is dispatchable
// through a read-only tool" contract. Currently none: every category the MCP
// catalog advertises ships at least one read-only tool. Adding a category
// here must be a conscious decision, not silent drift.
const EXPECTED_CATEGORIES_WITHOUT_READ_ONLY_TOOLS: string[] = [];
const createApiKeyToken = async (roleId: string): Promise<string> => {
const createResponse = await makeMetadataAPIRequest({
query: gql`
mutation CreateApiKey($input: CreateApiKeyInput!) {
createApiKey(input: $input) {
id
}
}
`,
variables: {
input: {
name: `MCP catalog test key ${roleId}`,
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
roleId,
},
},
});
const apiKeyId = createResponse.body.data?.createApiKey?.id;
jestExpectToBeDefined(apiKeyId);
createdApiKeyIds.push(apiKeyId);
const tokenResponse = await generateApiKeyToken({
apiKeyId,
accessToken: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
const token = tokenResponse.body.data?.generateApiKeyToken?.token;
jestExpectToBeDefined(token);
return token;
};
const createdApiKeyIds: string[] = [];
describe('MCP tool catalog (integration)', () => {
let adminApiKeyToken: string;
let restrictedApiKeyToken: string;
let restrictedRoleId: string;
beforeAll(async () => {
const rolesResponse = await makeMetadataAPIRequest({
query: gql`
query GetRoles {
getRoles {
id
label
}
}
`,
});
const adminRoleId = rolesResponse.body.data?.getRoles?.find(
(role: { label: string }) => role.label === 'Admin',
)?.id;
jestExpectToBeDefined(adminRoleId);
const { data: restrictedRoleData } = await createOneRole({
expectToFail: false,
input: {
label: 'MCP Catalog Restricted Role',
description: 'API-key role without settings permissions',
icon: 'IconKey',
canUpdateAllSettings: false,
canAccessAllTools: true,
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canBeAssignedToUsers: false,
canBeAssignedToAgents: false,
canBeAssignedToApiKeys: true,
},
});
restrictedRoleId = restrictedRoleData?.createOneRole?.id as string;
jestExpectToBeDefined(restrictedRoleId);
adminApiKeyToken = await createApiKeyToken(adminRoleId);
restrictedApiKeyToken = await createApiKeyToken(restrictedRoleId);
});
afterAll(async () => {
for (const apiKeyId of createdApiKeyIds) {
await testDataSource
.query('DELETE FROM core."apiKey" WHERE id = $1', [apiKeyId])
.catch(() => {});
}
if (restrictedRoleId) {
await deleteOneRole({
expectToFail: false,
input: { idToDelete: restrictedRoleId },
});
}
});
describe('catalog contract', () => {
it('should dispatch one read-only tool per advertised category through execute_tool', async () => {
const catalog = await getToolCatalog(adminApiKeyToken);
const categories = Object.keys(catalog);
expect(categories.length).toBeGreaterThan(0);
const categoriesWithoutReadOnlyTool: string[] = [];
for (const category of categories) {
const readOnlyCandidates = catalog[category]
.filter((tool) => READ_ONLY_TOOL_NAME_PATTERN.test(tool.name))
.slice(0, 3);
if (readOnlyCandidates.length === 0) {
// A write-only category has nothing safe to dispatch in CI; it is
// collected and checked against the deliberate exception list below.
categoriesWithoutReadOnlyTool.push(category);
continue;
}
let dispatched = false;
for (const candidate of readOnlyCandidates) {
const result = await callMcpTool(adminApiKeyToken, 'execute_tool', {
toolName: candidate.name,
arguments: {},
});
// Called with empty arguments, a resolved tool may legitimately
// return a structured failure (isError true since the MCP layer
// surfaces success: false); only a dispatch-layer failure means the
// category is advertised but not actually wired up.
if (!isDispatchFailure(result)) {
dispatched = true;
break;
}
}
expect({ category, dispatched }).toEqual({
category,
dispatched: true,
});
}
// Exact equality fails in both directions, so gaining or losing a
// skipped category forces a deliberate update of the exception list.
expect([...categoriesWithoutReadOnlyTool].sort()).toEqual(
EXPECTED_CATEGORIES_WITHOUT_READ_ONLY_TOOLS,
);
});
it('should report unknown tools as dispatch failures', async () => {
const result = await callMcpTool(adminApiKeyToken, 'execute_tool', {
toolName: 'definitely_not_a_registered_tool',
arguments: {},
});
expect(result.isError).toBe(true);
expect(isDispatchFailure(result)).toBe(true);
});
});
describe('permission gating', () => {
it('should expose role tools to an admin-bound API key', async () => {
const catalog = await getToolCatalog(adminApiKeyToken);
jestExpectToBeDefined(catalog[ToolCategory.ROLE]);
const roleToolNames = catalog[ToolCategory.ROLE].map((tool) => tool.name);
expect(roleToolNames).toEqual(
expect.arrayContaining(['list_roles', 'create_role', 'update_role']),
);
});
it('should hide role tools from an API key without settings permissions', async () => {
const catalog = await getToolCatalog(restrictedApiKeyToken);
expect(catalog[ToolCategory.ROLE]).toBeUndefined();
const allToolNames = Object.values(catalog)
.flat()
.map((tool) => tool.name);
expect(allToolNames).not.toEqual(expect.arrayContaining(['create_role']));
// The restricted role still sees record read tools, proving the empty
// ROLE category is gating rather than a broken catalog.
expect(catalog[ToolCategory.DATABASE_CRUD]?.length).toBeGreaterThan(0);
});
});
});

View File

@@ -0,0 +1,377 @@
import { randomUUID } from 'node:crypto';
import request from 'supertest';
import { deleteRecordsByIds } from 'test/integration/utils/delete-records-by-ids';
const TEST_WORKSPACE_SCHEMA = 'workspace_1wgvd1injqtife6y4rvfbu3h5';
const TOOL_NAMES = {
createCompany: 'create_one_company',
createNote: 'create_one_note',
createNoteTarget: 'create_one_note_target',
groupByNoteTargets: 'group_by_note_targets',
} as const;
type McpToolCallResult = {
content?: Array<{ type: string; text: string }>;
isError?: boolean;
};
type DatabaseToolPayload<TResult> = {
success: boolean;
message: string;
result: TResult;
error?: string;
};
type LearnToolsPayload = {
tools: Array<{
name: string;
description?: string;
inputSchema?: Record<string, unknown>;
}>;
notFound: string[];
message: string;
};
type CreatedRecord = { id: string };
const baseUrl = `http://localhost:${APP_PORT}`;
const endpoint = '/mcp';
const postMcp = (body: Record<string, unknown>) =>
request(baseUrl)
.post(endpoint)
.set('Authorization', `Bearer ${API_KEY_ACCESS_TOKEN}`)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.send(JSON.stringify(body));
const callMcpTool = async (
name: string,
args: Record<string, unknown>,
id: string = `call-${randomUUID()}`,
): Promise<McpToolCallResult> => {
const res = await postMcp({
jsonrpc: '2.0',
method: 'tools/call',
id,
params: { name, arguments: args },
}).expect(200);
expect(res.body.id).toBe(id);
expect(res.body.jsonrpc).toBe('2.0');
expect(res.body.error).toBeUndefined();
return res.body.result as McpToolCallResult;
};
const parseToolPayload = <T>(result: McpToolCallResult): T => {
expect(result.isError).toBe(false);
expect(result.content?.[0]?.type).toBe('text');
const raw = result.content?.[0]?.text;
expect(raw).toBeDefined();
return JSON.parse(raw as string) as T;
};
const executeWorkspaceTool = async <TResult>(
toolName: string,
args: Record<string, unknown>,
): Promise<TResult> => {
const mcpResult = await callMcpTool('execute_tool', {
toolName,
arguments: args,
});
const payload = parseToolPayload<DatabaseToolPayload<TResult>>(mcpResult);
expect(payload.success).toBe(true);
expect(payload.result).toBeDefined();
return payload.result;
};
const learnToolSchema = async (
toolName: string,
): Promise<Record<string, unknown>> => {
const mcpResult = await callMcpTool('learn_tools', {
toolNames: [toolName],
aspects: ['schema'],
});
const payload = parseToolPayload<LearnToolsPayload>(mcpResult);
expect(payload.notFound).toEqual([]);
expect(payload.tools).toHaveLength(1);
expect(payload.tools[0].name).toBe(toolName);
const inputSchema = payload.tools[0].inputSchema;
expect(inputSchema).toBeDefined();
return inputSchema as Record<string, unknown>;
};
describe('MCP tool execution (integration)', () => {
describe('create_note_target (morph relation join)', () => {
let createdCompanyId: string | undefined;
let createdNoteId: string | undefined;
let createdNoteTargetId: string | undefined;
afterAll(async () => {
if (createdNoteTargetId) {
await deleteRecordsByIds('noteTarget', [createdNoteTargetId]);
}
if (createdNoteId) {
await deleteRecordsByIds('note', [createdNoteId]);
}
if (createdCompanyId) {
await deleteRecordsByIds('company', [createdCompanyId]);
}
});
it('should expose the morph-relation join columns as `${name}Id` UUID parameters', async () => {
const inputSchema = await learnToolSchema(TOOL_NAMES.createNoteTarget);
const schema = inputSchema as {
properties?: Record<
string,
{ type?: string; format?: string; $ref?: string }
>;
$defs?: Record<string, { type?: string; format?: string }>;
};
const properties = schema.properties;
expect(properties).toBeDefined();
const resolveProperty = (
prop: { type?: string; format?: string; $ref?: string } | undefined,
) => {
if (!prop) return undefined;
if (prop.$ref && schema.$defs) {
const defKey = prop.$ref.replace('#/$defs/', '');
return schema.$defs[defKey];
}
return prop;
};
expect(resolveProperty(properties?.noteId)).toMatchObject({
type: 'string',
format: 'uuid',
});
// Morph relations must be exposed as `${name}Id` UUIDs (the join column),
// not as the relation name typed as string — the data-arg-processor only
// accepts the join-column form for write operations.
expect(resolveProperty(properties?.targetCompanyId)).toMatchObject({
type: 'string',
format: 'uuid',
});
expect(resolveProperty(properties?.targetPersonId)).toMatchObject({
type: 'string',
format: 'uuid',
});
expect(resolveProperty(properties?.targetOpportunityId)).toMatchObject({
type: 'string',
format: 'uuid',
});
expect(properties?.targetCompany).toBeUndefined();
expect(properties?.targetPerson).toBeUndefined();
expect(properties?.targetOpportunity).toBeUndefined();
});
it('should persist targetCompanyId when create_note_target is invoked via MCP', async () => {
const company = await executeWorkspaceTool<CreatedRecord>(
TOOL_NAMES.createCompany,
{ name: `mcp-tool-exec-company-${randomUUID()}` },
);
createdCompanyId = company.id;
expect(createdCompanyId).toBeDefined();
const note = await executeWorkspaceTool<CreatedRecord>(
TOOL_NAMES.createNote,
{ title: `mcp-tool-exec-note-${randomUUID()}` },
);
createdNoteId = note.id;
expect(createdNoteId).toBeDefined();
const noteTarget = await executeWorkspaceTool<CreatedRecord>(
TOOL_NAMES.createNoteTarget,
{
noteId: createdNoteId,
targetCompanyId: createdCompanyId,
},
);
createdNoteTargetId = noteTarget.id;
expect(createdNoteTargetId).toBeDefined();
const rows = (await global.testDataSource.query(
`SELECT "noteId", "targetCompanyId", "targetPersonId", "targetOpportunityId"
FROM "${TEST_WORKSPACE_SCHEMA}"."noteTarget" WHERE id = $1`,
[createdNoteTargetId],
)) as Array<{
noteId: string;
targetCompanyId: string | null;
targetPersonId: string | null;
targetOpportunityId: string | null;
}>;
expect(rows).toHaveLength(1);
expect(rows[0].noteId).toBe(createdNoteId);
expect(rows[0].targetCompanyId).toBe(createdCompanyId);
expect(rows[0].targetPersonId).toBeNull();
expect(rows[0].targetOpportunityId).toBeNull();
});
});
describe('group_by_note_targets (morph relation column)', () => {
let createdCompanyAId: string | undefined;
let createdCompanyBId: string | undefined;
const createdNoteIds: string[] = [];
const createdNoteTargetIds: string[] = [];
beforeAll(async () => {
const companyA = await executeWorkspaceTool<CreatedRecord>(
TOOL_NAMES.createCompany,
{ name: `mcp-group-by-company-A-${randomUUID()}` },
);
createdCompanyAId = companyA.id;
const companyB = await executeWorkspaceTool<CreatedRecord>(
TOOL_NAMES.createCompany,
{ name: `mcp-group-by-company-B-${randomUUID()}` },
);
createdCompanyBId = companyB.id;
// Two targets on company A, one on company B — the grouped counts
// we'll assert against later.
const targetCompanyIds = [
createdCompanyAId,
createdCompanyAId,
createdCompanyBId,
];
for (const targetCompanyId of targetCompanyIds) {
const note = await executeWorkspaceTool<CreatedRecord>(
TOOL_NAMES.createNote,
{ title: `mcp-group-by-note-${randomUUID()}` },
);
createdNoteIds.push(note.id);
const noteTarget = await executeWorkspaceTool<CreatedRecord>(
TOOL_NAMES.createNoteTarget,
{ noteId: note.id, targetCompanyId },
);
createdNoteTargetIds.push(noteTarget.id);
}
});
afterAll(async () => {
if (createdNoteTargetIds.length > 0) {
await deleteRecordsByIds('noteTarget', createdNoteTargetIds);
}
if (createdNoteIds.length > 0) {
await deleteRecordsByIds('note', createdNoteIds);
}
const companyIds = [createdCompanyAId, createdCompanyBId].filter(
(id): id is string => typeof id === 'string',
);
if (companyIds.length > 0) {
await deleteRecordsByIds('company', companyIds);
}
});
it('should expose targetCompanyId as a valid groupBy option', async () => {
const inputSchema = await learnToolSchema(TOOL_NAMES.groupByNoteTargets);
const groupByItems = (
inputSchema as {
properties?: {
groupBy?: {
items?: {
anyOf?: Array<{ properties?: Record<string, unknown> }>;
properties?: Record<string, unknown>;
};
};
};
}
).properties?.groupBy?.items;
expect(groupByItems).toBeDefined();
// Each groupBy variant is { [columnName]: true }. Collect every column
// the schema offers so we can assert on the morph-relation columns.
const groupByColumns = new Set<string>();
if (groupByItems?.anyOf) {
for (const variant of groupByItems.anyOf) {
for (const propertyName of Object.keys(variant.properties ?? {})) {
groupByColumns.add(propertyName);
}
}
} else if (groupByItems?.properties) {
for (const propertyName of Object.keys(groupByItems.properties)) {
groupByColumns.add(propertyName);
}
}
expect(groupByColumns.has('noteId')).toBe(true);
expect(groupByColumns.has('targetCompanyId')).toBe(true);
expect(groupByColumns.has('targetPersonId')).toBe(true);
expect(groupByColumns.has('targetOpportunityId')).toBe(true);
});
it('should group noteTargets by targetCompanyId via MCP', async () => {
type GroupByGroup = {
dimensions: unknown[];
value: string | number;
};
type GroupByResult = {
groups: GroupByGroup[];
dimensionLabels: string[];
aggregation: string;
groupCount: number;
};
const result = await executeWorkspaceTool<GroupByResult>(
TOOL_NAMES.groupByNoteTargets,
{
groupBy: [{ targetCompanyId: true }],
aggregateOperation: 'COUNT',
// Scope to the noteTargets we created so other seeded rows don't
// leak into the counts.
noteId: { in: createdNoteIds },
},
);
expect(result.dimensionLabels).toEqual(['targetCompanyId']);
expect(result.aggregation).toBe('COUNT');
expect(result.groupCount).toBe(2);
// Dimensions are returned positionally, aligned with dimensionLabels.
const countsByCompany = Object.fromEntries(
result.groups.map((group) => [
String(group.dimensions[0]),
Number(group.value),
]),
);
expect(countsByCompany[createdCompanyAId as string]).toBe(2);
expect(countsByCompany[createdCompanyBId as string]).toBe(1);
});
});
});

View File

@@ -0,0 +1,329 @@
import request from 'supertest';
import { MCP_PROTOCOL_VERSION } from 'src/engine/api/mcp/constants/mcp-protocol-version.const';
/**
* Integration tests for MCP core controller
*
* These tests hit the real Nest app bootstrapped by test/integration/utils/setup-test.ts
* and exercise the guarded POST /mcp endpoint using valid/invalid JSON-RPC payloads.
*/
describe('MCP Controller (integration)', () => {
const baseUrl = `http://localhost:${APP_PORT}`;
const endpoint = '/mcp';
const postMcp = (
body: any,
bearer: string = API_KEY_ACCESS_TOKEN,
accept: string = 'application/json',
) => {
return request(baseUrl)
.post(endpoint)
.set('Authorization', `Bearer ${bearer}`)
.set('Content-Type', 'application/json')
.set('Accept', accept)
.send(JSON.stringify(body));
};
it('should return 405 with JSON-RPC error for GET /mcp', async () => {
await request(baseUrl)
.get(endpoint)
.expect(405)
.expect((res) => {
expect(res.headers.allow).toBe('POST');
expect(res.body.jsonrpc).toBe('2.0');
expect(res.body.error).toBeDefined();
expect(res.body.error.code).toBe(-32600);
expect(res.body.id).toBeNull();
});
});
it('should return 405 with JSON-RPC error for DELETE /mcp', async () => {
await request(baseUrl)
.delete(endpoint)
.expect(405)
.expect((res) => {
expect(res.headers.allow).toBe('POST');
expect(res.body.jsonrpc).toBe('2.0');
expect(res.body.error).toBeDefined();
expect(res.body.error.code).toBe(-32600);
expect(res.body.id).toBeNull();
});
});
it('should respond to ping with a JSON-RPC result envelope', async () => {
await postMcp({ jsonrpc: '2.0', method: 'ping', id: '1' })
.expect(200)
.expect((res) => {
expect(res.body).toMatchObject({
id: '1',
jsonrpc: '2.0',
result: {},
});
expect(res.body.error).toBeUndefined();
});
});
it('should respond to initialize with spec-compliant InitializeResult', async () => {
await postMcp({ jsonrpc: '2.0', method: 'initialize', id: 123 })
.expect(200)
.expect((res) => {
expect(res.body.id).toBe(123);
expect(res.body.jsonrpc).toBe('2.0');
expect(res.body.result).toBeDefined();
expect(res.body.result.protocolVersion).toBe(MCP_PROTOCOL_VERSION);
expect(res.body.result.capabilities).toBeDefined();
expect(res.body.result.serverInfo).toBeDefined();
expect(res.body.result.serverInfo.name).toBe('Twenty MCP Server');
expect(typeof res.body.result.instructions).toBe('string');
});
});
it('should return 202 with no body for notifications/initialized (no id)', async () => {
await postMcp({ jsonrpc: '2.0', method: 'notifications/initialized' })
.expect(202)
.expect((res) => {
expect(res.body).toEqual({});
});
});
it('should validate request body and return 400 for invalid payload (missing method)', async () => {
await postMcp({})
.expect(400)
.expect((res) => {
expect(res.body.error).toBe('BadRequestException');
});
});
describe('MCP Tools', () => {
it('should list available tools with schemas', async () => {
const res = await postMcp({
jsonrpc: '2.0',
method: 'tools/list',
id: 'tools-list-1',
}).expect(200);
expect(res.body.id).toBe('tools-list-1');
expect(res.body.jsonrpc).toBe('2.0');
expect(res.body.result).toBeDefined();
expect(Array.isArray(res.body.result.tools)).toBe(true);
expect(res.body.result.tools.length).toBeGreaterThanOrEqual(0);
const first = res.body.result.tools[0];
if (first) {
expect(first.name).toBeDefined();
expect(first.description).toBeDefined();
expect(first.inputSchema).toBeDefined();
}
});
it('should return invalid params error for tools/call without params', async () => {
const res = await postMcp({
jsonrpc: '2.0',
method: 'tools/call',
id: 'tools-call-empty',
}).expect(200);
expect(res.body.id).toBe('tools-call-empty');
expect(res.body.jsonrpc).toBe('2.0');
expect(res.body.error).toBeDefined();
expect(res.body.error.code).toBe(-32602);
});
it('should return error when calling a non-existent tool', async () => {
const res = await postMcp({
jsonrpc: '2.0',
method: 'tools/call',
id: 'tools-call-missing',
params: { name: 'non_existent_tool', arguments: {} },
}).expect(200);
expect(res.body.id).toBe('tools-call-missing');
expect(res.body.jsonrpc).toBe('2.0');
expect(res.body.error).toBeDefined();
expect(res.body.error.code).toBe(-32602);
expect(String(res.body.error.message)).toMatch(/non_existent_tool/);
});
it('should exercise each listed tool with a call and receive a JSON-RPC envelope', async () => {
const list = await postMcp({
jsonrpc: '2.0',
method: 'tools/list',
id: 'tools-list-for-calls',
}).expect(200);
const tools: Array<{ name: string }> = list.body.result.tools || [];
// For each tool, attempt a call with empty arguments. We only assert we get a valid envelope
// with either a result or an error. This ensures the MCP controller routes tool calls correctly
// for every tool exposed in the workspace, without relying on specific seeded data.
for (let i = 0; i < tools.length; i++) {
const tool = tools[i];
const id = `tool-call-${i}`;
const res = await postMcp({
jsonrpc: '2.0',
method: 'tools/call',
id,
params: { name: tool.name, arguments: {} },
}).expect(200);
expect(res.body.jsonrpc).toBe('2.0');
expect(res.body.id).toBe(id);
const hasResultContent = !!res.body?.result?.content;
const hasError = !!res.body?.error;
expect(hasResultContent || hasError).toBe(true);
}
}, 60_000);
it('should list prompts and resources as empty arrays', async () => {
const prompts = await postMcp({
jsonrpc: '2.0',
method: 'prompts/list',
id: 'prompts-1',
}).expect(200);
expect(Array.isArray(prompts.body.result.prompts)).toBe(true);
const resources = await postMcp({
jsonrpc: '2.0',
method: 'resources/list',
id: 'resources-1',
}).expect(200);
expect(Array.isArray(resources.body.result.resources)).toBe(true);
});
});
describe('SSE Streaming', () => {
const parseSseEvents = (
rawBody: string,
): Array<Record<string, unknown>> => {
return rawBody
.split('\n\n')
.filter((block) => block.trim().length > 0)
.map((block) => {
const dataLine = block
.split('\n')
.find((line) => line.startsWith('data: '));
if (!dataLine) {
throw new Error(`No data line found in SSE block: ${block}`);
}
return JSON.parse(dataLine.slice('data: '.length));
});
};
it('should respond with SSE when Accept includes text/event-stream', async () => {
const res = await postMcp(
{ jsonrpc: '2.0', method: 'ping', id: 'sse-ping-1' },
API_KEY_ACCESS_TOKEN,
'application/json, text/event-stream',
).expect(200);
expect(res.headers['content-type']).toMatch(/text\/event-stream/);
const events = parseSseEvents(res.text);
expect(events.length).toBeGreaterThanOrEqual(1);
const lastEvent = events[events.length - 1];
expect(lastEvent).toMatchObject({
id: 'sse-ping-1',
jsonrpc: '2.0',
result: {},
});
});
it('should respond with JSON when Accept is application/json only', async () => {
const res = await postMcp(
{ jsonrpc: '2.0', method: 'ping', id: 'json-ping-1' },
API_KEY_ACCESS_TOKEN,
'application/json',
).expect(200);
expect(res.headers['content-type']).toMatch(/application\/json/);
expect(res.body).toMatchObject({
id: 'json-ping-1',
jsonrpc: '2.0',
result: {},
});
});
it('should echo the client progress token before the tool call result in SSE', async () => {
const res = await postMcp(
{
jsonrpc: '2.0',
method: 'tools/call',
id: 'sse-tool-1',
params: {
name: 'learn_tools',
arguments: { toolNames: [] },
_meta: { progressToken: 'client-token-1' },
},
},
API_KEY_ACCESS_TOKEN,
'application/json, text/event-stream',
).expect(200);
expect(res.headers['content-type']).toMatch(/text\/event-stream/);
const events = parseSseEvents(res.text);
expect(events.length).toBeGreaterThanOrEqual(2);
const progressEvent = events[0];
expect(progressEvent).toMatchObject({
jsonrpc: '2.0',
method: 'notifications/progress',
params: {
progressToken: 'client-token-1',
progress: 0,
total: 1,
},
});
const lastEvent = events[events.length - 1];
expect(lastEvent).toMatchObject({
id: 'sse-tool-1',
jsonrpc: '2.0',
});
const hasResultContent = !!(lastEvent as any)?.result?.content;
const hasError = !!(lastEvent as any)?.error;
expect(hasResultContent || hasError).toBe(true);
});
it('should send no progress notification when the client did not supply a token', async () => {
const res = await postMcp(
{
jsonrpc: '2.0',
method: 'tools/call',
id: 'sse-tool-no-progress',
params: { name: 'learn_tools', arguments: { toolNames: [] } },
},
API_KEY_ACCESS_TOKEN,
'application/json, text/event-stream',
).expect(200);
const events = parseSseEvents(res.text);
expect(
events.some((event) => event.method === 'notifications/progress'),
).toBe(false);
const lastEvent = events[events.length - 1];
expect(lastEvent).toMatchObject({
id: 'sse-tool-no-progress',
jsonrpc: '2.0',
});
});
});
});

View File

@@ -0,0 +1,129 @@
import { randomUUID } from 'node:crypto';
import request from 'supertest';
import { uploadFileWithDirectUpload } from 'test/integration/graphql/utils/upload-file-with-direct-upload.util';
import { deleteRecordsByIds } from 'test/integration/utils/delete-records-by-ids';
const TEST_WORKSPACE_SCHEMA = 'workspace_1wgvd1injqtife6y4rvfbu3h5';
type McpToolCallResult = {
content?: Array<{ type: string; text: string }>;
isError?: boolean;
};
type DatabaseToolPayload<TResult> = {
success: boolean;
message: string;
result: TResult;
};
const callExecuteTool = async <TResult>(
toolName: string,
args: Record<string, unknown>,
): Promise<DatabaseToolPayload<TResult>> => {
const id = `call-${randomUUID()}`;
const res = await request(`http://localhost:${APP_PORT}`)
.post('/mcp')
.set('Authorization', `Bearer ${API_KEY_ACCESS_TOKEN}`)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.send(
JSON.stringify({
jsonrpc: '2.0',
method: 'tools/call',
id,
params: {
name: 'execute_tool',
arguments: { toolName, arguments: args },
},
}),
)
.expect(200);
const result = res.body.result as McpToolCallResult;
expect(result.isError).toBe(false);
return JSON.parse(
result.content?.[0]?.text as string,
) as DatabaseToolPayload<TResult>;
};
const getAttachmentFileIds = async (
attachmentIds: string[],
): Promise<string[]> => {
const rows = await global.testDataSource.query(
`SELECT id, file FROM "${TEST_WORKSPACE_SCHEMA}"."attachment" WHERE id = ANY($1)`,
[attachmentIds],
);
return rows.map(
(row: { file: Array<{ fileId: string }> }) => row.file[0].fileId,
);
};
describe('agent chat files in record CRUD tools (integration)', () => {
let chatFileId: string;
const createdAttachmentIds: string[] = [];
beforeAll(async () => {
jest.useRealTimers();
const uploadedFile = await uploadFileWithDirectUpload({
filename: 'chat-upload.pdf',
content: Buffer.from('%PDF-1.4 chat upload'),
fileFolder: 'AgentChat',
});
chatFileId = uploadedFile.id;
expect(uploadedFile.path).toContain('agent-chat/');
});
afterAll(async () => {
await deleteRecordsByIds('attachment', createdAttachmentIds);
});
it('should copy a chat upload once per record when several records reference it', async () => {
const payload = await callExecuteTool<Array<{ id: string }>>(
'create_many_attachments',
{
records: [
{
name: 'first.pdf',
file: [{ fileId: chatFileId, label: 'first.pdf' }],
},
{
name: 'second.pdf',
file: [{ fileId: chatFileId, label: 'second.pdf' }],
},
],
},
);
expect(payload.success).toBe(true);
const attachmentIds = payload.result.map((record) => record.id);
createdAttachmentIds.push(...attachmentIds);
expect(attachmentIds).toHaveLength(2);
const storedFileIds = await getAttachmentFileIds(attachmentIds);
expect(storedFileIds).toHaveLength(2);
expect(storedFileIds[0]).not.toBe(storedFileIds[1]);
expect(storedFileIds).not.toContain(chatFileId);
const copiedFiles = await global.testDataSource.query(
'SELECT id, path FROM core."file" WHERE id = ANY($1)',
[storedFileIds],
);
expect(copiedFiles).toHaveLength(2);
copiedFiles.forEach((file: { path: string }) => {
expect(file.path).toContain('files-field/');
});
});
});

View File

@@ -0,0 +1,121 @@
import { TEST_AI_MODEL_ID } from 'test/integration/constants/test-ai-model-ids.constants';
import { getRepositoryToken } from '@nestjs/typeorm';
import { type Repository } from 'typeorm';
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
import { createOneAgent } from 'test/integration/metadata/suites/agent/utils/create-one-agent.util';
import { deleteOneAgent } from 'test/integration/metadata/suites/agent/utils/delete-one-agent.util';
import { createOneRole } from 'test/integration/metadata/suites/role/utils/create-one-role.util';
import { deleteOneRole } from 'test/integration/metadata/suites/role/utils/delete-one-role.util';
describe('Workflow agent role assignment persistence (integration)', () => {
let agentWithRoleId: string;
let agentWithoutRoleId: string;
let roleId: string;
beforeAll(async () => {
const { data: roleData } = await createOneRole({
expectToFail: false,
input: {
label: 'Workflow Agent Assignment Test Role',
description:
'Role used to verify workflow agent role assignment persistence',
canUpdateAllSettings: false,
canAccessAllTools: false,
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canBeAssignedToUsers: false,
canBeAssignedToAgents: true,
canBeAssignedToApiKeys: false,
},
});
roleId = roleData.createOneRole.id;
const { data: agentWithRoleData } = await createOneAgent({
expectToFail: false,
input: {
label: 'Role-Assigned Workflow Agent',
prompt: 'Test prompt',
modelId: TEST_AI_MODEL_ID,
roleId,
},
});
agentWithRoleId = agentWithRoleData.createOneAgent.id;
const { data: agentWithoutRoleData } = await createOneAgent({
expectToFail: false,
input: {
label: 'Roleless Workflow Agent',
prompt: 'Test prompt',
modelId: TEST_AI_MODEL_ID,
},
});
agentWithoutRoleId = agentWithoutRoleData.createOneAgent.id;
});
afterAll(async () => {
await deleteOneAgent({
expectToFail: false,
input: { id: agentWithRoleId },
});
await deleteOneAgent({
expectToFail: false,
input: { id: agentWithoutRoleId },
});
await deleteOneRole({
expectToFail: false,
input: { idToDelete: roleId },
});
});
it('persists a role target row when an agent is created with a roleId', async () => {
const rows = await global.testDataSource.query(
`SELECT "roleId" FROM "core"."roleTarget"
WHERE "agentId" = $1 AND "workspaceId" = $2`,
[agentWithRoleId, SEED_APPLE_WORKSPACE_ID],
);
expect(rows).toHaveLength(1);
expect(rows[0].roleId).toBe(roleId);
});
it('resolves the persisted agent role through the role target repository', async () => {
const roleTargetRepository = global.app.get<Repository<RoleTargetEntity>>(
getRepositoryToken(RoleTargetEntity),
);
const roleTarget = await roleTargetRepository.findOne({
where: {
agentId: agentWithRoleId,
workspaceId: SEED_APPLE_WORKSPACE_ID,
},
select: ['roleId'],
});
expect(roleTarget?.roleId).toBe(roleId);
});
it('does not create a role target row for an agent with no role assignment', async () => {
const roleTargetRepository = global.app.get<Repository<RoleTargetEntity>>(
getRepositoryToken(RoleTargetEntity),
);
const roleTarget = await roleTargetRepository.findOne({
where: {
agentId: agentWithoutRoleId,
workspaceId: SEED_APPLE_WORKSPACE_ID,
},
select: ['roleId'],
});
expect(roleTarget).toBeNull();
});
});

View File

@@ -0,0 +1,123 @@
import { createSign, generateKeyPairSync, randomUUID } from 'node:crypto';
import { http, HttpResponse } from 'msw';
import { type MswHandler } from 'test/integration/utils/http-mock.util';
// The SNS validator fetches the signing certificate over https and verifies the
// canonical string with it. Node's verify() accepts a bare public key, so the
// suite serves one at a URL matching the validator's amazonaws.com pattern
// instead of minting a real X.509 certificate.
export const SNS_SIGNING_CERT_URL =
'https://sns.eu-west-3.amazonaws.com/SimpleNotificationService-abcdef0123456789abcdef0123456789.pem';
const { publicKey, privateKey } = generateKeyPairSync('rsa', {
modulusLength: 2048,
});
const publicKeyPem = publicKey
.export({ type: 'spki', format: 'pem' })
.toString();
const SIGNED_KEYS_BY_TYPE = {
Notification: [
'Message',
'MessageId',
'Subject',
'Timestamp',
'TopicArn',
'Type',
],
SubscriptionConfirmation: [
'Message',
'MessageId',
'SubscribeURL',
'Timestamp',
'Token',
'TopicArn',
'Type',
],
UnsubscribeConfirmation: [
'Message',
'MessageId',
'SubscribeURL',
'Timestamp',
'Token',
'TopicArn',
'Type',
],
} as const;
export const snsSigningCertHandler = (): MswHandler =>
http.get(SNS_SIGNING_CERT_URL, () => HttpResponse.text(publicKeyPem));
type SnsPayloadType = keyof typeof SIGNED_KEYS_BY_TYPE;
const signPayload = (
type: SnsPayloadType,
payload: Record<string, string>,
): string => {
const signer = createSign('sha256WithRSAEncryption');
for (const key of SIGNED_KEYS_BY_TYPE[type]) {
if (key in payload) {
signer.write(`${key}\n${payload[key]}\n`);
}
}
signer.end();
return signer.sign(privateKey, 'base64');
};
export const snsNotification = ({
topicArn,
message,
signed = true,
}: {
topicArn: string;
message: unknown;
signed?: boolean;
}): Record<string, string> => {
const payload: Record<string, string> = {
Type: 'Notification',
MessageId: randomUUID(),
TopicArn: topicArn,
Message: JSON.stringify(message),
Timestamp: '2026-01-01T00:00:00.000Z',
SignatureVersion: '2',
SigningCertURL: SNS_SIGNING_CERT_URL,
};
return {
...payload,
Signature: signed
? signPayload('Notification', payload)
: Buffer.from('not-a-signature').toString('base64'),
};
};
export const snsSubscriptionConfirmation = ({
topicArn,
subscribeUrl,
}: {
topicArn: string;
subscribeUrl: string;
}): Record<string, string> => {
const payload: Record<string, string> = {
Type: 'SubscriptionConfirmation',
MessageId: randomUUID(),
TopicArn: topicArn,
Token: randomUUID().replace(/-/g, ''),
SubscribeURL: subscribeUrl,
Message: 'You have chosen to subscribe to the topic.',
Timestamp: '2026-01-01T00:00:00.000Z',
SignatureVersion: '2',
SigningCertURL: SNS_SIGNING_CERT_URL,
};
return {
...payload,
Signature: signPayload('SubscriptionConfirmation', payload),
};
};

View File

@@ -0,0 +1,200 @@
import { randomUUID } from 'node:crypto';
import { http, HttpResponse } from 'msw';
import request from 'supertest';
import { MessageSuppressionEntity } from 'src/engine/core-modules/emailing-domain/message-suppression.entity';
import { UnsubscribeTokenService } from 'src/engine/core-modules/emailing-domain/services/unsubscribe-token.service';
import { MessageSuppressionReason } from 'src/engine/core-modules/emailing-domain/types/message-suppression-reason.type';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
import {
snsNotification,
snsSigningCertHandler,
snsSubscriptionConfirmation,
} from 'test/integration/aws/mocks/sns-payload.util';
import { updateConfigVariable } from 'test/integration/twenty-config/utils/update-config-variable.util';
import { getAppProviderByClassName } from 'test/integration/utils/get-app-provider-by-class-name.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import { setupHttpMock } from 'test/integration/utils/http-mock.util';
const ALLOWED_TOPIC_ARN =
'arn:aws:sns:eu-west-3:000000000000:twenty-ses-inbound-test';
const SES_INBOUND_WEBHOOK_PATH = '/webhooks/messaging/ses/inbound';
const SUBSCRIBE_URL =
'https://sns.eu-west-3.amazonaws.com/?Action=ConfirmSubscription&Token=test-token';
const inboundNotification = ({
recipients,
subject,
actionType = 'S3',
}: {
recipients: string[];
subject?: string;
actionType?: string;
}) => ({
mail: { commonHeaders: { subject } },
receipt: {
recipients,
action: { type: actionType, objectKey: `inbound/${randomUUID()}` },
},
});
const postSesInboundWebhook = (payload: Record<string, string>) =>
request(`http://localhost:${APP_PORT}`)
.post(SES_INBOUND_WEBHOOK_PATH)
.set('Content-Type', 'application/json')
.send(JSON.stringify(payload));
describe('SES inbound webhook (integration)', () => {
const httpMock = setupHttpMock(snsSigningCertHandler());
let unsubscribeTokenService: UnsubscribeTokenService;
const findSuppressionReasons = async (emailAddress: string) => {
const suppressions = await getCoreRepository<MessageSuppressionEntity>(
MessageSuppressionEntity,
).findBy({ workspaceId: SEED_APPLE_WORKSPACE_ID, emailAddress });
return suppressions.map((suppression) => suppression.reason);
};
const postUnsubscribeMail = (subject?: string) =>
postSesInboundWebhook(
snsNotification({
topicArn: ALLOWED_TOPIC_ARN,
message: inboundNotification({
recipients: ['unsubscribe@campaigns.acme.com'],
subject,
}),
}),
);
beforeAll(async () => {
await updateConfigVariable({
input: { key: 'SES_SNS_TOPIC_ARN_ALLOWLIST', value: ALLOWED_TOPIC_ARN },
});
unsubscribeTokenService =
getAppProviderByClassName<UnsubscribeTokenService>(
'UnsubscribeTokenService',
);
}, 60000);
it('suppresses the sender of a valid unsubscribe email', async () => {
const emailAddress = `unsub-${randomUUID()}@acme.com`;
const response = await postUnsubscribeMail(
unsubscribeTokenService.sign({
workspaceId: SEED_APPLE_WORKSPACE_ID,
emailAddress,
}),
);
expect(response.status).toBe(200);
expect(await findSuppressionReasons(emailAddress)).toEqual([
MessageSuppressionReason.UNSUBSCRIBE,
]);
}, 60000);
it('suppresses nothing for a preview unsubscribe token', async () => {
const emailAddress = `preview-${randomUUID()}@acme.com`;
const response = await postUnsubscribeMail(
unsubscribeTokenService.sign({
workspaceId: SEED_APPLE_WORKSPACE_ID,
emailAddress,
preview: true,
}),
);
expect(response.status).toBe(200);
expect(await findSuppressionReasons(emailAddress)).toEqual([]);
}, 60000);
it('ignores an unsubscribe email whose subject is not a valid token', async () => {
const response = await postUnsubscribeMail('please unsubscribe me');
expect(response.status).toBe(200);
}, 60000);
it('ignores an unsubscribe email that carries no subject', async () => {
const response = await postUnsubscribeMail();
expect(response.status).toBe(200);
}, 60000);
it('ignores an inbound email delivered through an unsupported action', async () => {
const response = await postSesInboundWebhook(
snsNotification({
topicArn: ALLOWED_TOPIC_ARN,
message: inboundNotification({
recipients: ['sales@campaigns.acme.com'],
actionType: 'Lambda',
}),
}),
);
expect(response.status).toBe(200);
}, 60000);
it('confirms an SNS subscription by calling the subscribe URL', async () => {
let subscribeUrlWasCalled = false;
httpMock.use(
http.get(SUBSCRIBE_URL, () => {
subscribeUrlWasCalled = true;
return HttpResponse.text('<ConfirmSubscriptionResponse />');
}),
);
const response = await postSesInboundWebhook(
snsSubscriptionConfirmation({
topicArn: ALLOWED_TOPIC_ARN,
subscribeUrl: SUBSCRIBE_URL,
}),
);
expect(response.status).toBe(200);
expect(subscribeUrlWasCalled).toBe(true);
}, 60000);
it('rejects an inbound notification from a topic outside the allowlist', async () => {
const response = await postSesInboundWebhook(
snsNotification({
topicArn: 'arn:aws:sns:eu-west-3:000000000000:some-other-topic',
message: inboundNotification({
recipients: ['sales@campaigns.acme.com'],
}),
}),
);
expect(response.status).toBe(403);
}, 60000);
it('rejects an inbound body that is not valid JSON', async () => {
const response = await request(`http://localhost:${APP_PORT}`)
.post(SES_INBOUND_WEBHOOK_PATH)
.set('Content-Type', 'application/json')
.send('not-json');
expect(response.status).toBe(400);
}, 60000);
it('rejects an inbound notification whose signature does not verify', async () => {
const response = await postSesInboundWebhook(
snsNotification({
topicArn: ALLOWED_TOPIC_ARN,
signed: false,
message: inboundNotification({
recipients: ['sales@campaigns.acme.com'],
}),
}),
);
expect(response.status).toBe(403);
}, 60000);
});

View File

@@ -0,0 +1,112 @@
import request from 'supertest';
import { createMockStripeEntitlementUpdatedData } from 'test/integration/billing/utils/create-mock-stripe-entitlement-updated-data.util';
import { createMockStripePriceCreatedData } from 'test/integration/billing/utils/create-mock-stripe-price-created-data.util';
import { createMockStripeProductUpdatedData } from 'test/integration/billing/utils/create-mock-stripe-product-updated-data.util';
import { createMockStripeSubscriptionCreatedData } from 'test/integration/billing/utils/create-mock-stripe-subscription-created-data.util';
const client = request(`http://localhost:${APP_PORT}`);
describe('BillingController (integration)', () => {
it('should handle product.updated and price.created webhook events', async () => {
const productUpdatedPayload = {
type: 'product.updated',
data: createMockStripeProductUpdatedData(),
};
const priceCreatedPayload = {
type: 'price.created',
data: createMockStripePriceCreatedData(),
};
await client
.post('/webhooks/stripe')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.set('stripe-signature', 'correct-signature')
.set('Content-Type', 'application/json')
.send(JSON.stringify(productUpdatedPayload))
.expect(200)
.then((res) => {
expect(res.body.stripeProductId).toBeDefined();
});
await client
.post('/webhooks/stripe')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.set('stripe-signature', 'correct-signature')
.set('Content-Type', 'application/json')
.send(JSON.stringify(priceCreatedPayload))
.expect(200)
.then((res) => {
expect(res.body.stripePriceId).toBeDefined();
expect(res.body.stripeMeterId).toBeDefined();
});
});
it('should handle subscription.created webhook event', async () => {
const subscriptionCreatedPayload = {
type: 'customer.subscription.created',
data: createMockStripeSubscriptionCreatedData(),
};
const entitlementUpdatedPayload = {
type: 'entitlements.active_entitlement_summary.updated',
data: createMockStripeEntitlementUpdatedData(),
};
await client
.post('/webhooks/stripe')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.set('stripe-signature', 'correct-signature')
.set('Content-Type', 'application/json')
.send(JSON.stringify(subscriptionCreatedPayload))
.expect(200)
.then((res) => {
expect(res.body.stripeSubscriptionId).toBeDefined();
expect(res.body.stripeCustomerId).toBeDefined();
});
await client
.post('/webhooks/stripe')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.set('stripe-signature', 'correct-signature')
.set('Content-Type', 'application/json')
.send(JSON.stringify(entitlementUpdatedPayload))
.expect(200)
.then((res) => {
expect(res.body.stripeEntitlementCustomerId).toBeDefined();
});
});
it('should handle entitlements.active_entitlement_summary.updated when the subscription is not found', async () => {
const entitlementUpdatedPayload = {
type: 'entitlements.active_entitlement_summary.updated',
data: createMockStripeEntitlementUpdatedData({
customer: 'new_customer',
}),
};
await client
.post('/webhooks/stripe')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.set('stripe-signature', 'correct-signature')
.set('Content-Type', 'application/json')
.send(JSON.stringify(entitlementUpdatedPayload))
.expect(404);
});
it('should reject webhook with invalid signature', async () => {
const entitlementUpdatedPayload = {
type: 'customer.entitlement.created',
data: {
object: {
id: 'ent_test123',
},
},
};
await client
.post('/webhooks/stripe')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.set('stripe-signature', 'invalid-signature')
.set('Content-Type', 'application/json')
.send(JSON.stringify(entitlementUpdatedPayload))
.expect(500);
});
});

View File

@@ -0,0 +1,413 @@
import { INTERNAL_CREDITS_PER_DISPLAY_CREDIT } from 'twenty-shared/constants';
import { randomUUID } from 'node:crypto';
import { addDays, addMonths, startOfMonth } from 'date-fns';
import request from 'supertest';
import {
getBillingUsageCacheService,
getSeededBillingWorkspaceId,
listCreditGrants,
quitBillingFixtureRedis,
readAllowanceCounter,
resetBillingCreditState,
setupResourceCreditSubscription,
warmAllowanceCounter,
setSubscriptionStatus,
} from 'test/integration/billing/utils/billing-credit-fixtures.util';
import { BillingCreditGrantType } from 'src/engine/core-modules/billing/enums/billing-credit-grant-type.enum';
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
import { alignGrantExpiryToPeriodEnd } from 'src/engine/core-modules/billing/utils/align-grant-expiry-to-period-end.util';
const client = request(`http://localhost:${APP_PORT}`);
const PERIOD_START = startOfMonth(new Date());
const PERIOD_END = addMonths(PERIOD_START, 1);
const ALLOWANCE_MICRO = 1_000_000;
const GRANT_MUTATION = `
mutation GrantWorkspaceCredits(
$workspaceId: UUID!
$amount: Float!
$type: BillingCreditGrantType!
$reason: String
$expiresInDays: Int
$clientOperationId: UUID!
) {
grantWorkspaceCredits(
workspaceId: $workspaceId
amount: $amount
type: $type
reason: $reason
expiresInDays: $expiresInDays
clientOperationId: $clientOperationId
) {
id
amount
type
reason
expiresAt
isActive
}
}
`;
const REVOKE_MUTATION = `
mutation RevokeWorkspaceCreditGrant($workspaceId: UUID!, $creditGrantId: UUID!) {
revokeWorkspaceCreditGrant(
workspaceId: $workspaceId
creditGrantId: $creditGrantId
) {
id
revokedAt
}
}
`;
const grantCredits = (variables: Record<string, unknown>) =>
callAdminGraphql(GRANT_MUTATION, {
clientOperationId: randomUUID(),
...variables,
});
const callAdminGraphql = (query: string, variables: Record<string, unknown>) =>
client
.post('/admin-panel')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.set('Content-Type', 'application/json')
.send({ query, variables });
describe('Admin credit grant and revoke (integration)', () => {
let workspaceId: string;
beforeAll(async () => {
workspaceId = await getSeededBillingWorkspaceId();
});
beforeEach(async () => {
await resetBillingCreditState(workspaceId);
await setupResourceCreditSubscription({
workspaceId,
periodStart: PERIOD_START,
periodEnd: PERIOD_END,
creditAmountMicro: ALLOWANCE_MICRO,
});
});
afterEach(async () => {
await resetBillingCreditState(workspaceId);
});
afterAll(async () => {
await quitBillingFixtureRedis();
});
it('records a granted amount on the ledger and mirrors it', async () => {
const response = await grantCredits({
workspaceId,
amount: 2,
type: BillingCreditGrantType.COMPENSATION,
reason: 'Outage on the 3rd',
});
expect(response.body.errors).toBeUndefined();
const grants = await listCreditGrants(workspaceId);
expect(grants).toHaveLength(1);
expect(grants[0]).toMatchObject({
amountMicro: 2 * INTERNAL_CREDITS_PER_DISPLAY_CREDIT,
type: BillingCreditGrantType.COMPENSATION,
reason: 'Outage on the 3rd',
revokedAt: null,
});
});
it('leaves a granted amount without an expiry so no missed transition can drop it', async () => {
const response = await grantCredits({
workspaceId,
amount: 2,
type: BillingCreditGrantType.COMPENSATION,
reason: null,
});
expect(response.body.errors).toBeUndefined();
expect(response.body.data.grantWorkspaceCredits.expiresAt).toBeNull();
const grants = await listCreditGrants(workspaceId);
expect(grants[0].expiresAt).toBeNull();
});
// Rounded up to a period end rather than the exact day: credits are spent and
// settled a period at a time, so a deadline inside one would be invisible to
// both the cached counter and the carry-forward.
it('expires a time-boxed grant at the end of the period the requested day falls in', async () => {
const response = await grantCredits({
workspaceId,
amount: 2,
type: BillingCreditGrantType.SALES,
reason: 'Pilot credits',
expiresInDays: 30,
});
expect(response.body.errors).toBeUndefined();
expect(response.body.data.grantWorkspaceCredits.isActive).toBe(true);
const [storedGrant] = await listCreditGrants(workspaceId);
// Which period the thirtieth day lands in depends on when the suite runs,
// so the boundary is derived the same way the server derives it rather
// than listed.
const expectedExpiresAt = alignGrantExpiryToPeriodEnd({
requestedExpiresAt: addDays(new Date(), 30),
currentPeriodStart: PERIOD_START,
currentPeriodEnd: PERIOD_END,
interval: SubscriptionInterval.Month,
});
expect(storedGrant.expiresAt).toEqual(expectedExpiresAt);
expect(expectedExpiresAt.getTime()).toBeGreaterThanOrEqual(
addDays(new Date(), 30).getTime(),
);
});
it('refuses an expiry beyond the accepted range', async () => {
const response = await grantCredits({
workspaceId,
amount: 2,
type: BillingCreditGrantType.COMPENSATION,
reason: null,
expiresInDays: 100_000,
});
expect(response.body.errors).toBeDefined();
expect(await listCreditGrants(workspaceId)).toHaveLength(0);
});
it('adds the granted amount to a warm available-credits counter', async () => {
const cache = getBillingUsageCacheService();
await cache.warmAvailableCredits(
workspaceId,
PERIOD_START,
PERIOD_END,
500_000,
);
await grantCredits({
workspaceId,
amount: 2,
type: BillingCreditGrantType.COMPENSATION,
reason: null,
});
expect(await cache.getAvailableCredits(workspaceId, PERIOD_START)).toBe(
500_000 + 2 * INTERNAL_CREDITS_PER_DISPLAY_CREDIT,
);
});
// The aligned deadline lands on the period end the counter already expires
// with, so the cached balance stays valid and can simply be incremented.
it('keeps a warm available-credits counter usable for a time-boxed grant', async () => {
const cache = getBillingUsageCacheService();
await cache.warmAvailableCredits(
workspaceId,
PERIOD_START,
PERIOD_END,
500_000,
);
await grantCredits({
workspaceId,
amount: 2,
type: BillingCreditGrantType.SALES,
reason: null,
expiresInDays: 1,
});
expect(await cache.getAvailableCredits(workspaceId, PERIOD_START)).toBe(
500_000 + 2 * INTERNAL_CREDITS_PER_DISPLAY_CREDIT,
);
});
it('takes a revoked grant back off the ledger and the available-credits counter', async () => {
const cache = getBillingUsageCacheService();
await cache.warmAvailableCredits(
workspaceId,
PERIOD_START,
PERIOD_END,
500_000,
);
const granted = await grantCredits({
workspaceId,
amount: 2,
type: BillingCreditGrantType.COMPENSATION,
reason: null,
});
const creditGrantId = granted.body.data.grantWorkspaceCredits.id;
const revoked = await callAdminGraphql(REVOKE_MUTATION, {
workspaceId,
creditGrantId,
});
expect(revoked.body.errors).toBeUndefined();
expect(
revoked.body.data.revokeWorkspaceCreditGrant.revokedAt,
).not.toBeNull();
const grants = await listCreditGrants(workspaceId);
expect(grants[0].revokedAt).not.toBeNull();
expect(await cache.getAvailableCredits(workspaceId, PERIOD_START)).toBe(
500_000,
);
});
it.each([
BillingCreditGrantType.ROLLOVER,
BillingCreditGrantType.ONBOARDING_REWARD,
])('refuses to grant a %s by hand', async (type) => {
const response = await grantCredits({
workspaceId,
amount: 1,
type,
reason: null,
});
expect(response.body.errors?.[0]?.extensions?.subCode).toBe(
'BILLING_CREDIT_GRANT_TYPE_NOT_GRANTABLE',
);
expect(await listCreditGrants(workspaceId)).toHaveLength(0);
});
it('drops the warm allowance counter when a grant lands', async () => {
await warmAllowanceCounter(workspaceId, PERIOD_START, 500_000);
await grantCredits({
workspaceId,
amount: 2,
type: BillingCreditGrantType.COMPENSATION,
reason: null,
});
expect(await readAllowanceCounter(workspaceId, PERIOD_START)).toBeNull();
});
it('takes a revoked grant off the ledger and drops the counter', async () => {
const granted = await grantCredits({
workspaceId,
amount: 2,
type: BillingCreditGrantType.COMPENSATION,
reason: null,
});
const creditGrantId = granted.body.data.grantWorkspaceCredits.id;
await warmAllowanceCounter(workspaceId, PERIOD_START, 500_000);
const revoked = await callAdminGraphql(REVOKE_MUTATION, {
workspaceId,
creditGrantId,
});
expect(revoked.body.errors).toBeUndefined();
expect(
revoked.body.data.revokeWorkspaceCreditGrant.revokedAt,
).not.toBeNull();
const grants = await listCreditGrants(workspaceId);
expect(grants[0].revokedAt).not.toBeNull();
expect(await readAllowanceCounter(workspaceId, PERIOD_START)).toBeNull();
});
// The panel only offers the three operator types, but the mutation is
// reachable directly and these two are written by the period transition and
// the onboarding jobs.
it.each([
BillingCreditGrantType.ROLLOVER,
BillingCreditGrantType.ONBOARDING_REWARD,
])('refuses to grant a %s by hand', async (type) => {
const response = await grantCredits({
workspaceId,
amount: 1,
type,
reason: null,
});
expect(response.body.errors?.[0]?.extensions?.subCode).toBe(
'BILLING_CREDIT_GRANT_TYPE_NOT_GRANTABLE',
);
expect(await listCreditGrants(workspaceId)).toHaveLength(0);
});
it('refuses an amount above the configured ceiling', async () => {
const response = await grantCredits({
workspaceId,
amount: 1_000_000_000,
type: BillingCreditGrantType.COMPENSATION,
reason: null,
});
expect(response.body.errors?.[0]?.extensions?.subCode).toBe(
'BILLING_CREDIT_AMOUNT_INVALID',
);
expect(await listCreditGrants(workspaceId)).toHaveLength(0);
});
// The admin panel keeps one operation id per open modal, so an Apollo retry
// or a resubmit after a lost response must answer with the grant the first
// attempt wrote rather than crediting the workspace a second time.
it('answers a retried grant with the original instead of granting twice', async () => {
const clientOperationId = randomUUID();
const variables = {
workspaceId,
amount: 25,
type: BillingCreditGrantType.COMPENSATION,
reason: 'Retried by the client',
clientOperationId,
};
const first = await callAdminGraphql(GRANT_MUTATION, variables);
const second = await callAdminGraphql(GRANT_MUTATION, variables);
expect(second.body.data.grantWorkspaceCredits.id).toBe(
first.body.data.grantWorkspaceCredits.id,
);
expect(await listCreditGrants(workspaceId)).toHaveLength(1);
});
// The refusal to time-box a grant with no billing period must not reach a
// replay: the subscription that anchored the original deadline can be gone by
// the time the client retries, and the operation already succeeded.
it('answers a retried time-boxed grant after the subscription is canceled', async () => {
const clientOperationId = randomUUID();
const variables = {
workspaceId,
amount: 25,
type: BillingCreditGrantType.SALES,
reason: 'Retried after cancellation',
clientOperationId,
expiresInDays: 30,
};
const first = await callAdminGraphql(GRANT_MUTATION, variables);
expect(first.body.errors).toBeUndefined();
await setSubscriptionStatus(workspaceId, SubscriptionStatus.Canceled);
const second = await callAdminGraphql(GRANT_MUTATION, variables);
expect(second.body.errors).toBeUndefined();
expect(second.body.data.grantWorkspaceCredits.id).toBe(
first.body.data.grantWorkspaceCredits.id,
);
expect(await listCreditGrants(workspaceId)).toHaveLength(1);
});
});

View File

@@ -0,0 +1,343 @@
import { addMonths, startOfMonth, subMonths } from 'date-fns';
import request from 'supertest';
import { createMockStripeInvoiceFinalizedData } from 'test/integration/billing/utils/create-mock-stripe-invoice-finalized-data.util';
import {
getBillingUsageCacheService,
getSeededBillingWorkspaceId,
insertCreditGrant,
listCreditGrants,
quitBillingFixtureRedis,
readAllowanceCounter,
resetBillingCreditState,
setupResourceCreditSubscription,
TEST_STRIPE_CUSTOMER_ID,
warmAllowanceCounter,
} from 'test/integration/billing/utils/billing-credit-fixtures.util';
import { getAppProviderByClassName } from 'test/integration/utils/get-app-provider-by-class-name.util';
import { BillingCreditGrantType } from 'src/engine/core-modules/billing/enums/billing-credit-grant-type.enum';
import { type BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
const client = request(`http://localhost:${APP_PORT}`);
// Whole calendar months, anchored so the period the transition opens is the
// one running right now. The ledger filters on now(), so periods fixed in the
// past would read as expired and assert nothing.
const PERIOD_BOUNDARY = startOfMonth(new Date());
const CLOSING_PERIOD_START = subMonths(PERIOD_BOUNDARY, 1);
const CLOSING_PERIOD_END = PERIOD_BOUNDARY;
const NEXT_PERIOD_END = addMonths(PERIOD_BOUNDARY, 1);
const ALLOWANCE_MICRO = 1_000_000;
const postInvoiceFinalized = (
invoiceId = 'in_test_default',
{ periodStart = CLOSING_PERIOD_END, periodEnd = NEXT_PERIOD_END } = {},
) =>
client
.post('/webhooks/stripe')
.set('stripe-signature', 'correct-signature')
.set('Content-Type', 'application/json')
.send(
JSON.stringify({
type: 'invoice.finalized',
data: createMockStripeInvoiceFinalizedData({
periodStart,
periodEnd,
stripeCustomerId: TEST_STRIPE_CUSTOMER_ID,
invoiceId,
}),
}),
);
describe('Billing credit rollover (integration)', () => {
let workspaceId: string;
let billingUsageService: BillingUsageService;
let usageSpy: jest.SpyInstance;
beforeAll(async () => {
workspaceId = await getSeededBillingWorkspaceId();
billingUsageService = getAppProviderByClassName<BillingUsageService>(
'BillingUsageService',
);
});
beforeEach(async () => {
await resetBillingCreditState(workspaceId);
await setupResourceCreditSubscription({
workspaceId,
periodStart: CLOSING_PERIOD_START,
periodEnd: CLOSING_PERIOD_END,
creditAmountMicro: ALLOWANCE_MICRO,
});
// Usage is read from ClickHouse, whose inserts are asynchronous. Stubbing
// the read keeps every expectation exact instead of racing ingestion; the
// query itself has its own unit coverage.
usageSpy = jest.spyOn(billingUsageService, 'getCreditsUsedBetweenOrNull');
});
afterEach(async () => {
usageSpy?.mockRestore();
await resetBillingCreditState(workspaceId);
});
afterAll(async () => {
await quitBillingFixtureRedis();
});
it('carries the unspent allowance into the next period', async () => {
usageSpy.mockResolvedValue(300_000);
await postInvoiceFinalized().expect(200);
const grants = await listCreditGrants(workspaceId);
expect(grants).toHaveLength(1);
expect(grants[0]).toMatchObject({
amountMicro: 700_000,
type: BillingCreditGrantType.ROLLOVER,
revokedAt: null,
});
expect(new Date(grants[0].effectiveAt)).toEqual(CLOSING_PERIOD_END);
// Successors carry no deadline: stamping the next period end would put the
// balance back at the mercy of the transition after this one running.
expect(grants[0].expiresAt).toBeNull();
});
it('reads usage over the closing period, not the one just opened', async () => {
usageSpy.mockResolvedValue(0);
await postInvoiceFinalized().expect(200);
expect(usageSpy).toHaveBeenCalledWith({
workspaceId,
from: CLOSING_PERIOD_START,
to: CLOSING_PERIOD_END,
});
});
it('carries nothing when the whole allowance was spent', async () => {
usageSpy.mockResolvedValue(ALLOWANCE_MICRO);
await postInvoiceFinalized().expect(200);
expect(await listCreditGrants(workspaceId)).toHaveLength(0);
});
it('closes the grants it carried forward so they cannot be counted twice', async () => {
usageSpy.mockResolvedValue(0);
await insertCreditGrant({
workspaceId,
amountMicro: 500_000,
type: BillingCreditGrantType.COMPENSATION,
effectiveAt: CLOSING_PERIOD_START,
expiresAt: NEXT_PERIOD_END,
});
await postInvoiceFinalized().expect(200);
const grants = await listCreditGrants(workspaceId);
const compensation = grants.find(
(grant) =>
grant.type === BillingCreditGrantType.COMPENSATION &&
grant.sourceGrantId === null,
);
expect(compensation).toBeDefined();
// Its expiry was pulled back to the boundary, so the balance counts it
// once through its carried-forward copy rather than twice.
expect(compensation?.expiresAt).toEqual(CLOSING_PERIOD_END);
expect(
grants.some(
(grant) =>
grant.type === BillingCreditGrantType.COMPENSATION &&
grant.sourceGrantId === compensation!.id,
),
).toBe(true);
});
it('caps the carried rollover at the configured multiple of the allowance', async () => {
usageSpy.mockResolvedValue(0);
await insertCreditGrant({
workspaceId,
amountMicro: ALLOWANCE_MICRO,
type: BillingCreditGrantType.ROLLOVER,
effectiveAt: CLOSING_PERIOD_START,
expiresAt: CLOSING_PERIOD_END,
});
await postInvoiceFinalized().expect(200);
const carried = (await listCreditGrants(workspaceId)).filter(
(grant) => grant.sourceGrantId !== null || grant.idempotencyKey !== null,
);
const carriedRollover = carried.filter(
(grant) => grant.type === BillingCreditGrantType.ROLLOVER,
);
// allowance + expiring rollover = 2M unspent, clamped to (2 - 1) x 1M.
expect(
carriedRollover.reduce((total, grant) => total + grant.amountMicro, 0),
).toBe(ALLOWANCE_MICRO);
});
// A redelivery must not hand out the credits a second time.
it('is idempotent when Stripe redelivers the same invoice', async () => {
usageSpy.mockResolvedValue(300_000);
await postInvoiceFinalized().expect(200);
const afterFirst = await listCreditGrants(workspaceId);
await postInvoiceFinalized().expect(200);
const afterSecond = await listCreditGrants(workspaceId);
expect(afterSecond).toHaveLength(afterFirst.length);
expect(
afterSecond.reduce((total, grant) => total + grant.amountMicro, 0),
).toBe(afterFirst.reduce((total, grant) => total + grant.amountMicro, 0));
});
// Returning normally would answer 200 and Stripe would never redeliver, so
// the transition would be lost and the balance would expire unrolled.
it('fails the webhook when usage cannot be read so Stripe redelivers', async () => {
usageSpy.mockResolvedValue(null);
await postInvoiceFinalized().expect(500);
expect(await listCreditGrants(workspaceId)).toHaveLength(0);
});
it('adds the carried balance to a warm available-credits counter', async () => {
usageSpy.mockResolvedValue(300_000);
const cache = getBillingUsageCacheService();
await cache.warmAvailableCredits(
workspaceId,
CLOSING_PERIOD_START,
NEXT_PERIOD_END,
120_000,
);
await postInvoiceFinalized().expect(200);
expect(
await cache.getAvailableCredits(workspaceId, CLOSING_PERIOD_START),
).toBe(120_000 + 700_000);
});
it('leaves a warm available-credits counter alone when a successful delivery is repeated', async () => {
usageSpy.mockResolvedValue(300_000);
const cache = getBillingUsageCacheService();
await cache.warmAvailableCredits(
workspaceId,
CLOSING_PERIOD_START,
NEXT_PERIOD_END,
120_000,
);
await postInvoiceFinalized().expect(200);
const afterFirst = await cache.getAvailableCredits(
workspaceId,
CLOSING_PERIOD_START,
);
await postInvoiceFinalized().expect(200);
expect(afterFirst).toBe(120_000 + 700_000);
expect(
await cache.getAvailableCredits(workspaceId, CLOSING_PERIOD_START),
).toBe(afterFirst);
});
it('drops the allowance counter on the period transition', async () => {
usageSpy.mockResolvedValue(300_000);
// The counter is keyed by the period the subscription is currently in,
// which is the one the invoice is closing: Stripe moves the subscription
// forward in a separate event.
await warmAllowanceCounter(workspaceId, CLOSING_PERIOD_START, 120_000);
await postInvoiceFinalized().expect(200);
expect(
await readAllowanceCounter(workspaceId, CLOSING_PERIOD_START),
).toBeNull();
});
// A subscription anchored on the 31st runs January 31 to February 28. Once
// the subscription.updated webhook has moved the subscription on, calendar
// arithmetic clamps February 28 back to January 28 and the closing period
// swallows three days of the period before it.
describe('a month-end anchor whose subscription already advanced', () => {
const MONTH_END_BOUNDARY = new Date('2026-02-28T00:00:00.000Z');
const TRUE_CLOSING_PERIOD_START = new Date('2026-01-31T00:00:00.000Z');
const MONTH_END_NEXT_PERIOD_END = new Date('2026-03-31T00:00:00.000Z');
it('reads the closing period start off the ledger rather than the calendar', async () => {
usageSpy.mockResolvedValue(0);
await setupResourceCreditSubscription({
workspaceId,
periodStart: MONTH_END_BOUNDARY,
periodEnd: MONTH_END_NEXT_PERIOD_END,
creditAmountMicro: ALLOWANCE_MICRO,
});
// What the previous transition left behind: a grant closed at the instant
// the period it belonged to ended.
await insertCreditGrant({
workspaceId,
amountMicro: 100_000,
type: BillingCreditGrantType.ROLLOVER,
effectiveAt: new Date('2025-12-31T00:00:00.000Z'),
expiresAt: TRUE_CLOSING_PERIOD_START,
});
await postInvoiceFinalized('in_test_month_end', {
periodStart: MONTH_END_BOUNDARY,
periodEnd: MONTH_END_NEXT_PERIOD_END,
}).expect(200);
expect(usageSpy).toHaveBeenCalledWith({
workspaceId,
from: TRUE_CLOSING_PERIOD_START,
to: MONTH_END_BOUNDARY,
});
});
});
describe('closing a trial', () => {
it('carries credits earned during the trial into the first paid period', async () => {
usageSpy.mockResolvedValue(0);
await setupResourceCreditSubscription({
workspaceId,
periodStart: CLOSING_PERIOD_START,
periodEnd: CLOSING_PERIOD_END,
creditAmountMicro: ALLOWANCE_MICRO,
status: 'trialing',
trialStart: CLOSING_PERIOD_START,
trialEnd: CLOSING_PERIOD_END,
});
await insertCreditGrant({
workspaceId,
amountMicro: 250_000,
type: BillingCreditGrantType.ONBOARDING_REWARD,
effectiveAt: CLOSING_PERIOD_START,
expiresAt: null,
});
await postInvoiceFinalized('in_test_trial').expect(200);
const reward = (await listCreditGrants(workspaceId)).find(
(grant) =>
grant.type === BillingCreditGrantType.ONBOARDING_REWARD &&
grant.sourceGrantId !== null,
);
expect(reward).toBeDefined();
expect(reward?.amountMicro).toBe(250_000);
expect(reward?.expiresAt).toBeNull();
});
});
});

View File

@@ -0,0 +1,264 @@
import { createClient, type RedisClientType } from 'redis';
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
import { type BillingCreditGrantType } from 'src/engine/core-modules/billing/enums/billing-credit-grant-type.enum';
import { type SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
import { type BillingUsageCacheService } from 'src/engine/core-modules/billing/services/billing-usage-cache.service';
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
import { buildAllowanceCounterKey } from 'src/engine/core-modules/usage-limit/utils/build-allowance-counter-key.util';
import { getAppProviderByClassName } from 'test/integration/utils/get-app-provider-by-class-name.util';
// The dev seeder gives every workspace a billingCustomer and an active
// billingSubscription, but no subscription item, and no period. Rollover needs
// the whole chain — subscription -> item -> product -> price with a
// credit_amount — so these helpers finish the fixture and put the period where
// the test wants it.
export const TEST_STRIPE_CUSTOMER_ID = 'cus_default0';
export const TEST_STRIPE_SUBSCRIPTION_ID = 'sub_default0';
const TEST_STRIPE_PRODUCT_ID = 'prod_resource_credit_test';
const TEST_STRIPE_PRICE_ID = 'price_resource_credit_test';
const TEST_STRIPE_SUBSCRIPTION_ITEM_ID = 'si_resource_credit_test';
export type CreditGrantRow = {
id: string;
amountMicro: number;
type: BillingCreditGrantType;
effectiveAt: Date;
expiresAt: Date | null;
revokedAt: Date | null;
reason: string | null;
idempotencyKey: string | null;
sourceGrantId: string | null;
};
const query = async <T>(sql: string, params: unknown[] = []): Promise<T[]> =>
global.testDataSource.query(sql, params);
// The billing seeds insert with orIgnore, so the customer and subscription
// land on exactly one workspace and which one is not worth hardcoding.
export const getSeededBillingWorkspaceId = async (): Promise<string> => {
const [row] = await query<{ workspaceId: string }>(
`SELECT "workspaceId" FROM core."billingSubscription" WHERE "stripeSubscriptionId" = $1`,
[TEST_STRIPE_SUBSCRIPTION_ID],
);
if (!row) {
throw new Error(
`No seeded billing subscription ${TEST_STRIPE_SUBSCRIPTION_ID}: the dev seeder did not run`,
);
}
return row.workspaceId;
};
export const setupResourceCreditSubscription = async ({
workspaceId,
periodStart,
periodEnd,
creditAmountMicro,
status = 'active',
trialStart = null,
trialEnd = null,
}: {
workspaceId: string;
periodStart: Date;
periodEnd: Date;
creditAmountMicro: number;
status?: string;
trialStart?: Date | null;
trialEnd?: Date | null;
}): Promise<{ subscriptionId: string }> => {
await query(
`UPDATE core."billingSubscription"
SET "currentPeriodStart" = $2, "currentPeriodEnd" = $3, status = $4,
"trialStart" = $5, "trialEnd" = $6, interval = 'month'
WHERE "workspaceId" = $1 AND "stripeSubscriptionId" = $7`,
[
workspaceId,
periodStart,
periodEnd,
status,
trialStart,
trialEnd,
TEST_STRIPE_SUBSCRIPTION_ID,
],
);
const [subscription] = await query<{ id: string }>(
`SELECT id FROM core."billingSubscription"
WHERE "workspaceId" = $1 AND "stripeSubscriptionId" = $2`,
[workspaceId, TEST_STRIPE_SUBSCRIPTION_ID],
);
await query(
`INSERT INTO core."billingProduct"
("stripeProductId", active, name, description, metadata)
VALUES ($1, true, 'Test resource credit', '', $2)
ON CONFLICT ("stripeProductId") DO UPDATE SET metadata = EXCLUDED.metadata`,
[TEST_STRIPE_PRODUCT_ID, JSON.stringify({ productKey: 'RESOURCE_CREDIT' })],
);
await query(
`INSERT INTO core."billingPrice"
("stripePriceId", "stripeProductId", active, currency, "taxBehavior",
type, "billingScheme", "usageType", interval, "unitAmount", metadata)
VALUES ($1, $2, true, 'usd', 'UNSPECIFIED', 'RECURRING', 'PER_UNIT',
'LICENSED', 'month', 1000, $3)
ON CONFLICT ("stripePriceId") DO UPDATE SET metadata = EXCLUDED.metadata`,
[
TEST_STRIPE_PRICE_ID,
TEST_STRIPE_PRODUCT_ID,
JSON.stringify({ credit_amount: String(creditAmountMicro) }),
],
);
await query(
`INSERT INTO core."billingSubscriptionItem"
("billingSubscriptionId", "stripeSubscriptionId", "stripeProductId",
"stripePriceId", "stripeSubscriptionItemId", quantity)
VALUES ($1, $2, $3, $4, $5, 1)
ON CONFLICT ("stripeSubscriptionItemId") DO UPDATE
SET "stripePriceId" = EXCLUDED."stripePriceId",
"stripeProductId" = EXCLUDED."stripeProductId"`,
[
subscription.id,
TEST_STRIPE_SUBSCRIPTION_ID,
TEST_STRIPE_PRODUCT_ID,
TEST_STRIPE_PRICE_ID,
TEST_STRIPE_SUBSCRIPTION_ITEM_ID,
],
);
return { subscriptionId: subscription.id };
};
export const insertCreditGrant = async ({
workspaceId,
amountMicro,
type,
effectiveAt,
expiresAt,
idempotencyKey = null,
}: {
workspaceId: string;
amountMicro: number;
type: BillingCreditGrantType;
effectiveAt: Date;
expiresAt: Date | null;
idempotencyKey?: string | null;
}): Promise<string> => {
const [row] = await query<{ id: string }>(
`INSERT INTO core."billingCreditGrant"
("workspaceId", "amountMicro", type, "effectiveAt", "expiresAt", "idempotencyKey")
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id`,
[workspaceId, amountMicro, type, effectiveAt, expiresAt, idempotencyKey],
);
return row.id;
};
export const listCreditGrants = async (
workspaceId: string,
): Promise<CreditGrantRow[]> =>
query<CreditGrantRow>(
`SELECT id, "amountMicro"::bigint::int AS "amountMicro", type, "effectiveAt",
"expiresAt", "revokedAt", reason, "idempotencyKey", "sourceGrantId"
FROM core."billingCreditGrant"
WHERE "workspaceId" = $1
ORDER BY "createdAt" ASC`,
[workspaceId],
);
let redisClient: RedisClientType | null = null;
const getRedisClient = async (): Promise<RedisClientType> => {
if (!isDefined(redisClient)) {
redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();
}
return redisClient;
};
export const quitBillingFixtureRedis = async (): Promise<void> => {
if (isDefined(redisClient)) {
await redisClient.quit();
redisClient = null;
}
};
const buildTestAllowanceCounterKey = (
workspaceId: string,
periodStart: Date,
): string =>
`${CacheStorageNamespace.IntegrationTests}:${CacheStorageNamespace.EngineUsageLimit}:${buildAllowanceCounterKey({ workspaceId, periodStart })}`;
export const warmAllowanceCounter = async (
workspaceId: string,
periodStart: Date,
valueMicro: number,
): Promise<void> => {
const redis = await getRedisClient();
await redis.set(
buildTestAllowanceCounterKey(workspaceId, periodStart),
String(valueMicro),
);
};
export const readAllowanceCounter = async (
workspaceId: string,
periodStart: Date,
): Promise<number | null> => {
const redis = await getRedisClient();
const value = await redis.get(
buildTestAllowanceCounterKey(workspaceId, periodStart),
);
return isDefined(value) ? Number(value) : null;
};
export const resetBillingCreditState = async (
workspaceId: string,
): Promise<void> => {
await query(
`DELETE FROM core."billingCreditGrant" WHERE "workspaceId" = $1`,
[workspaceId],
);
const cache = getBillingUsageCacheService();
await cache.flushAvailableCredits(workspaceId);
await cache.flushCounterAdjustmentMarkers(workspaceId);
const redis = await getRedisClient();
const staleKeys = [
...(await redis.keys(`*{${workspaceId}}:quota:allowance:*`)),
...(await redis.keys(`*currentBillingSubscription:${workspaceId}*`)),
];
if (isNonEmptyArray(staleKeys)) {
await redis.del(staleKeys);
}
};
export const getBillingUsageCacheService = (): BillingUsageCacheService =>
getAppProviderByClassName<BillingUsageCacheService>(
'BillingUsageCacheService',
);
// Cancelling is what makes getCurrentBillingSubscription stop returning it, so
// this is how a test reaches the no-subscription path without deleting rows the
// rest of the suite shares.
export const setSubscriptionStatus = async (
workspaceId: string,
status: SubscriptionStatus,
): Promise<void> => {
await query(
`UPDATE "core"."billingSubscription" SET status = $2 WHERE "workspaceId" = $1`,
[workspaceId, status],
);
};

View File

@@ -0,0 +1,25 @@
import type Stripe from 'stripe';
export const createMockStripeEntitlementUpdatedData = (
overrides = {},
): Stripe.EntitlementsActiveEntitlementSummaryUpdatedEvent.Data => ({
object: {
object: 'entitlements.active_entitlement_summary',
customer: 'cus_default1',
livemode: false,
entitlements: {
object: 'list',
data: [
{
id: 'ent_test_61',
object: 'entitlements.active_entitlement',
feature: 'feat_test_61',
livemode: false,
lookup_key: 'SSO',
},
],
has_more: false,
url: '/v1/customer/cus_Q/entitlements',
},
...overrides,
},
});

View File

@@ -0,0 +1,60 @@
// Typed to what the handler actually reads rather than to Stripe.Invoice: the
// payload crosses the wire as JSON, so the runtime shape is what matters, and
// a full Stripe.Invoice fixture would be a few hundred lines of noise.
export type MockStripeInvoiceFinalizedData = {
object: {
id: string;
object: 'invoice';
billing_reason: string;
created: number;
customer: string;
period_start: number;
period_end: number;
parent: {
subscription_details: {
subscription: string;
};
};
};
};
const toUnixSeconds = (date: Date): number => Math.floor(date.getTime() / 1000);
export const createMockStripeInvoiceFinalizedData = ({
periodStart,
periodEnd,
stripeCustomerId = 'cus_default0',
stripeSubscriptionId = 'sub_default0',
billingReason = 'subscription_cycle',
invoiceId = 'in_test_default',
// Stripe raises a cycle invoice at the handover, which for an advance-stamped
// one is the period it opens. The boundary is resolved off this rather than
// off our own clock, so a fixture that leaves it out no longer models the
// handover at all.
createdAt = periodStart,
}: {
periodStart: Date;
periodEnd: Date;
createdAt?: Date;
stripeCustomerId?: string;
stripeSubscriptionId?: string;
billingReason?: string;
invoiceId?: string;
}): MockStripeInvoiceFinalizedData => ({
object: {
id: invoiceId,
object: 'invoice',
billing_reason: billingReason,
created: toUnixSeconds(createdAt),
customer: stripeCustomerId,
// The invoice for a subscription_cycle bills the period it opens, so its
// period_start is the instant the previous period closed.
period_start: toUnixSeconds(periodStart),
period_end: toUnixSeconds(periodEnd),
parent: {
subscription_details: {
subscription: stripeSubscriptionId,
},
},
},
});

View File

@@ -0,0 +1,33 @@
import type Stripe from 'stripe';
export const createMockStripePriceCreatedData = (
overrides = {},
): Stripe.PriceCreatedEvent.Data => ({
object: {
id: 'price_1Q',
object: 'price',
active: true,
billing_scheme: 'per_unit',
created: 1733734326,
currency: 'usd',
custom_unit_amount: null,
livemode: false,
lookup_key: null,
metadata: {},
nickname: null,
product: 'prod_RLN',
recurring: {
interval: 'month',
interval_count: 1,
meter: null,
trial_period_days: null,
usage_type: 'licensed',
},
tax_behavior: 'unspecified',
tiers_mode: null,
transform_quantity: null,
type: 'recurring',
unit_amount: 0,
unit_amount_decimal: '0',
...overrides,
},
});

View File

@@ -0,0 +1,39 @@
import type Stripe from 'stripe';
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
export const createMockStripeProductUpdatedData = (
overrides = {},
): Stripe.ProductUpdatedEvent.Data => ({
object: {
id: 'prod_RLN',
object: 'product',
active: true,
created: 1733410584,
default_price: null,
description: null,
images: [],
livemode: false,
marketing_features: [],
metadata: {
planKey: 'base',
priceUsageBased: BillingUsageType.LICENSED,
productKey: BillingProductKey.BASE_PRODUCT,
},
name: 'kjnnjkjknkjnjkn',
package_dimensions: null,
shippable: null,
statement_descriptor: null,
tax_code: 'txcd_10103001',
type: 'service',
unit_label: null,
updated: 1734694649,
url: null,
},
previous_attributes: {
default_price: 'price_1Q',
updated: 1733410585,
},
...overrides,
});

View File

@@ -0,0 +1,133 @@
import type Stripe from 'stripe';
export const createMockStripeSubscriptionCreatedData = (
overrides = {},
): Stripe.CustomerSubscriptionCreatedEvent.Data => ({
object: {
object: 'subscription',
id: 'sub_default',
customer: 'cus_default1',
customer_account: null,
status: 'active',
items: {
data: [
{
plan: {
id: 'plan_default',
object: 'plan',
active: true,
amount_decimal: '0',
billing_scheme: 'per_unit',
interval_count: 1,
livemode: false,
nickname: null,
tiers_mode: null,
transform_usage: null,
trial_period_days: null,
interval: 'month',
currency: 'usd',
amount: 0,
created: 1672531200,
product: 'prod_default',
usage_type: 'licensed',
metadata: {},
meter: null,
},
id: '',
object: 'subscription_item',
billing_thresholds: null,
created: 0,
current_period_end: 1672531200,
current_period_start: 1672531200,
discounts: [],
metadata: {},
price: {
id: 'price_default',
object: 'price',
active: true,
billing_scheme: 'per_unit',
created: 1672531200,
currency: 'usd',
custom_unit_amount: null,
livemode: false,
lookup_key: null,
metadata: {},
nickname: null,
product: 'prod_default',
recurring: {
interval: 'month',
interval_count: 1,
meter: null,
trial_period_days: null,
usage_type: 'licensed',
},
tax_behavior: null,
tiers_mode: null,
transform_quantity: null,
type: 'recurring',
unit_amount: 1000,
unit_amount_decimal: '1000',
},
subscription: '',
tax_rates: null,
},
],
object: 'list',
has_more: false,
url: '',
},
cancel_at_period_end: false,
currency: 'usd',
metadata: { workspaceId: '3b8e6458-5fc1-4e63-8563-008ccddaa6db' },
trial_end: null,
trial_start: null,
canceled_at: null,
...overrides,
application: null,
application_fee_percent: null,
automatic_tax: {
disabled_reason: null,
enabled: true,
liability: {
type: 'self',
},
},
billing_cycle_anchor: 0,
billing_cycle_anchor_config: null,
billing_mode: {
type: 'flexible',
flexible: {},
},
billing_thresholds: null,
cancel_at: null,
cancellation_details: null,
collection_method: 'charge_automatically',
created: 0,
days_until_due: null,
default_payment_method: null,
default_source: null,
description: null,
discounts: [],
ended_at: null,
invoice_settings: {
account_tax_ids: null,
issuer: {
type: 'self',
},
},
latest_invoice: null,
livemode: false,
next_pending_invoice_item_invoice: null,
on_behalf_of: null,
pause_collection: null,
payment_settings: null,
pending_invoice_item_interval: null,
pending_setup_intent: null,
pending_update: null,
schedule: null,
start_date: 0,
test_clock: null,
transfer_data: null,
trial_settings: null,
},
});

View File

@@ -0,0 +1,231 @@
import { randomUUID } from 'node:crypto';
import { isNonEmptyString } from '@sniptt/guards';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { deleteConnectedAccount } from 'test/integration/metadata/suites/connected-account/utils/delete-connected-account.util';
import { saveImapSmtpCaldavAccount } from 'test/integration/metadata/suites/connected-account/utils/save-imap-smtp-caldav-account.util';
import { updateConfigVariable } from 'test/integration/twenty-config/utils/update-config-variable.util';
import { findImportedCalendarEventTitles } from 'test/integration/utils/find-imported-records.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import { runCalendarChannelEventsImport } from 'test/integration/utils/run-calendar-channel-events-import.util';
import { runCalendarChannelListFetch } from 'test/integration/utils/run-calendar-channel-list-fetch.util';
import {
type NonCompliantCalDavProxy,
startNonCompliantCalDavProxy,
} from 'test/integration/utils/start-non-compliant-caldav-proxy.util';
import {
type RadicaleServer,
startRadicaleContainer,
} from 'test/integration/utils/start-radicale-container.util';
const HANDLE = `caldav-no-etag-${randomUUID()}@acme.test`;
const COLLECTION = 'personal';
const PASSWORD = 'radicale-password';
const authorizationHeader = `Basic ${Buffer.from(`${HANDLE}:${PASSWORD}`).toString('base64')}`;
const icalEvent = ({ uid, summary }: { uid: string; summary: string }) =>
[
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Twenty//CalDAV integration test//EN',
'BEGIN:VEVENT',
`UID:${uid}`,
'DTSTAMP:20231101T000000Z',
'DTSTART:20231115T100000Z',
'DTEND:20231115T110000Z',
`SUMMARY:${summary}`,
'END:VEVENT',
'END:VCALENDAR',
].join('\r\n');
describe('CalDAV calendar events import without entity tags (integration)', () => {
let radicale: RadicaleServer;
let proxy: NonCompliantCalDavProxy;
let connectedAccountId: string;
let calendarChannelId: string;
const collectionUrl = () =>
`http://${radicale.host}:${radicale.port}/${HANDLE}/${COLLECTION}/`;
const putEvent = async ({ uid, summary }: { uid: string; summary: string }) =>
fetch(`${collectionUrl()}${uid}.ics`, {
method: 'PUT',
headers: {
'Content-Type': 'text/calendar; charset=utf-8',
Authorization: authorizationHeader,
},
body: icalEvent({ uid, summary }),
});
const syncCalendarChannel = async () => {
await runCalendarChannelListFetch(calendarChannelId);
await runCalendarChannelEventsImport(calendarChannelId);
};
beforeAll(async () => {
await updateConfigVariable({
input: { key: 'OUTBOUND_HTTP_ALLOWED_INTERNAL_HOSTS', value: ['*'] },
});
radicale = await startRadicaleContainer({
username: HANDLE,
password: PASSWORD,
});
await fetch(collectionUrl(), {
method: 'MKCOL',
headers: {
'Content-Type': 'application/xml; charset=utf-8',
Authorization: authorizationHeader,
},
body: `<?xml version="1.0" encoding="utf-8"?>
<mkcol xmlns="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<set><prop>
<resourcetype><collection/><c:calendar/></resourcetype>
<displayname>Personal</displayname>
<c:supported-calendar-component-set><c:comp name="VEVENT"/></c:supported-calendar-component-set>
</prop></set>
</mkcol>`,
});
proxy = await startNonCompliantCalDavProxy({
targetHost: radicale.host,
targetPort: radicale.port,
});
const { data } = await saveImapSmtpCaldavAccount({
input: {
handle: HANDLE,
connectionParameters: {
CALDAV: {
host: `http://${proxy.host}:${proxy.port}`,
port: proxy.port,
username: HANDLE,
password: PASSWORD,
},
},
},
expectToFail: false,
});
connectedAccountId = data.connectedAccountId;
calendarChannelId = (
await getCoreRepository<CalendarChannelEntity>(
CalendarChannelEntity,
).findOneByOrFail({ connectedAccountId })
).id;
}, 300000);
afterAll(async () => {
await updateConfigVariable({
input: { key: 'OUTBOUND_HTTP_ALLOWED_INTERNAL_HOSTS', value: [] },
}).catch(() => undefined);
if (isNonEmptyString(connectedAccountId)) {
await deleteConnectedAccount({
id: connectedAccountId,
expectToFail: false,
}).catch(() => undefined);
}
await proxy?.stop().catch(() => undefined);
await radicale?.stop().catch(() => undefined);
});
afterEach(() => {
proxy.degradations.blankLastModified = false;
proxy.degradations.hideCollectionMembers = false;
});
it('imports an event when the server never returns an entity tag', async () => {
const summary = `CalDAV untagged event ${randomUUID()}`;
await putEvent({ uid: `caldav-event-${randomUUID()}`, summary });
await syncCalendarChannel();
expect(await findImportedCalendarEventTitles([summary])).toEqual([summary]);
}, 300000);
it('imports an event added after the collection tag advances', async () => {
const firstSummary = `CalDAV untagged event ${randomUUID()}`;
const secondSummary = `CalDAV untagged event ${randomUUID()}`;
await putEvent({
uid: `caldav-event-${randomUUID()}`,
summary: firstSummary,
});
await syncCalendarChannel();
await putEvent({
uid: `caldav-event-${randomUUID()}`,
summary: secondSummary,
});
await syncCalendarChannel();
expect(
await findImportedCalendarEventTitles([firstSummary, secondSummary]),
).toEqual([firstSummary, secondSummary].sort());
}, 300000);
it('re-imports the collection after a listing comes back with no members', async () => {
const importedSummary = `CalDAV untagged event ${randomUUID()}`;
const addedSummary = `CalDAV untagged event ${randomUUID()}`;
await putEvent({
uid: `caldav-event-${randomUUID()}`,
summary: importedSummary,
});
await syncCalendarChannel();
expect(await findImportedCalendarEventTitles([importedSummary])).toEqual([
importedSummary,
]);
proxy.degradations.hideCollectionMembers = true;
await putEvent({
uid: `caldav-event-${randomUUID()}`,
summary: addedSummary,
});
await syncCalendarChannel();
expect(
await findImportedCalendarEventTitles([importedSummary, addedSummary]),
).toEqual([]);
proxy.degradations.hideCollectionMembers = false;
await syncCalendarChannel();
expect(
await findImportedCalendarEventTitles([importedSummary, addedSummary]),
).toEqual([importedSummary, addedSummary].sort());
}, 300000);
it('imports an edit when the server returns neither entity tag nor last modified date', async () => {
const uid = `caldav-event-${randomUUID()}`;
const originalSummary = `CalDAV unversioned event ${randomUUID()}`;
const editedSummary = `CalDAV unversioned event ${randomUUID()}`;
proxy.degradations.blankLastModified = true;
await putEvent({ uid, summary: originalSummary });
await syncCalendarChannel();
expect(await findImportedCalendarEventTitles([originalSummary])).toEqual([
originalSummary,
]);
await putEvent({ uid, summary: editedSummary });
await syncCalendarChannel();
expect(await findImportedCalendarEventTitles([editedSummary])).toEqual([
editedSummary,
]);
}, 300000);
});

View File

@@ -0,0 +1,254 @@
import { randomUUID } from 'node:crypto';
import { isNonEmptyString } from '@sniptt/guards';
import { CalendarChannelSyncStatus } from 'twenty-shared/types';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { deleteConnectedAccount } from 'test/integration/metadata/suites/connected-account/utils/delete-connected-account.util';
import { saveImapSmtpCaldavAccount } from 'test/integration/metadata/suites/connected-account/utils/save-imap-smtp-caldav-account.util';
import { updateConfigVariable } from 'test/integration/twenty-config/utils/update-config-variable.util';
import { createCalendarEvent } from 'test/integration/utils/create-calendar-event.util';
import { findImportedCalendarEventTitles } from 'test/integration/utils/find-imported-records.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import { runCalendarChannelEventsImport } from 'test/integration/utils/run-calendar-channel-events-import.util';
import { runCalendarChannelListFetch } from 'test/integration/utils/run-calendar-channel-list-fetch.util';
import {
type RadicaleServer,
startRadicaleContainer,
} from 'test/integration/utils/start-radicale-container.util';
const HANDLE = `caldav-events-import-${randomUUID()}@acme.test`;
const COLLECTION = 'personal';
const PASSWORD = 'radicale-password';
const authorizationHeader = `Basic ${Buffer.from(`${HANDLE}:${PASSWORD}`).toString('base64')}`;
// A sync token the server no longer recognises, which drives the fallback from
// an incremental sync-collection report to a full re-sync.
const buildStaleSyncCursor = (collectionUrl: string) =>
JSON.stringify({
syncTokens: { [collectionUrl]: 'stale-sync-token' },
});
const icalEvent = ({ uid, summary }: { uid: string; summary: string }) =>
[
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Twenty//CalDAV integration test//EN',
'BEGIN:VEVENT',
`UID:${uid}`,
'DTSTAMP:20231101T000000Z',
'DTSTART:20231115T100000Z',
'DTEND:20231115T110000Z',
`SUMMARY:${summary}`,
'END:VEVENT',
'END:VCALENDAR',
].join('\r\n');
describe('CalDAV calendar events import (integration)', () => {
let radicale: RadicaleServer;
let connectedAccountId: string;
let calendarChannelId: string;
const collectionUrl = () =>
`http://${radicale.host}:${radicale.port}/${HANDLE}/${COLLECTION}/`;
const putEvent = async ({ uid, summary }: { uid: string; summary: string }) =>
fetch(`${collectionUrl()}${uid}.ics`, {
method: 'PUT',
headers: {
'Content-Type': 'text/calendar; charset=utf-8',
Authorization: authorizationHeader,
},
body: icalEvent({ uid, summary }),
});
const deleteEvent = async (uid: string) =>
fetch(`${collectionUrl()}${uid}.ics`, {
method: 'DELETE',
headers: { Authorization: authorizationHeader },
});
const readSyncCursor = async () =>
(
await getCoreRepository<CalendarChannelEntity>(
CalendarChannelEntity,
).findOneByOrFail({ id: calendarChannelId })
).syncCursor;
const syncCalendarChannel = async () => {
await runCalendarChannelListFetch(calendarChannelId);
await runCalendarChannelEventsImport(calendarChannelId);
};
beforeAll(async () => {
// The CalDAV driver wraps its fetch in the SSRF guard, which rejects the
// container's private address before the request is made.
await updateConfigVariable({
input: { key: 'OUTBOUND_HTTP_ALLOWED_INTERNAL_HOSTS', value: ['*'] },
});
radicale = await startRadicaleContainer({
username: HANDLE,
password: PASSWORD,
});
await fetch(collectionUrl(), {
method: 'MKCOL',
headers: {
'Content-Type': 'application/xml; charset=utf-8',
Authorization: authorizationHeader,
},
body: `<?xml version="1.0" encoding="utf-8"?>
<mkcol xmlns="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<set><prop>
<resourcetype><collection/><c:calendar/></resourcetype>
<displayname>Personal</displayname>
<c:supported-calendar-component-set><c:comp name="VEVENT"/></c:supported-calendar-component-set>
</prop></set>
</mkcol>`,
});
const { data } = await saveImapSmtpCaldavAccount({
input: {
handle: HANDLE,
connectionParameters: {
CALDAV: {
host: `http://${radicale.host}:${radicale.port}`,
port: radicale.port,
username: HANDLE,
password: PASSWORD,
},
},
},
expectToFail: false,
});
connectedAccountId = data.connectedAccountId;
calendarChannelId = (
await getCoreRepository<CalendarChannelEntity>(
CalendarChannelEntity,
).findOneByOrFail({ connectedAccountId })
).id;
}, 300000);
afterAll(async () => {
await updateConfigVariable({
input: { key: 'OUTBOUND_HTTP_ALLOWED_INTERNAL_HOSTS', value: [] },
}).catch(() => undefined);
if (isNonEmptyString(connectedAccountId)) {
await deleteConnectedAccount({
id: connectedAccountId,
expectToFail: false,
}).catch(() => undefined);
}
await radicale?.stop().catch(() => undefined);
});
it('imports an event published to the CalDAV collection', async () => {
const summary = `CalDAV event ${randomUUID()}`;
await putEvent({ uid: `caldav-event-${randomUUID()}`, summary });
await syncCalendarChannel();
expect(await findImportedCalendarEventTitles([summary])).toEqual([summary]);
}, 300000);
it('creates and persists an event in the CalDAV collection', async () => {
const title = `CalDAV calendar outbound ${randomUUID()}`;
const result = await createCalendarEvent({
connectedAccountId,
title,
description: 'Planning meeting',
location: 'Room 101',
startsAt: '2026-08-13T09:00:00Z',
endsAt: '2026-08-13T10:00:00Z',
timeZone: 'UTC',
attendees: 'attendee@example.com',
sendInvitations: true,
});
expect(result).toMatchObject({ success: true });
expect(result.iCalUid).toBeDefined();
expect(await findImportedCalendarEventTitles([title])).toEqual([title]);
const response = await fetch(`${collectionUrl()}${result.iCalUid}.ics`, {
headers: { Authorization: authorizationHeader },
});
expect(response.status).toBe(200);
expect(await response.text()).toContain(`SUMMARY:${title}`);
}, 300000);
it('advances the sync token and imports an event added afterwards', async () => {
const firstSummary = `CalDAV event ${randomUUID()}`;
const secondSummary = `CalDAV event ${randomUUID()}`;
await putEvent({
uid: `caldav-event-${randomUUID()}`,
summary: firstSummary,
});
await syncCalendarChannel();
const cursorAfterFirstSync = await readSyncCursor();
await putEvent({
uid: `caldav-event-${randomUUID()}`,
summary: secondSummary,
});
await syncCalendarChannel();
expect(await readSyncCursor()).not.toBe(cursorAfterFirstSync);
expect(
await findImportedCalendarEventTitles([firstSummary, secondSummary]),
).toEqual([firstSummary, secondSummary].sort());
}, 300000);
it('keeps the channel active when the collection has not changed', async () => {
await syncCalendarChannel();
const channel = await getCoreRepository<CalendarChannelEntity>(
CalendarChannelEntity,
).findOneByOrFail({ id: calendarChannelId });
expect(channel.syncStatus).toBe(CalendarChannelSyncStatus.ACTIVE);
}, 300000);
it('removes an event deleted from the CalDAV collection', async () => {
const summary = `CalDAV event ${randomUUID()}`;
const uid = `caldav-event-${randomUUID()}`;
await putEvent({ uid, summary });
await syncCalendarChannel();
expect(await findImportedCalendarEventTitles([summary])).toEqual([summary]);
await deleteEvent(uid);
await syncCalendarChannel();
expect(await findImportedCalendarEventTitles([summary])).toEqual([]);
}, 300000);
it('recovers with a full re-sync when the stored sync token is rejected', async () => {
const summary = `CalDAV event ${randomUUID()}`;
await putEvent({ uid: `caldav-event-${randomUUID()}`, summary });
await getCoreRepository<CalendarChannelEntity>(
CalendarChannelEntity,
).update(
{ id: calendarChannelId },
{ syncCursor: buildStaleSyncCursor(collectionUrl()) },
);
await syncCalendarChannel();
expect(await findImportedCalendarEventTitles([summary])).toEqual([summary]);
}, 300000);
});

View File

@@ -0,0 +1,161 @@
import { randomUUID } from 'node:crypto';
import { isNonEmptyString } from '@sniptt/guards';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { deleteConnectedAccount } from 'test/integration/metadata/suites/connected-account/utils/delete-connected-account.util';
import { saveImapSmtpCaldavAccount } from 'test/integration/metadata/suites/connected-account/utils/save-imap-smtp-caldav-account.util';
import { updateConfigVariable } from 'test/integration/twenty-config/utils/update-config-variable.util';
import { findImportedCalendarEventTitles } from 'test/integration/utils/find-imported-records.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import { runCalendarChannelEventsImport } from 'test/integration/utils/run-calendar-channel-events-import.util';
import { runCalendarChannelListFetch } from 'test/integration/utils/run-calendar-channel-list-fetch.util';
import {
type CalDavProxy,
startCalDavProxyOmittingComponentSet,
} from 'test/integration/utils/start-caldav-proxy-omitting-component-set.util';
import {
type RadicaleServer,
startRadicaleContainer,
} from 'test/integration/utils/start-radicale-container.util';
const HANDLE = `caldav-omitted-component-set-${randomUUID()}@acme.test`;
const COLLECTION = 'personal';
const PASSWORD = 'radicale-password';
const authorizationHeader = `Basic ${Buffer.from(`${HANDLE}:${PASSWORD}`).toString('base64')}`;
const icalEvent = ({ uid, summary }: { uid: string; summary: string }) =>
[
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Twenty//CalDAV integration test//EN',
'BEGIN:VEVENT',
`UID:${uid}`,
'DTSTAMP:20231101T000000Z',
'DTSTART:20231115T100000Z',
'DTEND:20231115T110000Z',
`SUMMARY:${summary}`,
'END:VEVENT',
'END:VCALENDAR',
].join('\r\n');
describe('CalDAV server omitting supported-calendar-component-set (integration)', () => {
let radicale: RadicaleServer;
let proxy: CalDavProxy;
let connectedAccountId: string;
let calendarChannelId: string;
const collectionUrl = () =>
`http://${radicale.host}:${radicale.port}/${HANDLE}/${COLLECTION}/`;
beforeAll(async () => {
await updateConfigVariable({
input: { key: 'OUTBOUND_HTTP_ALLOWED_INTERNAL_HOSTS', value: ['*'] },
});
radicale = await startRadicaleContainer({
username: HANDLE,
password: PASSWORD,
});
await fetch(collectionUrl(), {
method: 'MKCOL',
headers: {
'Content-Type': 'application/xml; charset=utf-8',
Authorization: authorizationHeader,
},
body: `<?xml version="1.0" encoding="utf-8"?>
<mkcol xmlns="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<set><prop>
<resourcetype><collection/><c:calendar/></resourcetype>
<displayname>Personal</displayname>
</prop></set>
</mkcol>`,
});
proxy = await startCalDavProxyOmittingComponentSet({
targetHost: radicale.host,
targetPort: radicale.port,
});
const { data } = await saveImapSmtpCaldavAccount({
input: {
handle: HANDLE,
connectionParameters: {
CALDAV: {
host: proxy.url,
port: Number(new URL(proxy.url).port),
username: HANDLE,
password: PASSWORD,
},
},
},
expectToFail: false,
});
connectedAccountId = data.connectedAccountId;
calendarChannelId = (
await getCoreRepository<CalendarChannelEntity>(
CalendarChannelEntity,
).findOneByOrFail({ connectedAccountId })
).id;
}, 300000);
afterAll(async () => {
await updateConfigVariable({
input: { key: 'OUTBOUND_HTTP_ALLOWED_INTERNAL_HOSTS', value: [] },
}).catch(() => undefined);
if (isNonEmptyString(connectedAccountId)) {
await deleteConnectedAccount({
id: connectedAccountId,
expectToFail: false,
}).catch(() => undefined);
}
await proxy?.stop().catch(() => undefined);
await radicale?.stop().catch(() => undefined);
});
it('serves the calendar collection without a component set', async () => {
const response = await fetch(`${proxy.url}/${HANDLE}/`, {
method: 'PROPFIND',
headers: {
'Content-Type': 'application/xml; charset=utf-8',
Authorization: authorizationHeader,
Depth: '1',
},
body: `<?xml version="1.0" encoding="utf-8"?>
<propfind xmlns="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<prop><resourcetype/><c:supported-calendar-component-set/></prop>
</propfind>`,
});
const body = await response.text();
expect(body).toContain('calendar');
expect(body).not.toContain('supported-calendar-component-set');
}, 300000);
it('imports an event even though the component set is not published', async () => {
const summary = `CalDAV event ${randomUUID()}`;
const uid = `caldav-event-${randomUUID()}`;
await fetch(`${collectionUrl()}${uid}.ics`, {
method: 'PUT',
headers: {
'Content-Type': 'text/calendar; charset=utf-8',
Authorization: authorizationHeader,
},
body: icalEvent({ uid, summary }),
});
await runCalendarChannelListFetch(calendarChannelId);
await runCalendarChannelEventsImport(calendarChannelId);
expect(await findImportedCalendarEventTitles([summary])).toEqual([summary]);
}, 300000);
});

View File

@@ -0,0 +1,9 @@
export const CALENDAR_EVENT_GQL_FIELDS = `
id
title
description
endsAt
startsAt
createdAt
updatedAt
deletedAt`;

View File

@@ -0,0 +1,22 @@
export const COMPANY_GQL_FIELDS = `
id
name
domainName {
primaryLinkLabel
primaryLinkUrl
secondaryLinks {
label
url
}
}
linkedinLink {
primaryLinkLabel
primaryLinkUrl
secondaryLinks {
label
url
}
}
createdAt
deletedAt
`;

View File

@@ -0,0 +1,7 @@
export const MESSAGE_GQL_FIELDS = `
id
subject
text
createdAt
updatedAt
deletedAt`;

View File

@@ -0,0 +1,7 @@
export const OBJECT_MODEL_COMMON_FIELDS = `
id
name
createdAt
updatedAt
deletedAt
`;

View File

@@ -0,0 +1,43 @@
export const PERSON_GQL_FIELDS = `
id
jobTitle
avatarUrl
avatarFile {
fileId
label
extension
url
}
intro
searchVector
name {
firstName
lastName
}
emails {
primaryEmail
additionalEmails
}
phones {
primaryPhoneNumber
primaryPhoneCountryCode
primaryPhoneCallingCode
additionalPhones {
number
callingCode
countryCode
}
}
whatsapp {
primaryPhoneNumber
primaryPhoneCountryCode
primaryPhoneCallingCode
additionalPhones {
number
callingCode
countryCode
}
}
createdAt
deletedAt
`;

View File

@@ -0,0 +1,22 @@
export const ROW_LEVEL_PERMISSION_PREDICATE_GQL_FIELDS = `
id
fieldMetadataId
objectMetadataId
operand
value
subFieldName
workspaceMemberFieldMetadataId
workspaceMemberSubFieldName
rowLevelPermissionPredicateGroupId
positionInRowLevelPermissionPredicateGroup
roleId
`;
export const ROW_LEVEL_PERMISSION_PREDICATE_GROUP_GQL_FIELDS = `
id
parentRowLevelPermissionPredicateGroupId
logicalOperator
positionInRowLevelPermissionPredicateGroup
roleId
objectMetadataId
`;

View File

@@ -0,0 +1,20 @@
import { DEFAULT_MODELS_BY_TIER } from 'src/engine/metadata-modules/ai/ai-models/utils/load-default-model-preferences.util';
// Any registered model can back an agent, so the suites read ids from the
// default chains rather than naming them: those lists are refreshed against the
// models.dev catalog on a daily automerge, and a hardcoded id broke every agent
// suite the day it dropped out of the catalog.
export const [TEST_AI_MODEL_ID, TEST_AI_OTHER_MODEL_ID] = [
DEFAULT_MODELS_BY_TIER.fast[0],
DEFAULT_MODELS_BY_TIER.smart[0],
];
// Two distinct ids are load-bearing: the update suites assert a transition from
// one model to another, and would pass without testing anything if both were
// the same. Fail at import rather than let a future list shape silently empty
// those assertions out.
if (TEST_AI_MODEL_ID === TEST_AI_OTHER_MODEL_ID) {
throw new Error(
'The agent integration suites need two distinct default models: one to create an agent with and one to update it to. The fast and smart chains now start with the same id.',
);
}

View File

@@ -0,0 +1,2 @@
export const TEST_COMPANY_1_ID = '525c282e-030a-4a3e-90a0-d8aad0d33a93';
export const TEST_COMPANY_2_ID = '2fd9a9ea-04a5-483b-846d-2819dd658fc1';

View File

@@ -0,0 +1,11 @@
export const TEST_PERSON_1_ID = '777a8457-eb2d-40ac-a707-551b615b6980';
export const TEST_PERSON_2_ID = '777a8457-eb2d-40ac-a707-551b615b6981';
export const TEST_PERSON_3_ID = '777a8457-eb2d-40ac-a707-551b615b6982';
export const TEST_PERSON_4_ID = '777a8457-eb2d-40ac-a707-551b615b6983';
export const TEST_PERSON_5_ID = '777a8457-eb2d-40ac-a707-551b615b6984';
export const TEST_PERSON_6_ID = '777a8457-eb2d-40ac-a707-551b615b6985';
export const TEST_PERSON_7_ID = '777a8457-eb2d-40ac-a707-551b615b6986';
export const TEST_PERSON_8_ID = '777a8457-eb2d-40ac-a707-551b615b6987';
export const TEST_PERSON_9_ID = '777a8457-eb2d-40ac-a707-551b615b6988';
export const NOT_EXISTING_TEST_PERSON_ID =
'777a8457-eb2d-40ac-a707-551b615b6990';

View File

@@ -0,0 +1,5 @@
export const TEST_PET_ID_1 = 'a4907cff-a582-4daf-8635-ad6c782c7c25';
export const TEST_PET_ID_2 = 'c4e97187-9b9b-4e1f-a3c5-b7883c590332';
export const TEST_PET_ID_3 = 'e4907cff-a582-4aaf-8635-ad6c782c7c26';
export const TEST_PET_ID_4 = 'f4907cff-a582-4aaf-8635-ad6c782c7c27';
export const TEST_PET_ID_5 = 'd4907cff-a582-4aaf-8635-ad6c782c7c28';

View File

@@ -0,0 +1,2 @@
export const TEST_PRIMARY_LINK_URL = 'https://test.com/';
export const TEST_PRIMARY_LINK_URL_AS_DOMAIN = 'test.com';

View File

@@ -0,0 +1,3 @@
export const TEST_ROCKET_ID_1 = '20202020-77d2-4000-8ce4-6a70b9720b32';
export const TEST_ROCKET_ID_2 = '20202020-ed89-413a-b31a-962986a3546f';
export const TEST_ROCKET_ID_3 = '20202020-1f3b-4e2a-9c1b-8d9e0f1a2b3c';

View File

@@ -0,0 +1,2 @@
export const TEST_SURVEY_RESULT_1_ID = '20202020-3d5f-4e2a-9c1b-7f8e2d3c4b5a';
export const TEST_SURVEY_RESULT_2_ID = '20202020-4e83-41ec-93e2-fd70ff09f68c';

View File

@@ -0,0 +1,9 @@
{
"APPLE_JANE_ADMIN_ACCESS_TOKEN": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik",
"EXPIRED_ACCESS_TOKEN": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ1c2VySWQiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaXNJbXBlcnNvbmF0aW5nIjpmYWxzZSwiaWF0IjoxNzY1NDgwNDkzLCJleHAiOjE3NjU0ODA1MDN9.H0rNZyYvaWsDqim8U0-knIpq-29EQ6ox9Eag4WpwZg8",
"INVALID_ACCESS_TOKEN": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwiaWF0IjoxNzM4MzIzODc5LCJleHAiOjE3MzgzMjU2Nzl9.m73hHVpnw5uGNGrSuKxn6XtKEUK3Wqkp4HsQdYfZiHp",
"APPLE_JONY_MEMBER_ACCESS_TOKEN": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0zOTU3LTQ5MDgtOWMzNi0yOTI5YTIzZjgzNTciLCJ1c2VySWQiOiIyMDIwMjAyMC0zOTU3LTQ5MDgtOWMzNi0yOTI5YTIzZjgzNTciLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNzdkNS00Y2I2LWI2MGEtZjRhODM1YTg1ZDYxIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMzk1Ny00OTA4LTljMzYtMjkyOWEyM2Y4MzUzIiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaXNJbXBlcnNvbmF0aW5nIjpmYWxzZSwiaWF0IjoxNzY1NDY5OTgzLCJleHAiOjI3MTIxOTc5ODN9.B55MfSd3LShO9_61nvKHUzsSJD6XszEbFGn_76VpaKs",
"APPLE_PHIL_GUEST_ACCESS_TOKEN": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC03MTY5LTQyY2YtYmM0Ny0xY2ZlZjE1MjY0YjgiLCJ1c2VySWQiOiIyMDIwMjAyMC03MTY5LTQyY2YtYmM0Ny0xY2ZlZjE1MjY0YjgiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMTU1My00NWM2LWEwMjgtNWE5MDY0Y2NlMDdmIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtNzE2OS00MmNmLWJjNDctMWNmZWYxNTI2NGIxIiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaXNJbXBlcnNvbmF0aW5nIjpmYWxzZSwiaWF0IjoxNzY1NDcwOTczLCJleHAiOjI3MTIxOTg5NzN9.cd3CmyWiwDJEWD3VgVqG0JfXQ9w21y2eWx67vGPH9SI",
"API_KEY_ACCESS_TOKEN": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJ0eXBlIjoiQVBJX0tFWSIsIndvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNzQ0OTgzNzUwLCJleHAiOjQ4OTg1ODM2OTMsImp0aSI6IjIwMjAyMDIwLWY0MDEtNGQ4YS1hNzMxLTY0ZDAwN2MyN2JhZCJ9.4xkkwz_uu2xzs_V8hJSaM15fGziT5zS3vq2lM48OHr0",
"APPLE_SARAH_IMPERSONATE_TIM_INVALID_ACCESS_TOKEN": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ1c2VySWQiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6ImltcGVyc29uYXRpb24iLCJpc0ltcGVyc29uYXRpbmciOnRydWUsImltcGVyc29uYXRvclVzZXJXb3Jrc3BhY2VJZCI6IjMxMzEzMTMxLTAwMDEtNDAwMC04MDAwLTAwMDAwMDAwMDAwMCIsImltcGVyc29uYXRlZFVzZXJXb3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTllM2ItNDZkNC1hNTU2LTg4YjlkZGMyYjAzNSIsImlhdCI6MTc1ODU1NDY2NSwiZXhwIjoyNzA1MjgyNjY1fQ.PHXdd0RB2M4YbJRIJQY43ZxAxOE2nU7YzPG-BkdNrQc"
}

View File

@@ -0,0 +1,88 @@
export const VIEW_GQL_FIELDS = `
id
name
objectMetadataId
type
key
mainGroupByFieldMetadataId
icon
position
isCompact
openRecordIn
workspaceId
anyFieldFilterValue
createdAt
updatedAt
deletedAt
`;
export const VIEW_FIELD_GQL_FIELDS = `
id
universalIdentifier
applicationId
isSystemSideEffect
fieldMetadataId
position
isVisible
size
viewId
viewFieldGroupId
createdAt
updatedAt
deletedAt
`;
export const VIEW_SORT_GQL_FIELDS = `
id
fieldMetadataId
direction
subFieldName
viewId
createdAt
updatedAt
deletedAt
`;
export const VIEW_FILTER_GQL_FIELDS = `
id
fieldMetadataId
operand
value
viewId
createdAt
updatedAt
deletedAt
`;
export const VIEW_GROUP_GQL_FIELDS = `
id
fieldValue
isVisible
position
viewId
createdAt
updatedAt
deletedAt
`;
export const VIEW_FILTER_GROUP_GQL_FIELDS = `
id
logicalOperator
parentViewFilterGroupId
positionInViewFilterGroup
viewId
createdAt
updatedAt
deletedAt
`;
export const VIEW_FIELD_GROUP_GQL_FIELDS = `
id
name
position
isVisible
viewId
createdAt
updatedAt
deletedAt
`;

View File

@@ -0,0 +1,216 @@
import { AggregateOperations } from 'twenty-shared/types';
import { type AggregateChartConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/aggregate-chart-configuration.dto';
import { type BarChartConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/bar-chart-configuration.dto';
import { type IframeConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/iframe-configuration.dto';
import { type LineChartConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/line-chart-configuration.dto';
import { type PieChartConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/pie-chart-configuration.dto';
import { type StandaloneRichTextConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/standalone-rich-text-configuration.dto';
import { AxisNameDisplay } from 'src/engine/metadata-modules/page-layout-widget/enums/axis-name-display.enum';
import { BarChartLayout } from 'src/engine/metadata-modules/page-layout-widget/enums/bar-chart-layout.enum';
import { ChartNumberFormat } from 'src/engine/metadata-modules/page-layout-widget/enums/chart-number-format.enum';
import { GraphOrderBy } from 'src/engine/metadata-modules/page-layout-widget/enums/graph-order-by.enum';
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
// Hardcoded UUIDs for unit tests only (no DB lookup needed)
const UNIT_TEST_FIELD_METADATA_ID_1 = '20202020-1111-4111-a111-111111111111';
const UNIT_TEST_FIELD_METADATA_ID_2 = '20202020-2222-4222-a222-222222222222';
const UNIT_TEST_FIELD_METADATA_ID_3 = '20202020-3333-4333-a333-333333333333';
export const TEST_IFRAME_CONFIG: IframeConfigurationDTO = {
configurationType: WidgetConfigurationType.IFRAME,
url: 'https://example.com/dashboard',
};
export const TEST_IFRAME_CONFIG_ALTERNATIVE: IframeConfigurationDTO = {
configurationType: WidgetConfigurationType.IFRAME,
url: 'https://app.twenty.com/analytics',
};
export const TEST_STANDALONE_RICH_TEXT_CONFIG: StandaloneRichTextConfigurationDTO =
{
configurationType: WidgetConfigurationType.STANDALONE_RICH_TEXT,
body: {
blocknote:
'{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Hello world"}]}]}',
markdown: '# Hello world',
},
};
export const TEST_STANDALONE_RICH_TEXT_CONFIG_MINIMAL: StandaloneRichTextConfigurationDTO =
{
configurationType: WidgetConfigurationType.STANDALONE_RICH_TEXT,
body: {
blocknote:
'{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Simple text"}]}]}',
markdown: null,
},
};
export const INVALID_STANDALONE_RICH_TEXT_CONFIG_MISSING_BODY = {};
export const INVALID_STANDALONE_RICH_TEXT_CONFIG_BODY_WRONG_TYPE = {
body: 'not an object',
};
export const INVALID_STANDALONE_RICH_TEXT_CONFIG_INVALID_SUBFIELDS = {
configurationType: WidgetConfigurationType.STANDALONE_RICH_TEXT,
body: {
blocknote: 'valid',
markdown: 'valid',
invalidField: 'should not be here',
},
};
export const INVALID_IFRAME_CONFIG_BAD_URL = {
url: 'not-a-valid-url',
};
export const INVALID_IFRAME_CONFIG_EMPTY_URL = {
url: '',
};
export const INVALID_NUMBER_CHART_CONFIG_MISSING_FIELDS = {
configurationType: WidgetConfigurationType.AGGREGATE_CHART,
};
export const INVALID_NUMBER_CHART_CONFIG_BAD_UUID = {
configurationType: WidgetConfigurationType.AGGREGATE_CHART,
aggregateFieldMetadataId: 'not-a-uuid',
aggregateOperation: AggregateOperations.COUNT,
};
export const INVALID_VERTICAL_BAR_CHART_CONFIG_MISSING_GROUP_BY = {
configurationType: WidgetConfigurationType.BAR_CHART,
aggregateFieldMetadataId: UNIT_TEST_FIELD_METADATA_ID_1,
aggregateOperation: AggregateOperations.SUM,
};
export const INVALID_HORIZONTAL_BAR_CHART_CONFIG_MISSING_GROUP_BY = {
configurationType: WidgetConfigurationType.BAR_CHART,
aggregateFieldMetadataId: UNIT_TEST_FIELD_METADATA_ID_1,
aggregateOperation: AggregateOperations.SUM,
};
export const TEST_NUMBER_CHART_CONFIG: AggregateChartConfigurationDTO = {
configurationType: WidgetConfigurationType.AGGREGATE_CHART,
aggregateFieldMetadataId: UNIT_TEST_FIELD_METADATA_ID_1,
aggregateOperation: AggregateOperations.COUNT,
label: 'Total Records',
description: 'Count of all records',
numberFormat: ChartNumberFormat.SHORT,
displayDataLabel: true,
};
export const TEST_NUMBER_CHART_CONFIG_MINIMAL: AggregateChartConfigurationDTO =
{
configurationType: WidgetConfigurationType.AGGREGATE_CHART,
aggregateFieldMetadataId: UNIT_TEST_FIELD_METADATA_ID_1,
aggregateOperation: AggregateOperations.SUM,
displayDataLabel: false,
};
export const TEST_VERTICAL_BAR_CHART_CONFIG: BarChartConfigurationDTO = {
configurationType: WidgetConfigurationType.BAR_CHART,
layout: BarChartLayout.VERTICAL,
aggregateFieldMetadataId: UNIT_TEST_FIELD_METADATA_ID_1,
aggregateOperation: AggregateOperations.SUM,
primaryAxisGroupByFieldMetadataId: UNIT_TEST_FIELD_METADATA_ID_2,
primaryAxisOrderBy: GraphOrderBy.FIELD_ASC,
displayDataLabel: true,
axisNameDisplay: AxisNameDisplay.NONE,
color: 'red',
description: 'Monthly revenue breakdown',
omitNullValues: true,
rangeMin: 0,
rangeMax: 100000,
};
export const TEST_VERTICAL_BAR_CHART_CONFIG_MINIMAL: BarChartConfigurationDTO =
{
configurationType: WidgetConfigurationType.BAR_CHART,
layout: BarChartLayout.VERTICAL,
aggregateFieldMetadataId: UNIT_TEST_FIELD_METADATA_ID_1,
aggregateOperation: AggregateOperations.COUNT,
primaryAxisGroupByFieldMetadataId: UNIT_TEST_FIELD_METADATA_ID_2,
primaryAxisOrderBy: GraphOrderBy.VALUE_DESC,
displayDataLabel: false,
axisNameDisplay: AxisNameDisplay.NONE,
};
export const TEST_HORIZONTAL_BAR_CHART_CONFIG: BarChartConfigurationDTO = {
configurationType: WidgetConfigurationType.BAR_CHART,
layout: BarChartLayout.HORIZONTAL,
aggregateFieldMetadataId: UNIT_TEST_FIELD_METADATA_ID_1,
aggregateOperation: AggregateOperations.SUM,
primaryAxisGroupByFieldMetadataId: UNIT_TEST_FIELD_METADATA_ID_2,
primaryAxisOrderBy: GraphOrderBy.FIELD_ASC,
displayDataLabel: true,
axisNameDisplay: AxisNameDisplay.NONE,
color: 'blue',
description: 'Horizontal revenue breakdown',
omitNullValues: true,
rangeMin: 0,
rangeMax: 100000,
};
export const TEST_HORIZONTAL_BAR_CHART_CONFIG_MINIMAL: BarChartConfigurationDTO =
{
configurationType: WidgetConfigurationType.BAR_CHART,
layout: BarChartLayout.HORIZONTAL,
aggregateFieldMetadataId: UNIT_TEST_FIELD_METADATA_ID_1,
aggregateOperation: AggregateOperations.COUNT,
primaryAxisGroupByFieldMetadataId: UNIT_TEST_FIELD_METADATA_ID_2,
primaryAxisOrderBy: GraphOrderBy.VALUE_DESC,
displayDataLabel: false,
axisNameDisplay: AxisNameDisplay.NONE,
};
export const TEST_LINE_CHART_CONFIG: LineChartConfigurationDTO = {
configurationType: WidgetConfigurationType.LINE_CHART,
aggregateFieldMetadataId: UNIT_TEST_FIELD_METADATA_ID_1,
aggregateOperation: AggregateOperations.AVG,
primaryAxisGroupByFieldMetadataId: UNIT_TEST_FIELD_METADATA_ID_2,
primaryAxisOrderBy: GraphOrderBy.FIELD_ASC,
secondaryAxisGroupByFieldMetadataId: UNIT_TEST_FIELD_METADATA_ID_3,
secondaryAxisOrderBy: GraphOrderBy.FIELD_DESC,
displayDataLabel: true,
axisNameDisplay: AxisNameDisplay.NONE,
color: 'cyan',
description: 'Trend over time',
omitNullValues: false,
rangeMin: -100,
rangeMax: 100,
};
export const TEST_LINE_CHART_CONFIG_MINIMAL: LineChartConfigurationDTO = {
configurationType: WidgetConfigurationType.LINE_CHART,
aggregateFieldMetadataId: UNIT_TEST_FIELD_METADATA_ID_1,
aggregateOperation: AggregateOperations.MAX,
primaryAxisGroupByFieldMetadataId: UNIT_TEST_FIELD_METADATA_ID_2,
primaryAxisOrderBy: GraphOrderBy.VALUE_ASC,
displayDataLabel: false,
axisNameDisplay: AxisNameDisplay.NONE,
};
export const TEST_PIE_CHART_CONFIG: PieChartConfigurationDTO = {
configurationType: WidgetConfigurationType.PIE_CHART,
groupByFieldMetadataId: UNIT_TEST_FIELD_METADATA_ID_1,
aggregateFieldMetadataId: UNIT_TEST_FIELD_METADATA_ID_2,
aggregateOperation: AggregateOperations.SUM,
orderBy: GraphOrderBy.VALUE_DESC,
displayDataLabel: true,
displayLegend: true,
showCenterMetric: true,
color: 'yellow',
description: 'Distribution by category',
};
export const TEST_PIE_CHART_CONFIG_MINIMAL: PieChartConfigurationDTO = {
configurationType: WidgetConfigurationType.PIE_CHART,
groupByFieldMetadataId: UNIT_TEST_FIELD_METADATA_ID_1,
aggregateFieldMetadataId: UNIT_TEST_FIELD_METADATA_ID_2,
aggregateOperation: AggregateOperations.COUNT,
orderBy: GraphOrderBy.FIELD_ASC,
displayDataLabel: false,
};

View File

@@ -0,0 +1,27 @@
export const WORKFLOW_GQL_FIELDS = `
id
name
lastPublishedVersionId
statuses
position
createdBy {
source
workspaceMemberId
name
}
createdAt
updatedAt
deletedAt
`;
export const WORKFLOW_RUN_GQL_FIELDS = `
id
name
status
startedAt
endedAt
createdAt
updatedAt
workflowVersionId
state
`;

View File

@@ -0,0 +1,431 @@
import { EmailOperation } from 'twenty-shared/types';
import { EmailComposerService } from 'src/engine/core-modules/tool/tools/email-tool/email-composer.service';
import { getAppProviderByClassName } from 'test/integration/utils/get-app-provider-by-class-name.util';
const WORKSPACE_ID = '20202020-1c25-4d02-bf25-6aeccf7ea419';
const PHIL_USER_WORKSPACE_ID = '20202020-7169-42cf-bc47-1cfef15264b1';
const PHIL_CONNECTED_ACCOUNT_ID = '20202020-cafc-4323-908d-e5b42ad69fdf';
const JONY_CONNECTED_ACCOUNT_ID = '20202020-0cc8-4d60-a3a4-803245698908';
const UNKNOWN_USER_WORKSPACE_ID = '20202020-0000-4000-8000-00000000dead';
const UNKNOWN_CONNECTED_ACCOUNT_ID = '20202020-0000-4000-8000-00000000beef';
const baseParams = {
recipients: { to: 'customer@example.com' },
subject: 'Subject',
body: '<p>body</p>',
files: [],
};
const getFirstWorkspaceConnectedAccountId = async (): Promise<string> => {
const [{ id }] = await global.testDataSource.query(
`SELECT id FROM core."connectedAccount"
WHERE "workspaceId" = $1 AND "archivedAt" IS NULL
AND provider NOT IN ('app', 'oidc', 'saml')
ORDER BY "createdAt" ASC, id ASC
LIMIT 1`,
[WORKSPACE_ID],
);
return id;
};
const readConnectedAccountState = async (
connectedAccountId: string,
): Promise<{ visibility: string; archivedAt: string | null }> => {
const [state] = await global.testDataSource.query(
`SELECT visibility, "archivedAt" FROM core."connectedAccount" WHERE id = $1`,
[connectedAccountId],
);
return state;
};
const setVisibility = async (
connectedAccountId: string,
visibility: string,
) => {
await global.testDataSource.query(
`UPDATE core."connectedAccount" SET visibility = $1 WHERE id = $2`,
[visibility, connectedAccountId],
);
};
const getWorkspaceSharedConnectedAccountIds = async (): Promise<string[]> => {
const rows = await global.testDataSource.query(
`SELECT id FROM core."connectedAccount"
WHERE "workspaceId" = $1 AND visibility = 'workspace'`,
[WORKSPACE_ID],
);
return rows.map((row: { id: string }) => row.id);
};
const insertConnectedAccount = async ({
id,
provider,
userWorkspaceId,
visibility = 'user',
}: {
id: string;
provider: 'app' | 'oidc' | 'saml' | 'email_group';
userWorkspaceId: string;
visibility?: 'user' | 'workspace';
}) => {
await global.testDataSource.query(
`INSERT INTO core."connectedAccount"
(id, handle, provider, "userWorkspaceId", "workspaceId", visibility, "createdAt", "updatedAt")
VALUES ($1, $2, $3, $4, $5, $6, '1970-01-01T00:00:00Z', now())`,
[
id,
`${provider}@example.com`,
provider,
userWorkspaceId,
WORKSPACE_ID,
visibility,
],
);
};
const insertNonMailboxConnectedAccount = async (params: {
id: string;
provider: 'app' | 'oidc' | 'saml';
userWorkspaceId: string;
}) => insertConnectedAccount(params);
const deleteConnectedAccount = async (connectedAccountId: string) => {
await global.testDataSource.query(
`DELETE FROM core."connectedAccount" WHERE id = $1`,
[connectedAccountId],
);
};
const setArchivedAt = async (
connectedAccountId: string,
archivedAt: string | null,
) => {
await global.testDataSource.query(
`UPDATE core."connectedAccount" SET "archivedAt" = $1 WHERE id = $2`,
[archivedAt, connectedAccountId],
);
};
describe('EmailComposerService connected account resolution (integration)', () => {
let service: EmailComposerService;
beforeAll(() => {
service = getAppProviderByClassName<EmailComposerService>(
'EmailComposerService',
);
});
describe('when the caller names a connected account', () => {
it('uses that account, whoever owns it', async () => {
const result = await service.composeEmail({
parameters: {
...baseParams,
connectedAccountId: JONY_CONNECTED_ACCOUNT_ID,
},
context: {
workspaceId: WORKSPACE_ID,
userWorkspaceId: PHIL_USER_WORKSPACE_ID,
},
operation: EmailOperation.SEND,
});
expect(result.success).toBe(true);
expect(result.success && result.data.connectedAccount.id).toBe(
JONY_CONNECTED_ACCOUNT_ID,
);
});
it('uses that account when there is no caller (workflow run)', async () => {
const result = await service.composeEmail({
parameters: {
...baseParams,
connectedAccountId: JONY_CONNECTED_ACCOUNT_ID,
},
context: { workspaceId: WORKSPACE_ID },
operation: EmailOperation.SEND,
});
expect(result.success).toBe(true);
expect(result.success && result.data.connectedAccount.id).toBe(
JONY_CONNECTED_ACCOUNT_ID,
);
});
it('throws when the id is not a valid UUID', async () => {
await expect(
service.composeEmail({
parameters: { ...baseParams, connectedAccountId: 'not-a-uuid' },
context: {
workspaceId: WORKSPACE_ID,
userWorkspaceId: PHIL_USER_WORKSPACE_ID,
},
operation: EmailOperation.SEND,
}),
).rejects.toThrow('Connected account id is not a valid UUID');
});
it('throws when no connected account matches the id', async () => {
await expect(
service.composeEmail({
parameters: {
...baseParams,
connectedAccountId: UNKNOWN_CONNECTED_ACCOUNT_ID,
},
context: {
workspaceId: WORKSPACE_ID,
userWorkspaceId: PHIL_USER_WORKSPACE_ID,
},
operation: EmailOperation.SEND,
}),
).rejects.toThrow('No connected account found for id');
});
it('refuses an application connection that cannot carry mail', async () => {
const appConnectedAccountId = '20202020-0000-4000-8000-0000000000a1';
await insertNonMailboxConnectedAccount({
id: appConnectedAccountId,
provider: 'app',
userWorkspaceId: PHIL_USER_WORKSPACE_ID,
});
try {
await expect(
service.composeEmail({
parameters: {
...baseParams,
connectedAccountId: appConnectedAccountId,
},
context: {
workspaceId: WORKSPACE_ID,
userWorkspaceId: PHIL_USER_WORKSPACE_ID,
},
operation: EmailOperation.SEND,
}),
).rejects.toThrow('cannot send email');
} finally {
await deleteConnectedAccount(appConnectedAccountId);
}
});
it('refuses an archived account named explicitly', async () => {
const { archivedAt } = await readConnectedAccountState(
JONY_CONNECTED_ACCOUNT_ID,
);
await setArchivedAt(JONY_CONNECTED_ACCOUNT_ID, new Date().toISOString());
try {
await expect(
service.composeEmail({
parameters: {
...baseParams,
connectedAccountId: JONY_CONNECTED_ACCOUNT_ID,
},
context: { workspaceId: WORKSPACE_ID },
operation: EmailOperation.SEND,
}),
).rejects.toThrow('No connected account found for id');
} finally {
await setArchivedAt(JONY_CONNECTED_ACCOUNT_ID, archivedAt);
}
});
});
describe('when drafting rather than sending', () => {
it('composes a draft from the caller own mailbox', async () => {
const result = await service.composeEmail({
parameters: baseParams,
context: {
workspaceId: WORKSPACE_ID,
userWorkspaceId: PHIL_USER_WORKSPACE_ID,
},
operation: EmailOperation.DRAFT,
});
expect(result.success).toBe(true);
expect(result.success && result.data.connectedAccount.id).toBe(
PHIL_CONNECTED_ACCOUNT_ID,
);
});
it('refuses an email group, which has no drafts folder', async () => {
const emailGroupConnectedAccountId =
'20202020-0000-4000-8000-0000000000a4';
await insertConnectedAccount({
id: emailGroupConnectedAccountId,
provider: 'email_group',
userWorkspaceId: PHIL_USER_WORKSPACE_ID,
visibility: 'workspace',
});
try {
await expect(
service.composeEmail({
parameters: {
...baseParams,
connectedAccountId: emailGroupConnectedAccountId,
},
context: { workspaceId: WORKSPACE_ID },
operation: EmailOperation.DRAFT,
}),
).rejects.toThrow('cannot draft email');
} finally {
await deleteConnectedAccount(emailGroupConnectedAccountId);
}
});
});
describe('when the caller names none', () => {
it('composes from the caller own account rather than the first of the workspace', async () => {
const result = await service.composeEmail({
parameters: baseParams,
context: {
workspaceId: WORKSPACE_ID,
userWorkspaceId: PHIL_USER_WORKSPACE_ID,
},
operation: EmailOperation.SEND,
});
expect(result.success).toBe(true);
expect(result.success && result.data.connectedAccount.id).toBe(
PHIL_CONNECTED_ACCOUNT_ID,
);
});
it('skips an older application connection and still picks the mailbox', async () => {
const appConnectedAccountId = '20202020-0000-4000-8000-0000000000a2';
await insertNonMailboxConnectedAccount({
id: appConnectedAccountId,
provider: 'app',
userWorkspaceId: PHIL_USER_WORKSPACE_ID,
});
try {
const result = await service.composeEmail({
parameters: baseParams,
context: {
workspaceId: WORKSPACE_ID,
userWorkspaceId: PHIL_USER_WORKSPACE_ID,
},
operation: EmailOperation.SEND,
});
expect(result.success).toBe(true);
expect(result.success && result.data.connectedAccount.id).toBe(
PHIL_CONNECTED_ACCOUNT_ID,
);
} finally {
await deleteConnectedAccount(appConnectedAccountId);
}
});
it('skips an older SSO connection when there is no caller (workflow run)', async () => {
const oidcConnectedAccountId = '20202020-0000-4000-8000-0000000000a3';
await insertNonMailboxConnectedAccount({
id: oidcConnectedAccountId,
provider: 'oidc',
userWorkspaceId: PHIL_USER_WORKSPACE_ID,
});
try {
const firstWorkspaceConnectedAccountId =
await getFirstWorkspaceConnectedAccountId();
const result = await service.composeEmail({
parameters: baseParams,
context: { workspaceId: WORKSPACE_ID },
operation: EmailOperation.SEND,
});
expect(result.success).toBe(true);
expect(result.success && result.data.connectedAccount.id).toBe(
firstWorkspaceConnectedAccountId,
);
expect(result.success && result.data.connectedAccount.id).not.toBe(
oidcConnectedAccountId,
);
} finally {
await deleteConnectedAccount(oidcConnectedAccountId);
}
});
it('falls back to an account shared with the whole workspace', async () => {
const { visibility } = await readConnectedAccountState(
JONY_CONNECTED_ACCOUNT_ID,
);
await setVisibility(JONY_CONNECTED_ACCOUNT_ID, 'workspace');
try {
const result = await service.composeEmail({
parameters: baseParams,
context: {
workspaceId: WORKSPACE_ID,
userWorkspaceId: UNKNOWN_USER_WORKSPACE_ID,
},
operation: EmailOperation.SEND,
});
expect(result.success).toBe(true);
expect(result.success && result.data.connectedAccount.id).toBe(
JONY_CONNECTED_ACCOUNT_ID,
);
} finally {
await setVisibility(JONY_CONNECTED_ACCOUNT_ID, visibility);
}
});
it('throws rather than composing from a colleague account', async () => {
const sharedConnectedAccountIds =
await getWorkspaceSharedConnectedAccountIds();
for (const connectedAccountId of sharedConnectedAccountIds) {
await setVisibility(connectedAccountId, 'user');
}
try {
await expect(
service.composeEmail({
parameters: baseParams,
context: {
workspaceId: WORKSPACE_ID,
userWorkspaceId: UNKNOWN_USER_WORKSPACE_ID,
},
operation: EmailOperation.SEND,
}),
).rejects.toThrow('available for user workspace');
} finally {
for (const connectedAccountId of sharedConnectedAccountIds) {
await setVisibility(connectedAccountId, 'workspace');
}
}
});
it('takes the first workspace account when there is no caller (workflow run)', async () => {
const firstWorkspaceConnectedAccountId =
await getFirstWorkspaceConnectedAccountId();
const result = await service.composeEmail({
parameters: baseParams,
context: { workspaceId: WORKSPACE_ID },
operation: EmailOperation.SEND,
});
expect(result.success).toBe(true);
expect(result.success && result.data.connectedAccount.id).toBe(
firstWorkspaceConnectedAccountId,
);
});
});
});

View File

@@ -0,0 +1,108 @@
import { randomUUID } from 'node:crypto';
import {
CalendarChannelVisibility,
ConnectedAccountProvider,
} from 'twenty-shared/types';
import { FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED } from 'twenty-shared/constants';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
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 { makeGraphqlAPIRequestWithMemberRole } from 'test/integration/graphql/utils/make-graphql-api-request-with-member-role.util';
import { googleCalendarEvent } from 'test/integration/google/mocks/google-calendar-event.util';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import { runCalendarChannelEventsImport } from 'test/integration/utils/run-calendar-channel-events-import.util';
import { runCalendarChannelListFetch } from 'test/integration/utils/run-calendar-channel-list-fetch.util';
const HANDLE = 'google-calendar-visibility@apple.dev';
const RESTRICTED = FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED;
describe('Calendar channel visibility (integration)', () => {
const eventTitle = `Calendar event ${randomUUID()}`;
const gmail = setupGoogleMock({ handle: HANDLE });
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
const calendarEventQuery = () =>
findManyOperationFactory({
objectMetadataSingularName: 'calendarEvent',
objectMetadataPluralName: 'calendarEvents',
gqlFields: 'title description',
filter: { title: { eq: eventTitle } },
});
const readEventAs = async (
makeRequest:
| typeof makeGraphqlAPIRequest
| typeof makeGraphqlAPIRequestWithMemberRole,
) => {
const response = await makeRequest(calendarEventQuery());
expect(response.body.errors).toBeUndefined();
return response.body.data.calendarEvents.edges.map(
(edge: { node: { title: string; description: string } }) => edge.node,
);
};
const setVisibility = async (visibility: CalendarChannelVisibility) => {
await getCoreRepository<CalendarChannelEntity>(
CalendarChannelEntity,
).update({ id: channel.calendarChannelId }, { visibility });
};
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
gmail.serveCalendarEvents([
googleCalendarEvent({
summary: eventTitle,
description: 'Agenda for the meeting',
}),
]);
await runCalendarChannelListFetch(channel.calendarChannelId);
await runCalendarChannelEventsImport(channel.calendarChannelId);
}, 120000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
it('shows the full event to another member when the channel shares everything', async () => {
await setVisibility(CalendarChannelVisibility.SHARE_EVERYTHING);
const [event] = await readEventAs(makeGraphqlAPIRequestWithMemberRole);
expect(event.title).toBe(eventTitle);
expect(event.description).not.toBe(RESTRICTED);
}, 60000);
it('masks the title and description for another member under metadata visibility', async () => {
await setVisibility(CalendarChannelVisibility.METADATA);
const [event] = await readEventAs(makeGraphqlAPIRequestWithMemberRole);
expect(event.title).toBe(RESTRICTED);
expect(event.description).toBe(RESTRICTED);
}, 60000);
it('always shows the full event to the owner of the connected account', async () => {
await setVisibility(CalendarChannelVisibility.METADATA);
const [event] = await readEventAs(makeGraphqlAPIRequest);
expect(event.title).toBe(eventTitle);
expect(event.description).not.toBe(RESTRICTED);
}, 60000);
});

View File

@@ -0,0 +1,102 @@
import { randomUUID } from 'node:crypto';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { googleCalendarEvent } from 'test/integration/google/mocks/google-calendar-event.util';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import {
findRecordIdsByFilter,
findRecordNodesByFilter,
} from 'test/integration/utils/find-records-by-filter.util';
import { deleteConnectedAccount } from 'test/integration/utils/query-messaging.util';
import { runCalendarChannelEventsImport } from 'test/integration/utils/run-calendar-channel-events-import.util';
import { runCalendarChannelListFetch } from 'test/integration/utils/run-calendar-channel-list-fetch.util';
const HANDLE = 'calendar-cleanup@apple.dev';
describe('Calendar connected account cleanup (integration)', () => {
const eventId = `google-calendar-event-${randomUUID()}`;
const gmail = setupGoogleMock({ handle: HANDLE });
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
gmail.serveCalendarEvents([
googleCalendarEvent({
id: eventId,
attendees: [
{ email: `organizer-${eventId}@example.com`, organizer: true },
{ email: `attendee-${eventId}@example.com` },
],
}),
]);
await runCalendarChannelListFetch(channel.calendarChannelId);
await runCalendarChannelEventsImport(channel.calendarChannelId);
}, 60000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
it('deletes all associated calendar data when the connected account is removed', async () => {
const associations = await findRecordNodesByFilter<{
id: string;
calendarEventId: string;
}>(
'calendarChannelEventAssociation',
'calendarChannelEventAssociations',
`id
calendarEventId`,
{ calendarChannelId: { eq: channel.calendarChannelId } },
);
expect(associations).toHaveLength(1);
const eventIds = associations.map(
(association) => association.calendarEventId,
);
expect(
await findRecordIdsByFilter('calendarEvent', 'calendarEvents', {
id: { in: eventIds },
}),
).not.toHaveLength(0);
expect(
await findRecordIdsByFilter(
'calendarEventParticipant',
'calendarEventParticipants',
{ calendarEventId: { in: eventIds } },
),
).not.toHaveLength(0);
await deleteConnectedAccount(channel.connectedAccountId);
expect(
await findRecordIdsByFilter(
'calendarChannelEventAssociation',
'calendarChannelEventAssociations',
{ calendarChannelId: { eq: channel.calendarChannelId } },
),
).toHaveLength(0);
expect(
await findRecordIdsByFilter('calendarEvent', 'calendarEvents', {
id: { in: eventIds },
}),
).toHaveLength(0);
expect(
await findRecordIdsByFilter(
'calendarEventParticipant',
'calendarEventParticipants',
{ calendarEventId: { in: eventIds } },
),
).toHaveLength(0);
}, 60000);
});

View File

@@ -0,0 +1,94 @@
import { randomUUID } from 'node:crypto';
import {
CalendarChannelContactAutoCreationPolicy,
ConnectedAccountProvider,
} from 'twenty-shared/types';
import { googleCalendarEvent } from 'test/integration/google/mocks/google-calendar-event.util';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { findCreatedPeopleEmails } from 'test/integration/utils/find-imported-records.util';
import {
queryCalendarChannel,
updateCalendarChannel,
} from 'test/integration/utils/query-messaging.util';
import { resetCalendarChannelSyncState } from 'test/integration/utils/reset-channel-sync-state.util';
import { runCalendarChannelEventsImport } from 'test/integration/utils/run-calendar-channel-events-import.util';
import { runCalendarChannelListFetch } from 'test/integration/utils/run-calendar-channel-list-fetch.util';
const HANDLE = 'google-calendar-contact-creation@apple.dev';
describe('Calendar contact auto-creation (integration)', () => {
const gmail = setupGoogleMock({ handle: HANDLE });
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
const syncEventWithAttendee = async (attendee: string) => {
gmail.serveCalendarEvents(
[
googleCalendarEvent({
summary: `Calendar event ${randomUUID()}`,
attendees: [{ email: attendee }],
}),
],
{ nextSyncToken: `sync-token-${randomUUID()}` },
);
await resetCalendarChannelSyncState(channel.calendarChannelId, '');
await runCalendarChannelListFetch(channel.calendarChannelId);
await runCalendarChannelEventsImport(channel.calendarChannelId);
return findCreatedPeopleEmails([attendee]);
};
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
}, 60000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
it('creates a person for an attendee when auto-creation is enabled', async () => {
await updateCalendarChannel(channel.calendarChannelId, {
isContactAutoCreationEnabled: true,
});
const attendee = `attendee-enabled-${randomUUID()}@acme.com`;
expect(await syncEventWithAttendee(attendee)).toEqual([attendee]);
}, 120000);
it('creates no person for an attendee when auto-creation is disabled', async () => {
await updateCalendarChannel(channel.calendarChannelId, {
isContactAutoCreationEnabled: false,
});
const attendee = `attendee-disabled-${randomUUID()}@acme.com`;
expect(await syncEventWithAttendee(attendee)).toEqual([]);
}, 120000);
// Calendar contact creation is gated on isContactAutoCreationEnabled alone;
// the policy is stored and served but no import path reads it.
it.each(Object.values(CalendarChannelContactAutoCreationPolicy))(
'persists the %s contact auto-creation policy',
async (contactAutoCreationPolicy) => {
await updateCalendarChannel(channel.calendarChannelId, {
contactAutoCreationPolicy,
});
const channelState = await queryCalendarChannel(channel);
expect(channelState.contactAutoCreationPolicy).toBe(
contactAutoCreationPolicy,
);
},
60000,
);
});

View File

@@ -0,0 +1,433 @@
import { randomUUID } from 'node:crypto';
import { type calendar_v3 } from 'googleapis';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { googleCalendarEvent } from 'test/integration/google/mocks/google-calendar-event.util';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { findRecordNodesByFilter } from 'test/integration/utils/find-records-by-filter.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import { resetCalendarChannelSyncState } from 'test/integration/utils/reset-channel-sync-state.util';
import { runCalendarChannelEventsImport } from 'test/integration/utils/run-calendar-channel-events-import.util';
import { runCalendarChannelListFetch } from 'test/integration/utils/run-calendar-channel-list-fetch.util';
const HANDLE = 'calendar-event-save-operations@apple.dev';
type CalendarEventNode = {
id: string;
title: string;
location: string;
isFullDay: boolean;
};
type AssociationNode = {
id: string;
eventExternalId: string;
recurringEventExternalId: string;
calendarEventId: string;
};
type ParticipantNode = {
id: string;
handle: string;
displayName: string;
isOrganizer: boolean;
responseStatus: string;
calendarEventId: string;
};
describe('Calendar event save operations (integration)', () => {
const google = setupGoogleMock({ handle: HANDLE });
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
const importEvents = async (
events: calendar_v3.Schema$Event[],
): Promise<void> => {
google.serveCalendarEvents(events, {
nextSyncToken: `sync-token-${randomUUID()}`,
});
await resetCalendarChannelSyncState(channel.calendarChannelId, '');
await runCalendarChannelListFetch(channel.calendarChannelId);
await runCalendarChannelEventsImport(channel.calendarChannelId);
};
const importEvent = (event: calendar_v3.Schema$Event): Promise<void> =>
importEvents([event]);
const findEventsByTitle = (title: string): Promise<CalendarEventNode[]> =>
findRecordNodesByFilter<CalendarEventNode>(
'calendarEvent',
'calendarEvents',
'id title location isFullDay',
{ title: { eq: title } },
);
const findAssociationsByExternalId = (
eventExternalId: string,
): Promise<AssociationNode[]> =>
findRecordNodesByFilter<AssociationNode>(
'calendarChannelEventAssociation',
'calendarChannelEventAssociations',
'id eventExternalId recurringEventExternalId calendarEventId',
{ eventExternalId: { eq: eventExternalId } },
);
const findParticipantsByCalendarEventId = async (
calendarEventId: string,
): Promise<ParticipantNode[]> => {
const participants = await findRecordNodesByFilter<ParticipantNode>(
'calendarEventParticipant',
'calendarEventParticipants',
'id handle displayName isOrganizer responseStatus calendarEventId',
{ calendarEventId: { eq: calendarEventId } },
);
return participants.sort((a, b) => a.handle.localeCompare(b.handle));
};
const countParticipantsByHandleSuffix = async (
handleSuffix: string,
): Promise<number> => {
const rows: { count: string }[] =
await getCoreRepository<CalendarChannelEntity>(
CalendarChannelEntity,
).manager.query(
`SELECT count(*) AS count FROM "${getWorkspaceSchemaName(
SEED_APPLE_WORKSPACE_ID,
)}"."calendarEventParticipant" WHERE handle LIKE $1`,
[`%${handleSuffix}`],
);
return Number(rows[0].count);
};
const countParticipantsByHandleSuffixAndDisplayName = async (
handleSuffix: string,
displayName: string,
): Promise<number> => {
const rows: { count: string }[] =
await getCoreRepository<CalendarChannelEntity>(
CalendarChannelEntity,
).manager.query(
`SELECT count(*) AS count FROM "${getWorkspaceSchemaName(
SEED_APPLE_WORKSPACE_ID,
)}"."calendarEventParticipant" WHERE handle LIKE $1 AND "displayName" = $2`,
[`%${handleSuffix}`, displayName],
);
return Number(rows[0].count);
};
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
}, 60000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
it('should insert the event, its channel association and its participants on a first import', async () => {
const eventExternalId = `google-calendar-event-${randomUUID()}`;
const title = `Calendar event ${randomUUID()}`;
const attendee = `attendee-${randomUUID()}@acme.com`;
await importEvent(
googleCalendarEvent({
id: eventExternalId,
summary: title,
location: 'Room 1',
attendees: [
{
email: attendee,
displayName: 'Ada Lovelace',
organizer: true,
responseStatus: 'accepted',
},
],
}),
);
const events = await findEventsByTitle(title);
expect(events).toHaveLength(1);
expect(events[0].location).toBe('Room 1');
expect(events[0].isFullDay).toBe(false);
const associations = await findAssociationsByExternalId(eventExternalId);
expect(associations).toHaveLength(1);
expect(associations[0].calendarEventId).toBe(events[0].id);
expect(associations[0].recurringEventExternalId).toBe('');
const participants = await findParticipantsByCalendarEventId(events[0].id);
expect(participants).toHaveLength(1);
expect(participants[0].handle).toBe(attendee);
expect(participants[0].displayName).toBe('Ada Lovelace');
expect(participants[0].isOrganizer).toBe(true);
expect(participants[0].responseStatus).toBe('ACCEPTED');
}, 120000);
it('should update the existing event and association rather than insert a second one when the same external event is imported again', async () => {
const eventExternalId = `google-calendar-event-${randomUUID()}`;
const initialTitle = `Calendar event ${randomUUID()}`;
const updatedTitle = `Calendar event ${randomUUID()}`;
const recurringEventExternalId = `recurring-${randomUUID()}`;
const attendee = `attendee-${randomUUID()}@acme.com`;
await importEvent(
googleCalendarEvent({
id: eventExternalId,
summary: initialTitle,
location: 'Room 1',
attendees: [{ email: attendee }],
}),
);
const [initialEvent] = await findEventsByTitle(initialTitle);
await importEvent(
googleCalendarEvent({
id: eventExternalId,
summary: updatedTitle,
location: 'Room 2',
recurringEventId: recurringEventExternalId,
attendees: [{ email: attendee }],
}),
);
expect(await findEventsByTitle(initialTitle)).toHaveLength(0);
const updatedEvents = await findEventsByTitle(updatedTitle);
expect(updatedEvents).toHaveLength(1);
expect(updatedEvents[0].id).toBe(initialEvent.id);
expect(updatedEvents[0].location).toBe('Room 2');
const associations = await findAssociationsByExternalId(eventExternalId);
expect(associations).toHaveLength(1);
expect(associations[0].recurringEventExternalId).toBe(
recurringEventExternalId,
);
}, 180000);
it('should update an existing participant in place when its attributes change', async () => {
const eventExternalId = `google-calendar-event-${randomUUID()}`;
const title = `Calendar event ${randomUUID()}`;
const attendee = `attendee-${randomUUID()}@acme.com`;
await importEvent(
googleCalendarEvent({
id: eventExternalId,
summary: title,
attendees: [
{
email: attendee,
displayName: 'Before',
responseStatus: 'needsAction',
},
],
}),
);
const [event] = await findEventsByTitle(title);
const [initialParticipant] = await findParticipantsByCalendarEventId(
event.id,
);
expect(initialParticipant.displayName).toBe('Before');
expect(initialParticipant.responseStatus).toBe('NEEDS_ACTION');
await importEvent(
googleCalendarEvent({
id: eventExternalId,
summary: title,
attendees: [
{
email: attendee,
displayName: 'After',
organizer: true,
responseStatus: 'declined',
},
],
}),
);
const participants = await findParticipantsByCalendarEventId(event.id);
expect(participants).toHaveLength(1);
expect(participants[0].id).toBe(initialParticipant.id);
expect(participants[0].displayName).toBe('After');
expect(participants[0].isOrganizer).toBe(true);
expect(participants[0].responseStatus).toBe('DECLINED');
}, 180000);
it('should insert an added attendee and delete a removed one while leaving the unchanged attendee alone', async () => {
const eventExternalId = `google-calendar-event-${randomUUID()}`;
const title = `Calendar event ${randomUUID()}`;
const keptAttendee = `attendee-kept-${randomUUID()}@acme.com`;
const removedAttendee = `attendee-removed-${randomUUID()}@acme.com`;
const addedAttendee = `attendee-added-${randomUUID()}@acme.com`;
await importEvent(
googleCalendarEvent({
id: eventExternalId,
summary: title,
attendees: [{ email: keptAttendee }, { email: removedAttendee }],
}),
);
const [event] = await findEventsByTitle(title);
const initialParticipants = await findParticipantsByCalendarEventId(
event.id,
);
expect(
initialParticipants.map((participant) => participant.handle),
).toEqual([keptAttendee, removedAttendee].sort());
const initialKeptParticipant = initialParticipants.find(
(participant) => participant.handle === keptAttendee,
);
await importEvent(
googleCalendarEvent({
id: eventExternalId,
summary: title,
attendees: [{ email: keptAttendee }, { email: addedAttendee }],
}),
);
const participants = await findParticipantsByCalendarEventId(event.id);
expect(participants.map((participant) => participant.handle)).toEqual(
[keptAttendee, addedAttendee].sort(),
);
const keptParticipant = participants.find(
(participant) => participant.handle === keptAttendee,
);
expect(keptParticipant?.id).toBe(initialKeptParticipant?.id);
}, 180000);
it('should insert every participant when a single import spans more than one insert chunk', async () => {
const titlePrefix = `Calendar event ${randomUUID()}`;
const attendeeSuffix = `chunked-${randomUUID()}@acme.com`;
const events = Array.from({ length: 100 }, (_unused, eventIndex) =>
googleCalendarEvent({
id: `google-calendar-event-${eventIndex}-${randomUUID()}`,
summary: `${titlePrefix} ${eventIndex}`,
attendees: [
{ email: `a-${eventIndex}-${attendeeSuffix}` },
{ email: `b-${eventIndex}-${attendeeSuffix}` },
{ email: `c-${eventIndex}-${attendeeSuffix}` },
],
}),
);
await importEvents(events);
expect(await countParticipantsByHandleSuffix(attendeeSuffix)).toBe(300);
}, 300000);
it('should update every participant when a re-import spans more than one update chunk', async () => {
const titlePrefix = `Calendar event ${randomUUID()}`;
const eventIdSuffix = randomUUID();
const attendeeSuffix = `rechunked-${randomUUID()}@acme.com`;
const buildEvents = (displayName: string): calendar_v3.Schema$Event[] =>
Array.from({ length: 100 }, (_unused, eventIndex) =>
googleCalendarEvent({
id: `google-calendar-event-${eventIndex}-${eventIdSuffix}`,
summary: `${titlePrefix} ${eventIndex}`,
attendees: [
{ email: `a-${eventIndex}-${attendeeSuffix}`, displayName },
{ email: `b-${eventIndex}-${attendeeSuffix}`, displayName },
{ email: `c-${eventIndex}-${attendeeSuffix}`, displayName },
],
}),
);
await importEvents(buildEvents('Before'));
expect(
await countParticipantsByHandleSuffixAndDisplayName(
attendeeSuffix,
'Before',
),
).toBe(300);
await importEvents(buildEvents('After'));
expect(await countParticipantsByHandleSuffix(attendeeSuffix)).toBe(300);
expect(
await countParticipantsByHandleSuffixAndDisplayName(
attendeeSuffix,
'After',
),
).toBe(300);
}, 300000);
it('should update exactly one row and leave the other untouched when two participants share a handle on the same event', async () => {
const eventExternalId = `google-calendar-event-${randomUUID()}`;
const title = `Calendar event ${randomUUID()}`;
const attendee = `attendee-${randomUUID()}@acme.com`;
await importEvent(
googleCalendarEvent({
id: eventExternalId,
summary: title,
attendees: [{ email: attendee, displayName: 'Original' }],
}),
);
const [event] = await findEventsByTitle(title);
const duplicateResponse = await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'calendarEventParticipant',
gqlFields: 'id',
data: {
calendarEventId: event.id,
handle: attendee,
displayName: 'Duplicate',
},
}),
);
expect(duplicateResponse.body.errors).toBeUndefined();
expect(await findParticipantsByCalendarEventId(event.id)).toHaveLength(2);
await importEvent(
googleCalendarEvent({
id: eventExternalId,
summary: title,
attendees: [{ email: attendee, displayName: 'Reconciled' }],
}),
);
const participants = await findParticipantsByCalendarEventId(event.id);
expect(participants).toHaveLength(2);
expect(
participants.filter(
(participant) => participant.displayName === 'Reconciled',
),
).toHaveLength(1);
}, 240000);
});

View File

@@ -0,0 +1,76 @@
import { randomUUID } from 'node:crypto';
import {
CalendarChannelSyncStage,
ConnectedAccountProvider,
} from 'twenty-shared/types';
import { googleCalendarEvent } from 'test/integration/google/mocks/google-calendar-event.util';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { findImportedCalendarEventTitles } from 'test/integration/utils/find-imported-records.util';
import { queryCalendarChannel } from 'test/integration/utils/query-messaging.util';
import { runCalendarChannelEventsImport } from 'test/integration/utils/run-calendar-channel-events-import.util';
import { runCalendarChannelListFetch } from 'test/integration/utils/run-calendar-channel-list-fetch.util';
const HANDLE = 'google-calendar-events-import@apple.dev';
describe('Google calendar events import (integration)', () => {
const gmail = setupGoogleMock({ handle: HANDLE });
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
}, 60000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
it('imports calendar events through the real list-fetch and import pipeline', async () => {
const eventTitle = `Calendar event ${randomUUID()}`;
gmail.serveCalendarEvents([googleCalendarEvent({ summary: eventTitle })]);
await runCalendarChannelListFetch(channel.calendarChannelId);
const channelState = await queryCalendarChannel(channel);
expect(channelState.syncStage).toBe(
CalendarChannelSyncStage.CALENDAR_EVENTS_IMPORT_PENDING,
);
await runCalendarChannelEventsImport(channel.calendarChannelId);
expect(await findImportedCalendarEventTitles([eventTitle])).toEqual([
eventTitle,
]);
}, 60000);
it('imports a newly created event through the sync-token incremental fetch', async () => {
const newEventTitle = `Calendar event ${randomUUID()}`;
gmail.serveCalendarEvents(
[googleCalendarEvent({ summary: newEventTitle })],
{ nextSyncToken: 'mock-calendar-sync-token-2' },
);
await runCalendarChannelListFetch(channel.calendarChannelId);
const channelState = await queryCalendarChannel(channel);
expect(channelState.syncStage).toBe(
CalendarChannelSyncStage.CALENDAR_EVENTS_IMPORT_PENDING,
);
await runCalendarChannelEventsImport(channel.calendarChannelId);
expect(await findImportedCalendarEventTitles([newEventTitle])).toEqual([
newEventTitle,
]);
}, 60000);
});

View File

@@ -0,0 +1,210 @@
import {
CalendarChannelSyncStage,
CalendarChannelSyncStatus,
ConnectedAccountProvider,
} from 'twenty-shared/types';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import {
type GoogleApiFailure,
setupGoogleMock,
} from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import {
queryCalendarChannel,
queryConnectedAccount,
} from 'test/integration/utils/query-messaging.util';
import { resetCalendarChannelSyncState } from 'test/integration/utils/reset-channel-sync-state.util';
import { runCalendarChannelListFetch } from 'test/integration/utils/run-calendar-channel-list-fetch.util';
const HANDLE = 'calendar-import-error-mapping@apple.dev';
// Providers return 401 and 403 transiently, so revoking on them disconnects
// working accounts. Only a response that proves the grant is gone revokes.
const INSUFFICIENT_PERMISSIONS_FAILURES: [string, GoogleApiFailure][] = [
[
'a 400 invalid_grant response',
{
status: 400,
reason: 'invalid_grant',
message: 'Token has been expired or revoked.',
},
],
];
const TEMPORARY_FAILURES: [string, GoogleApiFailure][] = [
[
'a 401 unauthorized response',
{ status: 401, reason: 'authError', message: 'Invalid Credentials' },
],
[
'a 403 response that is not a rate limit',
{
status: 403,
reason: 'insufficientPermissions',
message: 'Request had insufficient authentication scopes.',
},
],
[
'a 500 response with an unmapped reason',
{ status: 500, reason: 'somethingElse', message: 'Internal error' },
],
[
'a response with an unmapped status',
{ status: 418, reason: 'teapot', message: 'I am a teapot' },
],
[
'a 403 rateLimitExceeded response',
{
status: 403,
reason: 'rateLimitExceeded',
message: 'Rate Limit Exceeded',
},
],
[
'a 403 userRateLimitExceeded response',
{
status: 403,
reason: 'userRateLimitExceeded',
message: 'User Rate Limit Exceeded',
},
],
[
'a 400 failedPrecondition response',
{
status: 400,
reason: 'failedPrecondition',
message: 'Precondition check failed.',
},
],
[
'a 500 backendError response',
{ status: 500, reason: 'backendError', message: 'Backend Error' },
],
[
'a 500 internal_failure response',
{ status: 500, reason: 'internal_failure', message: 'Internal failure' },
],
];
const UNKNOWN_FAILURES: [string, GoogleApiFailure][] = [
[
'a 400 response with an unmapped reason',
{ status: 400, reason: 'invalidArgument', message: 'Invalid query' },
],
];
describe('Google Calendar import error mapping (integration)', () => {
const google = setupGoogleMock({ handle: HANDLE });
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
}, 60000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
beforeEach(async () => {
await resetCalendarChannelSyncState(channel.calendarChannelId);
await getCoreRepository<ConnectedAccountEntity>(
ConnectedAccountEntity,
).update({ id: channel.connectedAccountId }, { authFailedAt: null });
});
it.each(INSUFFICIENT_PERMISSIONS_FAILURES)(
'fails the calendar channel as insufficient-permissions on %s',
async (_label, failure) => {
google.failCalendarEventList(failure);
await runCalendarChannelListFetch(channel.calendarChannelId);
const channelState = await queryCalendarChannel(channel);
expect(channelState.syncStatus).toBe(
CalendarChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS,
);
expect(channelState.syncStage).toBe(CalendarChannelSyncStage.FAILED);
const account = await queryConnectedAccount(channel.connectedAccountId);
expect(account.authFailedAt).not.toBeNull();
},
60000,
);
it.each(TEMPORARY_FAILURES)(
'throttles the calendar channel without failing it on %s',
async (_label, failure) => {
google.failCalendarEventList(failure);
await runCalendarChannelListFetch(channel.calendarChannelId);
const channelState = await queryCalendarChannel(channel);
expect(channelState.throttleFailureCount).toBe(1);
expect(channelState.syncStage).toBe(
CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_PENDING,
);
expect(channelState.syncStatus).not.toBe(
CalendarChannelSyncStatus.FAILED_UNKNOWN,
);
expect(channelState.syncStatus).not.toBe(
CalendarChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS,
);
},
60000,
);
it.each(UNKNOWN_FAILURES)(
'fails the calendar channel as unknown on %s',
async (_label, failure) => {
google.failCalendarEventList(failure);
await runCalendarChannelListFetch(channel.calendarChannelId);
const channelState = await queryCalendarChannel(channel);
expect(channelState.syncStatus).toBe(
CalendarChannelSyncStatus.FAILED_UNKNOWN,
);
expect(channelState.syncStage).toBe(CalendarChannelSyncStage.FAILED);
},
60000,
);
it('leaves the calendar channel untouched on a 404 during list fetch', async () => {
google.failCalendarEventList({
status: 404,
reason: 'notFound',
message: 'Not Found',
});
await runCalendarChannelListFetch(channel.calendarChannelId);
const channelState = await queryCalendarChannel(channel);
expect(channelState.syncStatus).not.toBe(
CalendarChannelSyncStatus.FAILED_UNKNOWN,
);
expect(channelState.syncStatus).not.toBe(
CalendarChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS,
);
expect(channelState.throttleFailureCount).toBe(0);
const storedChannel = await getCoreRepository<CalendarChannelEntity>(
CalendarChannelEntity,
).findOneByOrFail({ id: channel.calendarChannelId });
expect(storedChannel.syncCursor).toBe('reset-cursor');
}, 60000);
});

View File

@@ -0,0 +1,188 @@
import { randomUUID } from 'node:crypto';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { googleCalendarEvent } from 'test/integration/google/mocks/google-calendar-event.util';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { findCalendarEventAssociationChannelIds } from 'test/integration/utils/find-calendar-event-association-channel-ids.util';
import { findImportedCalendarEventTitles } from 'test/integration/utils/find-imported-records.util';
import { runCalendarChannelEventsImport } from 'test/integration/utils/run-calendar-channel-events-import.util';
import { runCalendarChannelListFetch } from 'test/integration/utils/run-calendar-channel-list-fetch.util';
const HANDLE = 'google-calendar-orphan-cleanup@apple.dev';
const OTHER_HANDLE = 'google-calendar-orphan-cleanup-other@apple.dev';
const syncToken = () => `orphan-cleanup-sync-token-${randomUUID()}`;
const runCalendarSync = async (calendarChannelId: string): Promise<void> => {
await runCalendarChannelListFetch(calendarChannelId);
await runCalendarChannelEventsImport(calendarChannelId);
};
describe('Google calendar orphan cleanup (integration)', () => {
const google = setupGoogleMock({ handle: HANDLE });
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
let otherChannel: Awaited<ReturnType<typeof connectMessagingAccount>>;
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
google.actAsAccount(OTHER_HANDLE);
otherChannel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: OTHER_HANDLE,
});
google.actAsAccount(HANDLE);
}, 120000);
afterAll(async () => {
await otherChannel?.cleanup().catch(() => undefined);
await channel?.cleanup().catch(() => undefined);
});
it('deletes the calendar event once its last association is removed', async () => {
const eventExternalId = `google-calendar-event-${randomUUID()}`;
const eventTitle = `Orphan probe ${randomUUID()}`;
google.serveCalendarEvents(
[googleCalendarEvent({ id: eventExternalId, summary: eventTitle })],
{ nextSyncToken: syncToken() },
);
await runCalendarSync(channel.calendarChannelId);
expect(await findImportedCalendarEventTitles([eventTitle])).toEqual([
eventTitle,
]);
expect(
await findCalendarEventAssociationChannelIds(eventExternalId),
).toEqual([channel.calendarChannelId]);
google.serveCalendarEvents(
[
googleCalendarEvent({
id: eventExternalId,
summary: eventTitle,
status: 'cancelled',
}),
],
{ nextSyncToken: syncToken() },
);
await runCalendarSync(channel.calendarChannelId);
expect(
await findCalendarEventAssociationChannelIds(eventExternalId),
).toEqual([]);
expect(await findImportedCalendarEventTitles([eventTitle])).toEqual([]);
}, 60000);
it('keeps the events of the same channel that were not cancelled', async () => {
const cancelledExternalId = `google-calendar-event-${randomUUID()}`;
const cancelledTitle = `Orphan probe cancelled ${randomUUID()}`;
const survivingTitle = `Orphan probe surviving ${randomUUID()}`;
google.serveCalendarEvents(
[
googleCalendarEvent({
id: cancelledExternalId,
summary: cancelledTitle,
}),
googleCalendarEvent({ summary: survivingTitle }),
],
{ nextSyncToken: syncToken() },
);
await runCalendarSync(channel.calendarChannelId);
expect(
await findImportedCalendarEventTitles([cancelledTitle, survivingTitle]),
).toEqual([cancelledTitle, survivingTitle].sort());
google.serveCalendarEvents(
[
googleCalendarEvent({
id: cancelledExternalId,
summary: cancelledTitle,
status: 'cancelled',
}),
],
{ nextSyncToken: syncToken() },
);
await runCalendarSync(channel.calendarChannelId);
expect(await findImportedCalendarEventTitles([cancelledTitle])).toEqual([]);
expect(await findImportedCalendarEventTitles([survivingTitle])).toEqual([
survivingTitle,
]);
}, 60000);
it('keeps another channel copy of the same external event', async () => {
const sharedExternalId = `google-calendar-event-${randomUUID()}`;
const sharedTitle = `Orphan probe shared ${randomUUID()}`;
google.serveCalendarEvents(
[googleCalendarEvent({ id: sharedExternalId, summary: sharedTitle })],
{ nextSyncToken: syncToken() },
);
await runCalendarSync(channel.calendarChannelId);
await runCalendarSync(otherChannel.calendarChannelId);
expect(
await findCalendarEventAssociationChannelIds(sharedExternalId),
).toEqual(
[channel.calendarChannelId, otherChannel.calendarChannelId].sort(),
);
google.serveCalendarEvents(
[
googleCalendarEvent({
id: sharedExternalId,
summary: sharedTitle,
status: 'cancelled',
}),
],
{ nextSyncToken: syncToken() },
);
await runCalendarSync(channel.calendarChannelId);
expect(
await findCalendarEventAssociationChannelIds(sharedExternalId),
).toEqual([otherChannel.calendarChannelId]);
expect(await findImportedCalendarEventTitles([sharedTitle])).toEqual([
sharedTitle,
]);
}, 120000);
it('keeps previously imported events when the delta has no deletions', async () => {
const existingTitle = `Orphan probe existing ${randomUUID()}`;
const addedTitle = `Orphan probe added ${randomUUID()}`;
google.serveCalendarEvents(
[googleCalendarEvent({ summary: existingTitle })],
{ nextSyncToken: syncToken() },
);
await runCalendarSync(channel.calendarChannelId);
google.serveCalendarEvents([googleCalendarEvent({ summary: addedTitle })], {
nextSyncToken: syncToken(),
});
await runCalendarSync(channel.calendarChannelId);
expect(
await findImportedCalendarEventTitles([existingTitle, addedTitle]),
).toEqual([existingTitle, addedTitle].sort());
}, 60000);
});

View File

@@ -0,0 +1,90 @@
import {
CalendarChannelSyncStage,
ConnectedAccountProvider,
} from 'twenty-shared/types';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { CalendarOngoingStaleCronJob } from 'src/modules/calendar/calendar-event-import-manager/crons/jobs/calendar-ongoing-stale.cron.job';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import { queryCalendarChannel } from 'test/integration/utils/query-messaging.util';
import { runSyncCron } from 'test/integration/utils/run-sync-cron.util';
const STALE_HANDLE = 'calendar-stale-sync@apple.dev';
const RECENT_HANDLE = 'calendar-recent-sync@apple.dev';
const STALE_STARTED_AT = new Date(Date.now() - 61 * 60 * 1000);
const RECENT_STARTED_AT = new Date(Date.now() - 60 * 1000);
describe('Calendar stale-sync recovery (integration)', () => {
const gmail = setupGoogleMock({ handle: STALE_HANDLE });
let staleChannel: Awaited<ReturnType<typeof connectMessagingAccount>>;
let recentChannel: Awaited<ReturnType<typeof connectMessagingAccount>>;
beforeAll(async () => {
staleChannel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: STALE_HANDLE,
});
gmail.actAsAccount(RECENT_HANDLE);
recentChannel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: RECENT_HANDLE,
});
for (const connectedChannel of [staleChannel, recentChannel]) {
const channelState = await queryCalendarChannel(connectedChannel);
expect(channelState.syncStage).toBe(
CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_PENDING,
);
}
}, 120000);
afterAll(async () => {
await staleChannel?.cleanup().catch(() => undefined);
await recentChannel?.cleanup().catch(() => undefined);
});
it('resets a stale ongoing channel to pending and leaves a recent one running', async () => {
const calendarChannelRepository = getCoreRepository<CalendarChannelEntity>(
CalendarChannelEntity,
);
await calendarChannelRepository.update(
{ id: staleChannel.calendarChannelId },
{
syncStage: CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_ONGOING,
syncStageStartedAt: STALE_STARTED_AT,
},
);
await calendarChannelRepository.update(
{ id: recentChannel.calendarChannelId },
{
syncStage: CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_ONGOING,
syncStageStartedAt: RECENT_STARTED_AT,
},
);
await runSyncCron(CalendarOngoingStaleCronJob);
const staleChannelAfter = await queryCalendarChannel(staleChannel);
expect(staleChannelAfter.syncStage).toBe(
CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_PENDING,
);
expect(staleChannelAfter.syncStageStartedAt).toBeNull();
const recentChannelAfter = await queryCalendarChannel(recentChannel);
expect(recentChannelAfter.syncStage).toBe(
CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_ONGOING,
);
}, 60000);
});

View File

@@ -0,0 +1,151 @@
import {
CalendarChannelSyncStage,
CalendarChannelSyncStatus,
ConnectedAccountProvider,
} from 'twenty-shared/types';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { CALENDAR_THROTTLE_MAX_ATTEMPTS } from 'src/modules/calendar/calendar-event-import-manager/constants/calendar-throttle-max-attempts';
import { CalendarEventListFetchCronJob } from 'src/modules/calendar/calendar-event-import-manager/crons/jobs/calendar-event-list-fetch.cron.job';
import { CalendarRelaunchFailedCalendarChannelsCronJob } from 'src/modules/calendar/calendar-event-import-manager/crons/jobs/calendar-relaunch-failed-calendar-channels.cron.job';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import {
queryCalendarChannel,
queryConnectedAccount,
updateCalendarChannel,
} from 'test/integration/utils/query-messaging.util';
import { runSyncCron } from 'test/integration/utils/run-sync-cron.util';
const THROTTLED_HANDLE = 'calendar-throttled@apple.dev';
const REVOKED_HANDLE = 'calendar-revoked@apple.dev';
const ONE_HOUR_AGO = new Date(Date.now() - 60 * 60 * 1000);
const EXPIRED_CREDENTIALS_AT = new Date(Date.now() - 56 * 60 * 1000);
describe('Calendar sync failure lifecycle (integration)', () => {
const gmail = setupGoogleMock({ handle: THROTTLED_HANDLE });
let throttledChannel: Awaited<ReturnType<typeof connectMessagingAccount>>;
let revokedChannel: Awaited<ReturnType<typeof connectMessagingAccount>>;
beforeAll(async () => {
throttledChannel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: THROTTLED_HANDLE,
});
gmail.actAsAccount(REVOKED_HANDLE);
revokedChannel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: REVOKED_HANDLE,
});
await updateCalendarChannel(revokedChannel.calendarChannelId, {
isSyncEnabled: false,
});
}, 120000);
afterAll(async () => {
await throttledChannel?.cleanup().catch(() => undefined);
await revokedChannel?.cleanup().catch(() => undefined);
});
it('counts a throttle failure on a 429 and keeps the channel alive', async () => {
gmail.rateLimitCalendarEventList();
await runSyncCron(CalendarEventListFetchCronJob);
const channelState = await queryCalendarChannel(throttledChannel);
expect(channelState.throttleFailureCount).toBe(1);
expect(channelState.syncStatus).not.toBe(
CalendarChannelSyncStatus.FAILED_UNKNOWN,
);
}, 60000);
it('fails the channel as unknown once the throttle attempts are exhausted', async () => {
gmail.rateLimitCalendarEventList();
const calendarChannelRepository = getCoreRepository<CalendarChannelEntity>(
CalendarChannelEntity,
);
let channelState = await queryCalendarChannel(throttledChannel);
for (
let attempt = channelState.throttleFailureCount;
attempt <= CALENDAR_THROTTLE_MAX_ATTEMPTS &&
channelState.syncStatus !== CalendarChannelSyncStatus.FAILED_UNKNOWN;
attempt++
) {
await calendarChannelRepository.update(
{ id: throttledChannel.calendarChannelId },
{ syncStageStartedAt: ONE_HOUR_AGO },
);
await runSyncCron(CalendarEventListFetchCronJob);
channelState = await queryCalendarChannel(throttledChannel);
}
expect(channelState.syncStatus).toBe(
CalendarChannelSyncStatus.FAILED_UNKNOWN,
);
expect(channelState.syncStage).toBe(CalendarChannelSyncStage.FAILED);
}, 120000);
it('fails the channel as insufficient-permissions when the refresh token is declined', async () => {
await updateCalendarChannel(revokedChannel.calendarChannelId, {
isSyncEnabled: true,
});
await getCoreRepository<ConnectedAccountEntity>(
ConnectedAccountEntity,
).update(
{ id: revokedChannel.connectedAccountId },
{ lastCredentialsRefreshedAt: EXPIRED_CREDENTIALS_AT },
);
gmail.declineTokenRefresh();
await runSyncCron(CalendarEventListFetchCronJob);
const channelState = await queryCalendarChannel(revokedChannel);
expect(channelState.syncStatus).toBe(
CalendarChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS,
);
expect(channelState.syncStage).toBe(CalendarChannelSyncStage.FAILED);
const account = await queryConnectedAccount(
revokedChannel.connectedAccountId,
);
expect(account.authFailedAt).not.toBeNull();
}, 60000);
it('relaunches the unknown-failure channel and leaves the permissions-failure channel untouched', async () => {
await runSyncCron(CalendarRelaunchFailedCalendarChannelsCronJob);
const relaunchedChannel = await queryCalendarChannel(throttledChannel);
expect(relaunchedChannel.syncStatus).toBe(CalendarChannelSyncStatus.ACTIVE);
expect(relaunchedChannel.syncStage).toBe(
CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_PENDING,
);
expect(relaunchedChannel.throttleFailureCount).toBe(0);
expect(relaunchedChannel.syncStageStartedAt).toBeNull();
const untouchedChannel = await queryCalendarChannel(revokedChannel);
expect(untouchedChannel.syncStatus).toBe(
CalendarChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS,
);
expect(untouchedChannel.syncStage).toBe(CalendarChannelSyncStage.FAILED);
}, 60000);
});

View File

@@ -0,0 +1,270 @@
import { randomUUID } from 'node:crypto';
import {
CalendarChannelSyncStage,
CalendarChannelSyncStatus,
ConnectedAccountProvider,
} from 'twenty-shared/types';
import { POSTGRESQL_ERROR_CODES } from 'src/engine/api/graphql/workspace-query-runner/constants/postgres-error-codes.constants';
import { CalendarEventParticipantService } from 'src/modules/calendar/calendar-event-participant-manager/services/calendar-event-participant.service';
import { googleCalendarEvent } from 'test/integration/google/mocks/google-calendar-event.util';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { getAppProviderByClassName } from 'test/integration/utils/get-app-provider-by-class-name.util';
import { raiseSqlState } from 'test/integration/utils/raise-sql-state.util';
import { readBackendState } from 'test/integration/utils/read-backend-state.util';
import { queryCalendarChannel } from 'test/integration/utils/query-messaging.util';
import { resetCalendarChannelSyncState } from 'test/integration/utils/reset-channel-sync-state.util';
import { runCalendarChannelEventsImport } from 'test/integration/utils/run-calendar-channel-events-import.util';
import { runCalendarChannelListFetch } from 'test/integration/utils/run-calendar-channel-list-fetch.util';
const HANDLE = 'calendar-transient-database-error@apple.dev';
// The error has to be raised by Postgres itself: an Error built in the jest
// realm is not an `instanceof Error` for the application realm the app runs in.
describe('Calendar import transient database errors (integration)', () => {
const google = setupGoogleMock({ handle: HANDLE });
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
let calendarEventParticipantService: CalendarEventParticipantService;
const fetchEvent = async ({
eventId,
title,
attendees,
}: {
eventId: string;
title: string;
attendees: string[];
}): Promise<void> => {
google.serveCalendarEvents(
[
googleCalendarEvent({
id: eventId,
summary: title,
attendees: attendees.map((email) => ({ email })),
}),
],
{ nextSyncToken: `sync-token-${randomUUID()}` },
);
await resetCalendarChannelSyncState(channel.calendarChannelId, '');
await runCalendarChannelListFetch(channel.calendarChannelId);
};
const fetchOneEvent = async (): Promise<void> => {
await fetchEvent({
eventId: `google-calendar-event-${randomUUID()}`,
title: `Calendar event ${randomUUID()}`,
attendees: [`attendee-${randomUUID()}@acme.com`],
});
};
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
calendarEventParticipantService =
getAppProviderByClassName<CalendarEventParticipantService>(
'CalendarEventParticipantService',
);
}, 60000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should fail the calendar channel as unknown when a unique violation aborts the import transaction', async () => {
await fetchOneEvent();
jest
.spyOn(calendarEventParticipantService, 'writeCalendarEventParticipants')
.mockImplementation(async ({ transactionScope }) => {
await transactionScope.executeRawQuery(
raiseSqlState(POSTGRESQL_ERROR_CODES.UNIQUE_VIOLATION),
);
});
await runCalendarChannelEventsImport(channel.calendarChannelId);
const channelState = await queryCalendarChannel(channel);
expect(channelState.syncStatus).toBe(
CalendarChannelSyncStatus.FAILED_UNKNOWN,
);
expect(channelState.syncStage).toBe(CalendarChannelSyncStage.FAILED);
expect(channelState.throttleFailureCount).toBe(0);
}, 120000);
it('should reschedule the calendar channel when the import transaction is killed by the idle-in-transaction timeout', async () => {
await fetchOneEvent();
jest
.spyOn(calendarEventParticipantService, 'writeCalendarEventParticipants')
.mockImplementation(async ({ transactionScope }) => {
await transactionScope.executeRawQuery(
raiseSqlState(
POSTGRESQL_ERROR_CODES.IDLE_IN_TRANSACTION_SESSION_TIMEOUT,
),
);
});
await runCalendarChannelEventsImport(channel.calendarChannelId);
const channelState = await queryCalendarChannel(channel);
expect(channelState.throttleFailureCount).toBe(1);
expect(channelState.syncStage).toBe(
CalendarChannelSyncStage.CALENDAR_EVENTS_IMPORT_PENDING,
);
expect(channelState.syncStatus).toBe(CalendarChannelSyncStatus.ONGOING);
}, 120000);
it('should have ended the import transaction before the contact creation job is enqueued and the participants are matched', async () => {
await fetchOneEvent();
const writeCalendarEventParticipants =
calendarEventParticipantService.writeCalendarEventParticipants.bind(
calendarEventParticipantService,
);
const matchParticipantsAndEnqueueContactCreationJob =
calendarEventParticipantService.matchParticipantsAndEnqueueContactCreationJob.bind(
calendarEventParticipantService,
);
let importBackendPid: number | undefined;
let importBackendStateWhileMatching: string | undefined;
jest
.spyOn(calendarEventParticipantService, 'writeCalendarEventParticipants')
.mockImplementation(async (args) => {
const backends = await args.transactionScope.executeRawQuery(
'SELECT pg_backend_pid() AS pid',
);
importBackendPid = Number(backends[0].pid);
return writeCalendarEventParticipants(args);
});
jest
.spyOn(
calendarEventParticipantService,
'matchParticipantsAndEnqueueContactCreationJob',
)
.mockImplementation(async (args) => {
importBackendStateWhileMatching = await readBackendState(
importBackendPid ?? 0,
);
return matchParticipantsAndEnqueueContactCreationJob(args);
});
await runCalendarChannelEventsImport(channel.calendarChannelId);
expect(importBackendPid).toEqual(expect.any(Number));
expect(importBackendStateWhileMatching).toBeDefined();
expect(importBackendStateWhileMatching).not.toBe('idle in transaction');
}, 120000);
it('should return only the newly saved participants without appending to the caller participant list when an existing event gains an attendee', async () => {
const eventId = `google-calendar-event-${randomUUID()}`;
const title = `Calendar event ${randomUUID()}`;
const knownAttendee = `attendee-known-${randomUUID()}@acme.com`;
const newAttendee = `attendee-new-${randomUUID()}@acme.com`;
await fetchEvent({ eventId, title, attendees: [knownAttendee] });
await runCalendarChannelEventsImport(channel.calendarChannelId);
await fetchEvent({
eventId,
title,
attendees: [knownAttendee, newAttendee],
});
const writeCalendarEventParticipants =
calendarEventParticipantService.writeCalendarEventParticipants.bind(
calendarEventParticipantService,
);
let insertedHandles: string[] = [];
let updatedParticipantCount = -1;
jest
.spyOn(calendarEventParticipantService, 'writeCalendarEventParticipants')
.mockImplementation(async (args) => {
insertedHandles = args.operations.participantsToInsert.map(
(participant) => participant.handle,
);
updatedParticipantCount = args.operations.participantsToUpdate.length;
return writeCalendarEventParticipants(args);
});
await runCalendarChannelEventsImport(channel.calendarChannelId);
expect(insertedHandles).toEqual([newAttendee]);
expect(updatedParticipantCount).toBe(1);
}, 180000);
it('should read and compute outside the transaction and only write inside it', async () => {
await fetchOneEvent();
const calls: string[] = [];
const findCalendarEventParticipantsByCalendarEventIds =
calendarEventParticipantService.findCalendarEventParticipantsByCalendarEventIds.bind(
calendarEventParticipantService,
);
const writeCalendarEventParticipants =
calendarEventParticipantService.writeCalendarEventParticipants.bind(
calendarEventParticipantService,
);
const matchParticipantsAndEnqueueContactCreationJob =
calendarEventParticipantService.matchParticipantsAndEnqueueContactCreationJob.bind(
calendarEventParticipantService,
);
jest
.spyOn(
calendarEventParticipantService,
'findCalendarEventParticipantsByCalendarEventIds',
)
.mockImplementation(async (args) => {
calls.push('read');
return findCalendarEventParticipantsByCalendarEventIds(args);
});
jest
.spyOn(calendarEventParticipantService, 'writeCalendarEventParticipants')
.mockImplementation(async (args) => {
calls.push('write');
return writeCalendarEventParticipants(args);
});
jest
.spyOn(
calendarEventParticipantService,
'matchParticipantsAndEnqueueContactCreationJob',
)
.mockImplementation(async (args) => {
calls.push('match');
return matchParticipantsAndEnqueueContactCreationJob(args);
});
await runCalendarChannelEventsImport(channel.calendarChannelId);
expect(calls).toEqual(['read', 'write', 'match']);
}, 120000);
});

View File

@@ -0,0 +1,127 @@
import { randomUUID } from 'node:crypto';
import {
CalendarChannelSyncStage,
ConnectedAccountProvider,
} from 'twenty-shared/types';
import { CALENDAR_EVENT_WEBHOOK_SYNC_INLINE_IMPORT_MAX_EVENTS } from 'src/modules/connected-account-sync-webhooks/calendar-event-webhook-sync/constants/calendar-event-webhook-sync-inline-import-max-events.constant';
import { googleCalendarEvent } from 'test/integration/google/mocks/google-calendar-event.util';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { findImportedCalendarEventTitles } from 'test/integration/utils/find-imported-records.util';
import { queryCalendarChannel } from 'test/integration/utils/query-messaging.util';
import { runCalendarChannelEventsImport } from 'test/integration/utils/run-calendar-channel-events-import.util';
import { runCalendarChannelWebhookSync } from 'test/integration/utils/run-calendar-channel-webhook-sync.util';
const HANDLE = 'google-calendar-webhook-sync@apple.dev';
describe('Google calendar webhook sync (integration)', () => {
const google = setupGoogleMock({ handle: HANDLE });
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
}, 60000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
it('imports a new event in a single job without a separate import run', async () => {
const eventTitle = `Webhook event ${randomUUID()}`;
google.serveCalendarEvents([googleCalendarEvent({ summary: eventTitle })], {
nextSyncToken: `webhook-sync-token-${randomUUID()}`,
});
await runCalendarChannelWebhookSync(channel.calendarChannelId);
expect(await findImportedCalendarEventTitles([eventTitle])).toEqual([
eventTitle,
]);
const channelState = await queryCalendarChannel(channel);
expect(channelState.syncStage).toBe(
CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_PENDING,
);
}, 60000);
it('applies an update to an already imported event', async () => {
const eventExternalId = `google-calendar-event-${randomUUID()}`;
const originalTitle = `Webhook event ${randomUUID()}`;
const updatedTitle = `Webhook event updated ${randomUUID()}`;
google.serveCalendarEvents(
[googleCalendarEvent({ id: eventExternalId, summary: originalTitle })],
{ nextSyncToken: `webhook-sync-token-${randomUUID()}` },
);
await runCalendarChannelWebhookSync(channel.calendarChannelId);
expect(await findImportedCalendarEventTitles([originalTitle])).toEqual([
originalTitle,
]);
google.serveCalendarEvents(
[googleCalendarEvent({ id: eventExternalId, summary: updatedTitle })],
{ nextSyncToken: `webhook-sync-token-${randomUUID()}` },
);
await runCalendarChannelWebhookSync(channel.calendarChannelId);
expect(await findImportedCalendarEventTitles([updatedTitle])).toEqual([
updatedTitle,
]);
expect(await findImportedCalendarEventTitles([originalTitle])).toEqual([]);
}, 60000);
it('leaves a delta larger than the inline cap to the import cron', async () => {
const eventTitles = Array.from(
{ length: CALENDAR_EVENT_WEBHOOK_SYNC_INLINE_IMPORT_MAX_EVENTS + 1 },
() => `Webhook bulk event ${randomUUID()}`,
);
google.serveCalendarEvents(
eventTitles.map((summary) => googleCalendarEvent({ summary })),
{ nextSyncToken: `webhook-sync-token-${randomUUID()}` },
);
await runCalendarChannelWebhookSync(channel.calendarChannelId);
expect(await findImportedCalendarEventTitles(eventTitles)).toEqual([]);
const channelState = await queryCalendarChannel(channel);
expect(channelState.syncStage).toBe(
CalendarChannelSyncStage.CALENDAR_EVENTS_IMPORT_PENDING,
);
await runCalendarChannelEventsImport(channel.calendarChannelId);
expect(await findImportedCalendarEventTitles(eventTitles)).toEqual(
[...eventTitles].sort(),
);
}, 120000);
it('does not sync when the channel is already being synced', async () => {
const eventTitle = `Webhook event ${randomUUID()}`;
google.serveCalendarEvents([googleCalendarEvent({ summary: eventTitle })], {
nextSyncToken: `webhook-sync-token-${randomUUID()}`,
});
await runCalendarChannelWebhookSync(
channel.calendarChannelId,
CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_ONGOING,
);
expect(await findImportedCalendarEventTitles([eventTitle])).toEqual([]);
}, 60000);
});

View File

@@ -0,0 +1,221 @@
import { randomUUID } from 'node:crypto';
import { BlocklistScope, ConnectedAccountProvider } from 'twenty-shared/types';
import { WORKSPACE_MEMBER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/workspace-member-data-seeds.constant';
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 { googleCalendarEvent } from 'test/integration/google/mocks/google-calendar-event.util';
import { getGmailMessageSubject } from 'test/integration/google/mocks/gmail-message-subject.util';
import { gmailMessage } from 'test/integration/google/mocks/gmail-message.util';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import {
findImportedCalendarEventTitles,
findImportedMessageSubjects,
} from 'test/integration/utils/find-imported-records.util';
import { runCalendarChannelEventsImport } from 'test/integration/utils/run-calendar-channel-events-import.util';
import { runCalendarChannelListFetch } from 'test/integration/utils/run-calendar-channel-list-fetch.util';
import { resetMessageChannelSyncState } from 'test/integration/utils/reset-channel-sync-state.util';
import { runMessageChannelSync } from 'test/integration/utils/run-message-channel-sync.util';
import { waitForAllJobsToFinish } from 'test/integration/utils/wait-for-all-jobs-to-finish.util';
const HANDLE = 'gmail-blocklist-cleanup@apple.dev';
const BLOCKED_HANDLE = `blocked-${randomUUID()}@acme.com`;
const WORKSPACE_BLOCKED_HANDLE = `workspace-blocked-${randomUUID()}@acme.com`;
describe('Blocklist cleanup (integration)', () => {
const blockedMessage = gmailMessage({ from: BLOCKED_HANDLE, to: HANDLE });
const workspaceBlockedMessage = gmailMessage({
from: WORKSPACE_BLOCKED_HANDLE,
to: HANDLE,
});
const keptMessage = gmailMessage({
from: `kept-${randomUUID()}@acme.com`,
to: HANDLE,
});
const blockedSubject = getGmailMessageSubject(blockedMessage);
const workspaceBlockedSubject = getGmailMessageSubject(
workspaceBlockedMessage,
);
const keptSubject = getGmailMessageSubject(keptMessage);
const blockedEventTitle = `Blocked event ${randomUUID()}`;
const keptEventTitle = `Kept event ${randomUUID()}`;
const inbox = [blockedMessage, workspaceBlockedMessage, keptMessage];
const gmail = setupGoogleMock({ handle: HANDLE, inbox });
const blocklistIds: string[] = [];
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
await runMessageChannelSync(channel.channelId);
gmail.serveCalendarEvents([
googleCalendarEvent({
summary: blockedEventTitle,
attendees: [{ email: BLOCKED_HANDLE }],
}),
googleCalendarEvent({
summary: keptEventTitle,
attendees: [{ email: `kept-attendee-${randomUUID()}@acme.com` }],
}),
]);
await runCalendarChannelListFetch(channel.calendarChannelId);
await runCalendarChannelEventsImport(channel.calendarChannelId);
}, 180000);
afterAll(async () => {
for (const blocklistId of blocklistIds) {
await makeGraphqlAPIRequest(
destroyOneOperationFactory({
objectMetadataSingularName: 'blocklist',
gqlFields: 'id',
recordId: blocklistId,
}),
).catch(() => undefined);
}
await channel?.cleanup().catch(() => undefined);
});
it('imports every message and the calendar event before anything is blocked', async () => {
expect(
await findImportedMessageSubjects([
blockedSubject,
workspaceBlockedSubject,
keptSubject,
]),
).toEqual([blockedSubject, workspaceBlockedSubject, keptSubject].sort());
expect(
await findImportedCalendarEventTitles([
blockedEventTitle,
keptEventTitle,
]),
).toEqual([blockedEventTitle, keptEventTitle].sort());
}, 60000);
it('deletes the blocked handle messages and keeps the calendar events imported before the block', async () => {
const response = await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'blocklist',
gqlFields: 'id handle',
data: {
handle: BLOCKED_HANDLE,
workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.JANE,
},
}),
);
expect(response.body.errors).toBeUndefined();
blocklistIds.push(response.body.data.createBlocklist.id);
await waitForAllJobsToFinish();
expect(
await findImportedMessageSubjects([blockedSubject, keptSubject]),
).toEqual([keptSubject]);
expect(
await findImportedCalendarEventTitles([
blockedEventTitle,
keptEventTitle,
]),
).toEqual([blockedEventTitle, keptEventTitle].sort());
}, 120000);
it('deletes the messages of a handle blocked for the whole workspace', async () => {
const response = await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'blocklist',
gqlFields: 'id handle scope workspaceMemberId',
data: {
handle: WORKSPACE_BLOCKED_HANDLE,
scope: BlocklistScope.WORKSPACE,
},
}),
);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.createBlocklist.workspaceMemberId).toBeNull();
blocklistIds.push(response.body.data.createBlocklist.id);
await waitForAllJobsToFinish();
expect(
await findImportedMessageSubjects([workspaceBlockedSubject, keptSubject]),
).toEqual([keptSubject]);
}, 120000);
it('never imports new messages or calendar events involving a workspace-blocked handle', async () => {
const resyncBlockedMessage = gmailMessage({
from: WORKSPACE_BLOCKED_HANDLE,
to: HANDLE,
});
const resyncKeptMessage = gmailMessage({
from: `resync-kept-${randomUUID()}@acme.com`,
to: HANDLE,
});
const resyncBlockedSubject = getGmailMessageSubject(resyncBlockedMessage);
const resyncKeptSubject = getGmailMessageSubject(resyncKeptMessage);
inbox.push(resyncBlockedMessage, resyncKeptMessage);
gmail.serveMessageList([resyncBlockedMessage, resyncKeptMessage]);
await resetMessageChannelSyncState(channel.channelId, '');
await runMessageChannelSync(channel.channelId);
await waitForAllJobsToFinish();
expect(
await findImportedMessageSubjects([
resyncBlockedSubject,
resyncKeptSubject,
]),
).toEqual([resyncKeptSubject]);
const blockedAttendeeEventTitle = `Workspace blocked event ${randomUUID()}`;
const importedEventTitle = `Workspace kept event ${randomUUID()}`;
gmail.serveCalendarEvents(
[
googleCalendarEvent({
summary: blockedAttendeeEventTitle,
attendees: [{ email: WORKSPACE_BLOCKED_HANDLE }],
}),
googleCalendarEvent({
summary: importedEventTitle,
attendees: [
{ email: `workspace-kept-attendee-${randomUUID()}@acme.com` },
],
}),
],
{ nextSyncToken: 'mock-calendar-sync-token-workspace-blocklist' },
);
await runCalendarChannelListFetch(channel.calendarChannelId);
await runCalendarChannelEventsImport(channel.calendarChannelId);
await waitForAllJobsToFinish();
expect(
await findImportedCalendarEventTitles([
blockedAttendeeEventTitle,
importedEventTitle,
]),
).toEqual([importedEventTitle]);
}, 180000);
});

View File

@@ -0,0 +1,94 @@
import { randomUUID } from 'node:crypto';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import {
CalendarChannelDeletionCleanupJob,
type CalendarChannelDeletionCleanupJobData,
} from 'src/modules/calendar/calendar-event-cleaner/jobs/calendar-channel-deletion-cleanup.job';
import {
MessagingMessageChannelDeletionCleanupJob,
type MessagingMessageChannelDeletionCleanupJobData,
} from 'src/modules/messaging/message-cleaner/jobs/messaging-message-channel-deletion-cleanup.job';
import { googleCalendarEvent } from 'test/integration/google/mocks/google-calendar-event.util';
import { getGmailMessageSubject } from 'test/integration/google/mocks/gmail-message-subject.util';
import { gmailMessage } from 'test/integration/google/mocks/gmail-message.util';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { enqueueJobAndDrain } from 'test/integration/utils/enqueue-job-and-drain.util';
import {
findImportedCalendarEventTitles,
findImportedMessageSubjects,
} from 'test/integration/utils/find-imported-records.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import { runCalendarChannelEventsImport } from 'test/integration/utils/run-calendar-channel-events-import.util';
import { runCalendarChannelListFetch } from 'test/integration/utils/run-calendar-channel-list-fetch.util';
import { runMessageChannelSync } from 'test/integration/utils/run-message-channel-sync.util';
const HANDLE = 'gmail-channel-deletion-cleanup@apple.dev';
describe('Channel deletion cleanup (integration)', () => {
const inbox = [gmailMessage()];
const expectedSubject = getGmailMessageSubject(inbox[0]);
const eventTitle = `Calendar event ${randomUUID()}`;
const gmail = setupGoogleMock({ handle: HANDLE, inbox });
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
let workspaceId: string;
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
workspaceId = (
await getCoreRepository<MessageChannelEntity>(
MessageChannelEntity,
).findOneByOrFail({ id: channel.channelId })
).workspaceId;
await runMessageChannelSync(channel.channelId);
gmail.serveCalendarEvents([googleCalendarEvent({ summary: eventTitle })]);
await runCalendarChannelListFetch(channel.calendarChannelId);
await runCalendarChannelEventsImport(channel.calendarChannelId);
}, 180000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
it('drops the imported messages when the message channel is cleaned up', async () => {
expect(await findImportedMessageSubjects([expectedSubject])).toEqual([
expectedSubject,
]);
await enqueueJobAndDrain<MessagingMessageChannelDeletionCleanupJobData>(
MessageQueue.messagingQueue,
MessagingMessageChannelDeletionCleanupJob.name,
{ workspaceId, messageChannelId: channel.channelId },
);
expect(await findImportedMessageSubjects([expectedSubject])).toEqual([]);
}, 120000);
it('drops the imported calendar events when the calendar channel is cleaned up', async () => {
expect(await findImportedCalendarEventTitles([eventTitle])).toEqual([
eventTitle,
]);
await enqueueJobAndDrain<CalendarChannelDeletionCleanupJobData>(
MessageQueue.calendarQueue,
CalendarChannelDeletionCleanupJob.name,
{ workspaceId, calendarChannelId: channel.calendarChannelId },
);
expect(await findImportedCalendarEventTitles([eventTitle])).toEqual([]);
}, 120000);
});

View File

@@ -0,0 +1,88 @@
import {
ConnectedAccountProvider,
MessageChannelSyncStage,
MessageChannelSyncStatus,
} from 'twenty-shared/types';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { gmailMessage } from 'test/integration/google/mocks/gmail-message.util';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import { queryMessageChannel } from 'test/integration/utils/query-messaging.util';
import { resetMessageChannelSyncState } from 'test/integration/utils/reset-channel-sync-state.util';
import { runMessageChannelSync } from 'test/integration/utils/run-message-channel-sync.util';
const HANDLE = 'gmail-channel-misconfiguration@apple.dev';
describe('Gmail channel misconfiguration (integration)', () => {
// A full sync has no cursor to store when the first message carries no
// historyId.
const inbox = [gmailMessage({ historyId: undefined })];
setupGoogleMock({ handle: HANDLE, inbox });
const connectedAccountRepository = getCoreRepository<ConnectedAccountEntity>(
ConnectedAccountEntity,
);
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
let handleAliases: string[] | null;
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
handleAliases = (
await connectedAccountRepository.findOneByOrFail({
id: channel.connectedAccountId,
})
).handleAliases;
}, 60000);
afterAll(async () => {
await connectedAccountRepository
.update({ id: channel?.connectedAccountId }, { handleAliases })
.catch(() => undefined);
await channel?.cleanup().catch(() => undefined);
});
beforeEach(async () => {
await resetMessageChannelSyncState(channel.channelId, '');
});
it('fails the channel when the connected account has no handle aliases', async () => {
await connectedAccountRepository.update(
{ id: channel.connectedAccountId },
{ handleAliases: null },
);
await runMessageChannelSync(channel.channelId);
const channelState = await queryMessageChannel(channel);
expect(channelState.syncStatus).toBe(
MessageChannelSyncStatus.FAILED_UNKNOWN,
);
expect(channelState.syncStage).toBe(MessageChannelSyncStage.FAILED);
}, 60000);
it('fails the channel when the full sync yields no next sync cursor', async () => {
await connectedAccountRepository.update(
{ id: channel.connectedAccountId },
{ handleAliases },
);
await runMessageChannelSync(channel.channelId);
const channelState = await queryMessageChannel(channel);
expect(channelState.syncStatus).toBe(
MessageChannelSyncStatus.FAILED_UNKNOWN,
);
expect(channelState.syncStage).toBe(MessageChannelSyncStage.FAILED);
}, 60000);
});

View File

@@ -0,0 +1,109 @@
import {
ConnectedAccountProvider,
MessageChannelVisibility,
} from 'twenty-shared/types';
import { FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED } from 'twenty-shared/constants';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
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 { makeGraphqlAPIRequestWithMemberRole } from 'test/integration/graphql/utils/make-graphql-api-request-with-member-role.util';
import { getGmailMessageSubject } from 'test/integration/google/mocks/gmail-message-subject.util';
import { gmailMessage } from 'test/integration/google/mocks/gmail-message.util';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import { runMessageChannelSync } from 'test/integration/utils/run-message-channel-sync.util';
const HANDLE = 'gmail-channel-visibility@apple.dev';
const RESTRICTED = FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED;
describe('Message channel visibility (integration)', () => {
const inbox = [gmailMessage()];
const subject = getGmailMessageSubject(inbox[0]);
setupGoogleMock({ handle: HANDLE, inbox });
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
const messageQuery = () =>
findManyOperationFactory({
objectMetadataSingularName: 'message',
objectMetadataPluralName: 'messages',
gqlFields: 'subject text',
filter: { subject: { eq: subject } },
});
const readMessageAs = async (
makeRequest:
| typeof makeGraphqlAPIRequest
| typeof makeGraphqlAPIRequestWithMemberRole,
) => {
const response = await makeRequest(messageQuery());
expect(response.body.errors).toBeUndefined();
return response.body.data.messages.edges.map(
(edge: { node: { subject: string; text: string } }) => edge.node,
);
};
const setVisibility = async (visibility: MessageChannelVisibility) => {
await getCoreRepository<MessageChannelEntity>(MessageChannelEntity).update(
{ id: channel.channelId },
{ visibility },
);
};
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
await runMessageChannelSync(channel.channelId);
}, 120000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
it('shows the full message to another member when the channel shares everything', async () => {
await setVisibility(MessageChannelVisibility.SHARE_EVERYTHING);
const [message] = await readMessageAs(makeGraphqlAPIRequestWithMemberRole);
expect(message.subject).toBe(subject);
expect(message.text).not.toBe(RESTRICTED);
}, 60000);
it('masks the body but keeps the subject for another member under subject visibility', async () => {
await setVisibility(MessageChannelVisibility.SUBJECT);
const [message] = await readMessageAs(makeGraphqlAPIRequestWithMemberRole);
expect(message.subject).toBe(subject);
expect(message.text).toBe(RESTRICTED);
}, 60000);
it('masks both the subject and the body for another member under metadata visibility', async () => {
await setVisibility(MessageChannelVisibility.METADATA);
const [message] = await readMessageAs(makeGraphqlAPIRequestWithMemberRole);
expect(message.subject).toBe(RESTRICTED);
expect(message.text).toBe(RESTRICTED);
}, 60000);
it('always shows the full message to the owner of the connected account', async () => {
await setVisibility(MessageChannelVisibility.METADATA);
const [message] = await readMessageAs(makeGraphqlAPIRequest);
expect(message.subject).toBe(subject);
expect(message.text).not.toBe(RESTRICTED);
}, 60000);
});

View File

@@ -0,0 +1,211 @@
import { randomUUID } from 'node:crypto';
import { type gmail_v1 } from 'googleapis';
import {
ConnectedAccountProvider,
MessageChannelContactAutoCreationPolicy,
} from 'twenty-shared/types';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { gmailMessage } from 'test/integration/google/mocks/gmail-message.util';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { createManyOperationFactory } from 'test/integration/graphql/utils/create-many-operation-factory.util';
import { deleteOneOperationFactory } from 'test/integration/graphql/utils/delete-one-operation-factory.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { mergeManyOperationFactory } from 'test/integration/graphql/utils/merge-many-operation-factory.util';
import { updateOneOperationFactory } from 'test/integration/graphql/utils/update-one-operation-factory.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { deleteRecordsByIds } from 'test/integration/utils/delete-records-by-ids';
import { findRecordNodesByFilter } from 'test/integration/utils/find-records-by-filter.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import { resetMessageChannelSyncState } from 'test/integration/utils/reset-channel-sync-state.util';
import { runMessageChannelSync } from 'test/integration/utils/run-message-channel-sync.util';
import { waitForTimelineActivities } from 'test/integration/utils/wait-for-timeline-activities.util';
const HANDLE = 'gmail-company-domain-matching@apple.dev';
describe('Gmail contact auto-creation company domain matching (integration)', () => {
const inbox: gmail_v1.Schema$Message[] = [];
const gmail = setupGoogleMock({ handle: HANDLE, inbox });
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
const createdCompanyIds: string[] = [];
const createCompany = async (primaryLinkUrl: string) => {
const createResponse = await makeGraphqlAPIRequest(
createManyOperationFactory({
objectMetadataSingularName: 'company',
objectMetadataPluralName: 'companies',
gqlFields: 'id domainName { primaryLinkUrl }',
data: [
{
name: `Company ${randomUUID()}`,
domainName: { primaryLinkUrl },
},
],
}),
);
const [company] = createResponse.body.data.createCompanies;
createdCompanyIds.push(company.id);
return company;
};
const mergeTwoCompanies = async ({
survivingDomainName,
mergedAwayDomainName,
}: {
survivingDomainName: string;
mergedAwayDomainName: string;
}) => {
const survivingCompany = await createCompany(
`https://${survivingDomainName}`,
);
const mergedAwayCompany = await createCompany(
`https://www.${mergedAwayDomainName}`,
);
await waitForTimelineActivities('targetCompanyId', [
survivingCompany.id,
mergedAwayCompany.id,
]);
const mergeResponse = await makeGraphqlAPIRequest(
mergeManyOperationFactory({
objectMetadataPluralName: 'companies',
gqlFields: 'id domainName { primaryLinkUrl secondaryLinks }',
ids: [survivingCompany.id, mergedAwayCompany.id],
conflictPriorityIndex: 0,
}),
);
expect(mergeResponse.body.errors).toBeUndefined();
return mergeResponse.body.data.mergeCompanies;
};
const syncMessageFrom = async (sender: string) => {
const messages = [gmailMessage({ from: sender, to: HANDLE })];
inbox.push(...messages);
gmail.serveMessageList(messages);
await resetMessageChannelSyncState(channel.channelId, '');
await runMessageChannelSync(channel.channelId);
const [person] = await findRecordNodesByFilter<{
company: { id: string } | null;
}>('person', 'people', 'company { id }', {
emails: { primaryEmail: { eq: sender } },
});
return person?.company?.id;
};
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
await getCoreRepository<MessageChannelEntity>(MessageChannelEntity).update(
{ id: channel.channelId },
{
isContactAutoCreationEnabled: true,
contactAutoCreationPolicy:
MessageChannelContactAutoCreationPolicy.SENT_AND_RECEIVED,
excludeGroupEmails: false,
excludeNonProfessionalEmails: false,
},
);
}, 60000);
afterAll(async () => {
await deleteRecordsByIds('company', createdCompanyIds);
await channel?.cleanup().catch(() => undefined);
});
it('stores a domain name as its bare domain whatever spelling it was written with', async () => {
const domainName = `written-as-url-${randomUUID()}.com`;
const company = await createCompany(
`HTTPS://WWW.${domainName.toUpperCase()}/careers?utm=1`,
);
expect(company.domainName.primaryLinkUrl).toBe(domainName);
}, 120000);
it('attaches a contact to a company whose domain was written as a full url', async () => {
const domainName = `full-url-${randomUUID()}.com`;
const company = await createCompany(`https://www.${domainName}/careers`);
expect(await syncMessageFrom(`sender@${domainName}`)).toBe(company.id);
}, 120000);
it('attaches a contact to the surviving company when its domain was merged into secondary links', async () => {
const mergedAwayDomainName = `merged-away-${randomUUID()}.com`;
const mergedCompany = await mergeTwoCompanies({
survivingDomainName: `surviving-${randomUUID()}.com`,
mergedAwayDomainName,
});
expect(mergedCompany.domainName.secondaryLinks).toEqual([
expect.objectContaining({ url: mergedAwayDomainName }),
]);
expect(await syncMessageFrom(`sender@${mergedAwayDomainName}`)).toBe(
mergedCompany.id,
);
}, 120000);
it('prefers the live company holding the domain as a secondary link over a deleted one holding it as its primary', async () => {
const domainName = `contested-${randomUUID()}.com`;
const deletedCompany = await createCompany(domainName);
await makeGraphqlAPIRequest(
deleteOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: 'id',
recordId: deletedCompany.id,
}),
);
const liveCompany = await createCompany(`live-${randomUUID()}.com`);
await makeGraphqlAPIRequest(
updateOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: 'id domainName { secondaryLinks }',
recordId: liveCompany.id,
data: {
domainName: {
primaryLinkUrl: liveCompany.domainName.primaryLinkUrl,
secondaryLinks: [{ url: domainName, label: '' }],
},
},
}),
);
expect(await syncMessageFrom(`sender@${domainName}`)).toBe(liveCompany.id);
}, 120000);
it('attaches a contact to the surviving company on its primary domain', async () => {
const survivingDomainName = `surviving-${randomUUID()}.com`;
const mergedCompany = await mergeTwoCompanies({
survivingDomainName,
mergedAwayDomainName: `merged-away-${randomUUID()}.com`,
});
expect(await syncMessageFrom(`sender@${survivingDomainName}`)).toBe(
mergedCompany.id,
);
}, 120000);
});

View File

@@ -0,0 +1,120 @@
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { gmailMessage } from 'test/integration/google/mocks/gmail-message.util';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import {
findRecordIdsByFilter,
findRecordNodesByFilter,
} from 'test/integration/utils/find-records-by-filter.util';
import { deleteConnectedAccount } from 'test/integration/utils/query-messaging.util';
import { runMessageChannelSync } from 'test/integration/utils/run-message-channel-sync.util';
const HANDLE = 'messaging-cleanup@apple.dev';
describe('Messaging connected account cleanup (integration)', () => {
const inbox = [gmailMessage(), gmailMessage()];
setupGoogleMock({ handle: HANDLE, inbox });
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
await runMessageChannelSync(channel.channelId);
}, 60000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
it('deletes all associated messaging data when the connected account is removed', async () => {
const associations = await findRecordNodesByFilter<{
id: string;
messageId: string;
}>(
'messageChannelMessageAssociation',
'messageChannelMessageAssociations',
`id
messageId`,
{ messageChannelId: { eq: channel.channelId } },
);
expect(associations).toHaveLength(inbox.length);
const associationIds = associations.map((association) => association.id);
const messageIds = associations.map((association) => association.messageId);
const messages = await findRecordNodesByFilter<{
id: string;
messageThreadId: string | null;
}>(
'message',
'messages',
`id
messageThreadId`,
{ id: { in: messageIds } },
);
expect(messages).toHaveLength(inbox.length);
const threadIds = [
...new Set(messages.map((message) => message.messageThreadId)),
].filter(isDefined);
expect(
await findRecordIdsByFilter(
'messageChannelMessageAssociationMessageFolder',
'messageChannelMessageAssociationMessageFolders',
{ messageChannelMessageAssociationId: { in: associationIds } },
),
).not.toHaveLength(0);
expect(
await findRecordIdsByFilter('messageParticipant', 'messageParticipants', {
messageId: { in: messageIds },
}),
).not.toHaveLength(0);
expect(
await findRecordIdsByFilter('messageThread', 'messageThreads', {
id: { in: threadIds },
}),
).not.toHaveLength(0);
await deleteConnectedAccount(channel.connectedAccountId);
expect(
await findRecordIdsByFilter('message', 'messages', {
id: { in: messageIds },
}),
).toHaveLength(0);
expect(
await findRecordIdsByFilter(
'messageChannelMessageAssociation',
'messageChannelMessageAssociations',
{ messageChannelId: { eq: channel.channelId } },
),
).toHaveLength(0);
expect(
await findRecordIdsByFilter(
'messageChannelMessageAssociationMessageFolder',
'messageChannelMessageAssociationMessageFolders',
{ messageChannelMessageAssociationId: { in: associationIds } },
),
).toHaveLength(0);
expect(
await findRecordIdsByFilter('messageParticipant', 'messageParticipants', {
messageId: { in: messageIds },
}),
).toHaveLength(0);
expect(
await findRecordIdsByFilter('messageThread', 'messageThreads', {
id: { in: threadIds },
}),
).toHaveLength(0);
}, 60000);
});

View File

@@ -0,0 +1,185 @@
import { randomUUID } from 'node:crypto';
import { type gmail_v1 } from 'googleapis';
import {
ConnectedAccountProvider,
MessageChannelContactAutoCreationPolicy,
} from 'twenty-shared/types';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { gmailMessage } from 'test/integration/google/mocks/gmail-message.util';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { findCreatedPeopleEmails } from 'test/integration/utils/find-imported-records.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import { resetMessageChannelSyncState } from 'test/integration/utils/reset-channel-sync-state.util';
import { runMessageChannelSync } from 'test/integration/utils/run-message-channel-sync.util';
const HANDLE = 'gmail-contact-policy@apple.dev';
describe('Gmail contact auto-creation policy (integration)', () => {
const inbox: gmail_v1.Schema$Message[] = [];
const gmail = setupGoogleMock({ handle: HANDLE, inbox });
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
const syncConversationWith = async ({
inboundSender,
outboundRecipient,
channelSettings,
}: {
inboundSender: string;
outboundRecipient: string;
channelSettings: Pick<
MessageChannelEntity,
| 'contactAutoCreationPolicy'
| 'excludeGroupEmails'
| 'excludeNonProfessionalEmails'
> &
Partial<Pick<MessageChannelEntity, 'isContactAutoCreationEnabled'>>;
}) => {
const messages = [
gmailMessage({ from: inboundSender, to: HANDLE }),
gmailMessage({ from: HANDLE, to: outboundRecipient }),
];
inbox.push(...messages);
gmail.serveMessageList(messages);
await getCoreRepository<MessageChannelEntity>(MessageChannelEntity).update(
{ id: channel.channelId },
channelSettings,
);
await resetMessageChannelSyncState(channel.channelId, '');
await runMessageChannelSync(channel.channelId);
return findCreatedPeopleEmails([inboundSender, outboundRecipient]);
};
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
}, 60000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
it('creates no contact when the policy is NONE', async () => {
const inboundSender = `inbound-none-${randomUUID()}@acme.com`;
const outboundRecipient = `outbound-none-${randomUUID()}@acme.com`;
expect(
await syncConversationWith({
inboundSender,
outboundRecipient,
channelSettings: {
contactAutoCreationPolicy:
MessageChannelContactAutoCreationPolicy.NONE,
excludeGroupEmails: false,
excludeNonProfessionalEmails: false,
},
}),
).toEqual([]);
}, 60000);
it('creates only the recipient of a sent message when the policy is SENT', async () => {
const inboundSender = `inbound-sent-${randomUUID()}@acme.com`;
const outboundRecipient = `outbound-sent-${randomUUID()}@acme.com`;
expect(
await syncConversationWith({
inboundSender,
outboundRecipient,
channelSettings: {
contactAutoCreationPolicy:
MessageChannelContactAutoCreationPolicy.SENT,
excludeGroupEmails: false,
excludeNonProfessionalEmails: false,
},
}),
).toEqual([outboundRecipient]);
}, 60000);
it('creates both sides when the policy is SENT_AND_RECEIVED', async () => {
const inboundSender = `inbound-both-${randomUUID()}@acme.com`;
const outboundRecipient = `outbound-both-${randomUUID()}@acme.com`;
expect(
await syncConversationWith({
inboundSender,
outboundRecipient,
channelSettings: {
contactAutoCreationPolicy:
MessageChannelContactAutoCreationPolicy.SENT_AND_RECEIVED,
excludeGroupEmails: false,
excludeNonProfessionalEmails: false,
},
}),
).toEqual([inboundSender, outboundRecipient].sort());
}, 60000);
it('creates no contact when auto-creation is disabled on the channel', async () => {
const inboundSender = `inbound-disabled-${randomUUID()}@acme.com`;
const outboundRecipient = `outbound-disabled-${randomUUID()}@acme.com`;
expect(
await syncConversationWith({
inboundSender,
outboundRecipient,
channelSettings: {
isContactAutoCreationEnabled: false,
contactAutoCreationPolicy:
MessageChannelContactAutoCreationPolicy.SENT_AND_RECEIVED,
excludeGroupEmails: false,
excludeNonProfessionalEmails: false,
},
}),
).toEqual([]);
}, 60000);
it('skips group email addresses when they are excluded', async () => {
const inboundSender = `contact@acme-${randomUUID()}.com`;
const outboundRecipient = `outbound-group-${randomUUID()}@acme.com`;
expect(
await syncConversationWith({
inboundSender,
outboundRecipient,
channelSettings: {
isContactAutoCreationEnabled: true,
contactAutoCreationPolicy:
MessageChannelContactAutoCreationPolicy.SENT_AND_RECEIVED,
excludeGroupEmails: true,
excludeNonProfessionalEmails: false,
},
}),
).toEqual([outboundRecipient]);
}, 60000);
it('skips non-professional email addresses when they are excluded', async () => {
const inboundSender = `inbound-personal-${randomUUID()}@gmail.com`;
const outboundRecipient = `outbound-work-${randomUUID()}@acme.com`;
expect(
await syncConversationWith({
inboundSender,
outboundRecipient,
channelSettings: {
isContactAutoCreationEnabled: true,
contactAutoCreationPolicy:
MessageChannelContactAutoCreationPolicy.SENT_AND_RECEIVED,
excludeGroupEmails: false,
excludeNonProfessionalEmails: true,
},
}),
).toEqual([outboundRecipient]);
}, 60000);
});

View File

@@ -0,0 +1,94 @@
import { randomUUID } from 'node:crypto';
import {
ConnectedAccountProvider,
MessageChannelSyncStage,
MessageChannelSyncStatus,
MessageChannelType,
} from 'twenty-shared/types';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { MessagingRelaunchFailedMessageChannelsCronJob } from 'src/modules/messaging/message-import-manager/crons/jobs/messaging-relaunch-failed-message-channels.cron.job';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import { runSyncCron } from 'test/integration/utils/run-sync-cron.util';
const HANDLE = 'gmail-email-group-exclusion@apple.dev';
describe('Email group channel sync exclusion (integration)', () => {
setupGoogleMock({ handle: HANDLE });
const messageChannelRepository =
getCoreRepository<MessageChannelEntity>(MessageChannelEntity);
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
let emailGroupChannelId: string;
const readChannel = (id: string) =>
messageChannelRepository.findOneByOrFail({ id });
const failChannel = (id: string) =>
messageChannelRepository.update(
{ id },
{
syncStatus: MessageChannelSyncStatus.FAILED_UNKNOWN,
syncStage: MessageChannelSyncStage.FAILED,
throttleFailureCount: 3,
},
);
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
const emailChannel = await readChannel(channel.channelId);
// Cloned so columns added to the channel later do not break this insert.
const { id: _id, ...emailChannelColumns } = emailChannel;
const emailGroupChannel = await messageChannelRepository.save({
...emailChannelColumns,
handle: `group-${randomUUID()}@apple.dev`,
type: MessageChannelType.EMAIL_GROUP,
isSyncEnabled: true,
});
emailGroupChannelId = emailGroupChannel.id;
}, 60000);
afterAll(async () => {
await messageChannelRepository
.delete({ id: emailGroupChannelId })
.catch(() => undefined);
await channel?.cleanup().catch(() => undefined);
});
it('relaunches a failed email channel but never a failed email group channel', async () => {
await failChannel(channel.channelId);
await failChannel(emailGroupChannelId);
await runSyncCron(MessagingRelaunchFailedMessageChannelsCronJob);
const relaunchedChannel = await readChannel(channel.channelId);
expect(relaunchedChannel.syncStatus).toBe(MessageChannelSyncStatus.ACTIVE);
expect(relaunchedChannel.syncStage).toBe(
MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
);
expect(relaunchedChannel.throttleFailureCount).toBe(0);
const untouchedGroupChannel = await readChannel(emailGroupChannelId);
expect(untouchedGroupChannel.syncStatus).toBe(
MessageChannelSyncStatus.FAILED_UNKNOWN,
);
expect(untouchedGroupChannel.syncStage).toBe(
MessageChannelSyncStage.FAILED,
);
expect(untouchedGroupChannel.throttleFailureCount).toBe(3);
}, 120000);
});

View File

@@ -0,0 +1,79 @@
import {
ConnectedAccountProvider,
MessageFolderImportPolicy,
} from 'twenty-shared/types';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import {
queryMessageFolders,
updateMessageChannel,
} from 'test/integration/utils/query-messaging.util';
import { runMessageChannelSync } from 'test/integration/utils/run-message-channel-sync.util';
import { startChannelSyncAndAwait } from 'test/integration/utils/start-channel-sync-and-await.util';
const HANDLE = 'gmail-folder-discovery@apple.dev';
describe('Gmail folder discovery (integration)', () => {
const gmail = setupGoogleMock({
handle: HANDLE,
labels: [
{ id: 'INBOX', name: 'INBOX' },
{ id: 'SENT', name: 'SENT' },
{ id: 'Label_Work', name: 'Work' },
],
});
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
skipChannelConfiguration: false,
});
}, 60000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
it('syncs every folder discovered at connect time under the default all-folders policy', async () => {
await startChannelSyncAndAwait(channel.connectedAccountId);
const folders = await queryMessageFolders(channel.channelId);
expect(
Object.fromEntries(
folders.map((folder) => [folder.name, folder.isSynced]),
),
).toEqual({
INBOX: true,
SENT: true,
Work: true,
});
}, 60000);
it('leaves a folder discovered after switching to selected-folders unsynced', async () => {
await updateMessageChannel(channel.channelId, {
messageFolderImportPolicy: MessageFolderImportPolicy.SELECTED_FOLDERS,
});
gmail.labels.add({ id: 'Label_Archive', name: 'Archive' });
await runMessageChannelSync(channel.channelId);
const folders = await queryMessageFolders(channel.channelId);
expect(
Object.fromEntries(
folders.map((folder) => [folder.name, folder.isSynced]),
),
).toEqual({
INBOX: true,
SENT: true,
Work: true,
Archive: false,
});
}, 60000);
});

View File

@@ -0,0 +1,98 @@
import {
ConnectedAccountProvider,
MessageFolderPendingSyncAction,
} from 'twenty-shared/types';
import { getGmailMessageSubject } from 'test/integration/google/mocks/gmail-message-subject.util';
import { gmailMessage } from 'test/integration/google/mocks/gmail-message.util';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { findImportedMessageSubjects } from 'test/integration/utils/find-imported-records.util';
import {
type MessageFolderDto,
queryMessageFolders,
} from 'test/integration/utils/query-messaging.util';
import {
FOLDER_ACTION_NAMES,
MESSAGE_COUNT_ABOVE_V8_SPREAD_ARGUMENT_LIMIT,
runFolderActions,
runFolderActionsWithoutPendingActions,
} from 'test/integration/utils/run-folder-actions.util';
import { runMessageChannelSync } from 'test/integration/utils/run-message-channel-sync.util';
const HANDLE = 'gmail-folder-actions@apple.dev';
const DELETED_FOLDER_NAME = 'INBOX';
describe('Gmail folder actions (integration)', () => {
const inbox = [gmailMessage()];
const importedSubject = getGmailMessageSubject(inbox[0]);
setupGoogleMock({ handle: HANDLE, inbox });
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
let messageExternalIdsToImport: string[];
let messageExternalIdsWithoutPendingActions: string[];
let folders: MessageFolderDto[];
let subjectsBeforeFolderDeletion: string[];
let subjectsAfterFolderDeletion: string[];
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
await runMessageChannelSync(channel.channelId);
subjectsBeforeFolderDeletion = await findImportedMessageSubjects([
importedSubject,
]);
messageExternalIdsToImport = await runFolderActions({
messageChannelId: channel.channelId,
folderNameToDelete: DELETED_FOLDER_NAME,
});
subjectsAfterFolderDeletion = await findImportedMessageSubjects([
importedSubject,
]);
folders = await queryMessageFolders(channel.channelId);
messageExternalIdsWithoutPendingActions =
await runFolderActionsWithoutPendingActions(channel.channelId);
}, 180000);
afterAll(async () => {
jest.restoreAllMocks();
await channel?.cleanup().catch(() => undefined);
});
it('imports every message id of a folder larger than the spread-argument limit, deduplicated', () => {
expect(messageExternalIdsToImport).toHaveLength(
MESSAGE_COUNT_ABOVE_V8_SPREAD_ARGUMENT_LIMIT,
);
});
it('clears the pending action it processed, keeps the one it failed on, and drops the deleted folder', () => {
expect(
Object.fromEntries(
folders.map((folder) => [folder.name, folder.pendingSyncAction]),
),
).toEqual({
SENT: MessageFolderPendingSyncAction.NONE,
[FOLDER_ACTION_NAMES.imported]: MessageFolderPendingSyncAction.NONE,
[FOLDER_ACTION_NAMES.failing]:
MessageFolderPendingSyncAction.FOLDER_IMPORT,
[FOLDER_ACTION_NAMES.untouched]: MessageFolderPendingSyncAction.NONE,
});
});
it('deletes the messages of the folder marked for deletion', () => {
expect(subjectsBeforeFolderDeletion).toEqual([importedSubject]);
expect(subjectsAfterFolderDeletion).toEqual([]);
});
it('imports nothing when no folder carries a pending action', () => {
expect(messageExternalIdsWithoutPendingActions).toEqual([]);
});
});

View File

@@ -0,0 +1,224 @@
import {
ConnectedAccountProvider,
MessageChannelSyncStage,
MessageChannelSyncStatus,
} from 'twenty-shared/types';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import {
type GoogleApiFailure,
setupGoogleMock,
} from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import {
queryConnectedAccount,
queryMessageChannel,
} from 'test/integration/utils/query-messaging.util';
import { resetMessageChannelSyncState } from 'test/integration/utils/reset-channel-sync-state.util';
import { runMessageChannelSync } from 'test/integration/utils/run-message-channel-sync.util';
const HANDLE = 'gmail-import-error-mapping@apple.dev';
// Providers return 401 and 403 transiently, so revoking on them disconnects
// working accounts. Only a response that proves the grant is gone revokes.
const INSUFFICIENT_PERMISSIONS_FAILURES: [string, GoogleApiFailure][] = [
[
'a 400 invalid_grant response',
{
status: 400,
reason: 'invalid_grant',
message: 'Token has been expired or revoked.',
},
],
[
'a 400 failedPrecondition response for a disabled mail service',
{
status: 400,
reason: 'failedPrecondition',
message: 'Mail service not enabled',
},
],
];
const TEMPORARY_FAILURES: [string, GoogleApiFailure][] = [
[
'a 401 unauthorized response',
{ status: 401, reason: 'authError', message: 'Invalid Credentials' },
],
[
'a 403 insufficientPermissions response',
{
status: 403,
reason: 'insufficientPermissions',
message: 'Request had insufficient authentication scopes.',
},
],
[
'a 403 domainPolicy response',
{
status: 403,
reason: 'domainPolicy',
message: 'The domain policy has disabled third-party Drive apps',
},
],
[
'a 500 response with an unmapped reason',
{ status: 500, reason: 'somethingElse', message: 'Internal error' },
],
[
'a response with an unmapped status',
{ status: 418, reason: 'teapot', message: 'I am a teapot' },
],
[
'a 403 rate limit response',
{
status: 403,
reason: 'rateLimitExceeded',
message: 'Rate Limit Exceeded',
},
],
[
'a 400 failedPrecondition response unrelated to the mail service',
{
status: 400,
reason: 'failedPrecondition',
message: 'Precondition check failed.',
},
],
[
'a 503 service unavailable response',
{
status: 503,
reason: 'backendError',
message: 'The service is currently unavailable.',
},
],
[
'a 500 backendError response',
{ status: 500, reason: 'backendError', message: 'Backend Error' },
],
];
const UNKNOWN_FAILURES: [string, GoogleApiFailure][] = [
[
'a 400 response with an unmapped reason',
{ status: 400, reason: 'invalidArgument', message: 'Invalid query' },
],
];
describe('Gmail import error mapping (integration)', () => {
const gmail = setupGoogleMock({ handle: HANDLE });
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
}, 60000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
beforeEach(async () => {
await resetMessageChannelSyncState(channel.channelId);
await getCoreRepository<ConnectedAccountEntity>(
ConnectedAccountEntity,
).update({ id: channel.connectedAccountId }, { authFailedAt: null });
});
it.each(INSUFFICIENT_PERMISSIONS_FAILURES)(
'fails the channel as insufficient-permissions on %s',
async (_label, failure) => {
gmail.failMessageList(failure);
await runMessageChannelSync(channel.channelId);
const channelState = await queryMessageChannel(channel);
expect(channelState.syncStatus).toBe(
MessageChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS,
);
expect(channelState.syncStage).toBe(MessageChannelSyncStage.FAILED);
const account = await queryConnectedAccount(channel.connectedAccountId);
expect(account.authFailedAt).not.toBeNull();
},
60000,
);
it.each(TEMPORARY_FAILURES)(
'throttles the channel without failing it on %s',
async (_label, failure) => {
gmail.failMessageList(failure);
await runMessageChannelSync(channel.channelId);
const channelState = await queryMessageChannel(channel);
expect(channelState.throttleFailureCount).toBe(1);
expect(channelState.syncStage).toBe(
MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
);
expect(channelState.syncStatus).not.toBe(
MessageChannelSyncStatus.FAILED_UNKNOWN,
);
expect(channelState.syncStatus).not.toBe(
MessageChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS,
);
},
60000,
);
it.each(UNKNOWN_FAILURES)(
'fails the channel as unknown on %s',
async (_label, failure) => {
gmail.failMessageList(failure);
await runMessageChannelSync(channel.channelId);
const channelState = await queryMessageChannel(channel);
expect(channelState.syncStatus).toBe(
MessageChannelSyncStatus.FAILED_UNKNOWN,
);
expect(channelState.syncStage).toBe(MessageChannelSyncStage.FAILED);
expect(channelState.throttleFailureCount).toBe(0);
},
60000,
);
it('restarts the channel from a clean cursor on a 404 sync cursor error', async () => {
gmail.failMessageList({
status: 404,
reason: 'notFound',
message: 'Requested entity was not found.',
});
await runMessageChannelSync(channel.channelId);
const channelState = await queryMessageChannel(channel);
expect(channelState.syncStage).toBe(
MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
);
expect(channelState.syncStatus).not.toBe(
MessageChannelSyncStatus.FAILED_UNKNOWN,
);
expect(channelState.throttleFailureCount).toBe(0);
expect(channelState.syncStageStartedAt).toBeNull();
const storedChannel = await getCoreRepository<MessageChannelEntity>(
MessageChannelEntity,
).findOneByOrFail({ id: channel.channelId });
expect(storedChannel.syncCursor).toBeFalsy();
}, 60000);
});

View File

@@ -0,0 +1,60 @@
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { getGmailMessageSubject } from 'test/integration/google/mocks/gmail-message-subject.util';
import { gmailMessage } from 'test/integration/google/mocks/gmail-message.util';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { findImportedMessageSubjects } from 'test/integration/utils/find-imported-records.util';
import { runMessageChannelSync } from 'test/integration/utils/run-message-channel-sync.util';
const HANDLE = 'messaging-incremental-sync@apple.dev';
describe('Messaging incremental sync (integration)', () => {
const inbox = [gmailMessage()];
const gmail = setupGoogleMock({ handle: HANDLE, inbox });
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
}, 60000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
it('imports the initial inbox through a full sync', async () => {
await runMessageChannelSync(channel.channelId);
const initialSubject = getGmailMessageSubject(inbox[0]);
expect(await findImportedMessageSubjects([initialSubject])).toEqual([
initialSubject,
]);
}, 60000);
it('imports a newly arrived message through the history-based incremental sync', async () => {
const initialMessages = [...inbox];
const newMessage = gmailMessage();
inbox.push(newMessage);
// The list endpoint keeps serving only the initial inbox: the new message
// is reachable through the history endpoint alone, so a regression that
// re-runs a full list fetch instead of the history-based sync cannot pass.
gmail.serveMessageList(initialMessages);
gmail.serveHistory([newMessage]);
await runMessageChannelSync(channel.channelId);
const newSubject = getGmailMessageSubject(newMessage);
expect(await findImportedMessageSubjects([newSubject])).toEqual([
newSubject,
]);
}, 60000);
});

View File

@@ -0,0 +1,47 @@
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { getGmailMessageSubject } from 'test/integration/google/mocks/gmail-message-subject.util';
import { gmailMessage } from 'test/integration/google/mocks/gmail-message.util';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { findImportedMessageSubjects } from 'test/integration/utils/find-imported-records.util';
import { queryMessageFolders } from 'test/integration/utils/query-messaging.util';
import { runMessageChannelSync } from 'test/integration/utils/run-message-channel-sync.util';
const HANDLE = 'gmail-message-list-fetch@apple.dev';
describe('Gmail message list fetch (integration)', () => {
const inbox = [gmailMessage(), gmailMessage()];
setupGoogleMock({ handle: HANDLE, inbox });
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
}, 60000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
it('runs the full sync pipeline on the real worker: folders synced, messages imported', async () => {
await runMessageChannelSync(channel.channelId);
const expectedSubjects = inbox.map(getGmailMessageSubject);
expect(await findImportedMessageSubjects(expectedSubjects)).toEqual(
[...expectedSubjects].sort(),
);
const folders = await queryMessageFolders(channel.channelId);
expect(folders.map((folder) => folder.name).sort()).toEqual([
'INBOX',
'SENT',
]);
}, 60000);
});

View File

@@ -0,0 +1,74 @@
import {
ConnectedAccountProvider,
MessageChannelSyncStage,
} from 'twenty-shared/types';
import { getGmailMessageSubject } from 'test/integration/google/mocks/gmail-message-subject.util';
import { gmailMessage } from 'test/integration/google/mocks/gmail-message.util';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { expectEventually } from 'test/integration/utils/expect-eventually.util';
import { findImportedMessageSubjects } from 'test/integration/utils/find-imported-records.util';
import { queryMessageChannels } from 'test/integration/utils/query-messaging.util';
import { runMessageChannelSync } from 'test/integration/utils/run-message-channel-sync.util';
const HANDLE = 'gmail-onboarding-recent-messages-import@apple.dev';
const findChannel = async (channelId: string) => {
const channels = await queryMessageChannels();
return channels.find((channel) => channel.id === channelId);
};
describe('Gmail onboarding recent messages import (integration)', () => {
const sentMessages = [
gmailMessage({ labelIds: ['SENT'] }),
gmailMessage({ labelIds: ['SENT'] }),
];
const expectedSubjects = sentMessages.map(getGmailMessageSubject).sort();
setupGoogleMock({ handle: HANDLE, inbox: sentMessages });
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
}, 60000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
it('imports the recent sent messages during connect, before any sync job runs', async () => {
await expectEventually(
async () => {
expect(await findImportedMessageSubjects(expectedSubjects)).toEqual(
expectedSubjects,
);
},
{ timeoutMs: 30_000 },
);
}, 60000);
it('leaves the channel awaiting the full message list fetch', async () => {
await expectEventually(
async () => {
expect((await findChannel(channel.channelId))?.syncStage).toEqual(
MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
);
},
{ timeoutMs: 30_000 },
);
}, 60000);
it('lets the full sync complete afterwards without duplicating the imported messages', async () => {
await runMessageChannelSync(channel.channelId);
expect(await findImportedMessageSubjects(expectedSubjects)).toEqual(
expectedSubjects,
);
}, 60000);
});

View File

@@ -0,0 +1,300 @@
import { randomUUID } from 'node:crypto';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { gmailMessage } from 'test/integration/google/mocks/gmail-message.util';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { createCalendarEvent } from 'test/integration/utils/create-calendar-event.util';
import { findImportedCalendarEventTitles } from 'test/integration/utils/find-imported-records.util';
import { findRecordNodesByFilter } from 'test/integration/utils/find-records-by-filter.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import { runMessageChannelSync } from 'test/integration/utils/run-message-channel-sync.util';
import { sendEmail } from 'test/integration/utils/send-email.util';
const HANDLE = 'gmail-outbound@apple.dev';
const ALIAS = 'gmail-outbound-alias@apple.dev';
const RECIPIENTS = {
to: 'to-recipient@example.com',
cc: 'cc-recipient@example.com',
bcc: 'bcc-recipient@example.com',
};
const PARENT_MESSAGE = gmailMessage({
id: 'gmail-parent',
threadId: 'gmail-parent-thread',
to: HANDLE,
});
const DRAFT_SUBJECT = `Gmail draft ${randomUUID()}`;
const DRAFT_MESSAGE = gmailMessage({
id: 'gmail-draft-message',
threadId: 'gmail-draft-thread',
from: HANDLE,
to: RECIPIENTS.to,
labelIds: ['DRAFT'],
payload: {
mimeType: 'text/plain',
headers: [
{ name: 'From', value: HANDLE },
{ name: 'To', value: RECIPIENTS.to },
{ name: 'Cc', value: RECIPIENTS.cc },
{ name: 'Bcc', value: RECIPIENTS.bcc },
{ name: 'Subject', value: DRAFT_SUBJECT },
{ name: 'Message-ID', value: '<gmail-draft@example.com>' },
{ name: 'Date', value: 'Wed, 15 Nov 2023 00:00:00 +0000' },
],
body: {
data: Buffer.from('Gmail draft body').toString('base64'),
size: 16,
},
},
});
describe('Gmail outbound messaging and calendar creation (integration)', () => {
const google = setupGoogleMock({
handle: HANDLE,
aliases: [ALIAS],
inbox: [PARENT_MESSAGE, DRAFT_MESSAGE],
});
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
await runMessageChannelSync(channel.channelId);
}, 60000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
it('sends a reply with recipients and RFC 5322 threading headers', async () => {
const subject = `Gmail outbound ${randomUUID()}`;
const result = await sendEmail({
connectedAccountId: channel.connectedAccountId,
to: RECIPIENTS.to,
cc: RECIPIENTS.cc,
bcc: RECIPIENTS.bcc,
subject,
body: '<p>Gmail reply body</p>',
inReplyTo: '<gmail-parent@example.com>',
});
expect(result).toMatchObject({ success: true });
expect(google.sentMessages).toHaveLength(1);
const [{ raw, threadId }] = google.sentMessages;
expect(threadId).toBe(PARENT_MESSAGE.threadId);
expect(raw).toContain(`To: ${RECIPIENTS.to}`);
expect(raw).toContain(`Cc: ${RECIPIENTS.cc}`);
expect(raw).toContain(`Bcc: ${RECIPIENTS.bcc}`);
expect(raw).toContain(`Subject: ${subject}`);
expect(raw).toContain('In-Reply-To: <gmail-parent@example.com>');
expect(raw).toContain('References: <gmail-parent@example.com>');
const [message] = await findRecordNodesByFilter<{
id: string;
isDraft: boolean;
messageThreadId: string | null;
text: string | null;
}>('message', 'messages', 'id isDraft messageThreadId text', {
subject: { eq: subject },
});
expect(message).toMatchObject({
isDraft: false,
messageThreadId: expect.any(String),
text: 'Gmail reply body',
});
expect(
await findRecordNodesByFilter<{
messageChannelId: string;
}>(
'messageChannelMessageAssociation',
'messageChannelMessageAssociations',
'messageChannelId',
{ messageId: { eq: message.id } },
),
).toEqual([
expect.objectContaining({ messageChannelId: channel.channelId }),
]);
expect(
await findRecordNodesByFilter<{ handle: string; role: string }>(
'messageParticipant',
'messageParticipants',
'handle role',
{ messageId: { eq: message.id } },
),
).toEqual(
expect.arrayContaining([
expect.objectContaining({ handle: RECIPIENTS.to, role: 'TO' }),
expect.objectContaining({ handle: RECIPIENTS.cc, role: 'CC' }),
expect.objectContaining({ handle: RECIPIENTS.bcc, role: 'BCC' }),
]),
);
}, 60000);
it('sends a synced Gmail draft through GraphQL and replaces it in the database', async () => {
const [draft] = await findRecordNodesByFilter<{
id: string;
isDraft: boolean;
}>('message', 'messages', 'id isDraft', {
subject: { eq: DRAFT_SUBJECT },
});
expect(draft).toMatchObject({
isDraft: true,
});
expect(
await findRecordNodesByFilter<{
messageChannelId: string;
messageExternalId: string;
}>(
'messageChannelMessageAssociation',
'messageChannelMessageAssociations',
'messageChannelId messageExternalId',
{ messageId: { eq: draft.id } },
),
).toEqual([
expect.objectContaining({
messageChannelId: channel.channelId,
messageExternalId: DRAFT_MESSAGE.id,
}),
]);
expect(
await findRecordNodesByFilter<{ handle: string; role: string }>(
'messageParticipant',
'messageParticipants',
'handle role',
{ messageId: { eq: draft.id } },
),
).toEqual(
expect.arrayContaining([
expect.objectContaining({ handle: RECIPIENTS.to, role: 'TO' }),
expect.objectContaining({ handle: RECIPIENTS.cc, role: 'CC' }),
expect.objectContaining({ handle: RECIPIENTS.bcc, role: 'BCC' }),
]),
);
const result = await sendEmail({
connectedAccountId: channel.connectedAccountId,
to: RECIPIENTS.to,
cc: RECIPIENTS.cc,
bcc: RECIPIENTS.bcc,
subject: DRAFT_SUBJECT,
body: 'Gmail draft body',
draftMessageId: draft.id,
});
expect(result).toMatchObject({ success: true });
const [sentMessage] = await findRecordNodesByFilter<{
id: string;
isDraft: boolean;
messageThreadId: string | null;
text: string | null;
}>('message', 'messages', 'id isDraft messageThreadId text', {
subject: { eq: DRAFT_SUBJECT },
});
expect(sentMessage).toEqual(
expect.objectContaining({
isDraft: false,
messageThreadId: expect.any(String),
text: 'Gmail draft body',
}),
);
const [association] = await findRecordNodesByFilter<{
messageChannelId: string;
messageExternalId: string;
}>(
'messageChannelMessageAssociation',
'messageChannelMessageAssociations',
'messageChannelId messageExternalId',
{ messageId: { eq: sentMessage.id } },
);
expect(association.messageChannelId).toBe(channel.channelId);
expect(association.messageExternalId).not.toBe(DRAFT_MESSAGE.id);
expect(result.messageThreadId).toEqual(expect.any(String));
}, 60000);
it('imports the verified aliases during sync and sends from one of them', async () => {
const connectedAccount = await getCoreRepository<ConnectedAccountEntity>(
ConnectedAccountEntity,
).findOneByOrFail({ id: channel.connectedAccountId });
expect(connectedAccount.handleAliases).toEqual([ALIAS]);
const subject = `Gmail alias outbound ${randomUUID()}`;
const result = await sendEmail({
connectedAccountId: channel.connectedAccountId,
fromHandle: ALIAS,
to: RECIPIENTS.to,
subject,
body: '<p>Gmail alias body</p>',
});
expect(result).toMatchObject({ success: true });
const [{ raw }] = google.sentMessages.slice(-1);
expect(raw).toContain(`<${ALIAS}>`);
}, 60000);
it('refuses to send from an address the account has not verified', async () => {
const sentMessageCount = google.sentMessages.length;
const result = await sendEmail({
connectedAccountId: channel.connectedAccountId,
fromHandle: 'not-my-alias@apple.dev',
to: RECIPIENTS.to,
subject: `Gmail rejected sender ${randomUUID()}`,
body: '<p>Gmail rejected body</p>',
});
expect(result).toMatchObject({
success: false,
error: expect.stringContaining(
'is not the connected account handle nor one of its verified aliases',
),
});
expect(google.sentMessages).toHaveLength(sentMessageCount);
}, 60000);
it('creates and persists a calendar event with invitations and conferencing', async () => {
const title = `Google calendar outbound ${randomUUID()}`;
const result = await createCalendarEvent({
connectedAccountId: channel.connectedAccountId,
title,
description: 'Planning meeting',
location: 'Room 101',
startsAt: '2026-08-13T09:00:00Z',
endsAt: '2026-08-13T10:00:00Z',
timeZone: 'UTC',
attendees: RECIPIENTS.to,
sendInvitations: true,
addConferencing: true,
});
expect(result).toMatchObject({ success: true });
expect(result.iCalUid).toContain('@google.com');
expect(google.createdCalendarEvents).toEqual([
expect.objectContaining({
summary: title,
attendees: [expect.objectContaining({ email: RECIPIENTS.to })],
conferenceData: expect.objectContaining({
createRequest: expect.any(Object),
}),
}),
]);
expect(await findImportedCalendarEventTitles([title])).toEqual([title]);
}, 60000);
});

View File

@@ -0,0 +1,195 @@
import { randomUUID } from 'node:crypto';
import {
ConnectedAccountProvider,
MessageChannelContactAutoCreationPolicy,
} from 'twenty-shared/types';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { type TimelineActivityTypeSnapshot } from 'twenty-shared/timeline';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { googleCalendarEvent } from 'test/integration/google/mocks/google-calendar-event.util';
import { gmailMessage } from 'test/integration/google/mocks/gmail-message.util';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { findRecordNodesByFilter } from 'test/integration/utils/find-records-by-filter.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import { runCalendarChannelEventsImport } from 'test/integration/utils/run-calendar-channel-events-import.util';
import { runCalendarChannelListFetch } from 'test/integration/utils/run-calendar-channel-list-fetch.util';
import { runMessageChannelSync } from 'test/integration/utils/run-message-channel-sync.util';
import { waitForAllJobsToFinish } from 'test/integration/utils/wait-for-all-jobs-to-finish.util';
const HANDLE = 'gmail-existing-person-matching@apple.dev';
const KNOWN_SENDER = `known-sender-${randomUUID()}@acme.com`;
const KNOWN_ATTENDEE = `known-attendee-${randomUUID()}@acme.com`;
type ImportedParticipantLink<
TLinkedRecordIdField extends 'messageId' | 'calendarEventId',
> = {
personId: string | null;
} & Record<TLinkedRecordIdField, string>;
const findParticipantLinks = async <
TLinkedRecordIdField extends 'messageId' | 'calendarEventId',
>(
objectMetadataSingularName: string,
objectMetadataPluralName: string,
handle: string,
linkedRecordIdField: TLinkedRecordIdField,
): Promise<ImportedParticipantLink<TLinkedRecordIdField>[]> =>
findRecordNodesByFilter<ImportedParticipantLink<TLinkedRecordIdField>>(
objectMetadataSingularName,
objectMetadataPluralName,
`personId ${linkedRecordIdField}`,
{ handle: { eq: handle } },
);
const findLinkedTimelineActivities = async ({
personId,
linkedRecordId,
}: {
personId: string;
linkedRecordId: string;
}) =>
findRecordNodesByFilter<{
linkedRecordId: string;
timelineActivityTypeSnapshot: TimelineActivityTypeSnapshot;
}>(
'timelineActivity',
'timelineActivities',
'linkedRecordId timelineActivityTypeSnapshot',
{
targetPersonId: { eq: personId },
linkedRecordId: { eq: linkedRecordId },
},
);
const createPerson = async (primaryEmail: string) => {
const response = await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: 'id',
data: { emails: { primaryEmail } },
}),
);
expect(response.body.errors).toBeUndefined();
return response.body.data.createPerson.id as string;
};
describe('Participant matching when the person already exists (integration)', () => {
const gmail = setupGoogleMock({
handle: HANDLE,
inbox: [gmailMessage({ from: KNOWN_SENDER, to: HANDLE })],
});
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
let senderPersonId: string;
let attendeePersonId: string;
beforeAll(async () => {
senderPersonId = await createPerson(KNOWN_SENDER);
attendeePersonId = await createPerson(KNOWN_ATTENDEE);
await waitForAllJobsToFinish();
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
await getCoreRepository<MessageChannelEntity>(MessageChannelEntity).update(
{ id: channel.channelId },
{
isContactAutoCreationEnabled: false,
contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy.NONE,
},
);
await getCoreRepository<CalendarChannelEntity>(
CalendarChannelEntity,
).update(
{ id: channel.calendarChannelId },
{ isContactAutoCreationEnabled: false },
);
await runMessageChannelSync(channel.channelId);
gmail.serveCalendarEvents([
googleCalendarEvent({
summary: `Calendar event ${randomUUID()}`,
attendees: [{ email: KNOWN_ATTENDEE }],
}),
]);
await runCalendarChannelListFetch(channel.calendarChannelId);
await runCalendarChannelEventsImport(channel.calendarChannelId);
await waitForAllJobsToFinish();
}, 180000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
it('creates a typed timeline activity when importing an email for an existing person', async () => {
const participants = await findParticipantLinks(
'messageParticipant',
'messageParticipants',
KNOWN_SENDER,
'messageId',
);
const [participant] = participants;
expect(participants).toHaveLength(1);
expect(participant.personId).toBe(senderPersonId);
const timelineActivities = await findLinkedTimelineActivities({
personId: senderPersonId,
linkedRecordId: participant.messageId,
});
expect(timelineActivities).toHaveLength(1);
expect(timelineActivities[0]).toMatchObject({
linkedRecordId: participant.messageId,
timelineActivityTypeSnapshot: {
action: 'linked',
objectUniversalIdentifier: STANDARD_OBJECTS.message.universalIdentifier,
},
});
}, 60000);
it('creates a typed timeline activity when importing a calendar event for an existing person', async () => {
const participants = await findParticipantLinks(
'calendarEventParticipant',
'calendarEventParticipants',
KNOWN_ATTENDEE,
'calendarEventId',
);
const [participant] = participants;
expect(participants).toHaveLength(1);
expect(participant.personId).toBe(attendeePersonId);
const timelineActivities = await findLinkedTimelineActivities({
personId: attendeePersonId,
linkedRecordId: participant.calendarEventId,
});
expect(timelineActivities).toHaveLength(1);
expect(timelineActivities[0]).toMatchObject({
linkedRecordId: participant.calendarEventId,
timelineActivityTypeSnapshot: {
action: 'linked',
objectUniversalIdentifier:
STANDARD_OBJECTS.calendarEvent.universalIdentifier,
},
});
}, 60000);
});

View File

@@ -0,0 +1,143 @@
import { randomUUID } from 'node:crypto';
import {
ConnectedAccountProvider,
MessageChannelContactAutoCreationPolicy,
} from 'twenty-shared/types';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { googleCalendarEvent } from 'test/integration/google/mocks/google-calendar-event.util';
import { gmailMessage } from 'test/integration/google/mocks/gmail-message.util';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { findRecordNodesByFilter } from 'test/integration/utils/find-records-by-filter.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import { runCalendarChannelEventsImport } from 'test/integration/utils/run-calendar-channel-events-import.util';
import { runCalendarChannelListFetch } from 'test/integration/utils/run-calendar-channel-list-fetch.util';
import { runMessageChannelSync } from 'test/integration/utils/run-message-channel-sync.util';
import { waitForAllJobsToFinish } from 'test/integration/utils/wait-for-all-jobs-to-finish.util';
const HANDLE = 'gmail-participant-matching@apple.dev';
const UNMATCHED_HANDLE = `unmatched-${randomUUID()}@acme.com`;
const UNMATCHED_ATTENDEE = `attendee-${randomUUID()}@acme.com`;
const findParticipantPersonIds = async (
objectMetadataSingularName: string,
objectMetadataPluralName: string,
handle: string,
) => {
const participants = await findRecordNodesByFilter<{
personId: string | null;
}>(objectMetadataSingularName, objectMetadataPluralName, 'personId', {
handle: { eq: handle },
});
return participants.map((participant) => participant.personId);
};
describe('Participant matching on person creation (integration)', () => {
const gmail = setupGoogleMock({
handle: HANDLE,
inbox: [gmailMessage({ from: UNMATCHED_HANDLE, to: HANDLE })],
});
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
await getCoreRepository<MessageChannelEntity>(MessageChannelEntity).update(
{ id: channel.channelId },
{
isContactAutoCreationEnabled: false,
contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy.NONE,
},
);
await getCoreRepository<CalendarChannelEntity>(
CalendarChannelEntity,
).update(
{ id: channel.calendarChannelId },
{ isContactAutoCreationEnabled: false },
);
await runMessageChannelSync(channel.channelId);
gmail.serveCalendarEvents([
googleCalendarEvent({
summary: `Calendar event ${randomUUID()}`,
attendees: [{ email: UNMATCHED_ATTENDEE }],
}),
]);
await runCalendarChannelListFetch(channel.calendarChannelId);
await runCalendarChannelEventsImport(channel.calendarChannelId);
}, 180000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
it('leaves the imported participants unmatched while no person owns the handle', async () => {
expect(
await findParticipantPersonIds(
'messageParticipant',
'messageParticipants',
UNMATCHED_HANDLE,
),
).toEqual([null]);
expect(
await findParticipantPersonIds(
'calendarEventParticipant',
'calendarEventParticipants',
UNMATCHED_ATTENDEE,
),
).toEqual([null]);
}, 60000);
it('matches both message and calendar participants when the person is created', async () => {
const createPerson = async (primaryEmail: string) => {
const response = await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: 'id',
data: { emails: { primaryEmail } },
}),
);
expect(response.body.errors).toBeUndefined();
return response.body.data.createPerson.id as string;
};
const messagePersonId = await createPerson(UNMATCHED_HANDLE);
const attendeePersonId = await createPerson(UNMATCHED_ATTENDEE);
await waitForAllJobsToFinish();
expect(
await findParticipantPersonIds(
'messageParticipant',
'messageParticipants',
UNMATCHED_HANDLE,
),
).toEqual([messagePersonId]);
expect(
await findParticipantPersonIds(
'calendarEventParticipant',
'calendarEventParticipants',
UNMATCHED_ATTENDEE,
),
).toEqual([attendeePersonId]);
}, 120000);
});

View File

@@ -0,0 +1,89 @@
import {
ConnectedAccountProvider,
MessageChannelSyncStage,
} from 'twenty-shared/types';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { MessagingOngoingStaleCronJob } from 'src/modules/messaging/message-import-manager/crons/jobs/messaging-ongoing-stale.cron.job';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import { queryMessageChannel } from 'test/integration/utils/query-messaging.util';
import { runSyncCron } from 'test/integration/utils/run-sync-cron.util';
const STALE_HANDLE = 'messaging-stale-sync@apple.dev';
const RECENT_HANDLE = 'messaging-recent-sync@apple.dev';
const STALE_STARTED_AT = new Date(Date.now() - 31 * 60 * 1000);
const RECENT_STARTED_AT = new Date(Date.now() - 60 * 1000);
describe('Messaging stale-sync recovery (integration)', () => {
const gmail = setupGoogleMock({ handle: STALE_HANDLE });
let staleChannel: Awaited<ReturnType<typeof connectMessagingAccount>>;
let recentChannel: Awaited<ReturnType<typeof connectMessagingAccount>>;
beforeAll(async () => {
staleChannel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: STALE_HANDLE,
});
gmail.actAsAccount(RECENT_HANDLE);
recentChannel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: RECENT_HANDLE,
});
for (const connectedChannel of [staleChannel, recentChannel]) {
const channelState = await queryMessageChannel(connectedChannel);
expect(channelState.syncStage).toBe(
MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
);
}
}, 120000);
afterAll(async () => {
await staleChannel?.cleanup().catch(() => undefined);
await recentChannel?.cleanup().catch(() => undefined);
});
it('resets a stale ongoing channel to pending and leaves a recent one running', async () => {
const messageChannelRepository =
getCoreRepository<MessageChannelEntity>(MessageChannelEntity);
await messageChannelRepository.update(
{ id: staleChannel.channelId },
{
syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_ONGOING,
syncStageStartedAt: STALE_STARTED_AT,
},
);
await messageChannelRepository.update(
{ id: recentChannel.channelId },
{
syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_ONGOING,
syncStageStartedAt: RECENT_STARTED_AT,
},
);
await runSyncCron(MessagingOngoingStaleCronJob);
const staleChannelAfter = await queryMessageChannel(staleChannel);
expect(staleChannelAfter.syncStage).toBe(
MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
);
expect(staleChannelAfter.syncStageStartedAt).toBeNull();
const recentChannelAfter = await queryMessageChannel(recentChannel);
expect(recentChannelAfter.syncStage).toBe(
MessageChannelSyncStage.MESSAGE_LIST_FETCH_ONGOING,
);
}, 60000);
});

View File

@@ -0,0 +1,156 @@
import {
ConnectedAccountProvider,
MessageChannelSyncStage,
MessageChannelSyncStatus,
} from 'twenty-shared/types';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { MESSAGING_THROTTLE_MAX_ATTEMPTS } from 'src/modules/messaging/message-import-manager/constants/messaging-throttle-max-attempts';
import { MessagingMessageListFetchCronJob } from 'src/modules/messaging/message-import-manager/crons/jobs/messaging-message-list-fetch.cron.job';
import { MessagingRelaunchFailedMessageChannelsCronJob } from 'src/modules/messaging/message-import-manager/crons/jobs/messaging-relaunch-failed-message-channels.cron.job';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import {
queryConnectedAccount,
queryMessageChannel,
updateMessageChannel,
} from 'test/integration/utils/query-messaging.util';
import { runSyncCron } from 'test/integration/utils/run-sync-cron.util';
const THROTTLED_HANDLE = 'messaging-throttled@apple.dev';
const REVOKED_HANDLE = 'messaging-revoked@apple.dev';
const FUTURE_RETRY_AFTER_ISO = '2099-12-31T10:30:00.000Z';
const ONE_HOUR_AGO = new Date(Date.now() - 60 * 60 * 1000);
const EXPIRED_CREDENTIALS_AT = new Date(Date.now() - 56 * 60 * 1000);
describe('Messaging sync failure lifecycle (integration)', () => {
const gmail = setupGoogleMock({ handle: THROTTLED_HANDLE });
let throttledChannel: Awaited<ReturnType<typeof connectMessagingAccount>>;
let revokedChannel: Awaited<ReturnType<typeof connectMessagingAccount>>;
beforeAll(async () => {
throttledChannel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: THROTTLED_HANDLE,
});
gmail.actAsAccount(REVOKED_HANDLE);
revokedChannel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: REVOKED_HANDLE,
});
await updateMessageChannel(revokedChannel.channelId, {
isSyncEnabled: false,
});
}, 120000);
afterAll(async () => {
await throttledChannel?.cleanup().catch(() => undefined);
await revokedChannel?.cleanup().catch(() => undefined);
});
it('records the throttle backoff on a 429 and keeps the channel alive', async () => {
gmail.rateLimitMessageList(FUTURE_RETRY_AFTER_ISO);
await runSyncCron(MessagingMessageListFetchCronJob);
const channelState = await queryMessageChannel(throttledChannel);
expect(channelState.throttleFailureCount).toBe(1);
expect(channelState.throttleRetryAfter).toBe(FUTURE_RETRY_AFTER_ISO);
expect(channelState.syncStatus).not.toBe(
MessageChannelSyncStatus.FAILED_UNKNOWN,
);
}, 60000);
it('fails the channel as unknown once the throttle attempts are exhausted', async () => {
gmail.rateLimitMessageList(FUTURE_RETRY_AFTER_ISO);
const messageChannelRepository =
getCoreRepository<MessageChannelEntity>(MessageChannelEntity);
let channelState = await queryMessageChannel(throttledChannel);
for (
let attempt = channelState.throttleFailureCount;
attempt <= MESSAGING_THROTTLE_MAX_ATTEMPTS &&
channelState.syncStatus !== MessageChannelSyncStatus.FAILED_UNKNOWN;
attempt++
) {
await messageChannelRepository.update(
{ id: throttledChannel.channelId },
{ syncStageStartedAt: ONE_HOUR_AGO, throttleRetryAfter: null },
);
await runSyncCron(MessagingMessageListFetchCronJob);
channelState = await queryMessageChannel(throttledChannel);
}
expect(channelState.syncStatus).toBe(
MessageChannelSyncStatus.FAILED_UNKNOWN,
);
expect(channelState.syncStage).toBe(MessageChannelSyncStage.FAILED);
}, 120000);
it('fails the channel as insufficient-permissions when the refresh token is declined', async () => {
gmail.actAsAccount(REVOKED_HANDLE);
await updateMessageChannel(revokedChannel.channelId, {
isSyncEnabled: true,
});
await getCoreRepository<ConnectedAccountEntity>(
ConnectedAccountEntity,
).update(
{ id: revokedChannel.connectedAccountId },
{ lastCredentialsRefreshedAt: EXPIRED_CREDENTIALS_AT },
);
gmail.declineTokenRefresh();
await runSyncCron(MessagingMessageListFetchCronJob);
const channelState = await queryMessageChannel(revokedChannel);
expect(channelState.syncStatus).toBe(
MessageChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS,
);
expect(channelState.syncStage).toBe(MessageChannelSyncStage.FAILED);
const account = await queryConnectedAccount(
revokedChannel.connectedAccountId,
);
expect(account.authFailedAt).not.toBeNull();
}, 60000);
it('relaunches the unknown-failure channel and leaves the permissions-failure channel untouched', async () => {
await runSyncCron(MessagingRelaunchFailedMessageChannelsCronJob);
const relaunchedChannel = await queryMessageChannel(throttledChannel);
expect(relaunchedChannel.syncStatus).toBe(MessageChannelSyncStatus.ACTIVE);
expect(relaunchedChannel.syncStage).toBe(
MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
);
expect(relaunchedChannel.throttleFailureCount).toBe(0);
expect(relaunchedChannel.throttleRetryAfter).toBeNull();
expect(relaunchedChannel.syncStageStartedAt).toBeNull();
const untouchedChannel = await queryMessageChannel(revokedChannel);
expect(untouchedChannel.syncStatus).toBe(
MessageChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS,
);
expect(untouchedChannel.syncStage).toBe(MessageChannelSyncStage.FAILED);
}, 60000);
});

View File

@@ -0,0 +1,124 @@
import {
ConnectedAccountProvider,
MessageChannelSyncStage,
MessageChannelSyncStatus,
} from 'twenty-shared/types';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import { queryMessageChannel } from 'test/integration/utils/query-messaging.util';
import { resetMessageChannelSyncState } from 'test/integration/utils/reset-channel-sync-state.util';
import { runMessageChannelSync } from 'test/integration/utils/run-message-channel-sync.util';
const HANDLE = 'gmail-token-failure-modes@apple.dev';
// The refresh service only mints a new token once the stored credentials are
// stale; a recent refresh short-circuits to the tokens already on the account.
const STALE_CREDENTIALS_AT = new Date(Date.now() - 56 * 60 * 1000);
const FRESH_CREDENTIALS_AT = new Date();
describe('Gmail token failure modes (integration)', () => {
setupGoogleMock({ handle: HANDLE });
const connectedAccountRepository = getCoreRepository<ConnectedAccountEntity>(
ConnectedAccountEntity,
);
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
let storedAccount: ConnectedAccountEntity;
const restoreAccount = () =>
connectedAccountRepository.update(
{ id: channel.connectedAccountId },
{
accessToken: storedAccount.accessToken,
refreshToken: storedAccount.refreshToken,
provider: storedAccount.provider,
lastCredentialsRefreshedAt: storedAccount.lastCredentialsRefreshedAt,
authFailedAt: null,
},
);
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
storedAccount = await connectedAccountRepository.findOneByOrFail({
id: channel.connectedAccountId,
});
}, 60000);
afterAll(async () => {
await restoreAccount().catch(() => undefined);
await channel?.cleanup().catch(() => undefined);
});
beforeEach(async () => {
await restoreAccount();
await resetMessageChannelSyncState(channel.channelId);
});
it('fails the channel as insufficient-permissions when the refresh token is gone', async () => {
await connectedAccountRepository.update(
{ id: channel.connectedAccountId },
{
refreshToken: null,
lastCredentialsRefreshedAt: STALE_CREDENTIALS_AT,
},
);
await runMessageChannelSync(channel.channelId);
const channelState = await queryMessageChannel(channel);
expect(channelState.syncStatus).toBe(
MessageChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS,
);
expect(channelState.syncStage).toBe(MessageChannelSyncStage.FAILED);
}, 60000);
it('fails the channel as unknown when the stored access token is gone', async () => {
await connectedAccountRepository.update(
{ id: channel.connectedAccountId },
{
accessToken: null,
lastCredentialsRefreshedAt: FRESH_CREDENTIALS_AT,
},
);
await runMessageChannelSync(channel.channelId);
const channelState = await queryMessageChannel(channel);
expect(channelState.syncStatus).toBe(
MessageChannelSyncStatus.FAILED_UNKNOWN,
);
expect(channelState.syncStage).toBe(MessageChannelSyncStage.FAILED);
}, 60000);
// The driver rejects the unsupported provider before the refresh service is
// reached, so this surfaces as a permissions failure.
it('fails the channel as insufficient-permissions when the provider cannot refresh tokens', async () => {
await connectedAccountRepository.update(
{ id: channel.connectedAccountId },
{
provider: ConnectedAccountProvider.IMAP_SMTP_CALDAV,
lastCredentialsRefreshedAt: STALE_CREDENTIALS_AT,
},
);
await runMessageChannelSync(channel.channelId);
const channelState = await queryMessageChannel(channel);
expect(channelState.syncStatus).toBe(
MessageChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS,
);
expect(channelState.syncStage).toBe(MessageChannelSyncStage.FAILED);
}, 60000);
});

View File

@@ -0,0 +1,49 @@
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { gmailMessage } from 'test/integration/google/mocks/gmail-message.util';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import { queryConnectedAccount } from 'test/integration/utils/query-messaging.util';
import { runMessageChannelSync } from 'test/integration/utils/run-message-channel-sync.util';
const HANDLE = 'messaging-token-refresh@apple.dev';
const EXPIRED_CREDENTIALS_AT = new Date(Date.now() - 56 * 60 * 1000);
describe('Messaging token refresh (integration)', () => {
setupGoogleMock({ handle: HANDLE, inbox: [gmailMessage()] });
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
}, 60000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
it('refreshes expired credentials and completes the sync', async () => {
await getCoreRepository<ConnectedAccountEntity>(
ConnectedAccountEntity,
).update(
{ id: channel.connectedAccountId },
{ lastCredentialsRefreshedAt: EXPIRED_CREDENTIALS_AT },
);
await runMessageChannelSync(channel.channelId);
const account = await queryConnectedAccount(channel.connectedAccountId);
expect(account.authFailedAt).toBeNull();
expect(
new Date(account.lastCredentialsRefreshedAt ?? 0).getTime(),
).toBeGreaterThan(EXPIRED_CREDENTIALS_AT.getTime());
}, 60000);
});

View File

@@ -0,0 +1,141 @@
import { randomUUID } from 'node:crypto';
import {
ConnectedAccountProvider,
MessageChannelSyncStage,
MessageChannelSyncStatus,
} from 'twenty-shared/types';
import { POSTGRESQL_ERROR_CODES } from 'src/engine/api/graphql/workspace-query-runner/constants/postgres-error-codes.constants';
import { MessagingMessageParticipantService } from 'src/modules/messaging/message-participant-manager/services/messaging-message-participant.service';
import { gmailMessage } from 'test/integration/google/mocks/gmail-message.util';
import { setupGoogleMock } from 'test/integration/google/mocks/setup-google-mock.util';
import { connectMessagingAccount } from 'test/integration/utils/connect-messaging-account.util';
import { getAppProviderByClassName } from 'test/integration/utils/get-app-provider-by-class-name.util';
import { raiseSqlState } from 'test/integration/utils/raise-sql-state.util';
import { readBackendState } from 'test/integration/utils/read-backend-state.util';
import { queryMessageChannel } from 'test/integration/utils/query-messaging.util';
import { resetMessageChannelSyncState } from 'test/integration/utils/reset-channel-sync-state.util';
import { runMessageChannelSync } from 'test/integration/utils/run-message-channel-sync.util';
const HANDLE = 'messaging-transient-database-error@apple.dev';
// The error has to be raised by Postgres itself: an Error built in the jest
// realm is not an `instanceof Error` for the application realm the app runs in.
// The mock inbox is fixed at setup time, so every test needs its own message
// served out of it rather than a freshly built one.
const INBOX_MESSAGES = Array.from({ length: 2 }, () =>
gmailMessage({ from: `sender-${randomUUID()}@acme.com`, to: HANDLE }),
);
describe('Messaging import transient database errors (integration)', () => {
const google = setupGoogleMock({ handle: HANDLE, inbox: INBOX_MESSAGES });
let channel: Awaited<ReturnType<typeof connectMessagingAccount>>;
let messageParticipantService: MessagingMessageParticipantService;
const serveInboundMessage = async (messageIndex: number): Promise<void> => {
google.serveMessageList([INBOX_MESSAGES[messageIndex]]);
await resetMessageChannelSyncState(channel.channelId, '');
};
beforeAll(async () => {
channel = await connectMessagingAccount({
provider: ConnectedAccountProvider.GOOGLE,
handle: HANDLE,
});
messageParticipantService =
getAppProviderByClassName<MessagingMessageParticipantService>(
'MessagingMessageParticipantService',
);
}, 60000);
afterAll(async () => {
await channel?.cleanup().catch(() => undefined);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should reschedule the message channel instead of failing it when the connection is killed by the idle-in-transaction timeout', async () => {
await serveInboundMessage(0);
jest
.spyOn(messageParticipantService, 'saveMessageParticipants')
.mockImplementation(
async (_participants, _workspaceId, transactionScope) => {
await transactionScope.executeRawQuery(
raiseSqlState(
POSTGRESQL_ERROR_CODES.IDLE_IN_TRANSACTION_SESSION_TIMEOUT,
),
);
return [];
},
);
await runMessageChannelSync(channel.channelId);
const channelState = await queryMessageChannel(channel);
expect(channelState.throttleFailureCount).toBe(1);
expect(channelState.syncStage).toBe(
MessageChannelSyncStage.MESSAGES_IMPORT_PENDING,
);
expect(channelState.syncStatus).toBe(MessageChannelSyncStatus.ONGOING);
}, 120000);
it('should have ended the import transaction before the message participants are matched', async () => {
await serveInboundMessage(1);
const saveMessageParticipants =
messageParticipantService.saveMessageParticipants.bind(
messageParticipantService,
);
const matchMessageParticipants =
messageParticipantService.matchMessageParticipants.bind(
messageParticipantService,
);
let importBackendPid: number | undefined;
let importBackendStateWhileMatching: string | undefined;
jest
.spyOn(messageParticipantService, 'saveMessageParticipants')
.mockImplementation(
async (participants, workspaceId, transactionScope) => {
const backends = await transactionScope.executeRawQuery(
'SELECT pg_backend_pid() AS pid',
);
importBackendPid = Number(backends[0].pid);
return saveMessageParticipants(
participants,
workspaceId,
transactionScope,
);
},
);
jest
.spyOn(messageParticipantService, 'matchMessageParticipants')
.mockImplementation(async (args) => {
importBackendStateWhileMatching = await readBackendState(
importBackendPid ?? 0,
);
return matchMessageParticipants(args);
});
await runMessageChannelSync(channel.channelId);
expect(importBackendPid).toEqual(expect.any(Number));
expect(importBackendStateWhileMatching).toBeDefined();
expect(importBackendStateWhileMatching).not.toBe('idle in transaction');
}, 120000);
});

View File

@@ -0,0 +1,20 @@
import { type gmail_v1 } from 'googleapis';
import { http, HttpResponse } from 'msw';
import { type MswHandler } from 'test/integration/utils/http-mock.util';
export const gmailHistoryHandler = (
addedMessages: gmail_v1.Schema$Message[],
): MswHandler =>
http.get('*/gmail/v1/users/me/history', () =>
HttpResponse.json<gmail_v1.Schema$ListHistoryResponse>({
history: [
{
messagesAdded: addedMessages.map((message) => ({
message: { id: message.id, threadId: message.threadId },
})),
},
],
historyId: '987654322',
}),
);

View File

@@ -0,0 +1,77 @@
import { type gmail_v1 } from 'googleapis';
import { http, HttpResponse } from 'msw';
import { gmailMessageListHandler } from 'test/integration/google/mocks/gmail-message-list-handler.util';
import { type MswHandler } from 'test/integration/utils/http-mock.util';
import { type MockEntityStore } from 'test/integration/utils/mock-entity-store.util';
const buildBatchMultipartResponse = (
messages: gmail_v1.Schema$Message[],
): { body: string; contentType: string } => {
const boundary = 'batch_boundary';
const subResponses = messages
.map((message) =>
[
`--${boundary}`,
'Content-Type: application/http',
'',
'HTTP/1.1 200 OK',
'Content-Type: application/json; charset=UTF-8',
'',
JSON.stringify(message),
].join('\r\n'),
)
.join('\r\n');
return {
body: `${subResponses}\r\n--${boundary}--`,
contentType: `multipart/mixed; boundary=${boundary}`,
};
};
export const gmailMailboxHandlers = (
inbox: gmail_v1.Schema$Message[],
labelStore: MockEntityStore<gmail_v1.Schema$Label>,
): MswHandler[] => [
http.get('*/gmail/v1/users/me/labels', () =>
HttpResponse.json<gmail_v1.Schema$ListLabelsResponse>({
labels: labelStore.list(),
}),
),
gmailMessageListHandler(inbox),
http.get('*/gmail/v1/users/me/history', () =>
HttpResponse.json<gmail_v1.Schema$ListHistoryResponse>({
history: [],
historyId: inbox[0]?.historyId ?? '987654321',
}),
),
http.get('*/gmail/v1/users/me/messages/:messageId', ({ params }) => {
const message = inbox.find(
(candidate) => candidate.id === params.messageId,
);
if (!message) {
return HttpResponse.json(
{ error: { code: 404, message: 'Not Found' } },
{ status: 404 },
);
}
return HttpResponse.json<gmail_v1.Schema$Message>(message);
}),
http.post('*/batch', async ({ request }) => {
const requestedIds = [
...(await request.text()).matchAll(/messages\/([\w-]+)/g),
].map((match) => match[1]);
const requestedMessages = inbox.filter((message) =>
requestedIds.includes(message.id ?? ''),
);
const { body, contentType } =
buildBatchMultipartResponse(requestedMessages);
return new HttpResponse(body, {
headers: { 'Content-Type': contentType },
});
}),
];

View File

@@ -0,0 +1,34 @@
import { type gmail_v1 } from 'googleapis';
import { http, HttpResponse } from 'msw';
import { type MswHandler } from 'test/integration/utils/http-mock.util';
// Only the onboarding import sends a positive `in:` term. The full sync's
// `-label:` / `-category:` exclusions and `label:` inclusions stay unapplied.
const INCLUDED_LABEL_REGEX = /(?:^|\s)in:(\w+)/g;
const matchesSearchFilter = (
message: gmail_v1.Schema$Message,
searchFilter: string,
): boolean =>
[...searchFilter.matchAll(INCLUDED_LABEL_REGEX)].every(([, labelName]) =>
message.labelIds?.includes(labelName.toUpperCase()),
);
export const gmailMessageListHandler = (
messages: gmail_v1.Schema$Message[],
): MswHandler =>
http.get('*/gmail/v1/users/me/messages', ({ request }) => {
const searchFilter = new URL(request.url).searchParams.get('q') ?? '';
const matchingMessages = messages.filter((message) =>
matchesSearchFilter(message, searchFilter),
);
return HttpResponse.json<gmail_v1.Schema$ListMessagesResponse>({
messages: matchingMessages.map((message) => ({
id: message.id,
threadId: message.threadId,
})),
resultSizeEstimate: matchingMessages.length,
});
});

View File

@@ -0,0 +1,7 @@
import { type gmail_v1 } from 'googleapis';
export const getGmailMessageSubject = (
message: gmail_v1.Schema$Message,
): string =>
message.payload?.headers?.find((header) => header.name === 'Subject')
?.value ?? '';

View File

@@ -0,0 +1,39 @@
import { randomUUID } from 'node:crypto';
import { type gmail_v1 } from 'googleapis';
type GmailMessageOverrides = Partial<gmail_v1.Schema$Message> & {
from?: string;
to?: string;
};
export const gmailMessage = ({
from,
to,
...overrides
}: GmailMessageOverrides = {}): gmail_v1.Schema$Message => {
const id = overrides.id ?? `gmail-msg-${randomUUID()}`;
return {
id,
threadId: id,
historyId: '987654321',
internalDate: '1700000000000',
labelIds: ['INBOX'],
payload: {
mimeType: 'text/plain',
headers: [
{ name: 'From', value: from ?? `sender-${id}@example.com` },
{ name: 'To', value: to ?? `recipient-${id}@example.com` },
{ name: 'Subject', value: `Subject ${id}` },
{ name: 'Message-ID', value: `<${id}@example.com>` },
{ name: 'Date', value: 'Wed, 15 Nov 2023 00:00:00 +0000' },
],
body: {
data: Buffer.from(`body ${id}`).toString('base64'),
size: 10,
},
},
...overrides,
};
};

View File

@@ -0,0 +1,22 @@
import { randomUUID } from 'node:crypto';
import { type calendar_v3 } from 'googleapis';
export const googleCalendarEvent = (
overrides: Partial<calendar_v3.Schema$Event> = {},
): calendar_v3.Schema$Event => {
const id = overrides.id ?? `google-calendar-event-${randomUUID()}`;
return {
id,
iCalUID: `${id}@google.com`,
summary: `Calendar event ${id}`,
status: 'confirmed',
start: { dateTime: '2023-11-15T10:00:00Z' },
end: { dateTime: '2023-11-15T11:00:00Z' },
created: '2023-11-01T00:00:00.000Z',
updated: '2023-11-01T00:00:00.000Z',
attendees: [],
...overrides,
};
};

View File

@@ -0,0 +1,22 @@
import { type calendar_v3 } from 'googleapis';
import { http, HttpResponse } from 'msw';
import { GOOGLE_CALENDAR_EVENTS_URL } from 'test/integration/google/mocks/google-calendar-events-url.constant';
import { type MswHandler } from 'test/integration/utils/http-mock.util';
export const googleCalendarEventsHandlers = (
events: calendar_v3.Schema$Event[],
nextSyncToken: string,
): MswHandler[] => [
http.get(GOOGLE_CALENDAR_EVENTS_URL, () =>
HttpResponse.json<calendar_v3.Schema$Events>({
items: events,
nextSyncToken,
}),
),
...events.map((event) =>
http.get(`${GOOGLE_CALENDAR_EVENTS_URL}/${event.id}`, () =>
HttpResponse.json<calendar_v3.Schema$Event>(event),
),
),
];

View File

@@ -0,0 +1,2 @@
export const GOOGLE_CALENDAR_EVENTS_URL =
'https://www.googleapis.com/calendar/v3/calendars/primary/events';

View File

@@ -0,0 +1,37 @@
import { http, HttpResponse } from 'msw';
import { GOOGLE_OAUTH_SCOPES } from 'test/integration/google/mocks/google-oauth-scopes.constant';
import { type MswHandler } from 'test/integration/utils/http-mock.util';
export const googleIdentityHandlers = (
handle: string,
aliases: string[] = [],
): MswHandler[] => [
http.get('https://www.googleapis.com/oauth2/v3/userinfo', () =>
HttpResponse.json({
sub: `google-user-id-${handle}`,
email: handle,
email_verified: true,
name: 'Jane Austen',
given_name: 'Jane',
family_name: 'Austen',
}),
),
http.get('https://www.googleapis.com/oauth2/v3/tokeninfo', () =>
HttpResponse.json({ scope: GOOGLE_OAUTH_SCOPES, email: handle }),
),
http.get('https://gmail.googleapis.com/gmail/v1/users/me/profile', () =>
HttpResponse.json({ emailAddress: handle, messagesTotal: 0 }),
),
http.get('https://people.googleapis.com/v1/people/me', () =>
HttpResponse.json({ names: [{ displayName: 'Jane Austen' }] }),
),
http.get('*/gmail/v1/users/me/settings/sendAs', () =>
HttpResponse.json({
sendAs: [
{ sendAsEmail: handle, isPrimary: true },
...aliases.map((alias) => ({ sendAsEmail: alias, isPrimary: false })),
],
}),
),
];

View File

@@ -0,0 +1,9 @@
export const GOOGLE_OAUTH_SCOPES = [
'email',
'profile',
'https://www.googleapis.com/auth/gmail.readonly',
'https://www.googleapis.com/auth/calendar.events',
'https://www.googleapis.com/auth/profile.emails.read',
'https://www.googleapis.com/auth/gmail.send',
'https://www.googleapis.com/auth/gmail.compose',
].join(' ');

View File

@@ -0,0 +1,22 @@
import { http, HttpResponse } from 'msw';
import { GOOGLE_OAUTH_SCOPES } from 'test/integration/google/mocks/google-oauth-scopes.constant';
import { type MswHandler } from 'test/integration/utils/http-mock.util';
export const GOOGLE_TOKEN_URLS = [
'https://oauth2.googleapis.com/token',
'https://www.googleapis.com/oauth2/v4/token',
];
export const googleTokenHandlers = (): MswHandler[] =>
GOOGLE_TOKEN_URLS.map((url) =>
http.post(url, () =>
HttpResponse.json({
access_token: 'mock-access-token',
refresh_token: 'mock-refresh-token',
expires_in: 3600,
scope: GOOGLE_OAUTH_SCOPES,
token_type: 'Bearer',
}),
),
);

View File

@@ -0,0 +1,231 @@
import { type calendar_v3, type gmail_v1 } from 'googleapis';
import { http, HttpResponse } from 'msw';
import { gmailHistoryHandler } from 'test/integration/google/mocks/gmail-history-handler.util';
import { gmailMailboxHandlers } from 'test/integration/google/mocks/gmail-mailbox-handlers.util';
import { gmailMessageListHandler } from 'test/integration/google/mocks/gmail-message-list-handler.util';
import { googleCalendarEventsHandlers } from 'test/integration/google/mocks/google-calendar-events-handlers.util';
import { GOOGLE_CALENDAR_EVENTS_URL } from 'test/integration/google/mocks/google-calendar-events-url.constant';
import { googleIdentityHandlers } from 'test/integration/google/mocks/google-identity-handlers.util';
import {
GOOGLE_TOKEN_URLS,
googleTokenHandlers,
} from 'test/integration/google/mocks/google-token-handlers.util';
import { setupHttpMock } from 'test/integration/utils/http-mock.util';
import {
createMockEntityStore,
type MockEntityStore,
} from 'test/integration/utils/mock-entity-store.util';
const DEFAULT_LABELS: gmail_v1.Schema$Label[] = [
{ id: 'INBOX', name: 'INBOX', type: 'system' },
{ id: 'SENT', name: 'SENT', type: 'system' },
];
export type GoogleMock = {
labels: MockEntityStore<gmail_v1.Schema$Label>;
sentMessages: Array<{ raw: string; threadId?: string }>;
draftMessages: Array<{ raw: string; threadId?: string }>;
createdCalendarEvents: calendar_v3.Schema$Event[];
actAsAccount: (handle: string) => void;
serveMessageList: (messages: gmail_v1.Schema$Message[]) => void;
serveHistory: (addedMessages: gmail_v1.Schema$Message[]) => void;
serveCalendarEvents: (
events: calendar_v3.Schema$Event[],
options?: { nextSyncToken?: string },
) => void;
rateLimitMessageList: (retryAfterIso: string) => void;
rateLimitCalendarEventList: () => void;
failMessageList: (failure: GoogleApiFailure) => void;
failCalendarEventList: (failure: GoogleApiFailure) => void;
declineTokenRefresh: () => void;
};
export type GoogleApiFailure = {
status: number;
reason: string;
message: string;
};
const googleApiErrorResponse = ({
status,
reason,
message,
}: GoogleApiFailure) =>
HttpResponse.json(
{
error: {
code: status,
message,
errors: [{ reason, message }],
},
},
{ status },
);
export const setupGoogleMock = ({
handle,
aliases = [],
inbox = [],
labels = DEFAULT_LABELS,
}: {
handle: string;
aliases?: string[];
inbox?: gmail_v1.Schema$Message[];
labels?: gmail_v1.Schema$Label[];
}): GoogleMock => {
const labelStore = createMockEntityStore(labels, (label) => label.id ?? '');
const sentMessages: Array<{ raw: string; threadId?: string }> = [];
const draftMessages: Array<{ raw: string; threadId?: string }> = [];
const createdCalendarEvents: calendar_v3.Schema$Event[] = [];
const httpMock = setupHttpMock(
...googleTokenHandlers(),
...googleIdentityHandlers(handle, aliases),
...googleCalendarEventsHandlers([], 'mock-calendar-sync-token'),
...gmailMailboxHandlers(inbox, labelStore),
http.post('*/gmail/v1/users/me/messages/send', async ({ request }) => {
const body = (await request.json()) as { raw: string; threadId?: string };
sentMessages.push({
raw: Buffer.from(body.raw, 'base64url').toString(),
threadId: body.threadId,
});
const id = `gmail-sent-${sentMessages.length}`;
return HttpResponse.json({ id, threadId: body.threadId ?? id });
}),
http.post('*/gmail/v1/users/me/drafts', async ({ request }) => {
const body = (await request.json()) as {
message: { raw: string; threadId?: string };
};
draftMessages.push({
raw: Buffer.from(body.message.raw, 'base64url').toString(),
threadId: body.message.threadId,
});
return HttpResponse.json({ id: `gmail-draft-${draftMessages.length}` });
}),
http.get('*/gmail/v1/users/me/drafts', () =>
HttpResponse.json({
drafts: [
{
id: 'gmail-synced-draft',
message: { id: 'gmail-draft-message' },
},
],
}),
),
http.delete(
'*/gmail/v1/users/me/drafts/:draftId',
() => new HttpResponse(null, { status: 204 }),
),
http.post(GOOGLE_CALENDAR_EVENTS_URL, async ({ request }) => {
const event = (await request.json()) as calendar_v3.Schema$Event;
const id = `google-created-calendar-event-${createdCalendarEvents.length + 1}`;
const createdEvent = {
...event,
id,
iCalUID: `${id}@google.com`,
status: 'confirmed',
created: '2026-08-13T00:00:00.000Z',
updated: '2026-08-13T00:00:00.000Z',
};
createdCalendarEvents.push(createdEvent);
return HttpResponse.json(createdEvent);
}),
);
return {
labels: labelStore,
sentMessages,
draftMessages,
createdCalendarEvents,
actAsAccount: (accountHandle) =>
httpMock.use(...googleIdentityHandlers(accountHandle)),
serveMessageList: (messages) =>
httpMock.use(gmailMessageListHandler(messages)),
serveHistory: (addedMessages) =>
httpMock.use(gmailHistoryHandler(addedMessages)),
serveCalendarEvents: (
events,
{ nextSyncToken = 'mock-calendar-sync-token' } = {},
) => httpMock.use(...googleCalendarEventsHandlers(events, nextSyncToken)),
rateLimitMessageList: (retryAfterIso) =>
httpMock.use(
http.get('*/gmail/v1/users/me/messages', () =>
HttpResponse.json(
{
error: {
code: 429,
message: 'Rate Limit Exceeded',
errors: [
{
reason: 'rateLimitExceeded',
message: `Rate Limit Exceeded. Retry after ${retryAfterIso}`,
},
],
},
},
{ status: 429 },
),
),
),
rateLimitCalendarEventList: () =>
httpMock.use(
http.get(GOOGLE_CALENDAR_EVENTS_URL, () =>
HttpResponse.json(
{
error: {
code: 429,
message: 'Rate Limit Exceeded',
errors: [
{
reason: 'rateLimitExceeded',
message: 'Rate Limit Exceeded',
},
],
},
},
{ status: 429 },
),
),
),
// A channel carrying a sync cursor fetches through the history endpoint
// rather than the message list, so both have to fail for the failure to
// surface regardless of which path the channel takes.
failMessageList: (failure) =>
httpMock.use(
http.get('*/gmail/v1/users/me/messages', () =>
googleApiErrorResponse(failure),
),
http.get('*/gmail/v1/users/me/history', () =>
googleApiErrorResponse(failure),
),
),
failCalendarEventList: (failure) =>
httpMock.use(
http.get(GOOGLE_CALENDAR_EVENTS_URL, () =>
googleApiErrorResponse(failure),
),
),
declineTokenRefresh: () =>
httpMock.use(
...GOOGLE_TOKEN_URLS.map((url) =>
http.post(url, () =>
HttpResponse.json(
{
error: 'invalid_grant',
error_description: 'Token has been revoked',
},
{ status: 400 },
),
),
),
),
};
};

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

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