Files
twenty/packages/twenty-server/test/integration/graphql/utils/seed-workspace-invitation.util.ts
T
Paul Rastoin 3833015626 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.
2026-08-04 15:35:41 +00:00

50 lines
1.3 KiB
TypeScript

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],
);