diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts index ecbd215d3b..fcea6cb1aa 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts @@ -807,7 +807,10 @@ export class AuthService { .andWhere('"appToken".type IN (:...types)', { types: INVITATION_APP_TOKEN_TYPES, }) - .andWhere('"appToken"."deletedAt" IS NULL'); + .andWhere('"appToken"."deletedAt" IS NULL') + .andWhere('"appToken"."expiresAt" > :now', { + now: new Date(), + }); if ('workspacePersonalInviteToken' in params) { qr.andWhere('"appToken".value = :personalInviteToken', { diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts index a52db5c067..ff482a2ffe 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts @@ -1,7 +1,7 @@ import { Test, type TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { type DeleteResult, Repository } from 'typeorm'; import { AppTokenEntity, @@ -155,11 +155,10 @@ describe('WorkspaceInvitationService', () => { const email = 'test@example.com'; const workspace = { id: 'workspace-id' } as WorkspaceEntity; - jest.spyOn(appTokenRepository, 'createQueryBuilder').mockReturnValue({ - where: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - getOne: jest.fn().mockResolvedValue(null), - } as any); + jest.spyOn(appTokenRepository, 'findOne').mockResolvedValue(null); + jest + .spyOn(appTokenRepository, 'delete') + .mockResolvedValue({} as DeleteResult); jest.spyOn(userWorkspaceRepository, 'exists').mockResolvedValue(false); jest @@ -175,11 +174,9 @@ describe('WorkspaceInvitationService', () => { const email = 'test@example.com'; const workspace = { id: 'workspace-id' } as WorkspaceEntity; - jest.spyOn(appTokenRepository, 'createQueryBuilder').mockReturnValue({ - where: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - getOne: jest.fn().mockResolvedValue({}), - } as any); + jest + .spyOn(appTokenRepository, 'findOne') + .mockResolvedValue({} as AppTokenEntity); await expect( service.createWorkspaceInvitation(email, workspace), diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts index 1f7389f2de..7e32a5d4cb 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts @@ -9,7 +9,14 @@ import ms from 'ms'; import { SendInviteLinkEmail, renderEmail } from 'twenty-emails'; import { AppPath, FileFolder } from 'twenty-shared/types'; import { getAppPath, isDefined } from 'twenty-shared/utils'; -import { In, IsNull, Repository } from 'typeorm'; +import { + In, + IsNull, + LessThanOrEqual, + MoreThan, + Raw, + Repository, +} from 'typeorm'; import { AppTokenEntity, @@ -109,16 +116,15 @@ export class WorkspaceInvitationService { } async getOneWorkspaceInvitation(workspaceId: string, email: string) { - return await this.appTokenRepository - .createQueryBuilder('appToken') - .where('"appToken"."workspaceId" = :workspaceId', { + return await this.appTokenRepository.findOne({ + where: { workspaceId, - }) - .andWhere('"appToken".type IN (:...types)', { - types: INVITATION_APP_TOKEN_TYPES, - }) - .andWhere('"appToken".context->>\'email\' = :email', { email }) - .getOne(); + type: In(INVITATION_APP_TOKEN_TYPES), + deletedAt: IsNull(), + expiresAt: MoreThan(new Date()), + context: Raw((alias) => `${alias} ->> 'email' = :email`, { email }), + }, + }); } async getAppTokenByInvitationToken(invitationToken: string) { @@ -192,6 +198,15 @@ export class WorkspaceInvitationService { ); } + await this.appTokenRepository.delete({ + workspaceId: workspace.id, + type: In(INVITATION_APP_TOKEN_TYPES), + expiresAt: LessThanOrEqual(new Date()), + context: Raw((alias) => `${alias} ->> 'email' = :email`, { + email: email.toLowerCase(), + }), + }); + return this.generateInvitationToken( workspace.id, email, @@ -465,6 +480,7 @@ export class WorkspaceInvitationService { workspaceId, type: AppTokenType.OnboardingInvitationToken, deletedAt: IsNull(), + expiresAt: MoreThan(new Date()), }, }); diff --git a/packages/twenty-server/test/integration/graphql/suites/auth/sign-up/__snapshots__/failing-sign-up-with-expired-invitation.integration-spec.ts.snap b/packages/twenty-server/test/integration/graphql/suites/auth/sign-up/__snapshots__/failing-sign-up-with-expired-invitation.integration-spec.ts.snap new file mode 100644 index 0000000000..21a8191b72 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/auth/sign-up/__snapshots__/failing-sign-up-with-expired-invitation.integration-spec.ts.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`signUpInWorkspace with an expired personal invitation (integration) denies access when the personal invitation is expired 1`] = ` +{ + "extensions": { + "code": "FORBIDDEN", + "subCode": "FORBIDDEN_EXCEPTION", + "userFriendlyMessage": "User does not have access to this workspace", + }, + "message": "User does not have access to this workspace", + "name": "ForbiddenError", +} +`; diff --git a/packages/twenty-server/test/integration/graphql/suites/auth/sign-up/failing-sign-up-with-expired-invitation.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/auth/sign-up/failing-sign-up-with-expired-invitation.integration-spec.ts new file mode 100644 index 0000000000..f760f97f77 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/auth/sign-up/failing-sign-up-with-expired-invitation.integration-spec.ts @@ -0,0 +1,40 @@ +import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util'; +import { + deleteWorkspaceInvitationsByEmail, + seedWorkspaceInvitation, +} from 'test/integration/graphql/utils/seed-workspace-invitation.util'; +import { signUpInWorkspaceOperationFactory } from 'test/integration/graphql/utils/sign-up-in-workspace-operation-factory.util'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; + +import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant'; + +const ONE_HOUR_IN_MS = 60 * 60 * 1000; + +describe('signUpInWorkspace with an expired personal invitation (integration)', () => { + const email = `expired-invite-signup-${Date.now()}@example.com`; + const token = `expired-invite-signup-token-${Date.now()}`; + + beforeAll(() => + seedWorkspaceInvitation({ + email, + value: token, + expiresAt: new Date(Date.now() - ONE_HOUR_IN_MS), + }), + ); + + afterAll(() => deleteWorkspaceInvitationsByEmail({ email })); + + it('denies access when the personal invitation is expired', async () => { + const response = await makeMetadataAPIRequest( + signUpInWorkspaceOperationFactory({ + email, + workspaceId: SEED_APPLE_WORKSPACE_ID, + workspacePersonalInviteToken: token, + }), + undefined, + ); + + expect(response.body.data?.signUpInWorkspace).toBeFalsy(); + expectOneNotInternalServerErrorSnapshot({ errors: response.body.errors }); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/auth/sign-up/successful-sign-up-with-valid-invitation.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/auth/sign-up/successful-sign-up-with-valid-invitation.integration-spec.ts new file mode 100644 index 0000000000..8fe51d0c48 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/auth/sign-up/successful-sign-up-with-valid-invitation.integration-spec.ts @@ -0,0 +1,72 @@ +import { deleteUser } from 'test/integration/graphql/utils/delete-user.util'; +import { getAuthTokensFromLoginToken } from 'test/integration/graphql/utils/get-auth-tokens-from-login-token.util'; +import { + deleteWorkspaceInvitationsByEmail, + seedWorkspaceInvitation, +} from 'test/integration/graphql/utils/seed-workspace-invitation.util'; +import { signUpInWorkspaceOperationFactory } from 'test/integration/graphql/utils/sign-up-in-workspace-operation-factory.util'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; + +import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant'; + +const ONE_HOUR_IN_MS = 60 * 60 * 1000; + +describe('signUpInWorkspace with a valid personal invitation (integration)', () => { + const email = `valid-invite-signup-${Date.now()}@example.com`; + const token = `valid-invite-signup-token-${Date.now()}`; + + let accessToken: string | undefined; + + beforeAll(() => + seedWorkspaceInvitation({ + email, + value: token, + expiresAt: new Date(Date.now() + ONE_HOUR_IN_MS), + }), + ); + + afterAll(async () => { + if (accessToken) { + await deleteUser({ accessToken, expectToFail: false }); + } + + await deleteWorkspaceInvitationsByEmail({ email }); + }); + + it('grants access when the personal invitation is still valid', async () => { + const response = await makeMetadataAPIRequest( + signUpInWorkspaceOperationFactory({ + email, + workspaceId: SEED_APPLE_WORKSPACE_ID, + workspacePersonalInviteToken: token, + }), + undefined, + ); + + expect(response.body.errors).toBeUndefined(); + + const signUpPayload = response.body.data.signUpInWorkspace; + + expect(signUpPayload.workspace.id).toBe(SEED_APPLE_WORKSPACE_ID); + expect(signUpPayload.loginToken.token).toBeDefined(); + + await testDataSource.query( + 'UPDATE core."user" SET "isEmailVerified" = true WHERE email = $1', + [email], + ); + + const { + data: { getAuthTokensFromLoginToken: authTokensData }, + } = await getAuthTokensFromLoginToken({ + loginToken: signUpPayload.loginToken.token, + origin: + signUpPayload.workspace.workspaceUrls?.subdomainUrl ?? + 'http://localhost:3001', + expectToFail: false, + }); + + accessToken = authTokensData.tokens.accessOrWorkspaceAgnosticToken.token; + + expect(accessToken).toBeDefined(); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/expired-workspace-invitation.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/expired-workspace-invitation.integration-spec.ts new file mode 100644 index 0000000000..add4672291 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/expired-workspace-invitation.integration-spec.ts @@ -0,0 +1,62 @@ +import { + deleteWorkspaceInvitationsByEmail, + findWorkspaceInvitationsByEmail, + seedWorkspaceInvitation, +} from 'test/integration/graphql/utils/seed-workspace-invitation.util'; +import { sendInvitationsOperationFactory } from 'test/integration/graphql/utils/send-invitations-operation-factory.util'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; + +const ONE_HOUR_IN_MS = 60 * 60 * 1000; + +describe('sendInvitations expired invitation handling (integration)', () => { + const sendInvitations = (email: string) => + makeMetadataAPIRequest( + sendInvitationsOperationFactory({ emails: [email] }), + ); + + it('re-invites an email whose only existing invitation is expired', async () => { + const email = `expired-invite-resend-${Date.now()}@example.com`; + const staleToken = `expired-invite-resend-token-${Date.now()}`; + + await seedWorkspaceInvitation({ + email, + value: staleToken, + expiresAt: new Date(Date.now() - ONE_HOUR_IN_MS), + }); + + try { + const response = await sendInvitations(email); + + expect(response.body.errors).toBeUndefined(); + expect(response.body.data.sendInvitations.success).toBe(true); + + const remainingTokens = await findWorkspaceInvitationsByEmail({ email }); + + expect(remainingTokens).toHaveLength(1); + expect(remainingTokens[0].value).not.toBe(staleToken); + expect(new Date(remainingTokens[0].expiresAt).getTime()).toBeGreaterThan( + Date.now(), + ); + } finally { + await deleteWorkspaceInvitationsByEmail({ email }); + } + }); + + it('still reports a valid invitation as already existing', async () => { + const email = `valid-invite-resend-${Date.now()}@example.com`; + + await seedWorkspaceInvitation({ + email, + value: `valid-invite-resend-token-${Date.now()}`, + expiresAt: new Date(Date.now() + ONE_HOUR_IN_MS), + }); + + try { + const response = await sendInvitations(email); + + expect(response.body.data.sendInvitations.success).toBe(false); + } finally { + await deleteWorkspaceInvitationsByEmail({ email }); + } + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/seed-workspace-invitation.util.ts b/packages/twenty-server/test/integration/graphql/utils/seed-workspace-invitation.util.ts new file mode 100644 index 0000000000..36fbebe40c --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/seed-workspace-invitation.util.ts @@ -0,0 +1,49 @@ +import { AppTokenType } from 'src/engine/core-modules/app-token/app-token.entity'; +import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant'; + +export const seedWorkspaceInvitation = ({ + email, + value, + expiresAt, + workspaceId = SEED_APPLE_WORKSPACE_ID, +}: { + email: string; + value: string; + expiresAt: Date; + workspaceId?: string; +}) => + testDataSource.query( + `INSERT INTO core."appToken" ("workspaceId", "type", "value", "expiresAt", "context") + VALUES ($1, $2, $3, $4, $5::jsonb)`, + [ + workspaceId, + AppTokenType.InvitationToken, + value, + expiresAt.toISOString(), + JSON.stringify({ email }), + ], + ); + +export const findWorkspaceInvitationsByEmail = ({ + email, + workspaceId = SEED_APPLE_WORKSPACE_ID, +}: { + email: string; + workspaceId?: string; +}): Promise<{ value: string; expiresAt: string }[]> => + testDataSource.query( + `SELECT "value", "expiresAt" FROM core."appToken" WHERE "workspaceId" = $1 AND context->>'email' = $2`, + [workspaceId, email], + ); + +export const deleteWorkspaceInvitationsByEmail = ({ + email, + workspaceId = SEED_APPLE_WORKSPACE_ID, +}: { + email: string; + workspaceId?: string; +}) => + testDataSource.query( + `DELETE FROM core."appToken" WHERE "workspaceId" = $1 AND context->>'email' = $2`, + [workspaceId, email], + ); diff --git a/packages/twenty-server/test/integration/graphql/utils/send-invitations-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/send-invitations-operation-factory.util.ts new file mode 100644 index 0000000000..c5eaa04482 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/send-invitations-operation-factory.util.ts @@ -0,0 +1,18 @@ +import gql from 'graphql-tag'; + +export const sendInvitationsOperationFactory = ({ + emails, + roleId, +}: { + emails: string[]; + roleId?: string; +}) => ({ + query: gql` + mutation SendInvitations($emails: [String!]!, $roleId: UUID) { + sendInvitations(emails: $emails, roleId: $roleId) { + success + } + } + `, + variables: { emails, roleId }, +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/sign-up-in-workspace-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/sign-up-in-workspace-operation-factory.util.ts new file mode 100644 index 0000000000..0ec773ce74 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/sign-up-in-workspace-operation-factory.util.ts @@ -0,0 +1,50 @@ +import gql from 'graphql-tag'; + +export const signUpInWorkspaceOperationFactory = ({ + email, + password = 'Test123!@#', + workspaceId, + workspaceInviteHash, + workspacePersonalInviteToken, +}: { + email: string; + password?: string; + workspaceId?: string; + workspaceInviteHash?: string; + workspacePersonalInviteToken?: string; +}) => ({ + query: gql` + mutation SignUpInWorkspace( + $email: String! + $password: String! + $workspaceId: UUID + $workspaceInviteHash: String + $workspacePersonalInviteToken: String + ) { + signUpInWorkspace( + email: $email + password: $password + workspaceId: $workspaceId + workspaceInviteHash: $workspaceInviteHash + workspacePersonalInviteToken: $workspacePersonalInviteToken + ) { + loginToken { + token + } + workspace { + id + workspaceUrls { + subdomainUrl + } + } + } + } + `, + variables: { + email, + password, + workspaceId, + workspaceInviteHash, + workspacePersonalInviteToken, + }, +});