@shipfox/api-workspaces 10.2.0 → 11.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +14 -0
- package/dist/core/invitations.d.ts.map +1 -1
- package/dist/core/invitations.js +6 -3
- package/dist/core/invitations.js.map +1 -1
- package/dist/core/workspaces.d.ts +1 -0
- package/dist/core/workspaces.d.ts.map +1 -1
- package/dist/core/workspaces.js +5 -3
- package/dist/core/workspaces.js.map +1 -1
- package/dist/presentation/inter-module.d.ts.map +1 -1
- package/dist/presentation/inter-module.js +3 -2
- package/dist/presentation/inter-module.js.map +1 -1
- package/dist/presentation/routes/invitations/create.d.ts.map +1 -1
- package/dist/presentation/routes/invitations/create.js +5 -1
- package/dist/presentation/routes/invitations/create.js.map +1 -1
- package/dist/presentation/routes/invitations/list.d.ts.map +1 -1
- package/dist/presentation/routes/invitations/list.js +5 -1
- package/dist/presentation/routes/invitations/list.js.map +1 -1
- package/dist/presentation/routes/invitations/revoke.d.ts.map +1 -1
- package/dist/presentation/routes/invitations/revoke.js +5 -1
- package/dist/presentation/routes/invitations/revoke.js.map +1 -1
- package/dist/presentation/routes/members/list.d.ts.map +1 -1
- package/dist/presentation/routes/members/list.js +5 -1
- package/dist/presentation/routes/members/list.js.map +1 -1
- package/dist/presentation/routes/members/remove.d.ts.map +1 -1
- package/dist/presentation/routes/members/remove.js +5 -1
- package/dist/presentation/routes/members/remove.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/src/core/invitations.test.ts +8 -8
- package/src/core/invitations.ts +3 -0
- package/src/core/workspaces.test.ts +22 -3
- package/src/core/workspaces.ts +4 -1
- package/src/presentation/inter-module.test.ts +51 -0
- package/src/presentation/inter-module.ts +7 -4
- package/src/presentation/routes/invitations/create.test.ts +15 -0
- package/src/presentation/routes/invitations/create.ts +2 -1
- package/src/presentation/routes/invitations/list.test.ts +30 -0
- package/src/presentation/routes/invitations/list.ts +2 -1
- package/src/presentation/routes/invitations/revoke.test.ts +14 -0
- package/src/presentation/routes/invitations/revoke.ts +2 -1
- package/src/presentation/routes/members/list.test.ts +29 -1
- package/src/presentation/routes/members/list.ts +2 -1
- package/src/presentation/routes/members/remove.test.ts +14 -0
- package/src/presentation/routes/members/remove.ts +2 -1
- package/test/routes.ts +8 -3
- package/tsconfig.build.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/api-workspaces",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "11.0.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -25,11 +25,11 @@
|
|
|
25
25
|
"drizzle-orm": "^0.45.2",
|
|
26
26
|
"zod": "^4.4.3",
|
|
27
27
|
"@shipfox/api-common-dto": "9.2.0",
|
|
28
|
+
"@shipfox/api-auth-context": "11.0.0",
|
|
28
29
|
"@shipfox/api-auth-dto": "10.2.0",
|
|
29
|
-
"@shipfox/api-auth-context": "10.2.0",
|
|
30
30
|
"@shipfox/api-projects-dto": "10.1.0",
|
|
31
31
|
"@shipfox/api-runners-dto": "10.2.0",
|
|
32
|
-
"@shipfox/api-workspaces-dto": "
|
|
32
|
+
"@shipfox/api-workspaces-dto": "11.0.0",
|
|
33
33
|
"@shipfox/config": "1.2.4",
|
|
34
34
|
"@shipfox/inter-module": "0.2.2",
|
|
35
35
|
"@shipfox/node-drizzle": "0.3.4",
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
"@shipfox/node-mailer": "0.2.4",
|
|
39
39
|
"@shipfox/node-module": "1.0.4",
|
|
40
40
|
"@shipfox/node-opentelemetry": "0.6.3",
|
|
41
|
-
"@shipfox/node-outbox": "0.2.6",
|
|
42
41
|
"@shipfox/node-postgres": "0.4.4",
|
|
42
|
+
"@shipfox/node-outbox": "0.2.6",
|
|
43
43
|
"@shipfox/node-tokens": "0.3.2"
|
|
44
44
|
},
|
|
45
45
|
"imports": {
|
|
@@ -105,7 +105,7 @@ describe('invitations core', () => {
|
|
|
105
105
|
workspaceId: workspace.id,
|
|
106
106
|
email,
|
|
107
107
|
invitedByUserId: inviter.userId,
|
|
108
|
-
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin'}],
|
|
108
|
+
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin', workspaceStatus: 'active'}],
|
|
109
109
|
});
|
|
110
110
|
|
|
111
111
|
expect(invitation.email).toBe(email);
|
|
@@ -129,7 +129,7 @@ describe('invitations core', () => {
|
|
|
129
129
|
email,
|
|
130
130
|
invitedByUserId: inviter.userId,
|
|
131
131
|
invitedByDisplay: 'Dana Scully',
|
|
132
|
-
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin'}],
|
|
132
|
+
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin', workspaceStatus: 'active'}],
|
|
133
133
|
});
|
|
134
134
|
|
|
135
135
|
const payload = await latestInvitationPayload(email);
|
|
@@ -157,7 +157,7 @@ describe('invitations core', () => {
|
|
|
157
157
|
workspaceId: workspace.id,
|
|
158
158
|
email,
|
|
159
159
|
invitedByUserId: inviter.userId,
|
|
160
|
-
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin'}],
|
|
160
|
+
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin', workspaceStatus: 'active'}],
|
|
161
161
|
});
|
|
162
162
|
|
|
163
163
|
const payload = await latestInvitationPayload(email);
|
|
@@ -181,7 +181,7 @@ describe('invitations core', () => {
|
|
|
181
181
|
email,
|
|
182
182
|
invitedByUserId: inviter.userId,
|
|
183
183
|
invitedByDisplay: ' ',
|
|
184
|
-
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin'}],
|
|
184
|
+
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin', workspaceStatus: 'active'}],
|
|
185
185
|
});
|
|
186
186
|
|
|
187
187
|
const payload = await latestInvitationPayload(email);
|
|
@@ -204,14 +204,14 @@ describe('invitations core', () => {
|
|
|
204
204
|
workspaceId: workspace.id,
|
|
205
205
|
email,
|
|
206
206
|
invitedByUserId: inviter.userId,
|
|
207
|
-
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin'}],
|
|
207
|
+
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin', workspaceStatus: 'active'}],
|
|
208
208
|
});
|
|
209
209
|
|
|
210
210
|
const promise = createWorkspaceInvitation({
|
|
211
211
|
workspaceId: workspace.id,
|
|
212
212
|
email,
|
|
213
213
|
invitedByUserId: inviter.userId,
|
|
214
|
-
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin'}],
|
|
214
|
+
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin', workspaceStatus: 'active'}],
|
|
215
215
|
});
|
|
216
216
|
|
|
217
217
|
await expect(promise).rejects.toBeInstanceOf(OpenInvitationExistsError);
|
|
@@ -233,7 +233,7 @@ describe('invitations core', () => {
|
|
|
233
233
|
workspaceId: workspace.id,
|
|
234
234
|
email: invitee.email,
|
|
235
235
|
invitedByUserId: inviter.userId,
|
|
236
|
-
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin'}],
|
|
236
|
+
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin', workspaceStatus: 'active'}],
|
|
237
237
|
});
|
|
238
238
|
const token = extractToken((await latestInvitationPayload(invitee.email)).inviteLink);
|
|
239
239
|
|
|
@@ -269,7 +269,7 @@ describe('invitations core', () => {
|
|
|
269
269
|
workspaceId: workspace.id,
|
|
270
270
|
email,
|
|
271
271
|
invitedByUserId: inviter.userId,
|
|
272
|
-
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin'}],
|
|
272
|
+
invitedByMemberships: [{workspaceId: workspace.id, role: 'admin', workspaceStatus: 'active'}],
|
|
273
273
|
});
|
|
274
274
|
const payload = await latestInvitationPayload(email);
|
|
275
275
|
const token = extractToken(payload.inviteLink);
|
package/src/core/invitations.ts
CHANGED
|
@@ -46,6 +46,7 @@ export async function createWorkspaceInvitation(params: {
|
|
|
46
46
|
workspaceId: params.workspaceId,
|
|
47
47
|
userId: params.invitedByUserId,
|
|
48
48
|
memberships: params.invitedByMemberships,
|
|
49
|
+
enforceWorkspaceStatus: false,
|
|
49
50
|
});
|
|
50
51
|
|
|
51
52
|
const rawToken = generateOpaqueToken('invitation');
|
|
@@ -216,6 +217,7 @@ export async function listWorkspaceInvitations(params: {
|
|
|
216
217
|
workspaceId: params.workspaceId,
|
|
217
218
|
userId: params.requesterUserId,
|
|
218
219
|
memberships: params.requesterMemberships,
|
|
220
|
+
enforceWorkspaceStatus: false,
|
|
219
221
|
});
|
|
220
222
|
|
|
221
223
|
return listOpenInvitationsByWorkspace({workspaceId: params.workspaceId});
|
|
@@ -231,6 +233,7 @@ export async function revokeWorkspaceInvitation(params: {
|
|
|
231
233
|
workspaceId: params.workspaceId,
|
|
232
234
|
userId: params.requesterUserId,
|
|
233
235
|
memberships: params.requesterMemberships,
|
|
236
|
+
enforceWorkspaceStatus: false,
|
|
234
237
|
});
|
|
235
238
|
|
|
236
239
|
const invitation = await findInvitationById({id: params.invitationId});
|
|
@@ -5,7 +5,7 @@ import {findMembership} from '#db/memberships.js';
|
|
|
5
5
|
import {workspacesOutbox} from '#db/schema/outbox.js';
|
|
6
6
|
import {updateWorkspace} from '#db/workspaces.js';
|
|
7
7
|
import {userFactory} from '#test/index.js';
|
|
8
|
-
import {MembershipRequiredError, WorkspaceNotFoundError} from './errors.js';
|
|
8
|
+
import {MembershipRequiredError, WorkspaceInactiveError, WorkspaceNotFoundError} from './errors.js';
|
|
9
9
|
import {
|
|
10
10
|
createWorkspaceForUser,
|
|
11
11
|
getWorkspaceOperatingState,
|
|
@@ -64,7 +64,7 @@ describe('workspaces core', () => {
|
|
|
64
64
|
const result = await requireWorkspaceMembership({
|
|
65
65
|
workspaceId: workspace.id,
|
|
66
66
|
userId: user.userId,
|
|
67
|
-
memberships: [{workspaceId: workspace.id, role: 'admin'}],
|
|
67
|
+
memberships: [{workspaceId: workspace.id, role: 'admin', workspaceStatus: 'active'}],
|
|
68
68
|
});
|
|
69
69
|
|
|
70
70
|
expect(result.workspace.id).toBe(workspace.id);
|
|
@@ -85,6 +85,25 @@ describe('workspaces core', () => {
|
|
|
85
85
|
expect(await getWorkspaceOperatingState({workspaceId: workspace.id})).toBe('suspended');
|
|
86
86
|
});
|
|
87
87
|
|
|
88
|
+
test('requireWorkspaceMembership rejects a suspended workspace', async () => {
|
|
89
|
+
const user = userFactory.build();
|
|
90
|
+
const workspace = await createWorkspaceForUser({
|
|
91
|
+
name: 'Suspended Workspace',
|
|
92
|
+
userId: user.userId,
|
|
93
|
+
userEmail: user.email,
|
|
94
|
+
userName: user.name,
|
|
95
|
+
});
|
|
96
|
+
await updateWorkspace({id: workspace.id, status: 'suspended'});
|
|
97
|
+
|
|
98
|
+
const membership = requireWorkspaceMembership({
|
|
99
|
+
workspaceId: workspace.id,
|
|
100
|
+
userId: user.userId,
|
|
101
|
+
memberships: [{workspaceId: workspace.id, role: 'admin', workspaceStatus: 'active'}],
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
await expect(membership).rejects.toBeInstanceOf(WorkspaceInactiveError);
|
|
105
|
+
});
|
|
106
|
+
|
|
88
107
|
test('requireWorkspaceMembership rejects when memberships do not include the workspace', async () => {
|
|
89
108
|
const owner = userFactory.build();
|
|
90
109
|
const outsider = userFactory.build();
|
|
@@ -110,7 +129,7 @@ describe('workspaces core', () => {
|
|
|
110
129
|
const missingWorkspace = requireWorkspaceMembership({
|
|
111
130
|
workspaceId: ghostId,
|
|
112
131
|
userId: owner.userId,
|
|
113
|
-
memberships: [{workspaceId: ghostId, role: 'admin'}],
|
|
132
|
+
memberships: [{workspaceId: ghostId, role: 'admin', workspaceStatus: 'active'}],
|
|
114
133
|
});
|
|
115
134
|
|
|
116
135
|
await expect(missingWorkspace).rejects.toBeInstanceOf(WorkspaceNotFoundError);
|
package/src/core/workspaces.ts
CHANGED
|
@@ -31,6 +31,7 @@ export interface RequireWorkspaceMembershipParams {
|
|
|
31
31
|
workspaceId: string;
|
|
32
32
|
userId: string;
|
|
33
33
|
memberships: ReadonlyArray<UserContextMembership>;
|
|
34
|
+
enforceWorkspaceStatus?: boolean;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
export interface RequireWorkspaceMembershipResult {
|
|
@@ -52,7 +53,7 @@ export async function requireWorkspaceMembership(
|
|
|
52
53
|
if (!workspace) {
|
|
53
54
|
throw new WorkspaceNotFoundError(params.workspaceId);
|
|
54
55
|
}
|
|
55
|
-
if (workspace.status !== 'active') {
|
|
56
|
+
if (params.enforceWorkspaceStatus !== false && workspace.status !== 'active') {
|
|
56
57
|
throw new WorkspaceInactiveError(params.workspaceId);
|
|
57
58
|
}
|
|
58
59
|
|
|
@@ -128,6 +129,7 @@ export async function listWorkspaceMembers(params: {
|
|
|
128
129
|
workspaceId: params.workspaceId,
|
|
129
130
|
userId: params.requesterUserId,
|
|
130
131
|
memberships: params.requesterMemberships,
|
|
132
|
+
enforceWorkspaceStatus: false,
|
|
131
133
|
});
|
|
132
134
|
|
|
133
135
|
return listMembershipsByWorkspace({workspaceId: params.workspaceId});
|
|
@@ -143,6 +145,7 @@ export async function removeWorkspaceMember(params: {
|
|
|
143
145
|
workspaceId: params.workspaceId,
|
|
144
146
|
userId: params.requesterUserId,
|
|
145
147
|
memberships: params.requesterMemberships,
|
|
148
|
+
enforceWorkspaceStatus: false,
|
|
146
149
|
});
|
|
147
150
|
|
|
148
151
|
if (params.userId === params.requesterUserId) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {buildUserContext, requireWorkspaceAccess, setUserContext} from '@shipfox/api-auth-context';
|
|
1
2
|
import {workspacesInterModuleContract} from '@shipfox/api-workspaces-dto/inter-module';
|
|
2
3
|
import {isInterModuleKnownError} from '@shipfox/inter-module';
|
|
3
4
|
import {createInMemoryInterModuleTransport} from '@shipfox/node-module/inter-module';
|
|
@@ -54,6 +55,56 @@ describe('Workspaces inter-module presentation', () => {
|
|
|
54
55
|
expect(result).toEqual({creatorUserId: null});
|
|
55
56
|
});
|
|
56
57
|
|
|
58
|
+
test('carries workspace status into token claims', async () => {
|
|
59
|
+
const client = createClient();
|
|
60
|
+
const userId = crypto.randomUUID();
|
|
61
|
+
const active = await createWorkspaceForUser({name: 'Active Workspace', userId});
|
|
62
|
+
const suspended = await createWorkspaceForUser({name: 'Suspended Workspace', userId});
|
|
63
|
+
const deleted = await createWorkspaceForUser({name: 'Deleted Workspace', userId});
|
|
64
|
+
await updateWorkspace({id: suspended.id, status: 'suspended'});
|
|
65
|
+
await updateWorkspace({id: deleted.id, status: 'deleted'});
|
|
66
|
+
|
|
67
|
+
expect(await client.listMembershipsForTokenClaims({userId})).toEqual({
|
|
68
|
+
memberships: [
|
|
69
|
+
{workspaceId: active.id, role: 'admin', workspaceStatus: 'active'},
|
|
70
|
+
{workspaceId: deleted.id, role: 'admin', workspaceStatus: 'deleted'},
|
|
71
|
+
{workspaceId: suspended.id, role: 'admin', workspaceStatus: 'suspended'},
|
|
72
|
+
],
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
await updateWorkspace({id: suspended.id, status: 'active'});
|
|
76
|
+
|
|
77
|
+
expect(await client.listMembershipsForTokenClaims({userId})).toEqual({
|
|
78
|
+
memberships: [
|
|
79
|
+
{workspaceId: active.id, role: 'admin', workspaceStatus: 'active'},
|
|
80
|
+
{workspaceId: deleted.id, role: 'admin', workspaceStatus: 'deleted'},
|
|
81
|
+
{workspaceId: suspended.id, role: 'admin', workspaceStatus: 'active'},
|
|
82
|
+
],
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('preserves suspended-workspace access errors through the claim boundary', async () => {
|
|
87
|
+
const client = createClient();
|
|
88
|
+
const userId = crypto.randomUUID();
|
|
89
|
+
const workspace = await createWorkspaceForUser({name: 'Suspended Access Workspace', userId});
|
|
90
|
+
await updateWorkspace({id: workspace.id, status: 'suspended'});
|
|
91
|
+
|
|
92
|
+
const claims = await client.listMembershipsForTokenClaims({userId});
|
|
93
|
+
const request = {};
|
|
94
|
+
setUserContext(
|
|
95
|
+
request,
|
|
96
|
+
buildUserContext({
|
|
97
|
+
userId,
|
|
98
|
+
email: `user-${userId}@example.com`,
|
|
99
|
+
memberships: claims.memberships,
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
expect(() => requireWorkspaceAccess({request, workspaceId: workspace.id})).toThrow(
|
|
104
|
+
expect.objectContaining({code: 'workspace-suspended', status: 409}),
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
57
108
|
test('returns only the current workspace operating state', async () => {
|
|
58
109
|
const client = createClient();
|
|
59
110
|
const workspace = await createWorkspace({name: 'Operating State Workspace'});
|
|
@@ -26,10 +26,13 @@ export function createWorkspacesInterModulePresentation(): InterModulePresentati
|
|
|
26
26
|
> {
|
|
27
27
|
return defineInterModulePresentation(workspacesInterModuleContract, {
|
|
28
28
|
listMembershipsForTokenClaims: async ({userId}) => ({
|
|
29
|
-
memberships: (await listMembershipsByUser({userId})).map(
|
|
30
|
-
workspaceId,
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
memberships: (await listMembershipsByUser({userId})).map(
|
|
30
|
+
({workspaceId, workspaceStatus}) => ({
|
|
31
|
+
workspaceId,
|
|
32
|
+
role: 'admin' as const,
|
|
33
|
+
workspaceStatus,
|
|
34
|
+
}),
|
|
35
|
+
),
|
|
33
36
|
}),
|
|
34
37
|
getWorkspaceCreator: async (input) => {
|
|
35
38
|
try {
|
|
@@ -66,6 +66,21 @@ describe('POST /workspaces/:workspaceId/invitations', () => {
|
|
|
66
66
|
expect(await invitationOutboxEventsTo(inviteeEmail)).toHaveLength(1);
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
+
test('returns workspace-suspended for a suspended membership claim', async () => {
|
|
70
|
+
const workspaceId = crypto.randomUUID();
|
|
71
|
+
const token = `claim:${crypto.randomUUID()}:suspended-invite-create@example.com:${workspaceId}:suspended`;
|
|
72
|
+
|
|
73
|
+
const res = await app.inject({
|
|
74
|
+
method: 'POST',
|
|
75
|
+
url: `/workspaces/${workspaceId}/invitations`,
|
|
76
|
+
headers: {authorization: `Bearer ${token}`},
|
|
77
|
+
payload: {email: uniqueEmail('suspended-invite')},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(res.statusCode).toBe(409);
|
|
81
|
+
expect(res.json().code).toBe('workspace-suspended');
|
|
82
|
+
});
|
|
83
|
+
|
|
69
84
|
test('transforms a whitespace/case-equivalent duplicate open invitation into 409', async () => {
|
|
70
85
|
const owner = await signupVerifyLogin(app, 'invite-create-duplicate-equivalent');
|
|
71
86
|
const workspaceId = await createWorkspace(app, owner.token);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {AUTH_USER, getUserContext} from '@shipfox/api-auth-context';
|
|
1
|
+
import {AUTH_USER, getUserContext, requireWorkspaceAccess} from '@shipfox/api-auth-context';
|
|
2
2
|
import {createInvitationBodySchema, invitationDtoSchema} from '@shipfox/api-workspaces-dto';
|
|
3
3
|
import {ClientError, defineRoute} from '@shipfox/node-fastify';
|
|
4
4
|
import {z} from 'zod';
|
|
@@ -45,6 +45,7 @@ export const createInvitationRoute = defineRoute({
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
const {workspaceId} = request.params;
|
|
48
|
+
requireWorkspaceAccess({request, workspaceId});
|
|
48
49
|
const {email} = request.body;
|
|
49
50
|
const invitation = await createWorkspaceInvitation({
|
|
50
51
|
workspaceId,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type {FastifyInstance} from 'fastify';
|
|
2
|
+
import {updateWorkspace} from '#db/workspaces.js';
|
|
2
3
|
import {
|
|
3
4
|
createExpiredInvite,
|
|
4
5
|
createInvite,
|
|
@@ -46,6 +47,21 @@ describe('GET /workspaces/:workspaceId/invitations', () => {
|
|
|
46
47
|
]);
|
|
47
48
|
});
|
|
48
49
|
|
|
50
|
+
test('keeps an active claim usable when the workspace row is suspended', async () => {
|
|
51
|
+
const owner = await signupVerifyLogin(app, 'invite-list-snapshot');
|
|
52
|
+
const workspaceId = await createWorkspace(app, owner.token);
|
|
53
|
+
await updateWorkspace({id: workspaceId, status: 'suspended'});
|
|
54
|
+
|
|
55
|
+
const res = await app.inject({
|
|
56
|
+
method: 'GET',
|
|
57
|
+
url: `/workspaces/${workspaceId}/invitations`,
|
|
58
|
+
headers: {authorization: `Bearer ${owner.token}`},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(res.statusCode).toBe(200);
|
|
62
|
+
expect(res.json().invitations).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
|
|
49
65
|
test('excludes expired unaccepted invitations', async () => {
|
|
50
66
|
const owner = await signupVerifyLogin(app, 'invite-list-expired-owner');
|
|
51
67
|
const workspaceId = await createWorkspace(app, owner.token);
|
|
@@ -65,6 +81,20 @@ describe('GET /workspaces/:workspaceId/invitations', () => {
|
|
|
65
81
|
expect(res.json().invitations).toEqual([]);
|
|
66
82
|
});
|
|
67
83
|
|
|
84
|
+
test('returns workspace-suspended for a suspended membership claim', async () => {
|
|
85
|
+
const workspaceId = crypto.randomUUID();
|
|
86
|
+
const token = `claim:${crypto.randomUUID()}:suspended-invite-list@example.com:${workspaceId}:suspended`;
|
|
87
|
+
|
|
88
|
+
const res = await app.inject({
|
|
89
|
+
method: 'GET',
|
|
90
|
+
url: `/workspaces/${workspaceId}/invitations`,
|
|
91
|
+
headers: {authorization: `Bearer ${token}`},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(res.statusCode).toBe(409);
|
|
95
|
+
expect(res.json().code).toBe('workspace-suspended');
|
|
96
|
+
});
|
|
97
|
+
|
|
68
98
|
test('transforms missing membership into 403', async () => {
|
|
69
99
|
const outsider = await signupVerifyLogin(app, 'invite-list-outsider');
|
|
70
100
|
const workspaceId = crypto.randomUUID();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {AUTH_USER, getUserContext} from '@shipfox/api-auth-context';
|
|
1
|
+
import {AUTH_USER, getUserContext, requireWorkspaceAccess} from '@shipfox/api-auth-context';
|
|
2
2
|
import {listInvitationsResponseSchema} from '@shipfox/api-workspaces-dto';
|
|
3
3
|
import {ClientError, defineRoute} from '@shipfox/node-fastify';
|
|
4
4
|
import {z} from 'zod';
|
|
@@ -40,6 +40,7 @@ export const listInvitationsRoute = defineRoute({
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
const {workspaceId} = request.params;
|
|
43
|
+
requireWorkspaceAccess({request, workspaceId});
|
|
43
44
|
const invitations = await listWorkspaceInvitations({
|
|
44
45
|
workspaceId,
|
|
45
46
|
requesterUserId: client.userId,
|
|
@@ -82,6 +82,20 @@ describe('DELETE /workspaces/:workspaceId/invitations/:invitationId', () => {
|
|
|
82
82
|
expect(res.json().code).toBe('forbidden');
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
+
test('returns workspace-suspended for a suspended membership claim', async () => {
|
|
86
|
+
const workspaceId = crypto.randomUUID();
|
|
87
|
+
const token = `claim:${crypto.randomUUID()}:suspended-invite-revoke@example.com:${workspaceId}:suspended`;
|
|
88
|
+
|
|
89
|
+
const res = await app.inject({
|
|
90
|
+
method: 'DELETE',
|
|
91
|
+
url: `/workspaces/${workspaceId}/invitations/${crypto.randomUUID()}`,
|
|
92
|
+
headers: {authorization: `Bearer ${token}`},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
expect(res.statusCode).toBe(409);
|
|
96
|
+
expect(res.json().code).toBe('workspace-suspended');
|
|
97
|
+
});
|
|
98
|
+
|
|
85
99
|
test('transforms missing membership into 403', async () => {
|
|
86
100
|
const outsider = await signupVerifyLogin(app, 'invite-revoke-outsider');
|
|
87
101
|
const workspaceId = crypto.randomUUID();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {AUTH_USER, getUserContext} from '@shipfox/api-auth-context';
|
|
1
|
+
import {AUTH_USER, getUserContext, requireWorkspaceAccess} from '@shipfox/api-auth-context';
|
|
2
2
|
import {ClientError, defineRoute} from '@shipfox/node-fastify';
|
|
3
3
|
import {z} from 'zod';
|
|
4
4
|
import {
|
|
@@ -51,6 +51,7 @@ export const revokeInvitationRoute = defineRoute({
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
const {workspaceId, invitationId} = request.params;
|
|
54
|
+
requireWorkspaceAccess({request, workspaceId});
|
|
54
55
|
|
|
55
56
|
await revokeWorkspaceInvitation({
|
|
56
57
|
workspaceId,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type {FastifyInstance} from 'fastify';
|
|
2
|
-
import {createWorkspace as createWorkspaceRow} from '#db/workspaces.js';
|
|
2
|
+
import {createWorkspace as createWorkspaceRow, updateWorkspace} from '#db/workspaces.js';
|
|
3
3
|
import {
|
|
4
4
|
createInvite,
|
|
5
5
|
createWorkspace,
|
|
@@ -50,6 +50,20 @@ describe('GET /workspaces/:workspaceId/members', () => {
|
|
|
50
50
|
).toEqual([guest.email, owner.email].sort());
|
|
51
51
|
});
|
|
52
52
|
|
|
53
|
+
test('keeps an active claim usable when the workspace row is suspended', async () => {
|
|
54
|
+
const owner = await signupVerifyLogin(app, 'members-list-snapshot');
|
|
55
|
+
const workspaceId = await createWorkspace(app, owner.token);
|
|
56
|
+
await updateWorkspace({id: workspaceId, status: 'suspended'});
|
|
57
|
+
|
|
58
|
+
const res = await app.inject({
|
|
59
|
+
method: 'GET',
|
|
60
|
+
url: `/workspaces/${workspaceId}/members`,
|
|
61
|
+
headers: {authorization: `Bearer ${owner.token}`},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
expect(res.statusCode).toBe(200);
|
|
65
|
+
});
|
|
66
|
+
|
|
53
67
|
test('authorizes from a static membership claim without a membership row', async () => {
|
|
54
68
|
const userId = crypto.randomUUID();
|
|
55
69
|
const email = `members-list-claim-${crypto.randomUUID()}@example.com`;
|
|
@@ -66,6 +80,20 @@ describe('GET /workspaces/:workspaceId/members', () => {
|
|
|
66
80
|
expect(res.json().members).toEqual([]);
|
|
67
81
|
});
|
|
68
82
|
|
|
83
|
+
test('returns workspace-suspended for a suspended membership claim', async () => {
|
|
84
|
+
const workspaceId = crypto.randomUUID();
|
|
85
|
+
const token = `claim:${crypto.randomUUID()}:suspended-list@example.com:${workspaceId}:suspended`;
|
|
86
|
+
|
|
87
|
+
const res = await app.inject({
|
|
88
|
+
method: 'GET',
|
|
89
|
+
url: `/workspaces/${workspaceId}/members`,
|
|
90
|
+
headers: {authorization: `Bearer ${token}`},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(res.statusCode).toBe(409);
|
|
94
|
+
expect(res.json().code).toBe('workspace-suspended');
|
|
95
|
+
});
|
|
96
|
+
|
|
69
97
|
test('transforms missing membership into 403', async () => {
|
|
70
98
|
const outsider = await signupVerifyLogin(app, 'members-list-outsider');
|
|
71
99
|
const workspaceId = crypto.randomUUID();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {AUTH_USER, getUserContext} from '@shipfox/api-auth-context';
|
|
1
|
+
import {AUTH_USER, getUserContext, requireWorkspaceAccess} from '@shipfox/api-auth-context';
|
|
2
2
|
import {listMembersResponseSchema} from '@shipfox/api-workspaces-dto';
|
|
3
3
|
import {ClientError, defineRoute} from '@shipfox/node-fastify';
|
|
4
4
|
import {z} from 'zod';
|
|
@@ -40,6 +40,7 @@ export const listMembersRoute = defineRoute({
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
const {workspaceId} = request.params;
|
|
43
|
+
requireWorkspaceAccess({request, workspaceId});
|
|
43
44
|
const members = await listWorkspaceMembers({
|
|
44
45
|
workspaceId,
|
|
45
46
|
requesterUserId: client.userId,
|
|
@@ -79,6 +79,20 @@ describe('DELETE /workspaces/:workspaceId/members/:userId', () => {
|
|
|
79
79
|
expect(res.json().code).toBe('self-removal-not-allowed');
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
+
test('returns workspace-suspended for a suspended membership claim', async () => {
|
|
83
|
+
const workspaceId = crypto.randomUUID();
|
|
84
|
+
const token = `claim:${crypto.randomUUID()}:suspended-member-remove@example.com:${workspaceId}:suspended`;
|
|
85
|
+
|
|
86
|
+
const res = await app.inject({
|
|
87
|
+
method: 'DELETE',
|
|
88
|
+
url: `/workspaces/${workspaceId}/members/${crypto.randomUUID()}`,
|
|
89
|
+
headers: {authorization: `Bearer ${token}`},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(res.statusCode).toBe(409);
|
|
93
|
+
expect(res.json().code).toBe('workspace-suspended');
|
|
94
|
+
});
|
|
95
|
+
|
|
82
96
|
test('transforms missing membership into 403', async () => {
|
|
83
97
|
const outsider = await signupVerifyLogin(app, 'members-remove-outsider');
|
|
84
98
|
const workspaceId = crypto.randomUUID();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {AUTH_USER, getUserContext} from '@shipfox/api-auth-context';
|
|
1
|
+
import {AUTH_USER, getUserContext, requireWorkspaceAccess} from '@shipfox/api-auth-context';
|
|
2
2
|
import {ClientError, defineRoute} from '@shipfox/node-fastify';
|
|
3
3
|
import {z} from 'zod';
|
|
4
4
|
import {
|
|
@@ -53,6 +53,7 @@ export const removeMemberRoute = defineRoute({
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
const {workspaceId, userId} = request.params;
|
|
56
|
+
requireWorkspaceAccess({request, workspaceId});
|
|
56
57
|
|
|
57
58
|
await removeWorkspaceMember({
|
|
58
59
|
workspaceId,
|
package/test/routes.ts
CHANGED
|
@@ -52,14 +52,15 @@ const fakeUserAuth: AuthMethod = {
|
|
|
52
52
|
authenticate: async (request) => {
|
|
53
53
|
const raw = request.headers.authorization?.replace(BEARER_RE, '');
|
|
54
54
|
if (raw?.startsWith('claim:')) {
|
|
55
|
-
const [, userId, email, workspaceId] = raw.split(':');
|
|
55
|
+
const [, userId, email, workspaceId, status] = raw.split(':');
|
|
56
56
|
if (!userId || !email || !workspaceId) throw new Error('Invalid test user claim token');
|
|
57
|
+
const workspaceStatus = status === 'suspended' || status === 'deleted' ? status : 'active';
|
|
57
58
|
setUserContext(
|
|
58
59
|
request,
|
|
59
60
|
buildUserContext({
|
|
60
61
|
userId,
|
|
61
62
|
email,
|
|
62
|
-
memberships: [{workspaceId, role: 'admin'}],
|
|
63
|
+
memberships: [{workspaceId, role: 'admin', workspaceStatus}],
|
|
63
64
|
}),
|
|
64
65
|
);
|
|
65
66
|
return;
|
|
@@ -74,7 +75,11 @@ const fakeUserAuth: AuthMethod = {
|
|
|
74
75
|
userId,
|
|
75
76
|
email,
|
|
76
77
|
name: encodedName ? decodeURIComponent(encodedName) : null,
|
|
77
|
-
memberships: memberships.map((m) => ({
|
|
78
|
+
memberships: memberships.map((m) => ({
|
|
79
|
+
workspaceId: m.workspaceId,
|
|
80
|
+
role: 'admin' as const,
|
|
81
|
+
workspaceStatus: 'active' as const,
|
|
82
|
+
})),
|
|
78
83
|
}),
|
|
79
84
|
);
|
|
80
85
|
},
|