Ignore expired invitations in invitation lookups (#23749)
## What Invitation lookups did not filter on `expiresAt`, so expired invitations were still treated as active. This aligns them with the sibling `findInvitationsByEmail`, which already applied that filter. - `WorkspaceInvitationService.getOneWorkspaceInvitation` - added `deletedAt IS NULL` and `expiresAt > now` (also converted to a typed `findOne` so the column references are checked). - `AuthService.findInvitationForSignInUp` - added `expiresAt > now` (it already filtered `deletedAt`). - `throwIfOnboardingInvitationLimitReached` - expired tokens no longer count toward the onboarding invitation limit. - `createWorkspaceInvitation` - deletes the expired token for that email before issuing a replacement, so re-invites don't accumulate stale rows. ## Why Without the filter, an expired pending invitation behaved as if it were still active: - On sign-up with a personal invite token, an expired invitation still granted access to the workspace. - Re-inviting an email whose invitation had lapsed reported `INVITATION_ALREADY_EXIST` instead of sending a fresh invite. - Expired onboarding invitations still consumed quota, so the limit could be hit by invitations nobody could use. Once expired tokens are ignored on read, a re-invite would leave the old row behind, so `createWorkspaceInvitation` now removes it. The delete is scoped to the same workspace, invitation token types, that exact email, and `expiresAt <= now`, so it can only remove tokens that are already unusable. Closes twentyhq/private-issues#503 ## Tests Integration suites added, run against a real database: - `auth/sign-up/failing-sign-up-with-expired-invitation` - expired personal invitation is rejected (snapshot asserts the specific `FORBIDDEN` error). - `auth/sign-up/successful-sign-up-with-valid-invitation` - positive control: a valid invitation still grants access, so the rejection above cannot pass for an unrelated reason. - `expired-workspace-invitation` - re-invite over an expired invitation succeeds and leaves exactly one (fresh) token; a valid invitation is still reported as already existing. Each assertion was verified to fail when its corresponding filter is removed. Unit tests (`workspace-invitation.service.spec.ts`, `auth.service.spec.ts`), typecheck, and lint all pass. Not included: invitations that expire and are never re-invited still linger, since no cron reaps invitation tokens today.
This commit is contained in:
@@ -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', {
|
||||
|
||||
+8
-11
@@ -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),
|
||||
|
||||
+26
-10
@@ -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()),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+13
@@ -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",
|
||||
}
|
||||
`;
|
||||
+40
@@ -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 });
|
||||
});
|
||||
});
|
||||
+72
@@ -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();
|
||||
});
|
||||
});
|
||||
+62
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
+49
@@ -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],
|
||||
);
|
||||
+18
@@ -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 },
|
||||
});
|
||||
+50
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user