mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 20:38:52 +00:00
test(server): add more test for team workspace (#9182)
This commit is contained in:
@@ -347,6 +347,11 @@ export const USER_FRIENDLY_ERRORS = {
|
|||||||
args: { spaceId: 'string' },
|
args: { spaceId: 'string' },
|
||||||
message: ({ spaceId }) => `Space ${spaceId} not found.`,
|
message: ({ spaceId }) => `Space ${spaceId} not found.`,
|
||||||
},
|
},
|
||||||
|
member_not_found_in_space: {
|
||||||
|
type: 'action_forbidden',
|
||||||
|
args: { spaceId: 'string' },
|
||||||
|
message: ({ spaceId }) => `Member not found in Space ${spaceId}.`,
|
||||||
|
},
|
||||||
not_in_space: {
|
not_in_space: {
|
||||||
type: 'action_forbidden',
|
type: 'action_forbidden',
|
||||||
args: { spaceId: 'string' },
|
args: { spaceId: 'string' },
|
||||||
|
|||||||
@@ -191,6 +191,16 @@ export class SpaceNotFound extends UserFriendlyError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ObjectType()
|
@ObjectType()
|
||||||
|
class MemberNotFoundInSpaceDataType {
|
||||||
|
@Field() spaceId!: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MemberNotFoundInSpace extends UserFriendlyError {
|
||||||
|
constructor(args: MemberNotFoundInSpaceDataType, message?: string | ((args: MemberNotFoundInSpaceDataType) => string)) {
|
||||||
|
super('action_forbidden', 'member_not_found_in_space', message, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ObjectType()
|
||||||
class NotInSpaceDataType {
|
class NotInSpaceDataType {
|
||||||
@Field() spaceId!: string
|
@Field() spaceId!: string
|
||||||
}
|
}
|
||||||
@@ -615,6 +625,7 @@ export enum ErrorNames {
|
|||||||
ACCESS_DENIED,
|
ACCESS_DENIED,
|
||||||
EMAIL_VERIFICATION_REQUIRED,
|
EMAIL_VERIFICATION_REQUIRED,
|
||||||
SPACE_NOT_FOUND,
|
SPACE_NOT_FOUND,
|
||||||
|
MEMBER_NOT_FOUND_IN_SPACE,
|
||||||
NOT_IN_SPACE,
|
NOT_IN_SPACE,
|
||||||
ALREADY_IN_SPACE,
|
ALREADY_IN_SPACE,
|
||||||
SPACE_ACCESS_DENIED,
|
SPACE_ACCESS_DENIED,
|
||||||
@@ -674,5 +685,5 @@ registerEnumType(ErrorNames, {
|
|||||||
export const ErrorDataUnionType = createUnionType({
|
export const ErrorDataUnionType = createUnionType({
|
||||||
name: 'ErrorDataUnion',
|
name: 'ErrorDataUnion',
|
||||||
types: () =>
|
types: () =>
|
||||||
[WrongSignInCredentialsDataType, UnknownOauthProviderDataType, MissingOauthQueryParameterDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, SpaceNotFoundDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, DocNotFoundDataType, DocAccessDeniedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderSideErrorDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType] as const,
|
[WrongSignInCredentialsDataType, UnknownOauthProviderDataType, MissingOauthQueryParameterDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, DocNotFoundDataType, DocAccessDeniedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderSideErrorDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType] as const,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
Cache,
|
Cache,
|
||||||
EventEmitter,
|
EventEmitter,
|
||||||
type EventPayload,
|
type EventPayload,
|
||||||
NotInSpace,
|
MemberNotFoundInSpace,
|
||||||
OnEvent,
|
OnEvent,
|
||||||
RequestMutex,
|
RequestMutex,
|
||||||
TooManyRequest,
|
TooManyRequest,
|
||||||
@@ -152,7 +152,16 @@ export class TeamWorkspaceResolver {
|
|||||||
description: 'invite link for workspace',
|
description: 'invite link for workspace',
|
||||||
nullable: true,
|
nullable: true,
|
||||||
})
|
})
|
||||||
async inviteLink(@Parent() workspace: WorkspaceType) {
|
async inviteLink(
|
||||||
|
@Parent() workspace: WorkspaceType,
|
||||||
|
@CurrentUser() user: CurrentUser
|
||||||
|
) {
|
||||||
|
await this.permissions.checkWorkspace(
|
||||||
|
workspace.id,
|
||||||
|
user.id,
|
||||||
|
Permission.Admin
|
||||||
|
);
|
||||||
|
|
||||||
const cacheId = `workspace:inviteLink:${workspace.id}`;
|
const cacheId = `workspace:inviteLink:${workspace.id}`;
|
||||||
const id = await this.cache.get<{ inviteId: string }>(cacheId);
|
const id = await this.cache.get<{ inviteId: string }>(cacheId);
|
||||||
if (id) {
|
if (id) {
|
||||||
@@ -261,7 +270,7 @@ export class TeamWorkspaceResolver {
|
|||||||
}
|
}
|
||||||
return new TooManyRequest();
|
return new TooManyRequest();
|
||||||
} else {
|
} else {
|
||||||
return new NotInSpace({ spaceId: workspaceId });
|
return new MemberNotFoundInSpace({ spaceId: workspaceId });
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.error('failed to invite user', e);
|
this.logger.error('failed to invite user', e);
|
||||||
@@ -307,7 +316,7 @@ export class TeamWorkspaceResolver {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
} else {
|
} else {
|
||||||
return new NotInSpace({ spaceId: workspaceId });
|
return new MemberNotFoundInSpace({ spaceId: workspaceId });
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.error('failed to invite user', e);
|
this.logger.error('failed to invite user', e);
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ type EditorType {
|
|||||||
name: String!
|
name: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderSideErrorDataType | DocAccessDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | MissingOauthQueryParameterDataType | NotInSpaceDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | UnsupportedSubscriptionPlanDataType | VersionRejectedDataType | WrongSignInCredentialsDataType
|
union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderSideErrorDataType | DocAccessDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | MemberNotFoundInSpaceDataType | MissingOauthQueryParameterDataType | NotInSpaceDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | UnsupportedSubscriptionPlanDataType | VersionRejectedDataType | WrongSignInCredentialsDataType
|
||||||
|
|
||||||
enum ErrorNames {
|
enum ErrorNames {
|
||||||
ACCESS_DENIED
|
ACCESS_DENIED
|
||||||
@@ -257,6 +257,7 @@ enum ErrorNames {
|
|||||||
INVALID_SUBSCRIPTION_PARAMETERS
|
INVALID_SUBSCRIPTION_PARAMETERS
|
||||||
LINK_EXPIRED
|
LINK_EXPIRED
|
||||||
MAILER_SERVICE_IS_NOT_CONFIGURED
|
MAILER_SERVICE_IS_NOT_CONFIGURED
|
||||||
|
MEMBER_NOT_FOUND_IN_SPACE
|
||||||
MEMBER_QUOTA_EXCEEDED
|
MEMBER_QUOTA_EXCEEDED
|
||||||
MISSING_OAUTH_QUERY_PARAMETER
|
MISSING_OAUTH_QUERY_PARAMETER
|
||||||
NOT_FOUND
|
NOT_FOUND
|
||||||
@@ -472,6 +473,10 @@ input ManageUserInput {
|
|||||||
name: String
|
name: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MemberNotFoundInSpaceDataType {
|
||||||
|
spaceId: String!
|
||||||
|
}
|
||||||
|
|
||||||
type MissingOauthQueryParameterDataType {
|
type MissingOauthQueryParameterDataType {
|
||||||
name: String!
|
name: String!
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
/// <reference types="../src/global.d.ts" />
|
/// <reference types="../src/global.d.ts" />
|
||||||
|
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import { getCurrentMailMessageCount } from '@affine-test/kit/utils/cloud';
|
||||||
import { INestApplication } from '@nestjs/common';
|
import { INestApplication } from '@nestjs/common';
|
||||||
import { WorkspaceMemberStatus } from '@prisma/client';
|
import { WorkspaceMemberStatus } from '@prisma/client';
|
||||||
import type { TestFn } from 'ava';
|
import type { TestFn } from 'ava';
|
||||||
import ava from 'ava';
|
import ava from 'ava';
|
||||||
|
import Sinon from 'sinon';
|
||||||
|
|
||||||
import { AppModule } from '../src/app.module';
|
import { AppModule } from '../src/app.module';
|
||||||
|
import { EventEmitter } from '../src/base';
|
||||||
import { AuthService } from '../src/core/auth';
|
import { AuthService } from '../src/core/auth';
|
||||||
import { DocContentService } from '../src/core/doc-renderer';
|
import { DocContentService } from '../src/core/doc-renderer';
|
||||||
import { Permission, PermissionService } from '../src/core/permission';
|
import { Permission, PermissionService } from '../src/core/permission';
|
||||||
@@ -17,15 +22,20 @@ import {
|
|||||||
import { WorkspaceType } from '../src/core/workspaces';
|
import { WorkspaceType } from '../src/core/workspaces';
|
||||||
import {
|
import {
|
||||||
acceptInviteById,
|
acceptInviteById,
|
||||||
|
approveMember,
|
||||||
createInviteLink,
|
createInviteLink,
|
||||||
createTestingApp,
|
createTestingApp,
|
||||||
createWorkspace,
|
createWorkspace,
|
||||||
getInviteInfo,
|
getInviteInfo,
|
||||||
|
getInviteLink,
|
||||||
|
getWorkspace,
|
||||||
grantMember,
|
grantMember,
|
||||||
inviteUser,
|
inviteUser,
|
||||||
inviteUsers,
|
inviteUsers,
|
||||||
leaveWorkspace,
|
leaveWorkspace,
|
||||||
PermissionEnum,
|
PermissionEnum,
|
||||||
|
revokeInviteLink,
|
||||||
|
revokeUser,
|
||||||
signUp,
|
signUp,
|
||||||
sleep,
|
sleep,
|
||||||
UserAuthedType,
|
UserAuthedType,
|
||||||
@@ -34,6 +44,7 @@ import {
|
|||||||
const test = ava as TestFn<{
|
const test = ava as TestFn<{
|
||||||
app: INestApplication;
|
app: INestApplication;
|
||||||
auth: AuthService;
|
auth: AuthService;
|
||||||
|
event: Sinon.SinonStubbedInstance<EventEmitter>;
|
||||||
quota: QuotaService;
|
quota: QuotaService;
|
||||||
quotaManager: QuotaManagementService;
|
quotaManager: QuotaManagementService;
|
||||||
permissions: PermissionService;
|
permissions: PermissionService;
|
||||||
@@ -43,6 +54,9 @@ test.beforeEach(async t => {
|
|||||||
const { app } = await createTestingApp({
|
const { app } = await createTestingApp({
|
||||||
imports: [AppModule],
|
imports: [AppModule],
|
||||||
tapModule: module => {
|
tapModule: module => {
|
||||||
|
module
|
||||||
|
.overrideProvider(EventEmitter)
|
||||||
|
.useValue(Sinon.createStubInstance(EventEmitter));
|
||||||
module.overrideProvider(DocContentService).useValue({
|
module.overrideProvider(DocContentService).useValue({
|
||||||
getWorkspaceContent() {
|
getWorkspaceContent() {
|
||||||
return {
|
return {
|
||||||
@@ -54,50 +68,84 @@ test.beforeEach(async t => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const quota = app.get(QuotaService);
|
|
||||||
const quotaManager = app.get(QuotaManagementService);
|
|
||||||
const permissions = app.get(PermissionService);
|
|
||||||
const auth = app.get(AuthService);
|
|
||||||
|
|
||||||
t.context.app = app;
|
t.context.app = app;
|
||||||
t.context.quota = quota;
|
t.context.auth = app.get(AuthService);
|
||||||
t.context.quotaManager = quotaManager;
|
t.context.event = app.get(EventEmitter);
|
||||||
t.context.permissions = permissions;
|
t.context.quota = app.get(QuotaService);
|
||||||
t.context.auth = auth;
|
t.context.quotaManager = app.get(QuotaManagementService);
|
||||||
|
t.context.permissions = app.get(PermissionService);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.afterEach.always(async t => {
|
test.afterEach.always(async t => {
|
||||||
await t.context.app.close();
|
await t.context.app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
const init = async (app: INestApplication, memberLimit = 10) => {
|
const init = async (
|
||||||
const owner = await signUp(app, 'test', 'test@affine.pro', '123456');
|
app: INestApplication,
|
||||||
const workspace = await createWorkspace(app, owner.token.token);
|
memberLimit = 10,
|
||||||
|
prefix = randomUUID()
|
||||||
|
) => {
|
||||||
|
const owner = await signUp(
|
||||||
|
app,
|
||||||
|
'owner',
|
||||||
|
`${prefix}owner@affine.pro`,
|
||||||
|
'123456'
|
||||||
|
);
|
||||||
|
{
|
||||||
|
const quota = app.get(QuotaService);
|
||||||
|
await quota.switchUserQuota(owner.id, QuotaType.ProPlanV1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspace = await createWorkspace(app, owner.token.token);
|
||||||
const teamWorkspace = await createWorkspace(app, owner.token.token);
|
const teamWorkspace = await createWorkspace(app, owner.token.token);
|
||||||
const quota = app.get(QuotaManagementService);
|
{
|
||||||
await quota.addTeamWorkspace(teamWorkspace.id, 'test');
|
const quota = app.get(QuotaManagementService);
|
||||||
await quota.updateWorkspaceConfig(teamWorkspace.id, QuotaType.TeamPlanV1, {
|
await quota.addTeamWorkspace(teamWorkspace.id, 'test');
|
||||||
memberLimit,
|
await quota.updateWorkspaceConfig(teamWorkspace.id, QuotaType.TeamPlanV1, {
|
||||||
});
|
memberLimit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const invite = async (
|
const invite = async (
|
||||||
email: string,
|
email: string,
|
||||||
permission: PermissionEnum = 'Write'
|
permission: PermissionEnum = 'Write',
|
||||||
|
shouldSendEmail: boolean = false
|
||||||
) => {
|
) => {
|
||||||
const member = await signUp(app, email.split('@')[0], email, '123456');
|
const member = await signUp(app, email.split('@')[0], email, '123456');
|
||||||
const inviteId = await inviteUser(
|
|
||||||
app,
|
{
|
||||||
owner.token.token,
|
// normal workspace
|
||||||
teamWorkspace.id,
|
const inviteId = await inviteUser(
|
||||||
member.email,
|
app,
|
||||||
permission
|
owner.token.token,
|
||||||
);
|
workspace.id,
|
||||||
await acceptInviteById(app, teamWorkspace.id, inviteId);
|
member.email,
|
||||||
|
permission,
|
||||||
|
shouldSendEmail
|
||||||
|
);
|
||||||
|
await acceptInviteById(app, workspace.id, inviteId, shouldSendEmail);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
// team workspace
|
||||||
|
const inviteId = await inviteUser(
|
||||||
|
app,
|
||||||
|
owner.token.token,
|
||||||
|
teamWorkspace.id,
|
||||||
|
member.email,
|
||||||
|
permission,
|
||||||
|
shouldSendEmail
|
||||||
|
);
|
||||||
|
await acceptInviteById(app, teamWorkspace.id, inviteId, shouldSendEmail);
|
||||||
|
}
|
||||||
|
|
||||||
return member;
|
return member;
|
||||||
};
|
};
|
||||||
|
|
||||||
const inviteBatch = async (emails: string[]) => {
|
const inviteBatch = async (
|
||||||
|
emails: string[],
|
||||||
|
shouldSendEmail: boolean = false
|
||||||
|
) => {
|
||||||
const members = [];
|
const members = [];
|
||||||
for (const email of emails) {
|
for (const email of emails) {
|
||||||
const member = await signUp(app, email.split('@')[0], email, '123456');
|
const member = await signUp(app, email.split('@')[0], email, '123456');
|
||||||
@@ -107,7 +155,8 @@ const init = async (app: INestApplication, memberLimit = 10) => {
|
|||||||
app,
|
app,
|
||||||
owner.token.token,
|
owner.token.token,
|
||||||
teamWorkspace.id,
|
teamWorkspace.id,
|
||||||
emails
|
emails,
|
||||||
|
shouldSendEmail
|
||||||
);
|
);
|
||||||
return [members, invites] as const;
|
return [members, invites] as const;
|
||||||
};
|
};
|
||||||
@@ -122,9 +171,18 @@ const init = async (app: INestApplication, memberLimit = 10) => {
|
|||||||
const inviteId = link.split('/').pop()!;
|
const inviteId = link.split('/').pop()!;
|
||||||
return [
|
return [
|
||||||
inviteId,
|
inviteId,
|
||||||
async (email: string): Promise<UserAuthedType> => {
|
async (
|
||||||
|
email: string,
|
||||||
|
shouldSendEmail: boolean = false
|
||||||
|
): Promise<UserAuthedType> => {
|
||||||
const member = await signUp(app, email.split('@')[0], email, '123456');
|
const member = await signUp(app, email.split('@')[0], email, '123456');
|
||||||
await acceptInviteById(app, ws.id, inviteId, false, member.token.token);
|
await acceptInviteById(
|
||||||
|
app,
|
||||||
|
ws.id,
|
||||||
|
inviteId,
|
||||||
|
shouldSendEmail,
|
||||||
|
member.token.token
|
||||||
|
);
|
||||||
return member;
|
return member;
|
||||||
},
|
},
|
||||||
async (token: string) => {
|
async (token: string) => {
|
||||||
@@ -133,9 +191,9 @@ const init = async (app: INestApplication, memberLimit = 10) => {
|
|||||||
] as const;
|
] as const;
|
||||||
};
|
};
|
||||||
|
|
||||||
const admin = await invite('admin@affine.pro', 'Admin');
|
const admin = await invite(`${prefix}admin@affine.pro`, 'Admin');
|
||||||
const write = await invite('member1@affine.pro');
|
const write = await invite(`${prefix}write@affine.pro`);
|
||||||
const read = await invite('member2@affine.pro', 'Read');
|
const read = await invite(`${prefix}read@affine.pro`, 'Read');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
invite,
|
invite,
|
||||||
@@ -150,6 +208,57 @@ const init = async (app: INestApplication, memberLimit = 10) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
test('should be able to invite multiple users', async t => {
|
||||||
|
const { app } = t.context;
|
||||||
|
const { teamWorkspace: ws, owner, admin, write, read } = await init(app, 4);
|
||||||
|
|
||||||
|
{
|
||||||
|
// no permission
|
||||||
|
await t.throwsAsync(
|
||||||
|
inviteUsers(app, read.token.token, ws.id, ['test@affine.pro']),
|
||||||
|
{ instanceOf: Error },
|
||||||
|
'should throw error if not manager'
|
||||||
|
);
|
||||||
|
await t.throwsAsync(
|
||||||
|
inviteUsers(app, write.token.token, ws.id, ['test@affine.pro']),
|
||||||
|
{ instanceOf: Error },
|
||||||
|
'should throw error if not manager'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
// manager
|
||||||
|
const m1 = await signUp(app, 'm1', 'm1@affine.pro', '123456');
|
||||||
|
const m2 = await signUp(app, 'm2', 'm2@affine.pro', '123456');
|
||||||
|
t.is(
|
||||||
|
(await inviteUsers(app, owner.token.token, ws.id, [m1.email])).length,
|
||||||
|
1,
|
||||||
|
'should be able to invite user'
|
||||||
|
);
|
||||||
|
t.is(
|
||||||
|
(await inviteUsers(app, admin.token.token, ws.id, [m2.email])).length,
|
||||||
|
1,
|
||||||
|
'should be able to invite user'
|
||||||
|
);
|
||||||
|
t.is(
|
||||||
|
(await inviteUsers(app, admin.token.token, ws.id, [m2.email])).length,
|
||||||
|
0,
|
||||||
|
'should not be able to invite user if already in workspace'
|
||||||
|
);
|
||||||
|
|
||||||
|
await t.throwsAsync(
|
||||||
|
inviteUsers(
|
||||||
|
app,
|
||||||
|
admin.token.token,
|
||||||
|
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 => {
|
test('should be able to check seat limit', async t => {
|
||||||
const { app, permissions, quotaManager } = t.context;
|
const { app, permissions, quotaManager } = t.context;
|
||||||
const { invite, inviteBatch, teamWorkspace: ws } = await init(app, 4);
|
const { invite, inviteBatch, teamWorkspace: ws } = await init(app, 4);
|
||||||
@@ -267,6 +376,161 @@ test('should be able to leave workspace', async t => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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
|
||||||
|
t.throwsAsync(
|
||||||
|
revokeUser(app, read.token.token, ws.id, read.id),
|
||||||
|
{ instanceOf: Error },
|
||||||
|
'should throw error if not admin'
|
||||||
|
);
|
||||||
|
t.throwsAsync(
|
||||||
|
revokeUser(app, read.token.token, ws.id, write.id),
|
||||||
|
{ instanceOf: Error },
|
||||||
|
'should throw error if not admin'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
// manager
|
||||||
|
t.true(
|
||||||
|
await revokeUser(app, admin.token.token, ws.id, read.id),
|
||||||
|
'admin should be able to revoke member'
|
||||||
|
);
|
||||||
|
|
||||||
|
t.true(
|
||||||
|
await revokeUser(app, owner.token.token, ws.id, write.id),
|
||||||
|
'owner should be able to revoke member'
|
||||||
|
);
|
||||||
|
|
||||||
|
await t.throwsAsync(
|
||||||
|
revokeUser(app, admin.token.token, ws.id, admin.id),
|
||||||
|
{ instanceOf: Error },
|
||||||
|
'should not be able to revoke themselves'
|
||||||
|
);
|
||||||
|
|
||||||
|
t.false(
|
||||||
|
await revokeUser(app, owner.token.token, ws.id, owner.id),
|
||||||
|
'should not be able to revoke themselves'
|
||||||
|
);
|
||||||
|
|
||||||
|
await revokeUser(app, owner.token.token, ws.id, admin.id);
|
||||||
|
await t.throwsAsync(
|
||||||
|
revokeUser(app, admin.token.token, ws.id, read.id),
|
||||||
|
{ instanceOf: Error },
|
||||||
|
'should not be able to revoke member not in workspace'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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, 4);
|
||||||
|
|
||||||
|
for (const workspace of [ws, tws]) {
|
||||||
|
for (const manager of [owner, admin]) {
|
||||||
|
const { link } = await createInviteLink(
|
||||||
|
app,
|
||||||
|
manager.token.token,
|
||||||
|
workspace.id,
|
||||||
|
'OneDay'
|
||||||
|
);
|
||||||
|
const { link: currLink } = await getInviteLink(
|
||||||
|
app,
|
||||||
|
manager.token.token,
|
||||||
|
workspace.id
|
||||||
|
);
|
||||||
|
t.is(link, currLink, 'should be able to get invite link');
|
||||||
|
|
||||||
|
t.true(
|
||||||
|
await revokeInviteLink(app, manager.token.token, workspace.id),
|
||||||
|
'should be able to revoke invite link'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const collaborator of [write, read]) {
|
||||||
|
await t.throwsAsync(
|
||||||
|
createInviteLink(app, collaborator.token.token, workspace.id, 'OneDay'),
|
||||||
|
{ instanceOf: Error },
|
||||||
|
'should throw error if not manager'
|
||||||
|
);
|
||||||
|
await t.throwsAsync(
|
||||||
|
getInviteLink(app, collaborator.token.token, workspace.id),
|
||||||
|
{ instanceOf: Error },
|
||||||
|
'should throw error if not manager'
|
||||||
|
);
|
||||||
|
await t.throwsAsync(
|
||||||
|
revokeInviteLink(app, collaborator.token.token, 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, 5);
|
||||||
|
|
||||||
|
{
|
||||||
|
const { link } = await createInviteLink(
|
||||||
|
app,
|
||||||
|
owner.token.token,
|
||||||
|
tws.id,
|
||||||
|
'OneDay'
|
||||||
|
);
|
||||||
|
const inviteId = link.split('/').pop()!;
|
||||||
|
|
||||||
|
const member = await signUp(
|
||||||
|
app,
|
||||||
|
'newmember',
|
||||||
|
'newmember@affine.pro',
|
||||||
|
'123456'
|
||||||
|
);
|
||||||
|
t.true(
|
||||||
|
await acceptInviteById(app, tws.id, inviteId, false, member.token.token),
|
||||||
|
'should be able to accept invite'
|
||||||
|
);
|
||||||
|
|
||||||
|
const { members } = await getWorkspace(app, owner.token.token, tws.id);
|
||||||
|
const memberInvite = members.find(m => m.id === member.id)!;
|
||||||
|
t.is(memberInvite.status, 'UnderReview', 'should be under review');
|
||||||
|
|
||||||
|
t.is(
|
||||||
|
await approveMember(app, admin.token.token, tws.id, member.id),
|
||||||
|
memberInvite.inviteId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
await t.throwsAsync(
|
||||||
|
approveMember(app, admin.token.token, tws.id, 'not_exists_id'),
|
||||||
|
{ instanceOf: Error },
|
||||||
|
'should throw error if member not exists'
|
||||||
|
);
|
||||||
|
await t.throwsAsync(
|
||||||
|
approveMember(app, write.token.token, tws.id, 'not_exists_id'),
|
||||||
|
{ instanceOf: Error },
|
||||||
|
'should throw error if not manager'
|
||||||
|
);
|
||||||
|
await t.throwsAsync(
|
||||||
|
approveMember(app, read.token.token, tws.id, 'not_exists_id'),
|
||||||
|
{ instanceOf: Error },
|
||||||
|
'should throw error if not manager'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('should be able to invite by link', async t => {
|
test('should be able to invite by link', async t => {
|
||||||
const { app, permissions, quotaManager } = t.context;
|
const { app, permissions, quotaManager } = t.context;
|
||||||
const {
|
const {
|
||||||
@@ -291,19 +555,21 @@ test('should be able to invite by link', async t => {
|
|||||||
|
|
||||||
{
|
{
|
||||||
// invite link
|
// invite link
|
||||||
const t1 = await invite('test1@affine.pro');
|
for (const [i] of Array.from({ length: 6 }).entries()) {
|
||||||
const t2 = await invite('test2@affine.pro');
|
const user = await invite(`test${i}@affine.pro`);
|
||||||
|
const status = await permissions.getWorkspaceMemberStatus(ws.id, user.id);
|
||||||
|
t.is(
|
||||||
|
status,
|
||||||
|
WorkspaceMemberStatus.Accepted,
|
||||||
|
'should be able to check status'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await t.throwsAsync(
|
await t.throwsAsync(
|
||||||
invite('test3@affine.pro'),
|
invite('exceed@affine.pro'),
|
||||||
{ message: 'You have exceeded your workspace member quota.' },
|
{ message: 'You have exceeded your workspace member quota.' },
|
||||||
'should throw error if exceed member limit'
|
'should throw error if exceed member limit'
|
||||||
);
|
);
|
||||||
|
|
||||||
const s1 = await permissions.getWorkspaceMemberStatus(ws.id, t1.id);
|
|
||||||
t.is(s1, WorkspaceMemberStatus.Accepted, 'should be able to check status');
|
|
||||||
const s2 = await permissions.getWorkspaceMemberStatus(ws.id, t2.id);
|
|
||||||
t.is(s2, WorkspaceMemberStatus.Accepted, 'should be able to check status');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -361,3 +627,56 @@ test('should be able to invite by link', async t => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should be able to send mails', async t => {
|
||||||
|
const { app } = t.context;
|
||||||
|
const { inviteBatch } = await init(app, 4);
|
||||||
|
const primitiveMailCount = await getCurrentMailMessageCount();
|
||||||
|
|
||||||
|
{
|
||||||
|
await inviteBatch(['m3@affine.pro', 'm4@affine.pro'], true);
|
||||||
|
t.is(await getCurrentMailMessageCount(), primitiveMailCount + 2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should be able to emit events', async t => {
|
||||||
|
const { app, event } = t.context;
|
||||||
|
|
||||||
|
{
|
||||||
|
const { teamWorkspace: tws, inviteBatch } = await init(app, 4);
|
||||||
|
|
||||||
|
await inviteBatch(['m1@affine.pro', 'm2@affine.pro']);
|
||||||
|
t.true(
|
||||||
|
event.emit.calledOnceWith('workspace.members.updated', {
|
||||||
|
workspaceId: tws.id,
|
||||||
|
count: 6,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const { teamWorkspace: tws, owner, createInviteLink } = await init(app, 10);
|
||||||
|
const [, invite] = await createInviteLink(tws);
|
||||||
|
const user = await invite('m3@affine.pro');
|
||||||
|
const { members } = await getWorkspace(app, owner.token.token, tws.id);
|
||||||
|
const memberInvite = members.find(m => m.id === user.id)!;
|
||||||
|
t.deepEqual(
|
||||||
|
event.emit.lastCall.args,
|
||||||
|
[
|
||||||
|
'workspace.members.reviewRequested',
|
||||||
|
{ inviteId: memberInvite.inviteId },
|
||||||
|
],
|
||||||
|
'should emit review requested event'
|
||||||
|
);
|
||||||
|
|
||||||
|
await revokeUser(app, owner.token.token, tws.id, user.id);
|
||||||
|
t.deepEqual(
|
||||||
|
event.emit.lastCall.args,
|
||||||
|
[
|
||||||
|
'workspace.members.requestDeclined',
|
||||||
|
{ inviteId: memberInvite.inviteId },
|
||||||
|
],
|
||||||
|
'should emit review requested event'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -65,6 +65,34 @@ export async function inviteUsers(
|
|||||||
return res.body.data.inviteBatch;
|
return res.body.data.inviteBatch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getInviteLink(
|
||||||
|
app: INestApplication,
|
||||||
|
token: string,
|
||||||
|
workspaceId: string
|
||||||
|
): Promise<{ link: string; expireTime: string }> {
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.post(gql)
|
||||||
|
.auth(token, { type: 'bearer' })
|
||||||
|
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
||||||
|
.send({
|
||||||
|
query: `
|
||||||
|
query {
|
||||||
|
workspace(id: "${workspaceId}") {
|
||||||
|
inviteLink {
|
||||||
|
link
|
||||||
|
expireTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
if (res.body.errors) {
|
||||||
|
throw new Error(res.body.errors[0].message);
|
||||||
|
}
|
||||||
|
return res.body.data.workspace.inviteLink;
|
||||||
|
}
|
||||||
|
|
||||||
export async function createInviteLink(
|
export async function createInviteLink(
|
||||||
app: INestApplication,
|
app: INestApplication,
|
||||||
token: string,
|
token: string,
|
||||||
@@ -92,6 +120,29 @@ export async function createInviteLink(
|
|||||||
return res.body.data.createInviteLink;
|
return res.body.data.createInviteLink;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function revokeInviteLink(
|
||||||
|
app: INestApplication,
|
||||||
|
token: string,
|
||||||
|
workspaceId: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.post(gql)
|
||||||
|
.auth(token, { type: 'bearer' })
|
||||||
|
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
||||||
|
.send({
|
||||||
|
query: `
|
||||||
|
mutation {
|
||||||
|
revokeInviteLink(workspaceId: "${workspaceId}")
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
if (res.body.errors) {
|
||||||
|
throw new Error(res.body.errors[0].message);
|
||||||
|
}
|
||||||
|
return res.body.data.revokeInviteLink;
|
||||||
|
}
|
||||||
|
|
||||||
export async function acceptInviteById(
|
export async function acceptInviteById(
|
||||||
app: INestApplication,
|
app: INestApplication,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
@@ -119,6 +170,32 @@ export async function acceptInviteById(
|
|||||||
return res.body.data.acceptInviteById;
|
return res.body.data.acceptInviteById;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function approveMember(
|
||||||
|
app: INestApplication,
|
||||||
|
token: string,
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<string> {
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.post(gql)
|
||||||
|
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
|
||||||
|
.auth(token, { type: 'bearer' })
|
||||||
|
.send({
|
||||||
|
query: `
|
||||||
|
mutation {
|
||||||
|
approveMember(workspaceId: "${workspaceId}", userId: "${userId}")
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
if (res.body.errors) {
|
||||||
|
throw new Error(res.body.errors[0].message, {
|
||||||
|
cause: res.body.errors[0].cause,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return res.body.data.approveMember;
|
||||||
|
}
|
||||||
|
|
||||||
export async function leaveWorkspace(
|
export async function leaveWorkspace(
|
||||||
app: INestApplication,
|
app: INestApplication,
|
||||||
token: string,
|
token: string,
|
||||||
@@ -161,6 +238,11 @@ export async function revokeUser(
|
|||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
if (res.body.errors) {
|
||||||
|
throw new Error(res.body.errors[0].message, {
|
||||||
|
cause: res.body.errors[0].cause,
|
||||||
|
});
|
||||||
|
}
|
||||||
return res.body.data.revoke;
|
return res.body.data.revoke;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export async function getWorkspace(
|
|||||||
query: `
|
query: `
|
||||||
query {
|
query {
|
||||||
workspace(id: "${workspaceId}") {
|
workspace(id: "${workspaceId}") {
|
||||||
id, members(skip: ${skip}, take: ${take}) { id, name, email, permission, inviteId }
|
id, members(skip: ${skip}, take: ${take}) { id, name, email, permission, inviteId, status }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
|
|||||||
@@ -273,6 +273,7 @@ export type ErrorDataUnion =
|
|||||||
| InvalidHistoryTimestampDataType
|
| InvalidHistoryTimestampDataType
|
||||||
| InvalidPasswordLengthDataType
|
| InvalidPasswordLengthDataType
|
||||||
| InvalidRuntimeConfigTypeDataType
|
| InvalidRuntimeConfigTypeDataType
|
||||||
|
| MemberNotFoundInSpaceDataType
|
||||||
| MissingOauthQueryParameterDataType
|
| MissingOauthQueryParameterDataType
|
||||||
| NotInSpaceDataType
|
| NotInSpaceDataType
|
||||||
| RuntimeConfigNotFoundDataType
|
| RuntimeConfigNotFoundDataType
|
||||||
@@ -334,6 +335,7 @@ export enum ErrorNames {
|
|||||||
INVALID_SUBSCRIPTION_PARAMETERS = 'INVALID_SUBSCRIPTION_PARAMETERS',
|
INVALID_SUBSCRIPTION_PARAMETERS = 'INVALID_SUBSCRIPTION_PARAMETERS',
|
||||||
LINK_EXPIRED = 'LINK_EXPIRED',
|
LINK_EXPIRED = 'LINK_EXPIRED',
|
||||||
MAILER_SERVICE_IS_NOT_CONFIGURED = 'MAILER_SERVICE_IS_NOT_CONFIGURED',
|
MAILER_SERVICE_IS_NOT_CONFIGURED = 'MAILER_SERVICE_IS_NOT_CONFIGURED',
|
||||||
|
MEMBER_NOT_FOUND_IN_SPACE = 'MEMBER_NOT_FOUND_IN_SPACE',
|
||||||
MEMBER_QUOTA_EXCEEDED = 'MEMBER_QUOTA_EXCEEDED',
|
MEMBER_QUOTA_EXCEEDED = 'MEMBER_QUOTA_EXCEEDED',
|
||||||
MISSING_OAUTH_QUERY_PARAMETER = 'MISSING_OAUTH_QUERY_PARAMETER',
|
MISSING_OAUTH_QUERY_PARAMETER = 'MISSING_OAUTH_QUERY_PARAMETER',
|
||||||
NOT_FOUND = 'NOT_FOUND',
|
NOT_FOUND = 'NOT_FOUND',
|
||||||
@@ -541,6 +543,11 @@ export interface ManageUserInput {
|
|||||||
name?: InputMaybe<Scalars['String']['input']>;
|
name?: InputMaybe<Scalars['String']['input']>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MemberNotFoundInSpaceDataType {
|
||||||
|
__typename?: 'MemberNotFoundInSpaceDataType';
|
||||||
|
spaceId: Scalars['String']['output'];
|
||||||
|
}
|
||||||
|
|
||||||
export interface MissingOauthQueryParameterDataType {
|
export interface MissingOauthQueryParameterDataType {
|
||||||
__typename?: 'MissingOauthQueryParameterDataType';
|
__typename?: 'MissingOauthQueryParameterDataType';
|
||||||
name: Scalars['String']['output'];
|
name: Scalars['String']['output'];
|
||||||
|
|||||||
Reference in New Issue
Block a user