feat(server): delay subscription after invitation accepted or approved (#11992)

This commit is contained in:
liuyi
2025-04-30 14:27:47 +08:00
committed by GitHub
parent 9a721c65b5
commit 2e1bed061e
49 changed files with 1990 additions and 2694 deletions
@@ -13,6 +13,7 @@ import {
AFFiNELogger,
CacheInterceptor,
CloudThrottlerGuard,
EventBus,
GlobalExceptionFilter,
JobQueue,
OneMB,
@@ -20,6 +21,7 @@ import {
import { SocketIoAdapter } from '../../base/websocket';
import { AuthGuard, AuthService } from '../../core/auth';
import { Mailer } from '../../core/mail';
import { Models } from '../../models';
import {
createFactory,
MockedUser,
@@ -43,6 +45,8 @@ export class TestingApp extends NestApplication {
create = createFactory(this.get(PrismaClient, { strict: false }));
mails = this.get(Mailer, { strict: false }) as MockMailer;
queue = this.get(JobQueue, { strict: false }) as MockJobQueue;
eventBus = this.get(EventBus, { strict: false });
models = this.get(Models, { strict: false });
get url() {
const server = this.getHttpServer();
@@ -1,456 +0,0 @@
import {
acceptInviteByInviteIdMutation,
createInviteLinkMutation,
getInviteInfoQuery,
getMembersByWorkspaceIdQuery,
inviteByEmailMutation,
leaveWorkspaceMutation,
revokeMemberPermissionMutation,
WorkspaceInviteLinkExpireTime,
WorkspaceMemberStatus,
} from '@affine/graphql';
import { faker } from '@faker-js/faker';
import { Models } from '../../../models';
import { Mockers } from '../../mocks';
import { app, e2e } from '../test';
e2e('should invite a user', async t => {
const u2 = await app.signup();
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
const result = await app.gql({
query: inviteByEmailMutation,
variables: {
email: u2.email,
workspaceId: workspace.id,
},
});
t.truthy(result, 'failed to invite user');
// add invitation notification job
const invitationNotification = app.queue.last('notification.sendInvitation');
t.is(invitationNotification.payload.inviterId, owner.id);
t.is(invitationNotification.payload.inviteId, result.invite);
// invitation status is pending
const { getInviteInfo } = await app.gql({
query: getInviteInfoQuery,
variables: {
inviteId: result.invite,
},
});
t.is(getInviteInfo.status, WorkspaceMemberStatus.Pending);
// u2 accept invite
await app.switchUser(u2);
await app.gql({
query: acceptInviteByInviteIdMutation,
variables: {
workspaceId: workspace.id,
inviteId: result.invite,
},
});
// invitation status is accepted
const { getInviteInfo: getInviteInfo2 } = await app.gql({
query: getInviteInfoQuery,
variables: {
inviteId: result.invite,
},
});
t.is(getInviteInfo2.status, WorkspaceMemberStatus.Accepted);
});
e2e('should leave a workspace', async t => {
const u2 = await app.signup();
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: u2.id,
});
await app.switchUser(u2.id);
const { leaveWorkspace } = await app.gql({
query: leaveWorkspaceMutation,
variables: {
workspaceId: workspace.id,
},
});
t.true(leaveWorkspace, 'failed to leave workspace');
});
e2e('should revoke a user', async t => {
const u2 = await app.signup();
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: u2.id,
});
const { revoke } = await app.gql({
query: revokeMemberPermissionMutation,
variables: {
workspaceId: workspace.id,
userId: u2.id,
},
});
t.true(revoke, 'failed to revoke user');
});
e2e('should revoke a user on under review', async t => {
const user = await app.signup();
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: user.id,
status: WorkspaceMemberStatus.UnderReview,
});
const { revoke } = await app.gql({
query: revokeMemberPermissionMutation,
variables: {
workspaceId: workspace.id,
userId: user.id,
},
});
t.true(revoke, 'failed to revoke user');
const requestDeclinedNotification = app.queue.last(
'notification.sendInvitationReviewDeclined'
);
t.truthy(requestDeclinedNotification);
t.deepEqual(
requestDeclinedNotification.payload,
{
userId: user.id,
workspaceId: workspace.id,
reviewerId: owner.id,
},
'should send review declined notification'
);
});
e2e('should create user if not exist', async t => {
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
const email = faker.internet.email();
await app.gql({
query: inviteByEmailMutation,
variables: {
email,
workspaceId: workspace.id,
},
});
const u2 = await app.get(Models).user.getUserByEmail(email);
t.truthy(u2, 'failed to create user');
});
e2e('should invite a user by link', async t => {
const u2 = await app.signup();
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
const invite1 = await app.gql({
query: inviteByEmailMutation,
variables: {
email: u2.email,
workspaceId: workspace.id,
},
});
await app.switchUser(u2);
const accept = await app.gql({
query: acceptInviteByInviteIdMutation,
variables: {
inviteId: invite1.invite,
workspaceId: workspace.id,
},
});
t.true(accept.acceptInviteById, 'failed to accept invite');
await app.switchUser(owner);
const invite2 = await app.gql({
query: inviteByEmailMutation,
variables: {
email: u2.email,
workspaceId: workspace.id,
},
});
t.is(
invite2.invite,
invite1.invite,
'repeat the invitation must return same id'
);
const member = await app
.get(Models)
.workspaceUser.getActive(workspace.id, u2.id);
t.truthy(member, 'failed to invite user');
t.is(member!.id, invite1.invite, 'failed to check invite id');
});
e2e('should send invitation notification and leave email', async t => {
const u2 = await app.signup();
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
const invite = await app.gql({
query: inviteByEmailMutation,
variables: {
email: u2.email,
workspaceId: workspace.id,
},
});
const invitationNotification = app.queue.last('notification.sendInvitation');
t.is(invitationNotification.payload.inviterId, owner.id);
t.is(invitationNotification.payload.inviteId, invite.invite);
await app.switchUser(u2);
const accept = await app.gql({
query: acceptInviteByInviteIdMutation,
variables: {
inviteId: invite.invite,
workspaceId: workspace.id,
},
});
t.true(accept.acceptInviteById, 'failed to accept invite');
const acceptedNotification = app.queue.last(
'notification.sendInvitationAccepted'
);
t.is(acceptedNotification.payload.inviterId, owner.id);
t.is(acceptedNotification.payload.inviteId, invite.invite);
const leave = await app.gql({
query: leaveWorkspaceMutation,
variables: {
workspaceId: workspace.id,
sendLeaveMail: true,
},
});
t.true(leave.leaveWorkspace, 'failed to leave workspace');
const leaveMail = app.mails.last('MemberLeave');
t.is(leaveMail.to, owner.email);
t.is(leaveMail.props.user.$$userId, u2.id);
});
e2e('should support pagination for member', async t => {
const u1 = await app.signup();
const u2 = await app.signup();
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: u1.id,
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: u2.id,
});
let result = await app.gql({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId: workspace.id,
skip: 0,
take: 2,
},
});
t.is(result.workspace.memberCount, 3);
t.is(result.workspace.members.length, 2);
result = await app.gql({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId: workspace.id,
skip: 2,
take: 2,
},
});
t.is(result.workspace.memberCount, 3);
t.is(result.workspace.members.length, 1);
result = await app.gql({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId: workspace.id,
skip: 3,
take: 2,
},
});
t.is(result.workspace.memberCount, 3);
t.is(result.workspace.members.length, 0);
});
e2e('should limit member count correctly', async t => {
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await Promise.all(
Array.from({ length: 10 }).map(async () => {
const user = await app.signup();
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: user.id,
});
})
);
await app.switchUser(owner);
const result = await app.gql({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId: workspace.id,
skip: 0,
take: 10,
},
});
t.is(result.workspace.memberCount, 11);
t.is(result.workspace.members.length, 10);
});
e2e('should get invite link info with status', async t => {
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await app.login(owner);
const { createInviteLink } = await app.gql({
query: createInviteLinkMutation,
variables: {
workspaceId: workspace.id,
expireTime: WorkspaceInviteLinkExpireTime.OneDay,
},
});
t.truthy(createInviteLink, 'failed to create invite link');
const link = createInviteLink.link;
const inviteId = link.split('/').pop()!;
// owner/member see accept status
const { getInviteInfo } = await app.gql({
query: getInviteInfoQuery,
variables: {
inviteId,
},
});
t.truthy(getInviteInfo, 'failed to get invite info');
t.is(getInviteInfo.status, WorkspaceMemberStatus.Accepted);
// non-member see null status
await app.signup();
const { getInviteInfo: getInviteInfo2 } = await app.gql({
query: getInviteInfoQuery,
variables: {
inviteId,
},
});
t.truthy(getInviteInfo2, 'failed to get invite info');
t.is(getInviteInfo2.status, null);
// pending-member see under review status
await app.signup();
await app.gql({
query: acceptInviteByInviteIdMutation,
variables: {
workspaceId: workspace.id,
inviteId,
},
});
const { getInviteInfo: getInviteInfo3 } = await app.gql({
query: getInviteInfoQuery,
variables: {
inviteId,
},
});
t.truthy(getInviteInfo3, 'failed to get invite info');
t.is(getInviteInfo3.status, WorkspaceMemberStatus.UnderReview);
});
e2e(
'should accept invitation by link directly if status is pending',
async t => {
const owner = await app.create(Mockers.User);
const member = await app.create(Mockers.User);
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await app.login(owner);
// create a pending invitation
const invite = await app.gql({
query: inviteByEmailMutation,
variables: {
email: member.email,
workspaceId: workspace.id,
},
});
t.truthy(invite, 'failed to create invitation');
const { createInviteLink } = await app.gql({
query: createInviteLinkMutation,
variables: {
workspaceId: workspace.id,
expireTime: WorkspaceInviteLinkExpireTime.OneDay,
},
});
t.truthy(createInviteLink, 'failed to create invite link');
const link = createInviteLink.link;
const inviteLinkId = link.split('/').pop()!;
// member accept invitation by link
await app.login(member);
await app.gql({
query: acceptInviteByInviteIdMutation,
variables: {
inviteId: inviteLinkId,
workspaceId: workspace.id,
},
});
const { getInviteInfo } = await app.gql({
query: getInviteInfoQuery,
variables: {
inviteId: invite.invite,
},
});
t.is(getInviteInfo.status, WorkspaceMemberStatus.Accepted);
}
);
@@ -1,9 +1,459 @@
import { getMembersByWorkspaceIdQuery } from '@affine/graphql';
import {
acceptInviteByInviteIdMutation,
createInviteLinkMutation,
getInviteInfoQuery,
getMembersByWorkspaceIdQuery,
inviteByEmailsMutation,
leaveWorkspaceMutation,
revokeMemberPermissionMutation,
WorkspaceInviteLinkExpireTime,
WorkspaceMemberStatus,
} from '@affine/graphql';
import { faker } from '@faker-js/faker';
import { Models } from '../../../models';
import { Mockers } from '../../mocks';
import { app, e2e } from '../test';
async function createTeamWorkspace(quantity = 10) {
const owner = await app.create(Mockers.User);
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await app.create(Mockers.TeamWorkspace, {
id: workspace.id,
quantity,
});
return {
owner,
workspace,
};
}
e2e('should invite a user', async t => {
const u2 = await app.signup();
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
const result = await app.gql({
query: inviteByEmailsMutation,
variables: {
emails: [u2.email],
workspaceId: workspace.id,
},
});
t.truthy(result, 'failed to invite user');
// add invitation notification job
const invitationNotification = await app.queue.waitFor(
'notification.sendInvitation'
);
t.is(invitationNotification.payload.inviterId, owner.id);
t.is(
invitationNotification.payload.inviteId,
result.inviteMembers[0].inviteId!
);
// invitation status is pending
const { getInviteInfo } = await app.gql({
query: getInviteInfoQuery,
variables: {
inviteId: invitationNotification.payload.inviteId,
},
});
t.is(getInviteInfo.status, WorkspaceMemberStatus.Pending);
// u2 accept invite
await app.switchUser(u2);
await app.gql({
query: acceptInviteByInviteIdMutation,
variables: {
workspaceId: workspace.id,
inviteId: invitationNotification.payload.inviteId,
},
});
// invitation status is accepted
const { getInviteInfo: getInviteInfo2 } = await app.gql({
query: getInviteInfoQuery,
variables: {
inviteId: invitationNotification.payload.inviteId,
},
});
t.is(getInviteInfo2.status, WorkspaceMemberStatus.Accepted);
});
e2e('should leave a workspace', async t => {
const u2 = await app.signup();
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: u2.id,
});
await app.switchUser(u2.id);
const { leaveWorkspace } = await app.gql({
query: leaveWorkspaceMutation,
variables: {
workspaceId: workspace.id,
},
});
t.true(leaveWorkspace, 'failed to leave workspace');
const leaveMail = await app.mails.waitFor('MemberLeave');
t.is(leaveMail.to, owner.email);
t.is(leaveMail.props.user.$$userId, u2.id);
});
e2e('should revoke a user', async t => {
const u2 = await app.signup();
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: u2.id,
});
const { revokeMember } = await app.gql({
query: revokeMemberPermissionMutation,
variables: {
workspaceId: workspace.id,
userId: u2.id,
},
});
t.true(revokeMember, 'failed to revoke user');
});
e2e('should revoke a user on under review', async t => {
const user = await app.signup();
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: user.id,
status: WorkspaceMemberStatus.UnderReview,
});
const { revokeMember } = await app.gql({
query: revokeMemberPermissionMutation,
variables: {
workspaceId: workspace.id,
userId: user.id,
},
});
t.true(revokeMember, 'failed to revoke user');
const requestDeclinedNotification = app.queue.last(
'notification.sendInvitationReviewDeclined'
);
t.truthy(requestDeclinedNotification);
t.deepEqual(
requestDeclinedNotification.payload,
{
userId: user.id,
workspaceId: workspace.id,
reviewerId: owner.id,
},
'should send review declined notification'
);
});
e2e('should create user if not exist', async t => {
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
const email = faker.internet.email();
await app.gql({
query: inviteByEmailsMutation,
variables: {
emails: [email],
workspaceId: workspace.id,
},
});
const u2 = await app.get(Models).user.getUserByEmail(email);
t.truthy(u2, 'failed to create user');
});
e2e('should support pagination for member', async t => {
const u1 = await app.signup();
const u2 = await app.signup();
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: u1.id,
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: u2.id,
});
let result = await app.gql({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId: workspace.id,
skip: 0,
take: 2,
},
});
t.is(result.workspace.memberCount, 3);
t.is(result.workspace.members.length, 2);
result = await app.gql({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId: workspace.id,
skip: 2,
take: 2,
},
});
t.is(result.workspace.memberCount, 3);
t.is(result.workspace.members.length, 1);
result = await app.gql({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId: workspace.id,
skip: 3,
take: 2,
},
});
t.is(result.workspace.memberCount, 3);
t.is(result.workspace.members.length, 0);
});
e2e('should limit member count correctly', async t => {
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await Promise.all(
Array.from({ length: 10 }).map(async () => {
const user = await app.signup();
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: user.id,
});
})
);
await app.switchUser(owner);
const result = await app.gql({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId: workspace.id,
skip: 0,
take: 10,
},
});
t.is(result.workspace.memberCount, 11);
t.is(result.workspace.members.length, 10);
});
e2e('should get invite link info with status', async t => {
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await app.login(owner);
const { createInviteLink } = await app.gql({
query: createInviteLinkMutation,
variables: {
workspaceId: workspace.id,
expireTime: WorkspaceInviteLinkExpireTime.OneDay,
},
});
t.truthy(createInviteLink, 'failed to create invite link');
const link = createInviteLink.link;
const inviteId = link.split('/').pop()!;
// owner/member see accept status
const { getInviteInfo } = await app.gql({
query: getInviteInfoQuery,
variables: {
inviteId,
},
});
t.truthy(getInviteInfo, 'failed to get invite info');
t.is(getInviteInfo.status, WorkspaceMemberStatus.Accepted);
// non-member see null status
await app.signup();
const { getInviteInfo: getInviteInfo2 } = await app.gql({
query: getInviteInfoQuery,
variables: {
inviteId,
},
});
t.truthy(getInviteInfo2, 'failed to get invite info');
t.is(getInviteInfo2.status, null);
// pending-member see under review status
await app.signup();
await app.gql({
query: acceptInviteByInviteIdMutation,
variables: {
workspaceId: workspace.id,
inviteId,
},
});
const { getInviteInfo: getInviteInfo3 } = await app.gql({
query: getInviteInfoQuery,
variables: {
inviteId,
},
});
t.truthy(getInviteInfo3, 'failed to get invite info');
t.is(getInviteInfo3.status, WorkspaceMemberStatus.UnderReview);
});
e2e(
'should accept invitation by link directly if status is pending',
async t => {
const owner = await app.create(Mockers.User);
const member = await app.create(Mockers.User);
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
await app.login(owner);
// create a pending invitation
const invite = await app.gql({
query: inviteByEmailsMutation,
variables: {
emails: [member.email],
workspaceId: workspace.id,
},
});
t.truthy(invite, 'failed to create invitation');
const { createInviteLink } = await app.gql({
query: createInviteLinkMutation,
variables: {
workspaceId: workspace.id,
expireTime: WorkspaceInviteLinkExpireTime.OneDay,
},
});
t.truthy(createInviteLink, 'failed to create invite link');
const link = createInviteLink.link;
const inviteLinkId = link.split('/').pop()!;
// member accept invitation by link
await app.login(member);
await app.gql({
query: acceptInviteByInviteIdMutation,
variables: {
inviteId: inviteLinkId,
workspaceId: workspace.id,
},
});
const { getInviteInfo } = await app.gql({
query: getInviteInfoQuery,
variables: {
inviteId: invite.inviteMembers[0].inviteId!,
},
});
t.is(getInviteInfo.status, WorkspaceMemberStatus.Accepted);
}
);
e2e(
'should invite by link and send review request notification below quota limit',
async t => {
const { owner, workspace } = await createTeamWorkspace();
await app.login(owner);
const { createInviteLink } = await app.gql({
query: createInviteLinkMutation,
variables: {
workspaceId: workspace.id,
expireTime: WorkspaceInviteLinkExpireTime.OneDay,
},
});
t.truthy(createInviteLink, 'failed to create invite link');
const link = createInviteLink.link;
const inviteId = link.split('/').pop()!;
// accept invite by link
await app.signup();
const result = await app.gql({
query: acceptInviteByInviteIdMutation,
variables: {
workspaceId: workspace.id,
inviteId,
},
});
t.truthy(result, 'failed to accept invite');
const notification = app.queue.last(
'notification.sendInvitationReviewRequest'
);
t.is(notification.payload.reviewerId, owner.id);
t.truthy(notification.payload.inviteId);
}
);
e2e(
'should invite by link and send review request notification over quota limit',
async t => {
const { owner, workspace } = await createTeamWorkspace(1);
await app.login(owner);
const { createInviteLink } = await app.gql({
query: createInviteLinkMutation,
variables: {
workspaceId: workspace.id,
expireTime: WorkspaceInviteLinkExpireTime.OneDay,
},
});
t.truthy(createInviteLink, 'failed to create invite link');
const link = createInviteLink.link;
const inviteId = link.split('/').pop()!;
// accept invite by link
await app.signup();
const result = await app.gql({
query: acceptInviteByInviteIdMutation,
variables: {
workspaceId: workspace.id,
inviteId,
},
});
t.truthy(result, 'failed to accept invite');
const notification = app.queue.last(
'notification.sendInvitationReviewRequest'
);
t.is(notification.payload.reviewerId, owner.id);
t.truthy(notification.payload.inviteId);
}
);
e2e(
'should search members by name and email support case insensitive',
async t => {
@@ -1,146 +1,167 @@
import {
acceptInviteByInviteIdMutation,
createInviteLinkMutation,
getInviteInfoQuery,
inviteByEmailMutation,
WorkspaceInviteLinkExpireTime,
inviteByEmailsMutation,
WorkspaceMemberStatus,
} from '@affine/graphql';
import { WorkspaceRole } from '../../../models';
import { Mockers } from '../../mocks';
import { app, e2e } from '../test';
async function createTeamWorkspace(quantity = 10) {
const createTeamWorkspace = async (memberLimit = 3) => {
const owner = await app.create(Mockers.User);
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
owner: {
id: owner.id,
},
});
await app.create(Mockers.TeamWorkspace, {
id: workspace.id,
quantity,
quantity: memberLimit,
});
const writer = await app.create(Mockers.User);
await app.create(Mockers.WorkspaceUser, {
userId: writer.id,
workspaceId: workspace.id,
});
const admin = await app.create(Mockers.User);
await app.create(Mockers.WorkspaceUser, {
userId: admin.id,
workspaceId: workspace.id,
type: WorkspaceRole.Admin,
});
const external = await app.create(Mockers.User);
return {
owner,
workspace,
owner,
admin,
writer,
external,
};
}
};
e2e(
'should invite by link and send review request notification below quota limit',
async t => {
const { owner, workspace } = await createTeamWorkspace();
const getInvitationInfo = async (inviteId: string) => {
const result = await app.gql({
query: getInviteInfoQuery,
variables: {
inviteId,
},
});
return result.getInviteInfo;
};
await app.login(owner);
const { createInviteLink } = await app.gql({
query: createInviteLinkMutation,
variables: {
workspaceId: workspace.id,
expireTime: WorkspaceInviteLinkExpireTime.OneDay,
},
});
t.truthy(createInviteLink, 'failed to create invite link');
const link = createInviteLink.link;
const inviteId = link.split('/').pop()!;
e2e('should set new invited users to AllocatingSeat', async t => {
const { owner, workspace } = await createTeamWorkspace();
await app.login(owner);
// accept invite by link
await app.signup();
const result = await app.gql({
query: acceptInviteByInviteIdMutation,
variables: {
workspaceId: workspace.id,
inviteId,
},
});
t.truthy(result, 'failed to accept invite');
const notification = app.queue.last(
'notification.sendInvitationReviewRequest'
);
t.is(notification.payload.reviewerId, owner.id);
t.truthy(notification.payload.inviteId);
}
);
const u1 = await app.createUser();
e2e(
'should invite by link and send review request notification over quota limit',
async t => {
const { owner, workspace } = await createTeamWorkspace(1);
const result = await app.gql({
query: inviteByEmailsMutation,
variables: {
workspaceId: workspace.id,
emails: [u1.email],
},
});
await app.login(owner);
const { createInviteLink } = await app.gql({
query: createInviteLinkMutation,
variables: {
workspaceId: workspace.id,
expireTime: WorkspaceInviteLinkExpireTime.OneDay,
},
});
t.truthy(createInviteLink, 'failed to create invite link');
const link = createInviteLink.link;
const inviteId = link.split('/').pop()!;
t.not(result.inviteMembers[0].inviteId, null);
// accept invite by link
await app.signup();
const result = await app.gql({
query: acceptInviteByInviteIdMutation,
variables: {
workspaceId: workspace.id,
inviteId,
},
});
t.truthy(result, 'failed to accept invite');
const notification = app.queue.last(
'notification.sendInvitationReviewRequest'
);
t.is(notification.payload.reviewerId, owner.id);
t.truthy(notification.payload.inviteId);
}
);
const invitationInfo = await getInvitationInfo(
result.inviteMembers[0].inviteId!
);
t.is(invitationInfo.status, WorkspaceMemberStatus.AllocatingSeat);
});
e2e(
'should accept invitation by link directly if status is pending on team workspace',
async t => {
const { owner, workspace } = await createTeamWorkspace(2);
const member = await app.create(Mockers.User);
e2e('should allocate seats', async t => {
const { owner, workspace } = await createTeamWorkspace();
await app.login(owner);
await app.login(owner);
// create a pending invitation
const invite = await app.gql({
query: inviteByEmailMutation,
variables: {
email: member.email,
workspaceId: workspace.id,
},
});
t.truthy(invite, 'failed to create invitation');
const u1 = await app.createUser();
await app.create(Mockers.WorkspaceUser, {
userId: u1.id,
workspaceId: workspace.id,
status: WorkspaceMemberStatus.AllocatingSeat,
source: 'Email',
});
const { createInviteLink } = await app.gql({
query: createInviteLinkMutation,
variables: {
workspaceId: workspace.id,
expireTime: WorkspaceInviteLinkExpireTime.OneDay,
},
});
t.truthy(createInviteLink, 'failed to create invite link');
const link = createInviteLink.link;
const inviteLinkId = link.split('/').pop()!;
const u2 = await app.createUser();
await app.create(Mockers.WorkspaceUser, {
userId: u2.id,
workspaceId: workspace.id,
status: WorkspaceMemberStatus.AllocatingSeat,
source: 'Link',
});
// member accept invitation by link
await app.login(member);
await app.gql({
query: acceptInviteByInviteIdMutation,
variables: {
inviteId: inviteLinkId,
workspaceId: workspace.id,
},
});
await app.eventBus.emitAsync('workspace.members.allocateSeats', {
workspaceId: workspace.id,
quantity: 5,
});
const { getInviteInfo } = await app.gql({
query: getInviteInfoQuery,
variables: {
inviteId: invite.invite,
},
});
t.is(getInviteInfo.status, WorkspaceMemberStatus.Accepted);
}
);
const [members] = await app.models.workspaceUser.paginate(workspace.id, {
first: 10,
offset: 0,
});
t.is(
members.find(m => m.user.id === u1.id)?.status,
WorkspaceMemberStatus.Pending
);
t.is(
members.find(m => m.user.id === u2.id)?.status,
WorkspaceMemberStatus.Accepted
);
t.is(app.queue.count('notification.sendInvitation'), 1);
});
e2e('should set all rests to NeedMoreSeat', async t => {
const { owner, workspace } = await createTeamWorkspace();
await app.login(owner);
const u1 = await app.createUser();
await app.create(Mockers.WorkspaceUser, {
userId: u1.id,
workspaceId: workspace.id,
status: WorkspaceMemberStatus.AllocatingSeat,
source: 'Email',
});
const u2 = await app.createUser();
await app.create(Mockers.WorkspaceUser, {
userId: u2.id,
workspaceId: workspace.id,
status: WorkspaceMemberStatus.AllocatingSeat,
source: 'Email',
});
const u3 = await app.createUser();
await app.create(Mockers.WorkspaceUser, {
userId: u3.id,
workspaceId: workspace.id,
status: WorkspaceMemberStatus.AllocatingSeat,
source: 'Link',
});
await app.eventBus.emitAsync('workspace.members.allocateSeats', {
workspaceId: workspace.id,
quantity: 4,
});
const [members] = await app.models.workspaceUser.paginate(workspace.id, {
first: 10,
offset: 0,
});
t.is(
members.find(m => m.user.id === u2.id)?.status,
WorkspaceMemberStatus.NeedMoreSeat
);
t.is(
members.find(m => m.user.id === u3.id)?.status,
WorkspaceMemberStatus.NeedMoreSeat
);
});
@@ -1,3 +1,4 @@
import { interval, map, take, takeUntil } from 'rxjs';
import Sinon from 'sinon';
import { Mailer } from '../../core/mail';
@@ -22,6 +23,35 @@ export class MockMailer {
return last as any;
}
waitFor<Mail extends MailName>(
name: Mail,
timeout: number = 1000
): Promise<Extract<Jobs['notification.sendMail'], { name: Mail }>> {
const { promise, reject, resolve } = Promise.withResolvers<any>();
interval(10)
.pipe(
take(Math.floor(timeout / 10)),
takeUntil(promise),
map(() => {
const last = this.send.lastCall.args[0];
return last.name === name ? last : undefined;
})
)
.subscribe({
next: val => {
if (val) {
resolve(val);
}
},
complete: () => {
reject(new Error('Timeout wait for job coming'));
},
});
return promise;
}
count(name: MailName) {
return this.send.getCalls().filter(call => call.args[0].name === name)
.length;
@@ -1,3 +1,4 @@
import { interval, map, take, takeUntil } from 'rxjs';
import Sinon from 'sinon';
import { JobQueue } from '../../base';
@@ -20,6 +21,36 @@ export class MockJobQueue {
return { name, payload };
}
waitFor<Job extends JobName>(name: Job, timeout: number = 1000) {
const { promise, reject, resolve } = Promise.withResolvers<{
name: Job;
payload: Jobs[Job];
}>();
interval(10)
.pipe(
take(Math.floor(timeout / 10)),
takeUntil(promise),
map(() => {
const addJobName = this.add.lastCall?.args[0];
const payload = this.add.lastCall?.args[1];
return addJobName === name ? payload : undefined;
})
)
.subscribe({
next: val => {
if (val) {
resolve({ name, payload: val });
}
},
complete: () => {
reject(new Error('Timeout wait for job coming'));
},
});
return promise;
}
count(name: JobName) {
return this.add.getCalls().filter(call => call.args[0] === name).length;
}
@@ -1,10 +1,13 @@
import { randomBytes } from 'node:crypto';
import { PrismaClient } from '@prisma/client';
import test from 'ava';
import Sinon from 'sinon';
import { EventBus } from '../../base';
import { EventBus, NewOwnerIsNotActiveMember } from '../../base';
import { Models, WorkspaceMemberStatus, WorkspaceRole } from '../../models';
import { createTestingModule, TestingModule } from '../utils';
import { createModule, TestingModule } from '../create-module';
import { Mockers } from '../mocks';
let db: PrismaClient;
let models: Models;
@@ -12,7 +15,7 @@ let module: TestingModule;
let event: Sinon.SinonStubbedInstance<EventBus>;
test.before(async () => {
module = await createTestingModule({
module = await createModule({
tapModule: m => {
m.overrideProvider(EventBus).useValue(Sinon.createStubInstance(EventBus));
},
@@ -23,7 +26,6 @@ test.before(async () => {
});
test.beforeEach(async () => {
await module.initTestingDB();
Sinon.reset();
});
@@ -31,15 +33,9 @@ test.after(async () => {
await module.close();
});
async function create() {
return db.workspace.create({
data: { public: false },
});
}
test('should set workspace owner', async t => {
const workspace = await create();
const user = await models.user.create({ email: 'u1@affine.pro' });
const workspace = await module.create(Mockers.Workspace);
const user = await module.create(Mockers.User);
await models.workspaceUser.setOwner(workspace.id, user.id);
const owner = await models.workspaceUser.getOwner(workspace.id);
@@ -47,9 +43,15 @@ test('should set workspace owner', async t => {
});
test('should transfer workespace owner', async t => {
const user = await models.user.create({ email: 'u1@affine.pro' });
const user2 = await models.user.create({ email: 'u2@affine.pro' });
const workspace = await models.workspace.create(user.id);
const [user, user2] = await module.create(Mockers.User, 2);
const workspace = await module.create(Mockers.Workspace, {
owner: { id: user.id },
});
await module.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: user2.id,
});
await models.workspaceUser.setOwner(workspace.id, user2.id);
@@ -65,9 +67,30 @@ test('should transfer workespace owner', async t => {
t.is(owner2.id, user2.id);
});
test('should throw if transfer owner to non-active member', async t => {
const [user, user2] = await module.create(Mockers.User, 2);
const workspace = await module.create(Mockers.Workspace, {
owner: { id: user.id },
});
await t.throwsAsync(models.workspaceUser.setOwner(workspace.id, user2.id), {
instanceOf: NewOwnerIsNotActiveMember,
});
await module.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: user2.id,
status: WorkspaceMemberStatus.AllocatingSeat,
});
await t.throwsAsync(models.workspaceUser.setOwner(workspace.id, user2.id), {
instanceOf: NewOwnerIsNotActiveMember,
});
});
test('should get user role', async t => {
const workspace = await create();
const user = await models.user.create({ email: 'u1@affine.pro' });
const workspace = await module.create(Mockers.Workspace);
const user = await module.create(Mockers.User);
await models.workspaceUser.set(workspace.id, user.id, WorkspaceRole.Admin);
const role = await models.workspaceUser.get(workspace.id, user.id);
@@ -76,14 +99,11 @@ test('should get user role', async t => {
});
test('should get active workspace role', async t => {
const workspace = await create();
const user = await models.user.create({ email: 'u1@affine.pro' });
await models.workspaceUser.set(
workspace.id,
user.id,
WorkspaceRole.Admin,
WorkspaceMemberStatus.Accepted
);
const workspace = await module.create(Mockers.Workspace);
const user = await module.create(Mockers.User);
await models.workspaceUser.set(workspace.id, user.id, WorkspaceRole.Admin, {
status: WorkspaceMemberStatus.Accepted,
});
const role = await models.workspaceUser.getActive(workspace.id, user.id);
@@ -91,9 +111,8 @@ test('should get active workspace role', async t => {
});
test('should not get inactive workspace role', async t => {
const workspace = await create();
const u1 = await models.user.create({ email: 'u1@affine.pro' });
const workspace = await module.create(Mockers.Workspace);
const u1 = await module.create(Mockers.User);
await models.workspaceUser.set(workspace.id, u1.id, WorkspaceRole.Admin);
@@ -111,14 +130,11 @@ test('should not get inactive workspace role', async t => {
});
test('should update user role', async t => {
const workspace = await create();
const user = await models.user.create({ email: 'u1@affine.pro' });
await models.workspaceUser.set(
workspace.id,
user.id,
WorkspaceRole.Admin,
WorkspaceMemberStatus.Accepted
);
const workspace = await module.create(Mockers.Workspace);
const user = await module.create(Mockers.User);
await models.workspaceUser.set(workspace.id, user.id, WorkspaceRole.Admin, {
status: WorkspaceMemberStatus.Accepted,
});
const role = await models.workspaceUser.get(workspace.id, user.id);
t.is(role!.type, WorkspaceRole.Admin);
@@ -143,8 +159,8 @@ test('should update user role', async t => {
});
test('should return workspace role if status is Accepted', async t => {
const workspace = await create();
const u1 = await models.user.create({ email: 'u1@affine.pro' });
const workspace = await module.create(Mockers.Workspace);
const u1 = await module.create(Mockers.User);
await models.workspaceUser.set(workspace.id, u1.id, WorkspaceRole.Admin);
await models.workspaceUser.setStatus(
@@ -158,8 +174,8 @@ test('should return workspace role if status is Accepted', async t => {
});
test('should delete workspace user role', async t => {
const workspace = await create();
const u1 = await models.user.create({ email: 'u1@affine.pro' });
const workspace = await module.create(Mockers.Workspace);
const u1 = await module.create(Mockers.User);
await models.workspaceUser.set(workspace.id, u1.id, WorkspaceRole.Admin);
await models.workspaceUser.setStatus(
@@ -178,9 +194,9 @@ test('should delete workspace user role', async t => {
});
test('should get user workspace roles with filter', async t => {
const ws1 = await create();
const ws2 = await create();
const user = await models.user.create({ email: 'u1@affine.pro' });
const ws1 = await module.create(Mockers.Workspace);
const ws2 = await module.create(Mockers.Workspace);
const user = await module.create(Mockers.User);
await db.workspaceUserRole.createMany({
data: [
@@ -210,19 +226,19 @@ test('should get user workspace roles with filter', async t => {
});
test('should paginate workspace user roles', async t => {
const workspace = await create();
await db.user.createMany({
const workspace = await module.create(Mockers.Workspace);
const users = await db.user.createManyAndReturn({
data: Array.from({ length: 200 }, (_, i) => ({
id: String(i),
name: `u${i}`,
email: `${i}@affine.pro`,
email: `${randomBytes(10).toString('hex')}@affine.pro`,
})),
});
await db.workspaceUserRole.createMany({
data: Array.from({ length: 200 }, (_, i) => ({
data: users.map((user, i) => ({
workspaceId: workspace.id,
userId: String(i),
userId: user.id,
type: WorkspaceRole.Collaborator,
status: Object.values(WorkspaceMemberStatus)[
Math.floor(Math.random() * Object.values(WorkspaceMemberStatus).length)
@@ -253,3 +269,38 @@ test('should paginate workspace user roles', async t => {
.map(r => r.id)
);
});
test('should allocate seats for AllocatingSeat and NeedMoreSeat members', async t => {
const users = await module.create(Mockers.User, 4);
const workspace = await module.create(Mockers.Workspace);
for (const user of users) {
await module.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: user.id,
status: WorkspaceMemberStatus.AllocatingSeat,
});
}
await models.workspaceUser.allocateSeats(workspace.id, 1);
let count = await db.workspaceUserRole.count({
where: {
workspaceId: workspace.id,
status: WorkspaceMemberStatus.Pending,
},
});
t.is(count, 1);
await models.workspaceUser.allocateSeats(workspace.id, 3);
count = await db.workspaceUserRole.count({
where: {
workspaceId: workspace.id,
status: WorkspaceMemberStatus.Pending,
},
});
t.is(count, 3);
});
@@ -1,945 +0,0 @@
import { randomUUID } from 'node:crypto';
import { User, WorkspaceMemberStatus } from '@prisma/client';
import type { TestFn } from 'ava';
import ava from 'ava';
import { nanoid } from 'nanoid';
import Sinon from 'sinon';
import { AppModule } from '../app.module';
import { EventBus } from '../base';
import { AuthService } from '../core/auth';
import { DocReader } from '../core/doc';
import { DocRole, WorkspaceRole } from '../core/permission';
import { WorkspaceType } from '../core/workspaces';
import { Models } from '../models';
import {
acceptInviteById,
approveMember,
createInviteLink,
createTestingApp,
createWorkspace,
docGrantedUsersList,
getInviteInfo,
getInviteLink,
getWorkspace,
grantDocUserRoles,
grantMember,
inviteUser,
inviteUsers,
leaveWorkspace,
revokeDocUserRoles,
revokeInviteLink,
revokeMember,
revokeUser,
sleep,
TestingApp,
updateDocDefaultRole,
} from './utils';
const test = ava as TestFn<{
app: TestingApp;
auth: AuthService;
event: Sinon.SinonStubbedInstance<EventBus>;
models: Models;
}>;
test.before(async t => {
const app = await createTestingApp({
imports: [AppModule],
tapModule: module => {
module
.overrideProvider(EventBus)
.useValue(Sinon.createStubInstance(EventBus));
module.overrideProvider(DocReader).useValue({
getWorkspaceContent() {
return {
name: 'test',
avatarKey: null,
};
},
});
},
});
t.context.app = app;
t.context.auth = app.get(AuthService);
t.context.event = app.get(EventBus);
t.context.models = app.get(Models);
});
test.beforeEach(async t => {
await t.context.app.initTestingDB();
});
test.after.always(async t => {
await t.context.app.close();
});
const init = async (
app: TestingApp,
memberLimit = 10,
prefix = randomUUID()
) => {
const owner = await app.signupV1(`${prefix}owner@affine.pro`);
const models = app.get(Models);
{
await models.userFeature.add(owner.id, 'pro_plan_v1', 'test');
}
const workspace = await createWorkspace(app);
const teamWorkspace = await createWorkspace(app);
{
await models.workspaceFeature.add(
teamWorkspace.id,
'team_plan_v1',
'test',
{
memberLimit,
}
);
}
const invite = async (
email: string,
permission: WorkspaceRole = WorkspaceRole.Collaborator,
shouldSendEmail: boolean = false
) => {
const member = await app.signupV1(email);
{
// normal workspace
await app.switchUser(owner);
const inviteId = await inviteUser(
app,
workspace.id,
member.email,
shouldSendEmail
);
await app.switchUser(member);
await acceptInviteById(app, workspace.id, inviteId, shouldSendEmail);
}
{
// team workspace
await app.switchUser(owner);
const inviteId = await inviteUser(
app,
teamWorkspace.id,
member.email,
shouldSendEmail
);
await app.switchUser(member);
await acceptInviteById(app, teamWorkspace.id, inviteId, shouldSendEmail);
await app.switchUser(owner);
await grantMember(app, teamWorkspace.id, member.id, permission);
}
return member;
};
const inviteBatch = async (
emails: string[],
shouldSendEmail: boolean = false
) => {
const members = [];
for (const email of emails) {
const member = await app.signupV1(email);
members.push(member);
}
await app.switchUser(owner);
const invites = await inviteUsers(
app,
teamWorkspace.id,
emails,
shouldSendEmail
);
return [members, invites] as const;
};
const getCreateInviteLinkFetcher = async (ws: WorkspaceType) => {
await app.switchUser(owner);
const { link } = await createInviteLink(app, ws.id, 'OneDay');
const inviteId = link.split('/').pop()!;
return [
inviteId,
async (email: string, shouldSendEmail: boolean = false) => {
const member = await app.signupV1(email);
await acceptInviteById(app, ws.id, inviteId, shouldSendEmail);
return member;
},
async (userId: string) => {
await app.switchUser(userId);
await acceptInviteById(app, ws.id, inviteId, false);
},
] as const;
};
const admin = await invite(`${prefix}admin@affine.pro`, WorkspaceRole.Admin);
const write = await invite(`${prefix}write@affine.pro`);
const read = await invite(
`${prefix}read@affine.pro`,
WorkspaceRole.Collaborator
);
const external = await invite(
`${prefix}external@affine.pro`,
WorkspaceRole.External
);
await app.switchUser(owner.id);
return {
invite,
inviteBatch,
createInviteLink: getCreateInviteLinkFetcher,
owner,
workspace,
teamWorkspace,
admin,
write,
read,
external,
};
};
test('should be able to invite multiple users', async t => {
const { app } = t.context;
const { teamWorkspace: ws, owner, admin, write, read } = await init(app, 5);
{
// no permission
await app.switchUser(read);
await t.throwsAsync(
inviteUsers(app, ws.id, ['test@affine.pro']),
{ instanceOf: Error },
'should throw error if not manager'
);
await app.switchUser(write);
await t.throwsAsync(
inviteUsers(app, ws.id, ['test@affine.pro']),
{ instanceOf: Error },
'should throw error if not manager'
);
}
{
// manager
const m1 = await app.signupV1('m1@affine.pro');
const m2 = await app.signupV1('m2@affine.pro');
await app.switchUser(owner);
t.is(
(await inviteUsers(app, ws.id, [m1.email])).length,
1,
'should be able to invite user'
);
await app.switchUser(admin);
t.is(
(await inviteUsers(app, ws.id, [m2.email])).length,
1,
'should be able to invite user'
);
t.is(
(await inviteUsers(app, ws.id, [m2.email])).length,
0,
'should not be able to invite user if already in workspace'
);
await t.throwsAsync(
inviteUsers(
app,
ws.id,
Array.from({ length: 513 }, (_, i) => `m${i}@affine.pro`)
),
{ message: 'Too many requests.' },
'should throw error if exceed maximum number of invitations per request'
);
}
});
test('should be able to check seat limit', async t => {
const { app, models } = t.context;
const { invite, inviteBatch, teamWorkspace: ws } = await init(app, 5);
{
// invite
await t.throwsAsync(
invite('member3@affine.pro', WorkspaceRole.Collaborator),
{ message: 'You have exceeded your workspace member quota.' },
'should throw error if exceed member limit'
);
await models.workspaceFeature.add(ws.id, 'team_plan_v1', 'test', {
memberLimit: 6,
});
await t.notThrowsAsync(
invite('member4@affine.pro', WorkspaceRole.Collaborator),
'should not throw error if not exceed member limit'
);
}
{
const members1 = inviteBatch(['member5@affine.pro']);
// invite batch
await t.notThrowsAsync(
members1,
'should not throw error in batch invite event reach limit'
);
t.is(
(await models.workspaceUser.get(ws.id, (await members1)[0][0].id))
?.status,
WorkspaceMemberStatus.NeedMoreSeat,
'should be able to check member status'
);
// refresh seat, fifo
await sleep(1000);
const [[members2]] = await inviteBatch(['member6@affine.pro']);
await models.workspaceUser.refresh(ws.id, 7);
t.is(
(await models.workspaceUser.get(ws.id, (await members1)[0][0].id))
?.status,
WorkspaceMemberStatus.Pending,
'should become accepted after refresh'
);
t.is(
(await models.workspaceUser.get(ws.id, members2.id))?.status,
WorkspaceMemberStatus.NeedMoreSeat,
'should not change status'
);
}
});
test('should be able to grant team member permission', async t => {
const { app, models } = t.context;
const { owner, teamWorkspace: ws, write, read } = await init(app);
await app.switchUser(read);
await t.throwsAsync(
grantMember(app, ws.id, write.id, WorkspaceRole.Collaborator),
{ instanceOf: Error },
'should throw error if not owner'
);
await app.switchUser(write);
await t.throwsAsync(
grantMember(app, ws.id, read.id, WorkspaceRole.Collaborator),
{ instanceOf: Error },
'should throw error if not owner'
);
{
// owner should be able to grant permission
await app.switchUser(owner);
t.true(
(await models.workspaceUser.get(ws.id, read.id))?.type ===
WorkspaceRole.Collaborator,
'should be able to check permission'
);
t.truthy(
await grantMember(app, ws.id, read.id, WorkspaceRole.Admin),
'should be able to grant permission'
);
t.true(
(await models.workspaceUser.get(ws.id, read.id))?.type ===
WorkspaceRole.Admin,
'should be able to check permission'
);
}
});
test('should be able to leave workspace', async t => {
const { app } = t.context;
const { owner, teamWorkspace: ws, admin, write, read } = await init(app);
await app.switchUser(owner);
await t.throwsAsync(leaveWorkspace(app, ws.id), {
message: 'Owner can not leave the workspace.',
});
await app.switchUser(admin);
t.true(
await leaveWorkspace(app, ws.id),
'admin should be able to leave workspace'
);
await app.switchUser(write);
t.true(
await leaveWorkspace(app, ws.id),
'write should be able to leave workspace'
);
await app.switchUser(read);
t.true(
await leaveWorkspace(app, ws.id),
'read should be able to leave workspace'
);
});
test('should be able to revoke team member', async t => {
const { app } = t.context;
const { teamWorkspace: ws, owner, admin, write, read } = await init(app);
{
// no permission
await app.switchUser(read);
await t.throwsAsync(
revokeUser(app, ws.id, read.id),
{ instanceOf: Error },
'should throw error if not admin'
);
await t.throwsAsync(
revokeUser(app, ws.id, write.id),
{ instanceOf: Error },
'should throw error if not admin'
);
}
{
// manager
await app.switchUser(admin);
t.true(
await revokeUser(app, ws.id, read.id),
'admin should be able to revoke member'
);
await t.throwsAsync(
revokeUser(app, ws.id, admin.id),
{ instanceOf: Error },
'should not be able to revoke themselves'
);
await app.switchUser(owner);
t.true(
await revokeUser(app, ws.id, write.id),
'owner should be able to revoke member'
);
await t.throwsAsync(revokeUser(app, ws.id, owner.id), {
message: 'You can not revoke your own permission.',
});
await revokeUser(app, ws.id, admin.id);
await app.switchUser(admin);
await t.throwsAsync(
revokeUser(app, ws.id, read.id),
{ instanceOf: Error },
'should not be able to revoke member not in workspace after revoked'
);
}
});
test('should be able to manage invite link', async t => {
const { app } = t.context;
const {
workspace: ws,
teamWorkspace: tws,
owner,
admin,
write,
read,
} = await init(app);
for (const [workspace, managers] of [
[ws, [owner]],
[tws, [owner, admin]],
] as const) {
for (const manager of managers) {
await app.switchUser(manager.id);
const { link } = await createInviteLink(app, workspace.id, 'OneDay');
const { link: currLink } = await getInviteLink(app, workspace.id);
t.is(link, currLink, 'should be able to get invite link');
t.true(
await revokeInviteLink(app, workspace.id),
'should be able to revoke invite link'
);
}
for (const collaborator of [write, read]) {
await app.switchUser(collaborator.id);
await t.throwsAsync(
createInviteLink(app, workspace.id, 'OneDay'),
{ instanceOf: Error },
'should throw error if not manager'
);
await t.throwsAsync(
getInviteLink(app, workspace.id),
{ instanceOf: Error },
'should throw error if not manager'
);
await t.throwsAsync(
revokeInviteLink(app, workspace.id),
{ instanceOf: Error },
'should throw error if not manager'
);
}
}
});
test('should be able to approve team member', async t => {
const { app } = t.context;
const { teamWorkspace: tws, owner, admin, write, read } = await init(app, 6);
{
await app.switchUser(owner);
const { link } = await createInviteLink(app, tws.id, 'OneDay');
const inviteId = link.split('/').pop()!;
const member = await app.signupV1('newmember@affine.pro');
t.true(
await acceptInviteById(app, tws.id, inviteId, false),
'should be able to accept invite'
);
await app.switchUser(owner);
const { members } = await getWorkspace(app, tws.id);
const memberInvite = members.find(m => m.id === member.id)!;
t.is(memberInvite.status, 'UnderReview', 'should be under review');
t.true(await approveMember(app, tws.id, member.id));
const requestApprovedNotification = app.queue.last(
'notification.sendInvitationReviewApproved'
);
t.truthy(requestApprovedNotification);
t.deepEqual(
requestApprovedNotification.payload,
{
inviteId: memberInvite.inviteId,
reviewerId: owner.id,
},
'should send review approved notification'
);
}
{
await app.switchUser(admin);
await t.throwsAsync(
approveMember(app, tws.id, 'not_exists_id'),
{ instanceOf: Error },
'should throw error if member not exists'
);
await app.switchUser(write);
await t.throwsAsync(
approveMember(app, tws.id, 'not_exists_id'),
{ instanceOf: Error },
'should throw error if not manager'
);
await app.switchUser(read);
await t.throwsAsync(
approveMember(app, tws.id, 'not_exists_id'),
{ instanceOf: Error },
'should throw error if not manager'
);
}
});
test('should be able to invite by link', async t => {
const { app, models } = t.context;
const {
createInviteLink,
owner,
workspace: ws,
teamWorkspace: tws,
} = await init(app, 5);
const [inviteId, invite] = await createInviteLink(ws);
const [teamInviteId, teamInvite, acceptTeamInvite] =
await createInviteLink(tws);
const member = await app.signup();
{
// check invite link
await app.switchUser(member);
const info = await getInviteInfo(app, inviteId);
t.is(info.workspace.id, ws.id, 'should be able to get invite info');
t.falsy(info.status);
// check team invite link
const teamInfo = await getInviteInfo(app, teamInviteId);
t.is(teamInfo.workspace.id, tws.id, 'should be able to get invite info');
t.falsy(info.status);
}
{
// invite link
for (const [i] of Array.from({ length: 5 }).entries()) {
const user = await invite(`test${i}@affine.pro`);
const role = await models.workspaceUser.get(ws.id, user.id);
t.truthy(role);
const status = role!.status;
t.is(
status,
WorkspaceMemberStatus.UnderReview,
'should be able to check status'
);
const info = await getInviteInfo(app, role!.id);
t.is(info.status, WorkspaceMemberStatus.UnderReview);
}
await t.throwsAsync(
invite('exceed@affine.pro'),
{ message: 'You have exceeded your workspace member quota.' },
'should throw error if exceed member limit'
);
}
{
// team invite link
const members: User[] = [];
await t.notThrowsAsync(async () => {
members.push(await teamInvite('member3@affine.pro'));
members.push(await teamInvite('member4@affine.pro'));
}, 'should not throw error even exceed member limit');
const [m3, m4] = members;
t.is(
(await models.workspaceUser.get(tws.id, m3.id))?.status,
WorkspaceMemberStatus.NeedMoreSeatAndReview,
'should not change status'
);
t.is(
(await models.workspaceUser.get(tws.id, m4.id))?.status,
WorkspaceMemberStatus.NeedMoreSeatAndReview,
'should not change status'
);
await models.workspaceFeature.add(tws.id, 'team_plan_v1', 'test', {
memberLimit: 6,
});
await models.workspaceUser.refresh(tws.id, 6);
t.is(
(await models.workspaceUser.get(tws.id, m3.id))?.status,
WorkspaceMemberStatus.UnderReview,
'should not change status'
);
t.is(
(await models.workspaceUser.get(tws.id, m4.id))?.status,
WorkspaceMemberStatus.NeedMoreSeatAndReview,
'should not change status'
);
await models.workspaceFeature.add(tws.id, 'team_plan_v1', 'test', {
memberLimit: 7,
});
await models.workspaceUser.refresh(tws.id, 7);
t.is(
(await models.workspaceUser.get(tws.id, m4.id))?.status,
WorkspaceMemberStatus.UnderReview,
'should not change status'
);
{
await t.throwsAsync(acceptTeamInvite(owner.id), {
message: `You have already joined in Space ${tws.id}.`,
});
}
}
});
test('should be able to invite batch and send notifications', async t => {
const { app } = t.context;
const { inviteBatch } = await init(app, 5);
const currentCount = app.queue.count('notification.sendInvitation');
await inviteBatch(['m3@affine.pro', 'm4@affine.pro'], true);
t.is(app.queue.count('notification.sendInvitation'), currentCount + 2);
const job = app.queue.last('notification.sendInvitation');
t.truthy(job.payload.inviteId);
t.truthy(job.payload.inviterId);
});
test('should be able to emit events and send notifications', async t => {
const { app, event } = t.context;
{
const { teamWorkspace: tws, inviteBatch } = await init(app, 5);
await inviteBatch(['m1@affine.pro', 'm2@affine.pro']);
const [membersUpdated] = event.emit
.getCalls()
.map(call => call.args)
.toReversed();
t.deepEqual(membersUpdated, [
'workspace.members.updated',
{
workspaceId: tws.id,
count: 7,
},
]);
}
{
const { teamWorkspace: tws, owner, createInviteLink } = await init(app);
const [, invite] = await createInviteLink(tws);
const user = await invite('m3@affine.pro');
await app.switchUser(owner);
const { members } = await getWorkspace(app, tws.id);
const memberInvite = members.find(m => m.id === user.id)!;
const requestRequestNotification = app.queue.last(
'notification.sendInvitationReviewRequest'
);
t.truthy(requestRequestNotification);
// find admin
const admins = await t.context.models.workspaceUser.getAdmins(tws.id);
t.deepEqual(
requestRequestNotification.payload,
{
inviteId: memberInvite.inviteId,
reviewerId: admins[0].id,
},
'should send review request notification'
);
await app.switchUser(owner);
await revokeUser(app, tws.id, user.id);
const requestDeclinedNotification = app.queue.last(
'notification.sendInvitationReviewDeclined'
);
t.truthy(requestDeclinedNotification);
t.deepEqual(
requestDeclinedNotification.payload,
{
userId: user.id,
workspaceId: tws.id,
reviewerId: owner.id,
},
'should send review declined notification'
);
}
{
const { teamWorkspace: tws, owner, read } = await init(app);
await grantMember(app, tws.id, read.id, WorkspaceRole.Admin);
t.deepEqual(
event.emit.lastCall.args,
[
'workspace.members.roleChanged',
{
userId: read.id,
workspaceId: tws.id,
role: WorkspaceRole.Admin,
},
],
'should emit role changed event'
);
await grantMember(app, tws.id, read.id, WorkspaceRole.Owner);
const [ownershipTransferred] = event.emit
.getCalls()
.map(call => call.args)
.toReversed();
t.deepEqual(
ownershipTransferred,
[
'workspace.owner.changed',
{ from: owner.id, to: read.id, workspaceId: tws.id },
],
'should emit owner transferred event'
);
await app.switchUser(read);
await revokeMember(app, tws.id, owner.id);
const [memberRemoved, memberUpdated] = event.emit
.getCalls()
.map(call => call.args)
.toReversed();
t.deepEqual(
memberRemoved,
[
'workspace.members.removed',
{
userId: owner.id,
workspaceId: tws.id,
},
],
'should emit owner transferred event'
);
t.deepEqual(
memberUpdated,
[
'workspace.members.updated',
{
count: 4,
workspaceId: tws.id,
},
],
'should emit role changed event'
);
}
});
test('should be able to grant and revoke users role in page', async t => {
const { app } = t.context;
const {
teamWorkspace: ws,
admin,
write,
read,
external,
} = await init(app, 5);
const docId = nanoid();
await app.switchUser(admin);
const res = await grantDocUserRoles(
app,
ws.id,
docId,
[read.id, write.id],
DocRole.Manager
);
t.deepEqual(res, {
grantDocUserRoles: true,
});
// should not downgrade the role if role exists
{
await grantDocUserRoles(app, ws.id, docId, [read.id], DocRole.Reader);
// read still be the Manager of this doc
await app.switchUser(read);
const res = await grantDocUserRoles(
app,
ws.id,
docId,
[external.id],
DocRole.Editor
);
t.deepEqual(res, {
grantDocUserRoles: true,
});
await app.switchUser(admin);
const docUsersList = await docGrantedUsersList(app, ws.id, docId);
t.is(docUsersList.workspace.doc.grantedUsersList.totalCount, 3);
const externalRole = docUsersList.workspace.doc.grantedUsersList.edges.find(
(edge: any) => edge.node.user.id === external.id
)?.node.role;
t.is(externalRole, DocRole[DocRole.Editor]);
}
});
test('should be able to change the default role in page', async t => {
const { app } = t.context;
const { teamWorkspace: ws, admin } = await init(app, 5);
const docId = nanoid();
await app.switchUser(admin);
const res = await updateDocDefaultRole(app, ws.id, docId, DocRole.Reader);
t.deepEqual(res, {
updateDocDefaultRole: true,
});
});
test('default page role should be able to override the workspace role', async t => {
const { app } = t.context;
const {
teamWorkspace: workspace,
admin,
read,
external,
} = await init(app, 5);
const docId = nanoid();
await app.switchUser(admin);
const res = await updateDocDefaultRole(
app,
workspace.id,
docId,
DocRole.Manager
);
t.deepEqual(res, {
updateDocDefaultRole: true,
});
// reader can manage the page if the page default role is Manager
{
await app.switchUser(read);
const readerRes = await updateDocDefaultRole(
app,
workspace.id,
docId,
DocRole.Manager
);
t.deepEqual(readerRes, {
updateDocDefaultRole: true,
});
}
// external can't manage the page even if the page default role is Manager
{
await app.switchUser(external);
await t.throwsAsync(
updateDocDefaultRole(app, workspace.id, docId, DocRole.Manager),
{
message: `You do not have permission to perform Doc.Users.Manage action on doc ${docId}.`,
}
);
}
});
test('should be able to grant and revoke doc user role', async t => {
const { app } = t.context;
const { teamWorkspace: ws, admin, read, external } = await init(app, 5);
const docId = nanoid();
await app.switchUser(admin);
const res = await grantDocUserRoles(
app,
ws.id,
docId,
[external.id],
DocRole.Manager
);
t.deepEqual(res, {
grantDocUserRoles: true,
});
// external user can never be able to manage the page
{
await app.switchUser(external);
await t.throwsAsync(
grantDocUserRoles(app, ws.id, docId, [read.id], DocRole.Manager),
{
message: `You do not have permission to perform Doc.Users.Manage action on doc ${docId}.`,
}
);
}
// revoke the role of the external user
{
await app.switchUser(admin);
const revokeRes = await revokeDocUserRoles(app, ws.id, docId, external.id);
t.deepEqual(revokeRes, {
revokeDocUserRoles: true,
});
// external user can't manage the page
await app.switchUser(external);
await t.throwsAsync(revokeDocUserRoles(app, ws.id, docId, read.id), {
message: `You do not have permission to perform Doc.Users.Manage action on doc ${docId}.`,
});
}
});
test('update page default role should throw error if the space does not exist', async t => {
const { app } = t.context;
const { admin } = await init(app, 5);
const docId = nanoid();
const nonExistWorkspaceId = 'non-exist-workspace';
await app.switchUser(admin);
await t.throwsAsync(
updateDocDefaultRole(app, nonExistWorkspaceId, docId, DocRole.Manager),
{
message: `You do not have permission to perform Doc.Users.Manage action on doc ${docId}.`,
}
);
});
@@ -1,33 +1,33 @@
import type { InvitationType } from '../../core/workspaces';
import type { TestingApp } from './testing-app';
export async function inviteUser(
app: TestingApp,
workspaceId: string,
email: string,
sendInviteMail = false
email: string
): Promise<string> {
const res = await app.gql(`
mutation {
invite(workspaceId: "${workspaceId}", email: "${email}", sendInviteMail: ${sendInviteMail})
inviteMembers(workspaceId: "${workspaceId}", emails: ["${email}"]) {
inviteId
}
}
`);
return res.invite;
return res.inviteMembers[0].inviteId;
}
export async function inviteUsers(
app: TestingApp,
workspaceId: string,
emails: string[],
sendInviteMail = false
emails: string[]
): Promise<Array<{ email: string; inviteId?: string; sentSuccess?: boolean }>> {
const res = await app.gql(
`
mutation inviteBatch($workspaceId: String!, $emails: [String!]!, $sendInviteMail: Boolean) {
inviteBatch(
mutation inviteMembers($workspaceId: String!, $emails: [String!]!) {
inviteMembers(
workspaceId: $workspaceId
emails: $emails
sendInviteMail: $sendInviteMail
) {
email
inviteId
@@ -35,10 +35,10 @@ export async function inviteUsers(
}
}
`,
{ workspaceId, emails, sendInviteMail }
{ workspaceId, emails }
);
return res.inviteBatch;
return res.inviteMembers;
}
export async function getInviteLink(
@@ -164,7 +164,7 @@ test('should be able to get permission granted workspace', async t => {
'totally-private',
t.context.u1.id,
WorkspaceRole.Collaborator,
WorkspaceMemberStatus.Accepted
{ status: WorkspaceMemberStatus.Accepted }
);
storage.get.resolves(blob());