stack/apps/e2e/tests/backend/endpoints/api/v1/team-invitations.test.ts
2026-06-11 10:28:14 -07:00

1209 lines
39 KiB
TypeScript

import { expect } from "vitest";
import { it } from "../../../../helpers";
import { Auth, InternalProjectKeys, Project, Team, User, backendContext, createMailbox, niceBackendFetch } from "../../../backend-helpers";
async function createAndAddCurrentUserWithoutMemberPermission() {
const { teamId } = await Team.create();
const user = await User.getCurrent();
await Team.addMember(teamId, user.id);
const response = await niceBackendFetch(`/api/v1/team-permissions/${teamId}/${user.id}/team_member`, {
accessType: "server",
method: "DELETE",
body: {},
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": { "success": true },
"headers": Headers { <some fields may have been hidden> },
}
`);
return {
teamId,
};
}
it("requires $invite_members permission to send invitation", async ({ expect }) => {
await Auth.fastSignUp();
const { teamId } = await createAndAddCurrentUserWithoutMemberPermission();
const sendTeamInvitationResponse = await niceBackendFetch("/api/v1/team-invitations/send-code", {
method: "POST",
accessType: "client",
body: {
email: "some-email-test@example.com",
team_id: teamId,
callback_url: "http://localhost:12345/some-callback-url",
},
});
expect(sendTeamInvitationResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 401,
"body": {
"code": "TEAM_PERMISSION_REQUIRED",
"details": {
"permission_id": "$invite_members",
"team_id": "<stripped UUID>",
"user_id": "<stripped UUID>",
},
"error": "User <stripped UUID> does not have permission $invite_members in team <stripped UUID>.",
},
"headers": Headers {
"x-stack-known-error": "TEAM_PERMISSION_REQUIRED",
<some fields may have been hidden>,
},
}
`);
});
it("can send invitation", async ({ expect }) => {
await Project.createAndSwitch();
const { userId: userId1 } = await Auth.fastSignUp();
const { teamId } = await createAndAddCurrentUserWithoutMemberPermission();
const receiveMailbox = createMailbox();
await niceBackendFetch(`/api/v1/team-permissions/${teamId}/${userId1}/$invite_members`, {
accessType: "server",
method: "POST",
body: {},
});
await Team.sendInvitation(receiveMailbox, teamId);
backendContext.set({ mailbox: receiveMailbox });
await Auth.fastSignUp({ primary_email: receiveMailbox.emailAddress, primary_email_verified: true });
await Team.acceptInvitation();
const response = await niceBackendFetch(`/api/v1/teams?user_id=me`, {
accessType: "server",
method: "GET",
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"is_paginated": false,
"items": [
{
"client_metadata": null,
"client_read_only_metadata": null,
"created_at_millis": <stripped field 'created_at_millis'>,
"display_name": "New Team",
"id": "<stripped UUID>",
"profile_image_url": null,
"server_metadata": null,
},
],
},
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("grants 1 dashboard_admin seat when creating a team on the internal project", async ({ expect }) => {
await Auth.fastSignUp();
const { teamId } = await Team.create();
const productsResponse = await niceBackendFetch(`/api/latest/payments/products/team/${teamId}`, {
accessType: "server",
});
expect(productsResponse.status).toBe(200);
expect(productsResponse.body.items.length).toBeGreaterThan(0);
const response = await niceBackendFetch(`/api/latest/payments/items/team/${teamId}/dashboard_admins`, {
accessType: "server",
});
expect(response.status).toBe(200);
expect(response.body.quantity).toBe(1);
});
it("can send invitation without a current user on the server", async ({ expect }) => {
const { teamId } = await Team.create();
const receiveMailbox = createMailbox();
const grantSeatsResponse = await niceBackendFetch(`/api/v1/payments/items/team/${teamId}/dashboard_admins/update-quantity?allow_negative=false`, {
method: "POST",
accessType: "server",
body: { delta: 3 },
});
expect(grantSeatsResponse).toMatchObject({ status: 200 });
backendContext.set({ userAuth: null });
const sendTeamInvitationResponse = await niceBackendFetch("/api/v1/team-invitations/send-code", {
method: "POST",
accessType: "server",
body: {
email: receiveMailbox.emailAddress,
team_id: teamId,
callback_url: "http://localhost:12345/some-callback-url",
},
});
expect(sendTeamInvitationResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"id": "<stripped UUID>",
"success": true,
},
"headers": Headers { <some fields may have been hidden> },
}
`);
backendContext.set({ mailbox: receiveMailbox });
await Auth.fastSignUp({ primary_email: receiveMailbox.emailAddress, primary_email_verified: true });
await Team.acceptInvitation();
const response = await niceBackendFetch(`/api/v1/teams?user_id=me`, {
accessType: "server",
method: "GET",
});
expect(response.body.items).toHaveLength(2);
expect(response.body.items.find((item: any) => item.display_name === "New Team")).toBeDefined();
});
it("can list invitations on the server", async ({ expect }) => {
const { userId: inviter } = await Auth.fastSignUp();
const { teamId } = await createAndAddCurrentUserWithoutMemberPermission();
await niceBackendFetch(`/api/v1/team-permissions/${teamId}/${inviter}/$invite_members`, {
accessType: "server",
method: "POST",
body: {},
});
await Team.sendInvitation("some-email-test@example.com", teamId);
const listInvitationsResponse = await niceBackendFetch(`/api/v1/team-invitations?team_id=${teamId}`, {
accessType: "server",
method: "GET",
});
expect(listInvitationsResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"is_paginated": false,
"items": [
{
"expires_at_millis": <stripped field 'expires_at_millis'>,
"id": "<stripped UUID>",
"recipient_email": "some-email-test@example.com",
"team_display_name": "New Team",
"team_id": "<stripped UUID>",
},
],
},
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("can't list invitations without team_id or user_id", async ({ expect }) => {
const listInvitationsResponse = await niceBackendFetch(`/api/v1/team-invitations`, {
accessType: "server",
method: "GET",
});
expect(listInvitationsResponse.status).toBe(400);
});
it("allows team admins to list invitations", async ({ expect }) => {
await Project.createAndSwitch();
const { userId: inviter } = await Auth.fastSignUp();
const { teamId } = await createAndAddCurrentUserWithoutMemberPermission();
await niceBackendFetch(`/api/v1/team-permissions/${teamId}/${inviter}/$invite_members`, {
accessType: "server",
method: "POST",
body: {},
});
await Team.sendInvitation("some-email-test@example.com", teamId);
const { userId: teamAdmin } = await Auth.fastSignUp();
await Team.addMember(teamId, teamAdmin);
const listInvitationsResponse = await niceBackendFetch(`/api/v1/team-invitations?team_id=${teamId}`, {
accessType: "client",
method: "GET",
});
expect(listInvitationsResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"is_paginated": false,
"items": [
{
"expires_at_millis": <stripped field 'expires_at_millis'>,
"id": "<stripped UUID>",
"recipient_email": "some-email-test@example.com",
"team_display_name": "New Team",
"team_id": "<stripped UUID>",
},
],
},
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("requires $invite_members permission to list invitations", async ({ expect }) => {
const { userId: inviter } = await Auth.fastSignUp();
const { teamId } = await createAndAddCurrentUserWithoutMemberPermission();
// Create an invitation to list
await niceBackendFetch(`/api/v1/team-permissions/${teamId}/${inviter}/$invite_members`, {
accessType: "server",
method: "POST",
body: {},
});
await Team.sendInvitation("some-email-test@example.com", teamId);
const { userId: teamAdmin } = await Auth.fastSignUp();
await Team.addMember(teamId, teamAdmin);
const deletePermissionResponse = await niceBackendFetch(`/api/v1/team-permissions/${teamId}/${teamAdmin}/team_member`, {
accessType: "server",
method: "DELETE",
body: {},
});
expect(deletePermissionResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": { "success": true },
"headers": Headers { <some fields may have been hidden> },
}
`);
const grantPermissionResponse = await niceBackendFetch(`/api/v1/team-permissions/${teamId}/${teamAdmin}/$read_members`, {
accessType: "server",
method: "POST",
body: {},
});
expect(grantPermissionResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 201,
"body": {
"id": "$read_members",
"team_id": "<stripped UUID>",
"user_id": "<stripped UUID>",
},
"headers": Headers { <some fields may have been hidden> },
}
`);
const listInvitationsResponse = await niceBackendFetch(`/api/v1/team-invitations?team_id=${teamId}`, {
accessType: "client",
method: "GET",
});
expect(listInvitationsResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 401,
"body": {
"code": "TEAM_PERMISSION_REQUIRED",
"details": {
"permission_id": "$invite_members",
"team_id": "<stripped UUID>",
"user_id": "<stripped UUID>",
},
"error": "User <stripped UUID> does not have permission $invite_members in team <stripped UUID>.",
},
"headers": Headers {
"x-stack-known-error": "TEAM_PERMISSION_REQUIRED",
<some fields may have been hidden>,
},
}
`);
});
it("requires $read_members permission to list invitations", async ({ expect }) => {
const { userId: inviter } = await Auth.fastSignUp();
const { teamId } = await createAndAddCurrentUserWithoutMemberPermission();
await niceBackendFetch(`/api/v1/team-permissions/${teamId}/${inviter}/$invite_members`, {
accessType: "server",
method: "POST",
body: {},
});
const { sendTeamInvitationResponse } = await Team.sendInvitation("some-email-test@example.com", teamId);
const { userId: teamAdmin } = await Auth.fastSignUp();
await Team.addMember(teamId, teamAdmin);
await niceBackendFetch(`/api/v1/team-permissions/${teamId}/${teamAdmin}/team_member`, {
accessType: "server",
method: "DELETE",
body: {},
});
await niceBackendFetch(`/api/v1/team-permissions/${teamId}/${teamAdmin}/$invite_members`, {
accessType: "server",
method: "POST",
body: {},
});
const listInvitationsResponse = await niceBackendFetch(`/api/v1/team-invitations?team_id=${teamId}`, {
accessType: "client",
method: "GET",
});
expect(listInvitationsResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 401,
"body": {
"code": "TEAM_PERMISSION_REQUIRED",
"details": {
"permission_id": "$read_members",
"team_id": "<stripped UUID>",
"user_id": "<stripped UUID>",
},
"error": "User <stripped UUID> does not have permission $read_members in team <stripped UUID>.",
},
"headers": Headers {
"x-stack-known-error": "TEAM_PERMISSION_REQUIRED",
<some fields may have been hidden>,
},
}
`);
});
it("allows team admins to revoke invitations", async ({ expect }) => {
const { userId: inviter } = await Auth.fastSignUp();
const { teamId } = await createAndAddCurrentUserWithoutMemberPermission();
await niceBackendFetch(`/api/v1/team-permissions/${teamId}/${inviter}/$invite_members`, {
accessType: "server",
method: "POST",
body: {},
});
const { sendTeamInvitationResponse } = await Team.sendInvitation("some-email-test@example.com", teamId);
const invitationId = sendTeamInvitationResponse.body.id;
const { userId: teamAdmin } = await Auth.fastSignUp();
await Team.addMember(teamId, teamAdmin);
await niceBackendFetch(`/api/v1/team-permissions/${teamId}/${teamAdmin}/$remove_members`, {
accessType: "server",
method: "POST",
body: {},
});
const revokeInvitationResponse = await niceBackendFetch(`/api/v1/team-invitations/${invitationId}?team_id=${teamId}`, {
accessType: "client",
method: "DELETE",
});
expect(revokeInvitationResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": { "success": true },
"headers": Headers { <some fields may have been hidden> },
}
`);
await niceBackendFetch(`/api/v1/team-permissions/${teamId}/${teamAdmin}/$invite_members`, {
accessType: "server",
method: "POST",
body: {},
});
await niceBackendFetch(`/api/v1/team-permissions/${teamId}/${teamAdmin}/$read_members`, {
accessType: "server",
method: "POST",
body: {},
});
const listInvitationsResponse = await niceBackendFetch(`/api/v1/team-invitations?team_id=${teamId}`, {
accessType: "client",
method: "GET",
});
expect(listInvitationsResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"is_paginated": false,
"items": [],
},
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("cannot revoke another team's invitation by passing a team_id the caller controls", async ({ expect }) => {
await Project.createAndSwitch();
const { userId: attackerId } = await Auth.fastSignUp();
// Team A: attacker holds $remove_members here.
const { teamId: teamA } = await Team.create();
await niceBackendFetch(`/api/v1/team-permissions/${teamA}/${attackerId}/$remove_members`, {
accessType: "server",
method: "POST",
body: {},
});
// Team B: has a pending invitation. (Created by the same actor only so the test
// can read the invitation id; the attack is passing teamA as the team_id.)
const { teamId: teamB } = await Team.create();
await niceBackendFetch(`/api/v1/team-permissions/${teamB}/${attackerId}/$invite_members`, {
accessType: "server",
method: "POST",
body: {},
});
const { sendTeamInvitationResponse } = await Team.sendInvitation("victim-invite@example.com", teamB);
const teamBInvitationId = sendTeamInvitationResponse.body.id;
// Revoke team B's invitation while authorizing against team A. Must fail: the
// invitation does not belong to team A, so the delete is not performed.
const revokeResponse = await niceBackendFetch(`/api/v1/team-invitations/${teamBInvitationId}?team_id=${teamA}`, {
accessType: "client",
method: "DELETE",
});
expect(revokeResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 404,
"body": {
"code": "VERIFICATION_CODE_NOT_FOUND",
"error": "The verification code does not exist for this project.",
},
"headers": Headers {
"x-stack-known-error": "VERIFICATION_CODE_NOT_FOUND",
<some fields may have been hidden>,
},
}
`);
// Team B's invitation must still exist.
const listInvitationsResponse = await niceBackendFetch(`/api/v1/team-invitations?team_id=${teamB}`, {
accessType: "server",
method: "GET",
});
expect(listInvitationsResponse.status).toBe(200);
expect(listInvitationsResponse.body.items).toHaveLength(1);
expect(listInvitationsResponse.body.items[0].id).toBe(teamBInvitationId);
// And revoking with the correct team_id still works (no regression).
await niceBackendFetch(`/api/v1/team-permissions/${teamB}/${attackerId}/$remove_members`, {
accessType: "server",
method: "POST",
body: {},
});
const correctRevoke = await niceBackendFetch(`/api/v1/team-invitations/${teamBInvitationId}?team_id=${teamB}`, {
accessType: "client",
method: "DELETE",
});
expect(correctRevoke.status).toBe(200);
});
it("requires $remove_members permission to revoke invitations", async ({ expect }) => {
const { userId: inviter } = await Auth.fastSignUp();
const { teamId } = await createAndAddCurrentUserWithoutMemberPermission();
await niceBackendFetch(`/api/v1/team-permissions/${teamId}/${inviter}/$invite_members`, {
accessType: "server",
method: "POST",
body: {},
});
const { sendTeamInvitationResponse } = await Team.sendInvitation("some-email-test@example.com", teamId);
const invitationId = sendTeamInvitationResponse.body.id;
const { userId: teamAdmin } = await Auth.fastSignUp();
await Team.addMember(teamId, teamAdmin);
const revokeInvitationResponse = await niceBackendFetch(`/api/v1/team-invitations/${invitationId}?team_id=${teamId}`, {
accessType: "client",
method: "DELETE",
});
expect(revokeInvitationResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 401,
"body": {
"code": "TEAM_PERMISSION_REQUIRED",
"details": {
"permission_id": "$remove_members",
"team_id": "<stripped UUID>",
"user_id": "<stripped UUID>",
},
"error": "User <stripped UUID> does not have permission $remove_members in team <stripped UUID>.",
},
"headers": Headers {
"x-stack-known-error": "TEAM_PERMISSION_REQUIRED",
<some fields may have been hidden>,
},
}
`);
});
it("errors with item_quantity_insufficient_amount when accepting invite without remaining dashboard_admins", async ({ expect }) => {
backendContext.set({ projectKeys: InternalProjectKeys });
await Auth.fastSignUp({});
const { createProjectResponse } = await Project.create({ display_name: "Test Project (Insufficient Admins)" });
const ownerTeamId: string = createProjectResponse.body.owner_team_id;
const mailboxB = createMailbox();
const sendInvitationResponse = await niceBackendFetch("/api/v1/team-invitations/send-code", {
method: "POST",
accessType: "server",
body: {
email: mailboxB.emailAddress,
team_id: ownerTeamId,
callback_url: "http://localhost:12345/some-callback-url",
},
});
expect(sendInvitationResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"id": "<stripped UUID>",
"success": true,
},
"headers": Headers { <some fields may have been hidden> },
}
`);
backendContext.set({ mailbox: mailboxB });
await Auth.fastSignUp({ primary_email: mailboxB.emailAddress, primary_email_verified: true });
const invitationMessages = await mailboxB.waitForMessagesWithSubject("join");
const acceptResponse = await niceBackendFetch("/api/v1/team-invitations/accept", {
method: "POST",
accessType: "client",
body: {
code: invitationMessages.findLast((m) => m.subject.includes("join"))?.body?.text.match(/http:\/\/localhost:12345\/some-callback-url\?code=([a-zA-Z0-9]+)/)?.[1],
},
});
expect(acceptResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "ITEM_QUANTITY_INSUFFICIENT_AMOUNT",
"details": {
"customer_id": "<stripped UUID>",
"item_id": "dashboard_admins",
"quantity": -1,
},
"error": "The item with ID \\"dashboard_admins\\" has an insufficient quantity for the customer with ID \\"<stripped UUID>\\". An attempt was made to charge -1 credits.",
},
"headers": Headers {
"x-stack-known-error": "ITEM_QUANTITY_INSUFFICIENT_AMOUNT",
<some fields may have been hidden>,
},
}
`);
});
it("should error when untrusted callback URL is provided", async ({ expect }) => {
const { teamId } = await Team.create();
const receiveMailbox = createMailbox();
backendContext.set({ userAuth: null });
const sendTeamInvitationResponse = await niceBackendFetch("/api/v1/team-invitations/send-code", {
method: "POST",
accessType: "server",
body: {
email: receiveMailbox.emailAddress,
team_id: teamId,
callback_url: "https://malicious.com/callback",
},
});
expect(sendTeamInvitationResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "REDIRECT_URL_NOT_WHITELISTED",
"details": { "redirect_url": "https://malicious.com/callback" },
"error": "Redirect URL not whitelisted. Did you forget to add this domain to the trusted domains list on the Hexclave dashboard?",
},
"headers": Headers {
"x-stack-known-error": "REDIRECT_URL_NOT_WHITELISTED",
<some fields may have been hidden>,
},
}
`);
});
it("should not allow restricted users (unverified email) to accept team invitations", async ({ expect }) => {
// Create a project with email verification required
await Project.createAndSwitch({
config: {
magic_link_enabled: true,
credential_enabled: true,
},
});
await Project.updateConfig({
onboarding: { requireEmailVerification: true },
});
// Create a verified user to send the invitation
const { userId: inviterId } = await Auth.Otp.signIn();
const { teamId } = await createAndAddCurrentUserWithoutMemberPermission();
// Grant invite permission to the inviter
await niceBackendFetch(`/api/v1/team-permissions/${teamId}/${inviterId}/$invite_members`, {
accessType: "server",
method: "POST",
body: {},
});
// Send team invitation
const receiveMailbox = createMailbox();
await Team.sendInvitation(receiveMailbox, teamId);
// Create a restricted user (unverified email) via credential sign-up
const restrictedMailbox = createMailbox();
const signUpResponse = await niceBackendFetch("/api/v1/auth/password/sign-up", {
method: "POST",
accessType: "client",
body: {
email: restrictedMailbox.emailAddress,
password: "test-password-123",
verification_callback_url: "http://localhost:12345/verify",
},
});
expect(signUpResponse.status).toBe(200);
// Update context with new user's tokens
backendContext.set({
userAuth: {
accessToken: signUpResponse.body.access_token,
refreshToken: signUpResponse.body.refresh_token,
},
});
// Verify the user is restricted
const userResponse = await niceBackendFetch("/api/v1/users/me", {
accessType: "client",
headers: {
"x-stack-allow-restricted-user": "true",
},
});
expect(userResponse.body.is_restricted).toBe(true);
expect(userResponse.body.restricted_reason).toEqual({ type: "email_not_verified" });
// Get the invitation code from the email
const invitationMessages = await receiveMailbox.waitForMessagesWithSubject("join");
const invitationCode = invitationMessages.findLast((m) => m.subject.includes("join"))?.body?.text.match(/http:\/\/localhost:12345\/some-callback-url\?code=([a-zA-Z0-9_]+)/)?.[1];
expect(invitationCode).toBeDefined();
// Try to accept the invitation as a restricted user
const acceptResponse = await niceBackendFetch("/api/v1/team-invitations/accept", {
method: "POST",
accessType: "client",
headers: {
"x-stack-allow-restricted-user": "true",
},
body: {
code: invitationCode,
},
});
expect(acceptResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 403,
"body": {
"code": "TEAM_INVITATION_RESTRICTED_USER_NOT_ALLOWED",
"details": { "restricted_reason": { "type": "email_not_verified" } },
"error": "Restricted users cannot accept team invitations. Reason: email_not_verified. Please complete the onboarding process before accepting team invitations.",
},
"headers": Headers {
"x-stack-known-error": "TEAM_INVITATION_RESTRICTED_USER_NOT_ALLOWED",
<some fields may have been hidden>,
},
}
`);
});
it("should not allow anonymous users to accept team invitations", async ({ expect }) => {
await Project.createAndSwitch({
config: {
magic_link_enabled: true,
},
});
// Create a verified user to send the invitation
const { userId: inviterId } = await Auth.Otp.signIn();
const { teamId } = await createAndAddCurrentUserWithoutMemberPermission();
// Grant invite permission to the inviter
await niceBackendFetch(`/api/v1/team-permissions/${teamId}/${inviterId}/$invite_members`, {
accessType: "server",
method: "POST",
body: {},
});
// Send team invitation
const receiveMailbox = createMailbox();
await Team.sendInvitation(receiveMailbox, teamId);
// Create an anonymous user
const anonResponse = await niceBackendFetch("/api/v1/auth/anonymous/sign-up", {
method: "POST",
accessType: "client",
body: {},
});
expect(anonResponse.status).toBe(200);
// Update context with anonymous user's tokens
backendContext.set({
userAuth: {
accessToken: anonResponse.body.access_token,
refreshToken: anonResponse.body.refresh_token,
},
});
// Verify the user is anonymous and restricted
const userResponse = await niceBackendFetch("/api/v1/users/me", {
accessType: "client",
headers: {
"x-stack-allow-restricted-user": "true",
},
});
expect(userResponse.body.is_anonymous).toBe(true);
expect(userResponse.body.is_restricted).toBe(true);
expect(userResponse.body.restricted_reason).toEqual({ type: "anonymous" });
// Get the invitation code from the email
const invitationMessages = await receiveMailbox.waitForMessagesWithSubject("join");
const invitationCode = invitationMessages.findLast((m) => m.subject.includes("join"))?.body?.text.match(/http:\/\/localhost:12345\/some-callback-url\?code=([a-zA-Z0-9_]+)/)?.[1];
expect(invitationCode).toBeDefined();
// Try to accept the invitation as an anonymous user
const acceptResponse = await niceBackendFetch("/api/v1/team-invitations/accept", {
method: "POST",
accessType: "client",
headers: {
"x-stack-allow-anonymous-user": "true",
},
body: {
code: invitationCode,
},
});
expect(acceptResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 403,
"body": {
"code": "TEAM_INVITATION_RESTRICTED_USER_NOT_ALLOWED",
"details": { "restricted_reason": { "type": "anonymous" } },
"error": "Restricted users cannot accept team invitations. Reason: anonymous. Please complete the onboarding process before accepting team invitations.",
},
"headers": Headers {
"x-stack-known-error": "TEAM_INVITATION_RESTRICTED_USER_NOT_ALLOWED",
<some fields may have been hidden>,
},
}
`);
});
it("should not allow restricted users to get team invitation details", async ({ expect }) => {
// Create a project with email verification required
await Project.createAndSwitch({
config: {
magic_link_enabled: true,
credential_enabled: true,
},
});
await Project.updateConfig({
onboarding: { requireEmailVerification: true },
});
// Create a verified user to send the invitation
const { userId: inviterId } = await Auth.Otp.signIn();
const { teamId } = await createAndAddCurrentUserWithoutMemberPermission();
// Grant invite permission to the inviter
await niceBackendFetch(`/api/v1/team-permissions/${teamId}/${inviterId}/$invite_members`, {
accessType: "server",
method: "POST",
body: {},
});
// Send team invitation
const receiveMailbox = createMailbox();
await Team.sendInvitation(receiveMailbox, teamId);
// Create a restricted user (unverified email) via credential sign-up
const restrictedMailbox = createMailbox();
const signUpResponse = await niceBackendFetch("/api/v1/auth/password/sign-up", {
method: "POST",
accessType: "client",
body: {
email: restrictedMailbox.emailAddress,
password: "test-password-123",
verification_callback_url: "http://localhost:12345/verify",
},
});
expect(signUpResponse.status).toBe(200);
// Update context with new user's tokens
backendContext.set({
userAuth: {
accessToken: signUpResponse.body.access_token,
refreshToken: signUpResponse.body.refresh_token,
},
});
// Get the invitation code from the email
const invitationMessages = await receiveMailbox.waitForMessagesWithSubject("join");
const invitationCode = invitationMessages.findLast((m) => m.subject.includes("join"))?.body?.text.match(/http:\/\/localhost:12345\/some-callback-url\?code=([a-zA-Z0-9_]+)/)?.[1];
expect(invitationCode).toBeDefined();
// Try to get invitation details as a restricted user (without allowing restricted)
const detailsResponse = await niceBackendFetch("/api/v1/team-invitations/accept/details", {
method: "POST",
accessType: "client",
body: {
code: invitationCode,
},
});
expect(detailsResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 403,
"body": {
"code": "TEAM_INVITATION_RESTRICTED_USER_NOT_ALLOWED",
"details": { "restricted_reason": { "type": "email_not_verified" } },
"error": "Restricted users cannot accept team invitations. Reason: email_not_verified. Please complete the onboarding process before accepting team invitations.",
},
"headers": Headers {
"x-stack-known-error": "TEAM_INVITATION_RESTRICTED_USER_NOT_ALLOWED",
<some fields may have been hidden>,
},
}
`);
});
it("should allow a restricted user to accept invitation after verifying email", async ({ expect }) => {
// Create a project with email verification required
await Project.createAndSwitch({
config: {
magic_link_enabled: true,
credential_enabled: true,
},
});
await Project.updateConfig({
onboarding: { requireEmailVerification: true },
});
// Create a verified user to send the invitation
const { userId: inviterId } = await Auth.Otp.signIn();
const { teamId } = await createAndAddCurrentUserWithoutMemberPermission();
// Grant invite permission to the inviter
await niceBackendFetch(`/api/v1/team-permissions/${teamId}/${inviterId}/$invite_members`, {
accessType: "server",
method: "POST",
body: {},
});
// Send team invitation
const receiveMailbox = createMailbox();
await Team.sendInvitation(receiveMailbox, teamId);
// Get the invitation code from the email
const invitationMessages = await receiveMailbox.waitForMessagesWithSubject("join");
const invitationCode = invitationMessages.findLast((m) => m.subject.includes("join"))?.body?.text.match(/http:\/\/localhost:12345\/some-callback-url\?code=([a-zA-Z0-9_]+)/)?.[1];
expect(invitationCode).toBeDefined();
// Sign in with OTP using the same email (this verifies the email)
backendContext.set({ mailbox: receiveMailbox });
await Auth.Otp.signIn();
// Verify the user is NOT restricted
const userResponse = await niceBackendFetch("/api/v1/users/me", {
accessType: "client",
});
expect(userResponse.body.is_restricted).toBe(false);
expect(userResponse.body.primary_email_verified).toBe(true);
// Accept the invitation should work now
const acceptResponse = await niceBackendFetch("/api/v1/team-invitations/accept", {
method: "POST",
accessType: "client",
body: {
code: invitationCode,
},
});
expect(acceptResponse.status).toBe(200);
// Verify user is now a member of the team
const teamsResponse = await niceBackendFetch(`/api/v1/teams?user_id=me`, {
accessType: "server",
method: "GET",
});
expect(teamsResponse.body.items.find((item: any) => item.id === teamId)).toBeDefined();
}, {
timeout: 120_000,
});
it("can list invitations by user_id on the server", async ({ expect }) => {
await Project.createAndSwitch();
await Auth.fastSignUp();
const { teamId } = await Team.create();
const receiveMailbox = createMailbox();
backendContext.set({ userAuth: null });
await niceBackendFetch("/api/v1/team-invitations/send-code", {
method: "POST",
accessType: "server",
body: {
email: receiveMailbox.emailAddress,
team_id: teamId,
callback_url: "http://localhost:12345/some-callback-url",
},
});
// Create a new user with the invited email as a verified contact channel
const { userId: invitedUserId } = await Auth.fastSignUp({
primary_email: receiveMailbox.emailAddress,
primary_email_verified: true,
});
// List invitations for the invited user
const listResponse = await niceBackendFetch(`/api/v1/team-invitations?user_id=${invitedUserId}`, {
accessType: "server",
method: "GET",
});
expect(listResponse.status).toBe(200);
expect(listResponse.body.items).toHaveLength(1);
expect(listResponse.body.items[0].recipient_email).toBe(receiveMailbox.emailAddress);
expect(listResponse.body.items[0].team_display_name).toBe("New Team");
});
it("can list invitations by user_id=me on the client", async ({ expect }) => {
await Project.createAndSwitch();
await Auth.fastSignUp();
const { teamId } = await Team.create();
const receiveMailbox = createMailbox();
backendContext.set({ userAuth: null });
await niceBackendFetch("/api/v1/team-invitations/send-code", {
method: "POST",
accessType: "server",
body: {
email: receiveMailbox.emailAddress,
team_id: teamId,
callback_url: "http://localhost:12345/some-callback-url",
},
});
// Sign up as the invited user with verified email
backendContext.set({ mailbox: receiveMailbox });
await Auth.fastSignUp({
primary_email: receiveMailbox.emailAddress,
primary_email_verified: true,
});
// List invitations for the current user
const listResponse = await niceBackendFetch(`/api/v1/team-invitations?user_id=me`, {
accessType: "client",
method: "GET",
});
expect(listResponse.status).toBe(200);
expect(listResponse.body.items).toHaveLength(1);
expect(listResponse.body.items[0].recipient_email).toBe(receiveMailbox.emailAddress);
expect(listResponse.body.items[0].team_display_name).toBe("New Team");
});
it("returns empty list when user has no verified emails matching invitations", async ({ expect }) => {
await Project.createAndSwitch();
await Auth.fastSignUp();
const { teamId } = await Team.create();
backendContext.set({ userAuth: null });
await niceBackendFetch("/api/v1/team-invitations/send-code", {
method: "POST",
accessType: "server",
body: {
email: "unrelated@example.com",
team_id: teamId,
callback_url: "http://localhost:12345/some-callback-url",
},
});
// Sign up as a different user
const { userId: otherUserId } = await Auth.fastSignUp({
primary_email: "other@example.com",
primary_email_verified: true,
});
const listResponse = await niceBackendFetch(`/api/v1/team-invitations?user_id=${otherUserId}`, {
accessType: "server",
method: "GET",
});
expect(listResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"is_paginated": false,
"items": [],
},
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("does not return invitations for unverified emails", async ({ expect }) => {
await Project.createAndSwitch({
config: {
credential_enabled: true,
},
});
await Auth.fastSignUp();
const { teamId } = await Team.create();
const receiveMailbox = createMailbox();
backendContext.set({ userAuth: null });
await niceBackendFetch("/api/v1/team-invitations/send-code", {
method: "POST",
accessType: "server",
body: {
email: receiveMailbox.emailAddress,
team_id: teamId,
callback_url: "http://localhost:12345/some-callback-url",
},
});
// Create a user with the same email but NOT verified
const { userId: unverifiedUserId } = await Auth.fastSignUp({
primary_email: receiveMailbox.emailAddress,
primary_email_verified: false,
});
const listResponse = await niceBackendFetch(`/api/v1/team-invitations?user_id=${unverifiedUserId}`, {
accessType: "server",
method: "GET",
});
expect(listResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"is_paginated": false,
"items": [],
},
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("cannot specify both team_id and user_id", async ({ expect }) => {
await Auth.fastSignUp();
const { teamId } = await Team.create();
const listResponse = await niceBackendFetch(`/api/v1/team-invitations?team_id=${teamId}&user_id=me`, {
accessType: "server",
method: "GET",
});
expect(listResponse.status).toBe(400);
});
it("must specify either team_id or user_id", async ({ expect }) => {
await Auth.fastSignUp();
const listResponse = await niceBackendFetch(`/api/v1/team-invitations`, {
accessType: "server",
method: "GET",
});
expect(listResponse.status).toBe(400);
});
it("client cannot list invitations for a user_id other than 'me'", async ({ expect }) => {
await Project.createAndSwitch();
const { userId: otherUserId } = await Auth.fastSignUp({
primary_email: "other@example.com",
primary_email_verified: true,
});
// Sign in as a different user
await Auth.fastSignUp();
const listResponse = await niceBackendFetch(`/api/v1/team-invitations?user_id=${otherUserId}`, {
accessType: "client",
method: "GET",
});
expect(listResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 400,
"body": {
"code": "CANNOT_GET_OWN_USER_WITHOUT_USER",
"error": "You have specified 'me' as a userId, but did not provide authentication for a user.",
},
"headers": Headers {
"x-stack-known-error": "CANNOT_GET_OWN_USER_WITHOUT_USER",
<some fields may have been hidden>,
},
}
`);
});
it("can accept invitation by ID", async ({ expect }) => {
await Project.createAndSwitch();
await Auth.fastSignUp();
const { teamId } = await Team.create();
const receiveMailbox = createMailbox();
backendContext.set({ userAuth: null });
await niceBackendFetch("/api/v1/team-invitations/send-code", {
method: "POST",
accessType: "server",
body: {
email: receiveMailbox.emailAddress,
team_id: teamId,
callback_url: "http://localhost:12345/some-callback-url",
},
});
// Sign up as the invited user with the matching verified email
backendContext.set({ mailbox: receiveMailbox });
await Auth.fastSignUp({
primary_email: receiveMailbox.emailAddress,
primary_email_verified: true,
});
// List to get the invitation ID
const listBeforeAccept = await niceBackendFetch(`/api/v1/team-invitations?user_id=me`, {
accessType: "client",
method: "GET",
});
expect(listBeforeAccept.status).toBe(200);
expect(listBeforeAccept.body.items).toHaveLength(1);
const invitationId = listBeforeAccept.body.items[0].id;
// Accept the invitation by ID
const acceptResponse = await niceBackendFetch(`/api/v1/team-invitations/${invitationId}/accept?user_id=me`, {
accessType: "client",
method: "POST",
});
expect(acceptResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {},
"headers": Headers { <some fields may have been hidden> },
}
`);
// Verify the user is now a member
const teamsResponse = await niceBackendFetch(`/api/v1/teams?user_id=me`, {
accessType: "client",
method: "GET",
});
expect(teamsResponse.body.items.find((item: any) => item.id === teamId)).toBeDefined();
// Verify the invitation is consumed (no longer listed)
const listResponse = await niceBackendFetch(`/api/v1/team-invitations?user_id=me`, {
accessType: "client",
method: "GET",
});
expect(listResponse.body.items).toHaveLength(0);
});