@lobb-js/lobb-ext-auth 0.11.0 → 0.11.2
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/README.md +1 -0
- package/dist/auth.d.ts +2 -1
- package/dist/auth.js +23 -4
- package/dist/index.js +41 -2
- package/dist/lib/components/pages/settings/index.svelte +1 -1
- package/dist/lib/components/pages/settings/pages/activityFeed.svelte +1 -1
- package/dist/lib/components/pages/settings/pages/rolesAndPermissions.svelte +1 -1
- package/dist/lib/components/pages/settings/pages/users.svelte +1 -1
- package/dist/lib/components/pages/userSettings/index.svelte +45 -32
- package/dist/onStartup.js +17 -2
- package/dist/shared/permissions.d.ts +2 -0
- package/dist/shared/permissions.js +35 -0
- package/extensions/auth/collections/collections.ts +2 -0
- package/extensions/auth/collections/shares.ts +60 -0
- package/extensions/auth/config/extensionConfigSchema.d.ts +41 -0
- package/extensions/auth/config/permissionsAction/create.d.ts +18 -0
- package/extensions/auth/config/permissionsAction/delete.d.ts +3 -0
- package/extensions/auth/config/permissionsAction/read.d.ts +11 -0
- package/extensions/auth/config/permissionsAction/update.d.ts +18 -0
- package/extensions/auth/index.ts +0 -2
- package/extensions/auth/studio/auth.ts +25 -5
- package/extensions/auth/studio/index.ts +44 -2
- package/extensions/auth/studio/lib/components/pages/settings/index.svelte +1 -1
- package/extensions/auth/studio/lib/components/pages/settings/pages/activityFeed.svelte +1 -1
- package/extensions/auth/studio/lib/components/pages/settings/pages/rolesAndPermissions.svelte +1 -1
- package/extensions/auth/studio/lib/components/pages/settings/pages/users.svelte +1 -1
- package/extensions/auth/studio/lib/components/pages/userSettings/index.svelte +45 -32
- package/extensions/auth/studio/onStartup.ts +14 -2
- package/extensions/auth/studio/shared/permissions.ts +42 -0
- package/extensions/auth/tests/collections/shares.test.ts +657 -0
- package/extensions/auth/tests/configs/auth.ts +17 -0
- package/extensions/auth/tests/controllers/me.test.ts +104 -0
- package/extensions/auth/tests/permissions.test.ts +127 -0
- package/extensions/auth/tests/workflows/shareIntersection.test.ts +158 -0
- package/extensions/auth/workflows/baseWorkflow.ts +48 -26
- package/extensions/auth/workflows/currentUserPermissionsWorkflow.ts +32 -0
- package/extensions/auth/workflows/index.ts +12 -0
- package/extensions/auth/workflows/meAliasWorkflows.ts +26 -0
- package/extensions/auth/workflows/policiesWorkflows.ts +64 -117
- package/extensions/auth/workflows/shareIntersection.ts +64 -0
- package/extensions/auth/workflows/sharesWorkflows.ts +135 -0
- package/extensions/auth/workflows/utils.ts +132 -224
- package/package.json +3 -3
- package/dist/lib/components/pages/userSettings/components/account.svelte +0 -106
- package/dist/lib/components/pages/userSettings/components/account.svelte.d.ts +0 -14
- package/dist/lib/components/pages/userSettings/components/profile.svelte +0 -87
- package/dist/lib/components/pages/userSettings/components/profile.svelte.d.ts +0 -14
- package/extensions/auth/studio/lib/components/pages/userSettings/components/account.svelte +0 -106
- package/extensions/auth/studio/lib/components/pages/userSettings/components/profile.svelte +0 -87
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
import { Lobb } from "@lobb-js/core";
|
|
2
|
+
import { afterAll, beforeAll, describe, it, expect } from "bun:test";
|
|
3
|
+
import { authConfig } from "../configs/auth.ts";
|
|
4
|
+
|
|
5
|
+
describe("auth_shares", () => {
|
|
6
|
+
let lobb: Lobb;
|
|
7
|
+
let baseUrl: string;
|
|
8
|
+
let adminUserId: number;
|
|
9
|
+
let publishedArticleId: number;
|
|
10
|
+
|
|
11
|
+
// Helper: create a share row via the service layer. Token is generated
|
|
12
|
+
// server-side by the auth_generateShareToken workflow and returned in
|
|
13
|
+
// the response — never supplied by the caller.
|
|
14
|
+
async function createShare(args: {
|
|
15
|
+
permissions: unknown;
|
|
16
|
+
expires_at?: string;
|
|
17
|
+
label?: string;
|
|
18
|
+
}): Promise<string> {
|
|
19
|
+
const created = await lobb.collectionService.createOne({
|
|
20
|
+
collectionName: "auth_shares",
|
|
21
|
+
data: {
|
|
22
|
+
label: args.label,
|
|
23
|
+
permissions: JSON.stringify(args.permissions),
|
|
24
|
+
expires_at: args.expires_at
|
|
25
|
+
?? new Date(Date.now() + 60_000).toISOString(),
|
|
26
|
+
created_by: adminUserId,
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
return created.data.token;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Helper: fetch with a Bearer token (share or session — same syntax).
|
|
33
|
+
function fetchAs(
|
|
34
|
+
token: string | null,
|
|
35
|
+
path: string,
|
|
36
|
+
init: RequestInit = {},
|
|
37
|
+
) {
|
|
38
|
+
const headers = new Headers(init.headers ?? {});
|
|
39
|
+
if (token) headers.set("Authorization", `Bearer ${token}`);
|
|
40
|
+
return fetch(`${baseUrl}${path}`, { ...init, headers });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
beforeAll(async () => {
|
|
44
|
+
lobb = await Lobb.init(authConfig);
|
|
45
|
+
baseUrl = `http://127.0.0.1:${lobb.webServer.port}`;
|
|
46
|
+
adminUserId = (await lobb.collectionService.findAll({
|
|
47
|
+
collectionName: "auth_users",
|
|
48
|
+
params: { filter: { email: "admin@test.com" } },
|
|
49
|
+
})).data[0].id;
|
|
50
|
+
|
|
51
|
+
// Seed a couple of articles so we can exercise read filters.
|
|
52
|
+
publishedArticleId = (await lobb.collectionService.createOne({
|
|
53
|
+
collectionName: "articles",
|
|
54
|
+
data: {
|
|
55
|
+
title: "Published article",
|
|
56
|
+
body: "public copy",
|
|
57
|
+
published: true,
|
|
58
|
+
number_of_likes: 0,
|
|
59
|
+
user_id: adminUserId,
|
|
60
|
+
},
|
|
61
|
+
})).data.id;
|
|
62
|
+
await lobb.collectionService.createOne({
|
|
63
|
+
collectionName: "articles",
|
|
64
|
+
data: {
|
|
65
|
+
title: "Draft article",
|
|
66
|
+
body: "internal copy",
|
|
67
|
+
published: false,
|
|
68
|
+
number_of_likes: 0,
|
|
69
|
+
user_id: adminUserId,
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
afterAll(async () => {
|
|
75
|
+
await lobb.collectionService.deleteMany({
|
|
76
|
+
collectionName: "auth_shares",
|
|
77
|
+
});
|
|
78
|
+
await lobb.collectionService.deleteMany({
|
|
79
|
+
collectionName: "articles",
|
|
80
|
+
});
|
|
81
|
+
await lobb.close();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("strips function-valued permission entries when stored — the JSON serialisation boundary is the safety guarantee", async () => {
|
|
85
|
+
// Build a permissions object that mixes static data with dynamic function
|
|
86
|
+
// values (the kind that would exist for a role-backed permission set).
|
|
87
|
+
// We then `JSON.stringify` it the way share creation would, persist the
|
|
88
|
+
// result, and read it back. Anything function-typed must be gone.
|
|
89
|
+
const sourcePermissions: any = {
|
|
90
|
+
articles: {
|
|
91
|
+
read: {
|
|
92
|
+
filter: { id: ({ user }: any) => user?.id ?? null }, // function
|
|
93
|
+
},
|
|
94
|
+
create: {
|
|
95
|
+
fields: { title: true, body: true }, // static — should survive
|
|
96
|
+
payloadGuard: ({ payload }: any) => payload.title?.length > 0,
|
|
97
|
+
mutate: { author_id: ({ user }: any) => user.id },
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const created = await lobb.collectionService.createOne({
|
|
103
|
+
collectionName: "auth_shares",
|
|
104
|
+
data: {
|
|
105
|
+
token: "share_strip_test_token",
|
|
106
|
+
permissions: JSON.stringify(sourcePermissions),
|
|
107
|
+
expires_at: new Date(Date.now() + 60_000).toISOString(),
|
|
108
|
+
created_by: adminUserId,
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Reload from the DB to be sure we're checking what's actually persisted.
|
|
113
|
+
const reloaded = await lobb.collectionService.findOne({
|
|
114
|
+
collectionName: "auth_shares",
|
|
115
|
+
id: created.data.id,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const stored = JSON.parse(reloaded.data.permissions);
|
|
119
|
+
|
|
120
|
+
// Static data survives intact.
|
|
121
|
+
expect(stored.articles.create.fields).toEqual({ title: true, body: true });
|
|
122
|
+
|
|
123
|
+
// Function values inside containers get dropped, leaving empty
|
|
124
|
+
// containers behind (e.g. `filter` and `mutate` are kept but their
|
|
125
|
+
// inner function entries are gone).
|
|
126
|
+
expect(stored.articles.read.filter).toEqual({});
|
|
127
|
+
expect(stored.articles.create.mutate).toEqual({});
|
|
128
|
+
|
|
129
|
+
// Top-level function values cause the entire key to vanish — there's no
|
|
130
|
+
// empty-shell to leave behind in that case.
|
|
131
|
+
expect(stored.articles.create.payloadGuard).toBeUndefined();
|
|
132
|
+
|
|
133
|
+
// The real safety guarantee: no function-typed value exists ANYWHERE
|
|
134
|
+
// in the stored permissions. If anyone ever changes share creation to
|
|
135
|
+
// use a serialiser that preserves functions, this assertion fires.
|
|
136
|
+
const containsFunction = (v: unknown): boolean => {
|
|
137
|
+
if (typeof v === "function") return true;
|
|
138
|
+
if (v && typeof v === "object") {
|
|
139
|
+
return Object.values(v).some(containsFunction);
|
|
140
|
+
}
|
|
141
|
+
return false;
|
|
142
|
+
};
|
|
143
|
+
expect(containsFunction(stored)).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("share token as bearer credential", () => {
|
|
147
|
+
it("rejects an unknown bearer token (no access)", async () => {
|
|
148
|
+
const res = await fetchAs(
|
|
149
|
+
"share_unknown_token_xyz",
|
|
150
|
+
"/api/collections/articles",
|
|
151
|
+
);
|
|
152
|
+
// Unknown token → no auth_user, no auth_share → treated as public,
|
|
153
|
+
// which has no articles permissions in this config.
|
|
154
|
+
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("rejects an expired share", async () => {
|
|
158
|
+
const token = await createShare({
|
|
159
|
+
permissions: { articles: { read: true } },
|
|
160
|
+
expires_at: new Date(Date.now() - 60_000).toISOString(), // in the past
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const res = await fetchAs(token, "/api/collections/articles");
|
|
164
|
+
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("allows read on a collection the share permits (unconditional)", async () => {
|
|
168
|
+
const token = await createShare({
|
|
169
|
+
permissions: { articles: { read: true } },
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const res = await fetchAs(token, "/api/collections/articles");
|
|
173
|
+
expect(res.status).toEqual(200);
|
|
174
|
+
const body = await res.json();
|
|
175
|
+
expect(body.data.length).toBeGreaterThanOrEqual(2);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("denies read on a collection the share does NOT permit", async () => {
|
|
179
|
+
// Only grants articles.read; auth_users is not in the share.
|
|
180
|
+
const token = await createShare({
|
|
181
|
+
permissions: { articles: { read: true } },
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const res = await fetchAs(token, "/api/collections/auth_users");
|
|
185
|
+
expect(res.status).toEqual(403);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("applies the share's static filter on conditional read", async () => {
|
|
189
|
+
// Filter scopes the share to only the published article.
|
|
190
|
+
const token = await createShare({
|
|
191
|
+
permissions: {
|
|
192
|
+
articles: { read: { filter: { id: publishedArticleId } } },
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const res = await fetchAs(token, "/api/collections/articles");
|
|
197
|
+
expect(res.status).toEqual(200);
|
|
198
|
+
const body = await res.json();
|
|
199
|
+
expect(body.data.length).toEqual(1);
|
|
200
|
+
expect(body.data[0].id).toEqual(publishedArticleId);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("does NOT apply the self-row bypass for shares (no user identity)", async () => {
|
|
204
|
+
// A share with no auth_users perm should not be able to read any
|
|
205
|
+
// auth_users row — there's no `user` to match an id against, so the
|
|
206
|
+
// bypass doesn't apply. (The "/me" alias is exempt from this; it
|
|
207
|
+
// short-circuits with the share's permissions snapshot instead.)
|
|
208
|
+
const token = await createShare({
|
|
209
|
+
permissions: { articles: { read: true } },
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const byIdRes = await fetchAs(
|
|
213
|
+
token,
|
|
214
|
+
`/api/collections/auth_users/${adminUserId}`,
|
|
215
|
+
);
|
|
216
|
+
expect(byIdRes.status).toBeGreaterThanOrEqual(400);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("denies create when the share doesn't permit it", async () => {
|
|
220
|
+
// Share allows reading articles only — create should fail.
|
|
221
|
+
const token = await createShare({
|
|
222
|
+
permissions: { articles: { read: true } },
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const res = await fetchAs(token, "/api/collections/articles", {
|
|
226
|
+
method: "POST",
|
|
227
|
+
headers: { "Content-Type": "application/json" },
|
|
228
|
+
body: JSON.stringify({
|
|
229
|
+
data: { title: "Should be rejected", body: "no perm" },
|
|
230
|
+
}),
|
|
231
|
+
});
|
|
232
|
+
expect(res.status).toEqual(403);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("allows create when the share unconditionally permits it", async () => {
|
|
236
|
+
const token = await createShare({
|
|
237
|
+
permissions: { articles: { create: true, read: true } },
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const res = await fetchAs(token, "/api/collections/articles", {
|
|
241
|
+
method: "POST",
|
|
242
|
+
headers: { "Content-Type": "application/json" },
|
|
243
|
+
body: JSON.stringify({
|
|
244
|
+
data: {
|
|
245
|
+
title: "Created via share",
|
|
246
|
+
body: "by an external recipient",
|
|
247
|
+
published: true,
|
|
248
|
+
number_of_likes: 0,
|
|
249
|
+
user_id: adminUserId,
|
|
250
|
+
},
|
|
251
|
+
}),
|
|
252
|
+
});
|
|
253
|
+
expect(res.status).toBeLessThan(400);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("enforces the share's fields allowlist on create", async () => {
|
|
257
|
+
// The share lets you create articles but only set title/body — anything
|
|
258
|
+
// else in the payload must be rejected.
|
|
259
|
+
const token = await createShare({
|
|
260
|
+
permissions: {
|
|
261
|
+
articles: {
|
|
262
|
+
create: { fields: { title: true, body: true } },
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const res = await fetchAs(token, "/api/collections/articles", {
|
|
268
|
+
method: "POST",
|
|
269
|
+
headers: { "Content-Type": "application/json" },
|
|
270
|
+
body: JSON.stringify({
|
|
271
|
+
data: {
|
|
272
|
+
title: "Allowed",
|
|
273
|
+
body: "Allowed too",
|
|
274
|
+
// disallowed: not in the fields allowlist
|
|
275
|
+
published: true,
|
|
276
|
+
},
|
|
277
|
+
}),
|
|
278
|
+
});
|
|
279
|
+
expect(res.status).toEqual(403);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("permissions: true on a share grants full access (like admin)", async () => {
|
|
283
|
+
const token = await createShare({ permissions: true });
|
|
284
|
+
|
|
285
|
+
const res = await fetchAs(token, "/api/collections/articles");
|
|
286
|
+
expect(res.status).toEqual(200);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("accepts expires_in_seconds and converts it to expires_at", async () => {
|
|
290
|
+
// Caller passes a duration instead of computing an absolute timestamp.
|
|
291
|
+
// The auth_normalizeShareExpiry workflow converts before validation.
|
|
292
|
+
const before = Date.now();
|
|
293
|
+
const created = await lobb.collectionService.createOne({
|
|
294
|
+
collectionName: "auth_shares",
|
|
295
|
+
data: {
|
|
296
|
+
permissions: JSON.stringify({ articles: { read: true } }),
|
|
297
|
+
expires_in_seconds: 120,
|
|
298
|
+
created_by: adminUserId,
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
const after = Date.now();
|
|
302
|
+
|
|
303
|
+
const expiresAt = new Date(created.data.expires_at).getTime();
|
|
304
|
+
// Should be roughly 120s after `before` and at most 120s after `after`.
|
|
305
|
+
expect(expiresAt).toBeGreaterThanOrEqual(before + 120_000 - 1000);
|
|
306
|
+
expect(expiresAt).toBeLessThanOrEqual(after + 120_000 + 1000);
|
|
307
|
+
|
|
308
|
+
// The token works, confirming the row is otherwise valid.
|
|
309
|
+
const res = await fetchAs(created.data.token, "/api/collections/articles");
|
|
310
|
+
expect(res.status).toEqual(200);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("rejects expires_in_seconds = 0 or negative", async () => {
|
|
314
|
+
await expect(
|
|
315
|
+
lobb.collectionService.createOne({
|
|
316
|
+
collectionName: "auth_shares",
|
|
317
|
+
data: {
|
|
318
|
+
permissions: JSON.stringify({ articles: { read: true } }),
|
|
319
|
+
expires_in_seconds: 0,
|
|
320
|
+
created_by: adminUserId,
|
|
321
|
+
},
|
|
322
|
+
}),
|
|
323
|
+
).rejects.toThrow(/positive/);
|
|
324
|
+
|
|
325
|
+
await expect(
|
|
326
|
+
lobb.collectionService.createOne({
|
|
327
|
+
collectionName: "auth_shares",
|
|
328
|
+
data: {
|
|
329
|
+
permissions: JSON.stringify({ articles: { read: true } }),
|
|
330
|
+
expires_in_seconds: -5,
|
|
331
|
+
created_by: adminUserId,
|
|
332
|
+
},
|
|
333
|
+
}),
|
|
334
|
+
).rejects.toThrow(/positive/);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("rejects when neither expires_at nor expires_in_seconds is provided", async () => {
|
|
338
|
+
await expect(
|
|
339
|
+
lobb.collectionService.createOne({
|
|
340
|
+
collectionName: "auth_shares",
|
|
341
|
+
data: {
|
|
342
|
+
permissions: JSON.stringify({ articles: { read: true } }),
|
|
343
|
+
created_by: adminUserId,
|
|
344
|
+
},
|
|
345
|
+
}),
|
|
346
|
+
).rejects.toThrow(/expires_at|expires_in_seconds/);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("auto-generates a token server-side that's actually usable as a bearer", async () => {
|
|
350
|
+
// Confirms the generation workflow is firing and the returned token is
|
|
351
|
+
// not just stored but recognised by BearerTokenHandler.
|
|
352
|
+
const token = await createShare({
|
|
353
|
+
permissions: { articles: { read: true } },
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// 64-char hex string from generateRandomId (32 random bytes).
|
|
357
|
+
expect(token).toMatch(/^[a-f0-9]{64}$/);
|
|
358
|
+
|
|
359
|
+
const res = await fetchAs(token, "/api/collections/articles");
|
|
360
|
+
expect(res.status).toEqual(200);
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// Integration-level coverage for the intersection guard. The pure logic
|
|
365
|
+
// is covered in workflows/shareIntersection.test.ts — these tests confirm
|
|
366
|
+
// the guard is actually wired into the share creation flow and only fires
|
|
367
|
+
// for API-triggered creates (internal callers using the service layer
|
|
368
|
+
// remain trusted, which is what the suite above relies on).
|
|
369
|
+
describe("intersection guard on share creation", () => {
|
|
370
|
+
// The author role in authConfig has: auth_users read(conditional) +
|
|
371
|
+
// update/delete, articles read(conditional, fields) + create(fields),
|
|
372
|
+
// and auth_shares.create. We use it to exercise the guard.
|
|
373
|
+
const authorEmail = "share_creator@test.com";
|
|
374
|
+
const authorPassword = "test_password";
|
|
375
|
+
let authorToken: string;
|
|
376
|
+
let adminToken: string;
|
|
377
|
+
|
|
378
|
+
async function loginAs(email: string, password: string): Promise<string> {
|
|
379
|
+
const res = await fetch(`${baseUrl}/api/collections/auth_sessions`, {
|
|
380
|
+
method: "POST",
|
|
381
|
+
headers: { "Content-Type": "application/json" },
|
|
382
|
+
body: JSON.stringify({ data: { email, password } }),
|
|
383
|
+
});
|
|
384
|
+
const body = await res.json();
|
|
385
|
+
return body.data.access_token.token;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function postShareAs(
|
|
389
|
+
sessionToken: string,
|
|
390
|
+
permissions: unknown,
|
|
391
|
+
): Promise<Response> {
|
|
392
|
+
return await fetch(`${baseUrl}/api/collections/auth_shares`, {
|
|
393
|
+
method: "POST",
|
|
394
|
+
headers: {
|
|
395
|
+
"Content-Type": "application/json",
|
|
396
|
+
"Authorization": `Bearer ${sessionToken}`,
|
|
397
|
+
},
|
|
398
|
+
body: JSON.stringify({
|
|
399
|
+
data: {
|
|
400
|
+
permissions: JSON.stringify(permissions),
|
|
401
|
+
expires_at: new Date(Date.now() + 60_000).toISOString(),
|
|
402
|
+
},
|
|
403
|
+
}),
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
beforeAll(async () => {
|
|
408
|
+
// Create the author user up-front so each test can just login.
|
|
409
|
+
await fetch(`${baseUrl}/api/collections/auth_users`, {
|
|
410
|
+
method: "POST",
|
|
411
|
+
headers: { "Content-Type": "application/json" },
|
|
412
|
+
body: JSON.stringify({
|
|
413
|
+
data: {
|
|
414
|
+
email: authorEmail,
|
|
415
|
+
password: authorPassword,
|
|
416
|
+
role: "author",
|
|
417
|
+
},
|
|
418
|
+
}),
|
|
419
|
+
});
|
|
420
|
+
authorToken = await loginAs(authorEmail, authorPassword);
|
|
421
|
+
adminToken = await loginAs("admin@test.com", "admin");
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("rejects an author granting a collection they don't have", async () => {
|
|
425
|
+
// The author role has no perm at all on a hypothetical extra collection;
|
|
426
|
+
// here we use auth_sessions which the author also can't read/write.
|
|
427
|
+
const res = await postShareAs(authorToken, {
|
|
428
|
+
auth_sessions: { read: true },
|
|
429
|
+
});
|
|
430
|
+
expect(res.status).toEqual(403);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("rejects an author granting unconditional read when their own read is conditional", async () => {
|
|
434
|
+
// Author's articles.read is conditional (only their own rows). Granting
|
|
435
|
+
// `articles: { read: true }` would let a share recipient read every row —
|
|
436
|
+
// strictly broader than the author can.
|
|
437
|
+
const res = await postShareAs(authorToken, {
|
|
438
|
+
articles: { read: true },
|
|
439
|
+
});
|
|
440
|
+
expect(res.status).toEqual(403);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it("rejects an author granting an action they don't have on a collection", async () => {
|
|
444
|
+
// Author has articles.create + articles.read but no articles.update.
|
|
445
|
+
const res = await postShareAs(authorToken, {
|
|
446
|
+
articles: { update: true },
|
|
447
|
+
});
|
|
448
|
+
expect(res.status).toEqual(403);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it("rejects an author granting a field they aren't allowed to write", async () => {
|
|
452
|
+
// Author's articles.create allows only {title, body}; `published` would
|
|
453
|
+
// be a new field the author themselves cannot set.
|
|
454
|
+
const res = await postShareAs(authorToken, {
|
|
455
|
+
articles: {
|
|
456
|
+
create: { fields: { title: true, published: true } },
|
|
457
|
+
},
|
|
458
|
+
});
|
|
459
|
+
expect(res.status).toEqual(403);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it("rejects an author dropping a field allowlist when they have one", async () => {
|
|
463
|
+
// Removing the `fields` key entirely makes the share's create
|
|
464
|
+
// unconstrained — broader than the author's.
|
|
465
|
+
const res = await postShareAs(authorToken, {
|
|
466
|
+
articles: { create: {} },
|
|
467
|
+
});
|
|
468
|
+
expect(res.status).toEqual(403);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it("rejects `permissions: true` from a non-admin creator", async () => {
|
|
472
|
+
const res = await postShareAs(authorToken, true);
|
|
473
|
+
expect(res.status).toEqual(403);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it("accepts a strictly-narrower share from an author", async () => {
|
|
477
|
+
// Conditional share read with a filter; author also has conditional
|
|
478
|
+
// read so this is structurally a subset (we don't compare filters).
|
|
479
|
+
const res = await postShareAs(authorToken, {
|
|
480
|
+
articles: {
|
|
481
|
+
read: {
|
|
482
|
+
filter: { id: publishedArticleId },
|
|
483
|
+
fields: { id: true, title: true },
|
|
484
|
+
},
|
|
485
|
+
},
|
|
486
|
+
});
|
|
487
|
+
expect(res.status).toBeLessThan(400);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it("accepts a fields-allowlist share that subsets the creator's", async () => {
|
|
491
|
+
const res = await postShareAs(authorToken, {
|
|
492
|
+
articles: {
|
|
493
|
+
create: { fields: { title: true } },
|
|
494
|
+
},
|
|
495
|
+
});
|
|
496
|
+
expect(res.status).toBeLessThan(400);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it("admin can mint any share, including permissions: true", async () => {
|
|
500
|
+
const res = await postShareAs(adminToken, true);
|
|
501
|
+
expect(res.status).toBeLessThan(400);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it("admin can mint shares granting collections an author couldn't", async () => {
|
|
505
|
+
const res = await postShareAs(adminToken, {
|
|
506
|
+
auth_users: { read: true },
|
|
507
|
+
});
|
|
508
|
+
expect(res.status).toBeLessThan(400);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it("rejects an unauthenticated (public) request — public users can't mint shares", async () => {
|
|
512
|
+
// Defense in depth: the policy layer rejects first because public has
|
|
513
|
+
// no auth_shares.create perm. Even if that were misconfigured, the
|
|
514
|
+
// auth_setShareCreatedBy workflow only runs when there's an auth_user,
|
|
515
|
+
// so `created_by` would stay undefined and the required-field check
|
|
516
|
+
// would refuse the insert.
|
|
517
|
+
const res = await fetch(`${baseUrl}/api/collections/auth_shares`, {
|
|
518
|
+
method: "POST",
|
|
519
|
+
headers: { "Content-Type": "application/json" },
|
|
520
|
+
body: JSON.stringify({
|
|
521
|
+
data: {
|
|
522
|
+
permissions: JSON.stringify({ articles: { read: true } }),
|
|
523
|
+
expires_at: new Date(Date.now() + 60_000).toISOString(),
|
|
524
|
+
},
|
|
525
|
+
}),
|
|
526
|
+
});
|
|
527
|
+
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it("rejects when permissions is not valid JSON", async () => {
|
|
531
|
+
const res = await fetch(`${baseUrl}/api/collections/auth_shares`, {
|
|
532
|
+
method: "POST",
|
|
533
|
+
headers: {
|
|
534
|
+
"Content-Type": "application/json",
|
|
535
|
+
"Authorization": `Bearer ${authorToken}`,
|
|
536
|
+
},
|
|
537
|
+
body: JSON.stringify({
|
|
538
|
+
data: {
|
|
539
|
+
permissions: "not-json-at-all",
|
|
540
|
+
expires_at: new Date(Date.now() + 60_000).toISOString(),
|
|
541
|
+
},
|
|
542
|
+
}),
|
|
543
|
+
});
|
|
544
|
+
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
// The earlier expires_in_seconds tests exercise the service layer
|
|
548
|
+
// directly. The studio UI hits the HTTP API instead, so we need a
|
|
549
|
+
// parallel check that the convenience field also works end-to-end:
|
|
550
|
+
// service-level normalize must convert it to expires_at BEFORE the
|
|
551
|
+
// store-level required check fires, otherwise callers see "Validation
|
|
552
|
+
// failed".
|
|
553
|
+
it("accepts expires_in_seconds via HTTP and round-trips a working token", async () => {
|
|
554
|
+
const before = Date.now();
|
|
555
|
+
const res = await fetch(`${baseUrl}/api/collections/auth_shares`, {
|
|
556
|
+
method: "POST",
|
|
557
|
+
headers: {
|
|
558
|
+
"Content-Type": "application/json",
|
|
559
|
+
"Authorization": `Bearer ${adminToken}`,
|
|
560
|
+
},
|
|
561
|
+
body: JSON.stringify({
|
|
562
|
+
data: {
|
|
563
|
+
permissions: JSON.stringify({ articles: { read: true } }),
|
|
564
|
+
expires_in_seconds: 120,
|
|
565
|
+
},
|
|
566
|
+
}),
|
|
567
|
+
});
|
|
568
|
+
expect(res.status).toEqual(201);
|
|
569
|
+
const body = await res.json();
|
|
570
|
+
const after = Date.now();
|
|
571
|
+
|
|
572
|
+
// expires_at was computed server-side from the seconds the caller sent.
|
|
573
|
+
const expiresAt = new Date(body.data.expires_at).getTime();
|
|
574
|
+
expect(expiresAt).toBeGreaterThanOrEqual(before + 120_000 - 1000);
|
|
575
|
+
expect(expiresAt).toBeLessThanOrEqual(after + 120_000 + 1000);
|
|
576
|
+
|
|
577
|
+
// expires_in_seconds is virtual and must NOT round-trip back to the client.
|
|
578
|
+
expect("expires_in_seconds" in body.data).toBe(false);
|
|
579
|
+
|
|
580
|
+
// The token is usable as a bearer credential — confirms the row was
|
|
581
|
+
// actually inserted with valid created_by/token/permissions.
|
|
582
|
+
const useRes = await fetchAs(body.data.token, "/api/collections/articles");
|
|
583
|
+
expect(useRes.status).toEqual(200);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it("rejects via HTTP when neither expires_at nor expires_in_seconds is provided", async () => {
|
|
587
|
+
const res = await fetch(`${baseUrl}/api/collections/auth_shares`, {
|
|
588
|
+
method: "POST",
|
|
589
|
+
headers: {
|
|
590
|
+
"Content-Type": "application/json",
|
|
591
|
+
"Authorization": `Bearer ${adminToken}`,
|
|
592
|
+
},
|
|
593
|
+
body: JSON.stringify({
|
|
594
|
+
data: {
|
|
595
|
+
permissions: JSON.stringify({ articles: { read: true } }),
|
|
596
|
+
},
|
|
597
|
+
}),
|
|
598
|
+
});
|
|
599
|
+
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it("rejects via HTTP when expires_in_seconds is zero or negative", async () => {
|
|
603
|
+
for (const seconds of [0, -5]) {
|
|
604
|
+
const res = await fetch(`${baseUrl}/api/collections/auth_shares`, {
|
|
605
|
+
method: "POST",
|
|
606
|
+
headers: {
|
|
607
|
+
"Content-Type": "application/json",
|
|
608
|
+
"Authorization": `Bearer ${adminToken}`,
|
|
609
|
+
},
|
|
610
|
+
body: JSON.stringify({
|
|
611
|
+
data: {
|
|
612
|
+
permissions: JSON.stringify({ articles: { read: true } }),
|
|
613
|
+
expires_in_seconds: seconds,
|
|
614
|
+
},
|
|
615
|
+
}),
|
|
616
|
+
});
|
|
617
|
+
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
618
|
+
}
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
// /api/collections/auth_users/me also serves share bearers: when there's
|
|
622
|
+
// no user but a valid share, meAlias short-circuits with the share's
|
|
623
|
+
// permissions snapshot. The studio uses this single endpoint to learn
|
|
624
|
+
// what the current bearer can do, regardless of bearer type.
|
|
625
|
+
describe("GET /api/collections/auth_users/me with a share bearer", () => {
|
|
626
|
+
it("returns { data: null, permissions } with the share's snapshot", async () => {
|
|
627
|
+
const sharePermissions = {
|
|
628
|
+
articles: { read: { filter: { id: publishedArticleId } } },
|
|
629
|
+
};
|
|
630
|
+
const token = await createShare({ permissions: sharePermissions });
|
|
631
|
+
|
|
632
|
+
const res = await fetch(
|
|
633
|
+
`${baseUrl}/api/collections/auth_users/me`,
|
|
634
|
+
{ headers: { Authorization: `Bearer ${token}` } },
|
|
635
|
+
);
|
|
636
|
+
expect(res.status).toEqual(200);
|
|
637
|
+
const body = await res.json();
|
|
638
|
+
expect(body.data).toBeNull();
|
|
639
|
+
expect(body.permissions).toEqual(sharePermissions);
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it("still returns the user row + role permissions for a session bearer", async () => {
|
|
643
|
+
const res = await fetch(
|
|
644
|
+
`${baseUrl}/api/collections/auth_users/me`,
|
|
645
|
+
{ headers: { Authorization: `Bearer ${authorToken}` } },
|
|
646
|
+
);
|
|
647
|
+
expect(res.status).toEqual(200);
|
|
648
|
+
const body = await res.json();
|
|
649
|
+
expect(body.data.email).toEqual("share_creator@test.com");
|
|
650
|
+
expect(body.permissions.articles.create.fields).toEqual({
|
|
651
|
+
title: true,
|
|
652
|
+
body: true,
|
|
653
|
+
});
|
|
654
|
+
});
|
|
655
|
+
});
|
|
656
|
+
});
|
|
657
|
+
});
|
|
@@ -50,6 +50,23 @@ export const authConfig: Config = {
|
|
|
50
50
|
update: true,
|
|
51
51
|
delete: true,
|
|
52
52
|
},
|
|
53
|
+
articles: {
|
|
54
|
+
read: {
|
|
55
|
+
filter: { user_id: ({ user }: any) => user?.id ?? null },
|
|
56
|
+
fields: { id: true, title: true, body: true },
|
|
57
|
+
},
|
|
58
|
+
create: { fields: { title: true, body: true } },
|
|
59
|
+
},
|
|
60
|
+
auth_shares: {
|
|
61
|
+
create: true,
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
// Role with no auth_users perm at all — used to test that /me still
|
|
66
|
+
// works for a logged-in user whose role can't generally read auth_users.
|
|
67
|
+
reader: {
|
|
68
|
+
permissions: {
|
|
69
|
+
articles: { read: true },
|
|
53
70
|
},
|
|
54
71
|
},
|
|
55
72
|
},
|