@shipfox/api-workspaces 11.0.0 → 12.2.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.
Files changed (140) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +48 -0
  3. package/README.md +27 -4
  4. package/dist/core/entities/workspace.d.ts +1 -0
  5. package/dist/core/entities/workspace.d.ts.map +1 -1
  6. package/dist/core/entities/workspace.js.map +1 -1
  7. package/dist/core/errors.d.ts +3 -0
  8. package/dist/core/errors.d.ts.map +1 -1
  9. package/dist/core/errors.js +6 -0
  10. package/dist/core/errors.js.map +1 -1
  11. package/dist/core/index.d.ts +2 -2
  12. package/dist/core/index.d.ts.map +1 -1
  13. package/dist/core/index.js +2 -2
  14. package/dist/core/index.js.map +1 -1
  15. package/dist/core/invitations.js +1 -1
  16. package/dist/core/invitations.js.map +1 -1
  17. package/dist/core/rate-limit.d.ts +39 -0
  18. package/dist/core/rate-limit.d.ts.map +1 -0
  19. package/dist/core/rate-limit.js +70 -0
  20. package/dist/core/rate-limit.js.map +1 -0
  21. package/dist/core/workspaces.d.ts +7 -0
  22. package/dist/core/workspaces.d.ts.map +1 -1
  23. package/dist/core/workspaces.js +42 -4
  24. package/dist/core/workspaces.js.map +1 -1
  25. package/dist/db/db.d.ts +332 -12
  26. package/dist/db/db.d.ts.map +1 -1
  27. package/dist/db/db.js +3 -1
  28. package/dist/db/db.js.map +1 -1
  29. package/dist/db/index.d.ts +4 -1
  30. package/dist/db/index.d.ts.map +1 -1
  31. package/dist/db/index.js +3 -1
  32. package/dist/db/index.js.map +1 -1
  33. package/dist/db/memberships.d.ts +1 -0
  34. package/dist/db/memberships.d.ts.map +1 -1
  35. package/dist/db/memberships.js +2 -0
  36. package/dist/db/memberships.js.map +1 -1
  37. package/dist/db/rate-limits.d.ts +9 -0
  38. package/dist/db/rate-limits.d.ts.map +1 -0
  39. package/dist/db/rate-limits.js +43 -0
  40. package/dist/db/rate-limits.js.map +1 -0
  41. package/dist/db/schema/admin-command-results.d.ts +1 -1
  42. package/dist/db/schema/invitations.d.ts +1 -1
  43. package/dist/db/schema/memberships.d.ts +1 -1
  44. package/dist/db/schema/outbox.d.ts +1 -1
  45. package/dist/db/schema/rate-limits.d.ts +145 -0
  46. package/dist/db/schema/rate-limits.d.ts.map +1 -0
  47. package/dist/db/schema/rate-limits.js +25 -0
  48. package/dist/db/schema/rate-limits.js.map +1 -0
  49. package/dist/db/schema/workspaces.d.ts +19 -2
  50. package/dist/db/schema/workspaces.d.ts.map +1 -1
  51. package/dist/db/schema/workspaces.js +5 -2
  52. package/dist/db/schema/workspaces.js.map +1 -1
  53. package/dist/db/workspaces.d.ts +11 -1
  54. package/dist/db/workspaces.d.ts.map +1 -1
  55. package/dist/db/workspaces.js +12 -3
  56. package/dist/db/workspaces.js.map +1 -1
  57. package/dist/index.d.ts +1 -1
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +1 -1
  60. package/dist/index.js.map +1 -1
  61. package/dist/metrics/instance.d.ts +6 -0
  62. package/dist/metrics/instance.d.ts.map +1 -1
  63. package/dist/metrics/instance.js +12 -0
  64. package/dist/metrics/instance.js.map +1 -1
  65. package/dist/presentation/dto/membership.d.ts.map +1 -1
  66. package/dist/presentation/dto/membership.js +1 -0
  67. package/dist/presentation/dto/membership.js.map +1 -1
  68. package/dist/presentation/dto/workspace.d.ts.map +1 -1
  69. package/dist/presentation/dto/workspace.js +1 -0
  70. package/dist/presentation/dto/workspace.js.map +1 -1
  71. package/dist/presentation/e2eRoutes/create-workspace.d.ts.map +1 -1
  72. package/dist/presentation/e2eRoutes/create-workspace.js +11 -1
  73. package/dist/presentation/e2eRoutes/create-workspace.js.map +1 -1
  74. package/dist/presentation/routes/index.d.ts.map +1 -1
  75. package/dist/presentation/routes/index.js +3 -1
  76. package/dist/presentation/routes/index.js.map +1 -1
  77. package/dist/presentation/routes/rate-limit.d.ts +3 -0
  78. package/dist/presentation/routes/rate-limit.d.ts.map +1 -0
  79. package/dist/presentation/routes/rate-limit.js +42 -0
  80. package/dist/presentation/routes/rate-limit.js.map +1 -0
  81. package/dist/presentation/routes/workspaces/create.d.ts.map +1 -1
  82. package/dist/presentation/routes/workspaces/create.js +11 -1
  83. package/dist/presentation/routes/workspaces/create.js.map +1 -1
  84. package/dist/presentation/routes/workspaces/index.d.ts +2 -0
  85. package/dist/presentation/routes/workspaces/index.d.ts.map +1 -1
  86. package/dist/presentation/routes/workspaces/index.js +2 -0
  87. package/dist/presentation/routes/workspaces/index.js.map +1 -1
  88. package/dist/presentation/routes/workspaces/slug-availability.d.ts +2 -0
  89. package/dist/presentation/routes/workspaces/slug-availability.d.ts.map +1 -0
  90. package/dist/presentation/routes/workspaces/slug-availability.js +31 -0
  91. package/dist/presentation/routes/workspaces/slug-availability.js.map +1 -0
  92. package/dist/presentation/routes/workspaces/update.d.ts +2 -0
  93. package/dist/presentation/routes/workspaces/update.d.ts.map +1 -0
  94. package/dist/presentation/routes/workspaces/update.js +49 -0
  95. package/dist/presentation/routes/workspaces/update.js.map +1 -0
  96. package/dist/tsconfig.test.tsbuildinfo +1 -1
  97. package/drizzle/0000_initial.sql +2 -0
  98. package/drizzle/0003_dapper_talos.sql +13 -0
  99. package/drizzle/meta/0000_snapshot.json +23 -1
  100. package/drizzle/meta/0003_snapshot.json +689 -0
  101. package/drizzle/meta/_journal.json +7 -0
  102. package/package.json +16 -14
  103. package/src/core/entities/workspace.ts +1 -0
  104. package/src/core/errors.ts +7 -0
  105. package/src/core/index.ts +3 -0
  106. package/src/core/invitations.ts +1 -1
  107. package/src/core/rate-limit.test.ts +113 -0
  108. package/src/core/rate-limit.ts +119 -0
  109. package/src/core/workspaces.test.ts +9 -0
  110. package/src/core/workspaces.ts +47 -3
  111. package/src/db/db.ts +2 -0
  112. package/src/db/index.ts +10 -0
  113. package/src/db/memberships.ts +3 -0
  114. package/src/db/rate-limits.test.ts +74 -0
  115. package/src/db/rate-limits.ts +59 -0
  116. package/src/db/schema/rate-limits.ts +27 -0
  117. package/src/db/schema/workspaces.ts +7 -2
  118. package/src/db/workspaces.ts +21 -1
  119. package/src/index.test.ts +2 -0
  120. package/src/index.ts +1 -0
  121. package/src/metrics/instance.ts +26 -0
  122. package/src/presentation/dto/membership.ts +1 -0
  123. package/src/presentation/dto/workspace.ts +1 -0
  124. package/src/presentation/e2eRoutes/create-workspace.ts +9 -1
  125. package/src/presentation/inter-module.test.ts +22 -4
  126. package/src/presentation/routes/index.ts +8 -1
  127. package/src/presentation/routes/rate-limit.ts +45 -0
  128. package/src/presentation/routes/workspaces/create.test.ts +33 -3
  129. package/src/presentation/routes/workspaces/create.ts +9 -1
  130. package/src/presentation/routes/workspaces/index.ts +2 -0
  131. package/src/presentation/routes/workspaces/list.test.ts +1 -0
  132. package/src/presentation/routes/workspaces/slug-availability.test.ts +159 -0
  133. package/src/presentation/routes/workspaces/slug-availability.ts +30 -0
  134. package/src/presentation/routes/workspaces/update.test.ts +114 -0
  135. package/src/presentation/routes/workspaces/update.ts +41 -0
  136. package/test/env.ts +1 -0
  137. package/test/factories/workspace.ts +3 -1
  138. package/test/globalSetup.ts +1 -1
  139. package/test/routes.ts +18 -4
  140. package/tsconfig.build.tsbuildinfo +1 -1
@@ -0,0 +1,159 @@
1
+ import {sql} from 'drizzle-orm';
2
+ import {
3
+ hashWorkspacesRateLimitIdentifier,
4
+ WORKSPACE_SLUG_AVAILABILITY_RATE_LIMIT,
5
+ } from '#core/rate-limit.js';
6
+ import {createWorkspaceForUser} from '#core/workspaces.js';
7
+ import {db} from '#db/db.js';
8
+ import {workspacesRateLimits} from '#db/schema/rate-limits.js';
9
+ import {createWorkspacesTestApp, signupVerifyLogin} from '#test/routes.js';
10
+
11
+ let ipSequence = 1;
12
+
13
+ function uniqueIp(): string {
14
+ ipSequence += 1;
15
+ return `203.0.113.${ipSequence}`;
16
+ }
17
+
18
+ function windowStartFor(now: Date, windowSeconds: number): Date {
19
+ const windowMs = windowSeconds * 1000;
20
+ return new Date(Math.floor(now.getTime() / windowMs) * windowMs);
21
+ }
22
+
23
+ describe('GET /workspaces/slug-availability', () => {
24
+ test('returns true for an available slug and false for a taken slug', async () => {
25
+ const app = await createWorkspacesTestApp({fastifyOptions: {trustProxy: true}});
26
+ const user = await signupVerifyLogin(app, 'workspace-slug-availability');
27
+ const takenSlug = `taken-${crypto.randomUUID().slice(0, 8)}`;
28
+ await createWorkspaceForUser({
29
+ name: 'Taken Workspace',
30
+ slug: takenSlug,
31
+ userId: crypto.randomUUID(),
32
+ });
33
+
34
+ const headers = {
35
+ authorization: `Bearer ${user.token}`,
36
+ 'x-forwarded-for': uniqueIp(),
37
+ };
38
+ const available = await app.inject({
39
+ method: 'GET',
40
+ url: `/workspaces/slug-availability?slug=available-${crypto.randomUUID().slice(0, 8)}`,
41
+ headers,
42
+ });
43
+ const taken = await app.inject({
44
+ method: 'GET',
45
+ url: `/workspaces/slug-availability?slug=${takenSlug}`,
46
+ headers: {...headers, 'x-forwarded-for': uniqueIp()},
47
+ });
48
+
49
+ expect(available.statusCode).toBe(200);
50
+ expect(available.json()).toEqual({available: true});
51
+ expect(taken.statusCode).toBe(200);
52
+ expect(taken.json()).toEqual({available: false});
53
+
54
+ await app.close();
55
+ });
56
+
57
+ test('rejects malformed slugs instead of reporting them as taken', async () => {
58
+ const app = await createWorkspacesTestApp({fastifyOptions: {trustProxy: true}});
59
+ const user = await signupVerifyLogin(app, 'workspace-slug-invalid');
60
+
61
+ const res = await app.inject({
62
+ method: 'GET',
63
+ url: '/workspaces/slug-availability?slug=Not%20A%20Slug',
64
+ headers: {
65
+ authorization: `Bearer ${user.token}`,
66
+ 'x-forwarded-for': uniqueIp(),
67
+ },
68
+ });
69
+
70
+ expect(res.statusCode).toBe(400);
71
+
72
+ await app.close();
73
+ });
74
+
75
+ test('rejects unauthenticated callers', async () => {
76
+ const app = await createWorkspacesTestApp({fastifyOptions: {trustProxy: true}});
77
+
78
+ const res = await app.inject({
79
+ method: 'GET',
80
+ url: '/workspaces/slug-availability?slug=available',
81
+ });
82
+
83
+ expect(res.statusCode).toBe(401);
84
+
85
+ await app.close();
86
+ });
87
+
88
+ test('rate-limits repeated availability checks by source IP', async () => {
89
+ const app = await createWorkspacesTestApp({fastifyOptions: {trustProxy: true}});
90
+ const user = await signupVerifyLogin(app, 'workspace-slug-rate-limit');
91
+ const headers = {
92
+ authorization: `Bearer ${user.token}`,
93
+ 'x-forwarded-for': uniqueIp(),
94
+ };
95
+ const url = `/workspaces/slug-availability?slug=rate-limit-${crypto.randomUUID().slice(0, 8)}`;
96
+ let lastResponse = await app.inject({method: 'GET', url, headers});
97
+
98
+ for (let attempt = 1; attempt <= WORKSPACE_SLUG_AVAILABILITY_RATE_LIMIT.limit; attempt += 1) {
99
+ lastResponse = await app.inject({method: 'GET', url, headers});
100
+ }
101
+
102
+ expect(lastResponse.statusCode).toBe(429);
103
+ expect(lastResponse.json()).toMatchObject({
104
+ code: 'rate-limited',
105
+ details: {retry_after_seconds: expect.any(Number)},
106
+ });
107
+ expect(lastResponse.headers['retry-after']).toEqual(expect.any(String));
108
+
109
+ await app.close();
110
+ });
111
+
112
+ test('fails closed when limiter storage is unavailable', async () => {
113
+ const app = await createWorkspacesTestApp({fastifyOptions: {trustProxy: true}});
114
+ const user = await signupVerifyLogin(app, 'workspace-slug-rate-limit-unavailable');
115
+ const ip = uniqueIp();
116
+ const now = new Date();
117
+ const windowStart = windowStartFor(now, WORKSPACE_SLUG_AVAILABILITY_RATE_LIMIT.windowSeconds);
118
+ const identifierHmac = hashWorkspacesRateLimitIdentifier({
119
+ action: 'slug-availability',
120
+ scope: 'ip',
121
+ identifier: ip,
122
+ });
123
+
124
+ await db()
125
+ .insert(workspacesRateLimits)
126
+ .values({
127
+ action: 'slug-availability',
128
+ scope: 'ip',
129
+ identifierHmac,
130
+ windowStart,
131
+ count: 1,
132
+ expiresAt: new Date(
133
+ windowStart.getTime() + WORKSPACE_SLUG_AVAILABILITY_RATE_LIMIT.windowSeconds * 1000,
134
+ ),
135
+ });
136
+
137
+ await db().transaction(async (tx) => {
138
+ await tx.execute(sql`
139
+ SELECT 1
140
+ FROM workspaces_rate_limits
141
+ WHERE identifier_hmac = ${identifierHmac}
142
+ FOR UPDATE
143
+ `);
144
+ const res = await app.inject({
145
+ method: 'GET',
146
+ url: `/workspaces/slug-availability?slug=unavailable-${crypto.randomUUID().slice(0, 8)}`,
147
+ headers: {
148
+ authorization: `Bearer ${user.token}`,
149
+ 'x-forwarded-for': ip,
150
+ },
151
+ });
152
+
153
+ expect(res.statusCode).toBe(503);
154
+ expect(res.json()).toEqual({code: 'workspace-rate-limit-unavailable'});
155
+ });
156
+
157
+ await app.close();
158
+ });
159
+ });
@@ -0,0 +1,30 @@
1
+ import {AUTH_USER, getUserContext} from '@shipfox/api-auth-context';
2
+ import {
3
+ workspaceSlugAvailabilityQuerySchema,
4
+ workspaceSlugAvailabilityResponseSchema,
5
+ } from '@shipfox/api-workspaces-dto';
6
+ import {ClientError, defineRoute} from '@shipfox/node-fastify';
7
+ import {checkWorkspaceSlugAvailability} from '#core/index.js';
8
+ import {createWorkspaceSlugAvailabilityRateLimitPreHandler} from '../rate-limit.js';
9
+
10
+ export const workspaceSlugAvailabilityRoute = defineRoute({
11
+ method: 'GET',
12
+ path: '/slug-availability',
13
+ description: 'Check whether a workspace slug is available.',
14
+ auth: AUTH_USER,
15
+ schema: {
16
+ querystring: workspaceSlugAvailabilityQuerySchema,
17
+ response: {
18
+ 200: workspaceSlugAvailabilityResponseSchema,
19
+ },
20
+ },
21
+ preHandler: createWorkspaceSlugAvailabilityRateLimitPreHandler(),
22
+ handler: async (request) => {
23
+ const client = getUserContext(request);
24
+ if (!client) {
25
+ throw new ClientError('Authentication required', 'unauthorized', {status: 401});
26
+ }
27
+
28
+ return {available: await checkWorkspaceSlugAvailability(request.query.slug)};
29
+ },
30
+ });
@@ -0,0 +1,114 @@
1
+ import {WORKSPACES_WORKSPACE_UPDATED} from '@shipfox/api-workspaces-dto';
2
+ import {sql} from 'drizzle-orm';
3
+ import {createWorkspaceForUser} from '#core/workspaces.js';
4
+ import {db} from '#db/db.js';
5
+ import {workspacesOutbox} from '#db/schema/outbox.js';
6
+ import {createWorkspacesTestApp, signupVerifyLogin} from '#test/routes.js';
7
+
8
+ describe('PATCH /workspaces/:workspaceId', () => {
9
+ test('updates workspace details and writes the updated event', async () => {
10
+ const app = await createWorkspacesTestApp();
11
+ const owner = await signupVerifyLogin(app, 'workspace-update-owner');
12
+ const workspace = await createWorkspaceForUser({
13
+ name: 'Before',
14
+ slug: `before-${crypto.randomUUID().slice(0, 8)}`,
15
+ userId: owner.userId,
16
+ userEmail: owner.email,
17
+ });
18
+
19
+ const res = await app.inject({
20
+ method: 'PATCH',
21
+ url: `/workspaces/${workspace.id}`,
22
+ headers: {authorization: `Bearer ${owner.token}`},
23
+ payload: {name: 'After', slug: 'after'},
24
+ });
25
+
26
+ expect(res.statusCode).toBe(200);
27
+ expect(res.json()).toMatchObject({id: workspace.id, name: 'After', slug: 'after'});
28
+
29
+ const [event] = await db()
30
+ .select()
31
+ .from(workspacesOutbox)
32
+ .where(sql`${workspacesOutbox.payload}->>'workspaceId' = ${workspace.id}`)
33
+ .orderBy(sql`${workspacesOutbox.createdAt} DESC`)
34
+ .limit(1);
35
+ expect(event).toMatchObject({
36
+ eventType: WORKSPACES_WORKSPACE_UPDATED,
37
+ payload: {workspaceId: workspace.id, name: 'After', slug: 'after'},
38
+ });
39
+ });
40
+
41
+ test('rejects an empty body instead of writing a no-op updated event', async () => {
42
+ const app = await createWorkspacesTestApp();
43
+ const owner = await signupVerifyLogin(app, 'workspace-update-empty');
44
+ const workspace = await createWorkspaceForUser({
45
+ name: 'Untouched',
46
+ slug: `untouched-${crypto.randomUUID().slice(0, 8)}`,
47
+ userId: owner.userId,
48
+ userEmail: owner.email,
49
+ });
50
+
51
+ const res = await app.inject({
52
+ method: 'PATCH',
53
+ url: `/workspaces/${workspace.id}`,
54
+ headers: {authorization: `Bearer ${owner.token}`},
55
+ payload: {},
56
+ });
57
+
58
+ expect(res.statusCode).toBe(400);
59
+ });
60
+
61
+ test('returns slug-conflict for a taken slug', async () => {
62
+ const app = await createWorkspacesTestApp();
63
+ const owner = await signupVerifyLogin(app, 'workspace-update-conflict');
64
+ await createWorkspaceForUser({
65
+ name: 'Taken',
66
+ slug: 'taken',
67
+ userId: crypto.randomUUID(),
68
+ });
69
+ const workspace = await createWorkspaceForUser({
70
+ name: 'Available',
71
+ slug: `available-${crypto.randomUUID().slice(0, 8)}`,
72
+ userId: owner.userId,
73
+ userEmail: owner.email,
74
+ });
75
+
76
+ const res = await app.inject({
77
+ method: 'PATCH',
78
+ url: `/workspaces/${workspace.id}`,
79
+ headers: {authorization: `Bearer ${owner.token}`},
80
+ payload: {slug: 'taken'},
81
+ });
82
+
83
+ expect(res.statusCode).toBe(409);
84
+ expect(res.json().code).toBe('slug-conflict');
85
+ });
86
+
87
+ test('frees the old slug for immediate reuse', async () => {
88
+ const app = await createWorkspacesTestApp();
89
+ const owner = await signupVerifyLogin(app, 'workspace-update-reuse');
90
+ const oldSlug = `old-${crypto.randomUUID().slice(0, 8)}`;
91
+ const workspace = await createWorkspaceForUser({
92
+ name: 'Renamed',
93
+ slug: oldSlug,
94
+ userId: owner.userId,
95
+ userEmail: owner.email,
96
+ });
97
+
98
+ const renamed = await app.inject({
99
+ method: 'PATCH',
100
+ url: `/workspaces/${workspace.id}`,
101
+ headers: {authorization: `Bearer ${owner.token}`},
102
+ payload: {slug: 'renamed'},
103
+ });
104
+
105
+ expect(renamed.statusCode).toBe(200);
106
+ await expect(
107
+ createWorkspaceForUser({
108
+ name: 'Reused',
109
+ slug: oldSlug,
110
+ userId: crypto.randomUUID(),
111
+ }),
112
+ ).resolves.toMatchObject({slug: oldSlug});
113
+ });
114
+ });
@@ -0,0 +1,41 @@
1
+ import {AUTH_USER, requireWorkspaceAccess} from '@shipfox/api-auth-context';
2
+ import {updateWorkspaceBodySchema, workspaceResponseSchema} from '@shipfox/api-workspaces-dto';
3
+ import {ClientError, defineRoute} from '@shipfox/node-fastify';
4
+ import {z} from 'zod';
5
+ import {WorkspaceNotFoundError, WorkspaceSlugConflictError} from '#core/errors.js';
6
+ import {updateWorkspaceDetails} from '#core/index.js';
7
+ import {toWorkspaceDto} from '#presentation/dto/index.js';
8
+
9
+ export const updateWorkspaceRoute = defineRoute({
10
+ method: 'PATCH',
11
+ path: '/:workspaceId',
12
+ description: 'Update workspace details.',
13
+ auth: AUTH_USER,
14
+ schema: {
15
+ params: z.object({workspaceId: z.string().uuid()}),
16
+ body: updateWorkspaceBodySchema,
17
+ response: {
18
+ 200: workspaceResponseSchema,
19
+ },
20
+ },
21
+ errorHandler: (error) => {
22
+ if (error instanceof WorkspaceNotFoundError) {
23
+ throw new ClientError('Workspace not found', 'not-found', {status: 404});
24
+ }
25
+ if (error instanceof WorkspaceSlugConflictError) {
26
+ throw new ClientError('Workspace slug is already taken', 'slug-conflict', {status: 409});
27
+ }
28
+ throw error;
29
+ },
30
+ handler: async (request) => {
31
+ requireWorkspaceAccess({request, workspaceId: request.params.workspaceId});
32
+
33
+ const workspace = await updateWorkspaceDetails({
34
+ workspaceId: request.params.workspaceId,
35
+ name: request.body.name,
36
+ slug: request.body.slug,
37
+ });
38
+
39
+ return toWorkspaceDto(workspace);
40
+ },
41
+ });
package/test/env.ts CHANGED
@@ -5,4 +5,5 @@ process.env.POSTGRES_PASSWORD ??= 'password';
5
5
  process.env.POSTGRES_DATABASE = 'api_test';
6
6
  process.env.POSTGRES_MAX_CONNECTIONS ??= '5';
7
7
  process.env.WORKSPACE_JWT_SECRET = 'test-secret';
8
+ process.env.AUTH_ROOT_KEY = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=';
8
9
  process.env.TZ = 'UTC';
@@ -1,15 +1,17 @@
1
+ import {withSlugSuffix} from '@shipfox/api-common-dto';
1
2
  import {Factory} from 'fishery';
2
3
  import type {Workspace} from '#core/entities/workspace.js';
3
4
  import {createWorkspace} from '#db/workspaces.js';
4
5
 
5
6
  export const workspaceFactory = Factory.define<Workspace>(({sequence, onCreate}) => {
6
7
  onCreate((workspace) => {
7
- return createWorkspace({name: workspace.name});
8
+ return createWorkspace({name: workspace.name, slug: workspace.slug});
8
9
  });
9
10
 
10
11
  return {
11
12
  id: crypto.randomUUID(),
12
13
  name: `Test Workspace ${sequence}`,
14
+ slug: withSlugSuffix(`test-workspace-${crypto.randomUUID().slice(0, 8)}`, sequence + 1),
13
15
  status: 'active',
14
16
  settings: {},
15
17
  createdBy: null,
@@ -9,7 +9,7 @@ export async function setup() {
9
9
 
10
10
  await runMigrations(db(), migrationsPath, '__drizzle_migrations_workspaces');
11
11
  await db().execute(
12
- sql`TRUNCATE workspaces_admin_command_results, workspaces_outbox, workspaces_workspaces CASCADE`,
12
+ sql`TRUNCATE workspaces_admin_command_results, workspaces_outbox, workspaces_rate_limits, workspaces_workspaces CASCADE`,
13
13
  );
14
14
 
15
15
  closeDb();
package/test/routes.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import type {OutgoingHttpHeaders} from 'node:http';
2
2
  import {AUTH_USER, buildUserContext, setUserContext} from '@shipfox/api-auth-context';
3
+ import {withSlugSuffix} from '@shipfox/api-common-dto';
3
4
  import {WORKSPACES_INVITATION_SEND_REQUESTED} from '@shipfox/api-workspaces-dto';
4
- import type {AuthMethod} from '@shipfox/node-fastify';
5
- import {createApp, type FastifyInstance} from '@shipfox/node-fastify';
5
+ import type {AppConfig, AuthMethod} from '@shipfox/node-fastify';
6
+ import {ClientError, createApp, type FastifyInstance} from '@shipfox/node-fastify';
6
7
  import type {Mailer, MailMessage} from '@shipfox/node-mailer';
7
8
  import {hashOpaqueToken} from '@shipfox/node-tokens';
8
9
  import {and, desc, eq, sql} from 'drizzle-orm';
@@ -44,6 +45,7 @@ vi.mock('#config.js', () => ({
44
45
 
45
46
  const TOKEN_RE = /token=([\w\-_=]+)/;
46
47
  const BEARER_RE = /^Bearer /u;
48
+ let workspaceSlugSequence = 1;
47
49
 
48
50
  export const ROUTE_TEST_SECRET = testConfig.secret;
49
51
 
@@ -51,6 +53,9 @@ const fakeUserAuth: AuthMethod = {
51
53
  name: AUTH_USER,
52
54
  authenticate: async (request) => {
53
55
  const raw = request.headers.authorization?.replace(BEARER_RE, '');
56
+ if (!raw) {
57
+ throw new ClientError('Authentication required', 'unauthorized', {status: 401});
58
+ }
54
59
  if (raw?.startsWith('claim:')) {
55
60
  const [, userId, email, workspaceId, status] = raw.split(':');
56
61
  if (!userId || !email || !workspaceId) throw new Error('Invalid test user claim token');
@@ -143,8 +148,11 @@ export async function latestInvitationLinkTo(email: string): Promise<string> {
143
148
  return payload?.inviteLink ?? '';
144
149
  }
145
150
 
146
- export async function createWorkspacesTestApp(): Promise<FastifyInstance> {
151
+ export async function createWorkspacesTestApp(
152
+ options: Pick<AppConfig, 'fastifyOptions'> = {},
153
+ ): Promise<FastifyInstance> {
147
154
  return await createApp({
155
+ ...options,
148
156
  auth: [fakeUserAuth],
149
157
  routes: workspacesRoutes,
150
158
  swagger: false,
@@ -217,7 +225,13 @@ export async function createWorkspace(
217
225
  method: 'POST',
218
226
  url: '/workspaces',
219
227
  headers: {authorization: `Bearer ${token}`},
220
- payload: {name},
228
+ payload: {
229
+ name,
230
+ slug: withSlugSuffix(
231
+ `route-workspace-${crypto.randomUUID().slice(0, 8)}`,
232
+ workspaceSlugSequence++,
233
+ ),
234
+ },
221
235
  });
222
236
 
223
237
  expect(res.statusCode).toBe(201);