@manablox/db 0.1.0 → 0.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.
- package/README.md +21 -0
- package/drizzle.config.ts +1 -1
- package/migrations/0005_menus.sql +44 -0
- package/migrations/0006_roles.sql +13 -0
- package/migrations/0007_apikey-permissions.sql +4 -0
- package/migrations/0008_workflows.sql +49 -0
- package/migrations/meta/0005_snapshot.json +2504 -0
- package/migrations/meta/0006_snapshot.json +2605 -0
- package/migrations/meta/0007_snapshot.json +2605 -0
- package/migrations/meta/0008_snapshot.json +2986 -0
- package/migrations/meta/_journal.json +28 -0
- package/package.json +10 -5
- package/src/cli/create-db.ts +30 -0
- package/src/cli/migrate.ts +2 -9
- package/src/client.ts +8 -1
- package/src/errors.ts +50 -0
- package/src/index.ts +8 -2
- package/src/migrate.ts +21 -0
- package/src/pagination.ts +52 -0
- package/src/query.ts +1 -5
- package/src/repositories/asset-usage.ts +1 -1
- package/src/repositories/asset.ts +13 -21
- package/src/repositories/content-type.ts +1 -1
- package/src/repositories/content.ts +150 -97
- package/src/repositories/index.ts +12 -0
- package/src/repositories/menu.ts +235 -0
- package/src/repositories/role.ts +85 -0
- package/src/repositories/space.ts +7 -2
- package/src/repositories/user.ts +171 -25
- package/src/repositories/webhook.ts +46 -0
- package/src/repositories/workflow.ts +306 -0
- package/src/schema/assets.ts +108 -0
- package/src/schema/auth.ts +166 -0
- package/src/schema/content-types.ts +31 -0
- package/src/schema/content.ts +133 -0
- package/src/schema/index.ts +38 -0
- package/src/schema/menus.ts +61 -0
- package/src/schema/relations.ts +64 -0
- package/src/schema/spaces.ts +20 -0
- package/src/schema/webhooks.ts +46 -0
- package/src/schema/workflows.ts +92 -0
- package/{test/helpers.ts → src/testing-fixtures.ts} +21 -35
- package/src/testing.ts +105 -0
- package/test/asset-usage.test.ts +3 -3
- package/test/menu.test.ts +126 -0
- package/test/publish.test.ts +31 -3
- package/test/query.test.ts +33 -3
- package/test/role.test.ts +81 -0
- package/test/tree.test.ts +3 -3
- package/test/user.test.ts +126 -0
- package/test/webhook.test.ts +48 -0
- package/vitest.config.ts +0 -2
- package/src/schema.ts +0 -513
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
2
|
+
import { isUniqueViolation } from '../src/errors.js';
|
|
3
|
+
import { createRepositoryContext, makeNode, type RepositoryTestContext } from '../src/testing.js';
|
|
4
|
+
|
|
5
|
+
let ctx: RepositoryTestContext;
|
|
6
|
+
|
|
7
|
+
beforeAll(async () => {
|
|
8
|
+
ctx = await createRepositoryContext('menu');
|
|
9
|
+
});
|
|
10
|
+
afterAll(async () => {
|
|
11
|
+
await ctx?.close();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const menu = (machineName: string) =>
|
|
15
|
+
ctx.repos.menus.create({ spaceId: ctx.spaceId, name: machineName, machineName });
|
|
16
|
+
|
|
17
|
+
describe('menus', () => {
|
|
18
|
+
it('stores a nested tree and reads it back in order', async () => {
|
|
19
|
+
const main = await menu('main');
|
|
20
|
+
const home = await makeNode(ctx, { title: 'Home', slug: 'home' });
|
|
21
|
+
const about = await makeNode(ctx, { title: 'About', slug: 'about' });
|
|
22
|
+
const team = await makeNode(ctx, { title: 'Team', slug: 'team', parentId: about.id });
|
|
23
|
+
|
|
24
|
+
await ctx.repos.menus.setItems(main.id, [
|
|
25
|
+
{ localizationId: home.localizationId },
|
|
26
|
+
{
|
|
27
|
+
localizationId: about.localizationId,
|
|
28
|
+
label: 'Who we are',
|
|
29
|
+
children: [
|
|
30
|
+
{ localizationId: team.localizationId },
|
|
31
|
+
{ url: 'https://jobs.example', label: 'Jobs' },
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
const tree = await ctx.repos.menus.tree(main.id);
|
|
37
|
+
expect(tree.map((node) => node.item.localizationId)).toEqual([
|
|
38
|
+
home.localizationId,
|
|
39
|
+
about.localizationId,
|
|
40
|
+
]);
|
|
41
|
+
expect(tree[1]?.item.label).toBe('Who we are');
|
|
42
|
+
expect(tree[1]?.children.map((node) => node.item.url)).toEqual([null, 'https://jobs.example']);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('keeps an entry’s id across saves when it is handed back', async () => {
|
|
46
|
+
const footer = await menu('footer');
|
|
47
|
+
const legal = await makeNode(ctx, { title: 'Legal', slug: 'legal' });
|
|
48
|
+
const [first] = await ctx.repos.menus.setItems(footer.id, [
|
|
49
|
+
{ localizationId: legal.localizationId },
|
|
50
|
+
]);
|
|
51
|
+
const [second] = await ctx.repos.menus.setItems(footer.id, [
|
|
52
|
+
{ id: first?.item.id, localizationId: legal.localizationId, label: 'Imprint' },
|
|
53
|
+
]);
|
|
54
|
+
expect(second?.item.id).toBe(first?.item.id);
|
|
55
|
+
expect(second?.item.label).toBe('Imprint');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('resolves each entry to the document of the requested locale, published or draft', async () => {
|
|
59
|
+
const nav = await menu('nav');
|
|
60
|
+
const en = await makeNode(ctx, { title: 'Contact', slug: 'contact' });
|
|
61
|
+
const de = await ctx.repos.content.create({
|
|
62
|
+
spaceId: ctx.spaceId,
|
|
63
|
+
typeId: ctx.types.page.id,
|
|
64
|
+
locale: 'de',
|
|
65
|
+
localizationId: en.localizationId,
|
|
66
|
+
parentId: null,
|
|
67
|
+
title: 'Kontakt',
|
|
68
|
+
slug: 'kontakt',
|
|
69
|
+
fields: {},
|
|
70
|
+
hasSlug: true,
|
|
71
|
+
});
|
|
72
|
+
const onlyEnglish = await makeNode(ctx, { title: 'Blog', slug: 'blog' });
|
|
73
|
+
await ctx.repos.menus.setItems(nav.id, [
|
|
74
|
+
{ localizationId: en.localizationId },
|
|
75
|
+
{ localizationId: onlyEnglish.localizationId },
|
|
76
|
+
{ url: '/rss', label: 'Feed' },
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
const german = await ctx.repos.menus.resolve(nav, 'de');
|
|
80
|
+
expect(german.map((item) => item.content?.title ?? null)).toEqual(['Kontakt', null, null]);
|
|
81
|
+
expect(german[1]?.localizationId).toBe(onlyEnglish.localizationId);
|
|
82
|
+
|
|
83
|
+
// Nothing is published yet, so the delivery view has no documents at all.
|
|
84
|
+
const live = await ctx.repos.menus.resolve(nav, 'de', true);
|
|
85
|
+
expect(live.map((item) => item.content)).toEqual([null, null, null]);
|
|
86
|
+
|
|
87
|
+
await ctx.repos.content.publish(de.id);
|
|
88
|
+
const afterPublish = await ctx.repos.menus.resolve(nav, 'de', true);
|
|
89
|
+
expect(afterPublish[0]?.content?.title).toBe('Kontakt');
|
|
90
|
+
expect(afterPublish[0]?.content?.status).toBe('published');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('removes a document from every menu, sub-entries included', async () => {
|
|
94
|
+
const a = await menu('a');
|
|
95
|
+
const b = await menu('b');
|
|
96
|
+
const parent = await makeNode(ctx, { title: 'Parent', slug: 'parent' });
|
|
97
|
+
const child = await makeNode(ctx, { title: 'Child', slug: 'child', parentId: parent.id });
|
|
98
|
+
await ctx.repos.menus.setItems(a.id, [
|
|
99
|
+
{
|
|
100
|
+
localizationId: parent.localizationId,
|
|
101
|
+
children: [{ localizationId: child.localizationId }],
|
|
102
|
+
},
|
|
103
|
+
]);
|
|
104
|
+
await ctx.repos.menus.setItems(b.id, [{ localizationId: parent.localizationId }]);
|
|
105
|
+
|
|
106
|
+
const referencing = await ctx.repos.menus.menusReferencing(ctx.spaceId, parent.localizationId);
|
|
107
|
+
expect(referencing.map((row) => row.machineName).sort()).toEqual(['a', 'b']);
|
|
108
|
+
|
|
109
|
+
expect(await ctx.repos.menus.removeContent(parent.localizationId)).toBe(2);
|
|
110
|
+
expect(await ctx.repos.menus.items(a.id)).toEqual([]);
|
|
111
|
+
expect(await ctx.repos.menus.items(b.id)).toEqual([]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('deletes a menu with its entries and refuses a duplicate technical name', async () => {
|
|
115
|
+
const gone = await menu('gone');
|
|
116
|
+
const page = await makeNode(ctx, { title: 'Gone', slug: 'gone' });
|
|
117
|
+
await ctx.repos.menus.setItems(gone.id, [{ localizationId: page.localizationId }]);
|
|
118
|
+
await ctx.repos.menus.delete(gone.id);
|
|
119
|
+
expect(await ctx.repos.menus.findById(gone.id)).toBeNull();
|
|
120
|
+
expect(await ctx.repos.menus.items(gone.id)).toEqual([]);
|
|
121
|
+
|
|
122
|
+
await expect(menu('main')).rejects.toSatisfy((error: unknown) =>
|
|
123
|
+
isUniqueViolation(error, 'menus_space_machine_name_key'),
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
});
|
package/test/publish.test.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import { createRepositoryContext, makeNode, type RepositoryTestContext } from '../src/testing.js';
|
|
3
3
|
|
|
4
|
-
let ctx:
|
|
4
|
+
let ctx: RepositoryTestContext;
|
|
5
5
|
|
|
6
6
|
beforeAll(async () => {
|
|
7
|
-
ctx = await
|
|
7
|
+
ctx = await createRepositoryContext('publish');
|
|
8
8
|
});
|
|
9
9
|
afterAll(async () => {
|
|
10
10
|
await ctx?.close();
|
|
@@ -100,3 +100,31 @@ describe('versions and optimistic locking', () => {
|
|
|
100
100
|
);
|
|
101
101
|
});
|
|
102
102
|
});
|
|
103
|
+
|
|
104
|
+
describe('republishing', () => {
|
|
105
|
+
it('rewrites descendants published beneath a parent that was not yet projected', async () => {
|
|
106
|
+
const root = await makeNode(ctx, { title: 'Late', slug: 'late' });
|
|
107
|
+
const child = await makeNode(ctx, { title: 'Early', slug: 'early', parentId: root.id });
|
|
108
|
+
// The child goes live first. Its parent is not in the projection, so it is
|
|
109
|
+
// transparent in the published path until it is published too.
|
|
110
|
+
await ctx.repos.content.publish(child.id);
|
|
111
|
+
expect((await ctx.repos.content.findById(child.id, true))?.permalink).toBe('early');
|
|
112
|
+
|
|
113
|
+
await update(root.id, { title: 'Late', slug: 'later', parentId: null });
|
|
114
|
+
await ctx.repos.content.publish(root.id);
|
|
115
|
+
|
|
116
|
+
expect((await ctx.repos.content.findById(child.id, true))?.permalink).toBe('later/early');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('skips the subtree walk when a republish leaves the permalink path alone', async () => {
|
|
120
|
+
const node = await makeNode(ctx, { title: 'Same', slug: 'same', fields: { body: 'v1' } });
|
|
121
|
+
await ctx.repos.content.publish(node.id);
|
|
122
|
+
await update(node.id, { title: 'Same', slug: 'same', fields: { body: 'v2' } });
|
|
123
|
+
|
|
124
|
+
ctx.queries.length = 0;
|
|
125
|
+
await ctx.repos.content.publish(node.id);
|
|
126
|
+
|
|
127
|
+
expect(ctx.queries.some((query) => /with recursive/i.test(query))).toBe(false);
|
|
128
|
+
expect((await ctx.repos.content.findById(node.id, true))?.fields).toEqual({ body: 'v2' });
|
|
129
|
+
});
|
|
130
|
+
});
|
package/test/query.test.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { sql } from 'drizzle-orm';
|
|
2
2
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
3
|
-
import {
|
|
3
|
+
import { createRepositoryContext, makeNode, type RepositoryTestContext } from '../src/testing.js';
|
|
4
4
|
|
|
5
|
-
let ctx:
|
|
5
|
+
let ctx: RepositoryTestContext;
|
|
6
6
|
|
|
7
7
|
beforeAll(async () => {
|
|
8
|
-
ctx = await
|
|
8
|
+
ctx = await createRepositoryContext('query');
|
|
9
9
|
await makeNode(ctx, {
|
|
10
10
|
title: 'Alpha',
|
|
11
11
|
slug: 'alpha',
|
|
@@ -138,3 +138,33 @@ describe('full-text search', () => {
|
|
|
138
138
|
expect(result.items.map((i) => i.slug)).toEqual(['beta']);
|
|
139
139
|
});
|
|
140
140
|
});
|
|
141
|
+
|
|
142
|
+
describe('paginate', () => {
|
|
143
|
+
it('answers a page and the total in one statement, and the true total past the end', async () => {
|
|
144
|
+
const { paginate } = await import('../src/pagination.js');
|
|
145
|
+
const { users } = await import('../src/schema/index.js');
|
|
146
|
+
for (const n of [1, 2, 3]) {
|
|
147
|
+
await ctx.repos.users.create({
|
|
148
|
+
name: `P${n}`,
|
|
149
|
+
email: `p${n}@example.com`,
|
|
150
|
+
role: 'editor',
|
|
151
|
+
passwordHash: 'x',
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
ctx.queries.length = 0;
|
|
155
|
+
const first = await paginate(ctx.handle.db, users, {
|
|
156
|
+
orderBy: users.createdAt,
|
|
157
|
+
pagination: { limit: 2, offset: 0 },
|
|
158
|
+
});
|
|
159
|
+
expect(first.items).toHaveLength(2);
|
|
160
|
+
expect(first.total).toBe(3);
|
|
161
|
+
expect(ctx.queries).toHaveLength(1);
|
|
162
|
+
|
|
163
|
+
const past = await paginate(ctx.handle.db, users, {
|
|
164
|
+
orderBy: users.createdAt,
|
|
165
|
+
pagination: { limit: 2, offset: 10 },
|
|
166
|
+
});
|
|
167
|
+
expect(past.items).toEqual([]);
|
|
168
|
+
expect(past.total).toBe(3);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
2
|
+
import { createRepositoryContext, type RepositoryTestContext } from '../src/testing.js';
|
|
3
|
+
|
|
4
|
+
let ctx: RepositoryTestContext;
|
|
5
|
+
|
|
6
|
+
beforeAll(async () => {
|
|
7
|
+
ctx = await createRepositoryContext('role');
|
|
8
|
+
});
|
|
9
|
+
afterAll(async () => {
|
|
10
|
+
await ctx?.close();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const stamp = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
14
|
+
|
|
15
|
+
describe('RoleRepository and the principal', () => {
|
|
16
|
+
it('resolves a membership naming a custom role to that role’s grants', async () => {
|
|
17
|
+
const role = await ctx.repos.roles.create(ctx.spaceId, {
|
|
18
|
+
name: 'Blogger',
|
|
19
|
+
machineName: `blogger-${stamp()}`,
|
|
20
|
+
permissions: ['space:read', `content:write:${ctx.types.page.id}`],
|
|
21
|
+
});
|
|
22
|
+
const user = await ctx.repos.users.create({
|
|
23
|
+
name: 'Blogger',
|
|
24
|
+
email: `blogger-${stamp()}@example.com`,
|
|
25
|
+
role: 'editor',
|
|
26
|
+
passwordHash: 'x',
|
|
27
|
+
});
|
|
28
|
+
await ctx.repos.users.grant(user.id, ctx.spaceId, role.machineName);
|
|
29
|
+
|
|
30
|
+
const principal = await ctx.repos.users.principal(user.id);
|
|
31
|
+
expect(principal?.spaces).toEqual({ [ctx.spaceId]: role.machineName });
|
|
32
|
+
expect(principal?.permissions).toEqual({ [ctx.spaceId]: role.permissions });
|
|
33
|
+
expect(await ctx.repos.roles.countMembers(ctx.spaceId, role.machineName)).toBe(1);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('carries no grants for a built-in role, which the auth package knows itself', async () => {
|
|
37
|
+
const user = await ctx.repos.users.create({
|
|
38
|
+
name: 'Editor',
|
|
39
|
+
email: `editor-${stamp()}@example.com`,
|
|
40
|
+
role: 'editor',
|
|
41
|
+
passwordHash: 'x',
|
|
42
|
+
});
|
|
43
|
+
await ctx.repos.users.grant(user.id, ctx.spaceId, 'editor');
|
|
44
|
+
const principal = await ctx.repos.users.principal(user.id);
|
|
45
|
+
expect(principal?.spaces).toEqual({ [ctx.spaceId]: 'editor' });
|
|
46
|
+
expect(principal?.permissions).toEqual({});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('prunes the grants naming a content type from every role, keeping the rest', async () => {
|
|
50
|
+
const gone = '99999999-9999-4999-8999-999999999999';
|
|
51
|
+
const a = await ctx.repos.roles.create(ctx.spaceId, {
|
|
52
|
+
name: 'A',
|
|
53
|
+
machineName: `a-${stamp()}`,
|
|
54
|
+
permissions: ['space:read', `content:read:${gone}`, `content:write:${ctx.types.page.id}`],
|
|
55
|
+
});
|
|
56
|
+
const b = await ctx.repos.roles.create(ctx.spaceId, {
|
|
57
|
+
name: 'B',
|
|
58
|
+
machineName: `b-${stamp()}`,
|
|
59
|
+
permissions: ['space:read', 'content:read'],
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
await ctx.repos.roles.pruneContentType(gone);
|
|
63
|
+
|
|
64
|
+
expect((await ctx.repos.roles.findById(a.id))?.permissions).toEqual([
|
|
65
|
+
'space:read',
|
|
66
|
+
`content:write:${ctx.types.page.id}`,
|
|
67
|
+
]);
|
|
68
|
+
expect((await ctx.repos.roles.findById(b.id))?.permissions).toEqual([
|
|
69
|
+
'space:read',
|
|
70
|
+
'content:read',
|
|
71
|
+
]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('refuses two roles with one machine name in a space', async () => {
|
|
75
|
+
const machineName = `dup-${stamp()}`;
|
|
76
|
+
const make = () =>
|
|
77
|
+
ctx.repos.roles.create(ctx.spaceId, { name: 'Dup', machineName, permissions: [] });
|
|
78
|
+
await make();
|
|
79
|
+
await expect(make()).rejects.toMatchObject({ cause: { code: '23505' } });
|
|
80
|
+
});
|
|
81
|
+
});
|
package/test/tree.test.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import { createRepositoryContext, makeNode, type RepositoryTestContext } from '../src/testing.js';
|
|
3
3
|
|
|
4
|
-
let ctx:
|
|
4
|
+
let ctx: RepositoryTestContext;
|
|
5
5
|
|
|
6
6
|
beforeAll(async () => {
|
|
7
|
-
ctx = await
|
|
7
|
+
ctx = await createRepositoryContext('tree');
|
|
8
8
|
});
|
|
9
9
|
afterAll(async () => {
|
|
10
10
|
await ctx?.close();
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { eq } from 'drizzle-orm';
|
|
2
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
3
|
+
import { accounts, memberships, sessions } from '../src/schema/index.js';
|
|
4
|
+
import { createRepositoryContext, type RepositoryTestContext } from '../src/testing.js';
|
|
5
|
+
|
|
6
|
+
let ctx: RepositoryTestContext;
|
|
7
|
+
|
|
8
|
+
beforeAll(async () => {
|
|
9
|
+
ctx = await createRepositoryContext('user');
|
|
10
|
+
});
|
|
11
|
+
afterAll(async () => {
|
|
12
|
+
await ctx?.close();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const stamp = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
16
|
+
|
|
17
|
+
describe('UserRepository.create', () => {
|
|
18
|
+
it('writes the credential the way better-auth 1.7 looks it up', async () => {
|
|
19
|
+
const user = await ctx.repos.users.create({
|
|
20
|
+
name: 'Sam',
|
|
21
|
+
email: `sam-${stamp()}@example.com`,
|
|
22
|
+
role: 'editor',
|
|
23
|
+
passwordHash: '$argon2id$hash',
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const rows = await ctx.handle.db.select().from(accounts).where(eq(accounts.userId, user.id));
|
|
27
|
+
expect(rows).toHaveLength(1);
|
|
28
|
+
// Sign-in matches on all three; a row missing the issuer is invisible to it.
|
|
29
|
+
expect(rows[0]).toMatchObject({
|
|
30
|
+
providerId: 'credential',
|
|
31
|
+
issuer: 'local:credential',
|
|
32
|
+
accountId: user.id,
|
|
33
|
+
password: '$argon2id$hash',
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('refuses a second account on the same email', async () => {
|
|
38
|
+
const email = `dup-${stamp()}@example.com`;
|
|
39
|
+
const make = () =>
|
|
40
|
+
ctx.repos.users.create({ name: 'A', email, role: 'editor', passwordHash: 'x' });
|
|
41
|
+
await make();
|
|
42
|
+
await expect(make()).rejects.toMatchObject({ cause: { code: '23505' } });
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('UserRepository passwords and sessions', () => {
|
|
47
|
+
it('replaces the credential rather than adding a second one', async () => {
|
|
48
|
+
const user = await ctx.repos.users.create({
|
|
49
|
+
name: 'Pat',
|
|
50
|
+
email: `pat-${stamp()}@example.com`,
|
|
51
|
+
role: 'editor',
|
|
52
|
+
passwordHash: 'old',
|
|
53
|
+
});
|
|
54
|
+
await ctx.repos.users.setPasswordHash(user.id, 'new');
|
|
55
|
+
|
|
56
|
+
const rows = await ctx.handle.db.select().from(accounts).where(eq(accounts.userId, user.id));
|
|
57
|
+
expect(rows).toHaveLength(1);
|
|
58
|
+
expect(rows[0]?.password).toBe('new');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('revokes every session of one user and nobody else', async () => {
|
|
62
|
+
const [a, b] = await Promise.all(
|
|
63
|
+
['a', 'b'].map((n) =>
|
|
64
|
+
ctx.repos.users.create({
|
|
65
|
+
name: n,
|
|
66
|
+
email: `${n}-${stamp()}@example.com`,
|
|
67
|
+
role: 'editor',
|
|
68
|
+
passwordHash: 'x',
|
|
69
|
+
}),
|
|
70
|
+
),
|
|
71
|
+
);
|
|
72
|
+
const expiresAt = new Date(Date.now() + 60_000);
|
|
73
|
+
await ctx.handle.db.insert(sessions).values([
|
|
74
|
+
{ userId: a!.id, token: `a1-${stamp()}`, expiresAt },
|
|
75
|
+
{ userId: a!.id, token: `a2-${stamp()}`, expiresAt },
|
|
76
|
+
{ userId: b!.id, token: `b1-${stamp()}`, expiresAt },
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
await ctx.repos.users.revokeSessions(a!.id);
|
|
80
|
+
|
|
81
|
+
expect(
|
|
82
|
+
await ctx.handle.db.select().from(sessions).where(eq(sessions.userId, a!.id)),
|
|
83
|
+
).toHaveLength(0);
|
|
84
|
+
expect(
|
|
85
|
+
await ctx.handle.db.select().from(sessions).where(eq(sessions.userId, b!.id)),
|
|
86
|
+
).toHaveLength(1);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('UserRepository.delete', () => {
|
|
91
|
+
it('takes the credential and the memberships with it', async () => {
|
|
92
|
+
const user = await ctx.repos.users.create({
|
|
93
|
+
name: 'Gone',
|
|
94
|
+
email: `gone-${stamp()}@example.com`,
|
|
95
|
+
role: 'editor',
|
|
96
|
+
passwordHash: 'x',
|
|
97
|
+
});
|
|
98
|
+
await ctx.repos.users.grant(user.id, ctx.spaceId, 'viewer');
|
|
99
|
+
expect(await ctx.repos.users.membershipsWithSpaces(user.id)).toMatchObject([
|
|
100
|
+
{ role: 'viewer', space: { id: ctx.spaceId } },
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
await ctx.repos.users.delete(user.id);
|
|
104
|
+
|
|
105
|
+
expect(await ctx.repos.users.findById(user.id)).toBeNull();
|
|
106
|
+
expect(
|
|
107
|
+
await ctx.handle.db.select().from(accounts).where(eq(accounts.userId, user.id)),
|
|
108
|
+
).toHaveLength(0);
|
|
109
|
+
expect(
|
|
110
|
+
await ctx.handle.db.select().from(memberships).where(eq(memberships.userId, user.id)),
|
|
111
|
+
).toHaveLength(0);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('UserRepository.countByRole', () => {
|
|
116
|
+
it('counts the instance role', async () => {
|
|
117
|
+
const before = await ctx.repos.users.countByRole('superadmin');
|
|
118
|
+
await ctx.repos.users.create({
|
|
119
|
+
name: 'Root',
|
|
120
|
+
email: `root-${stamp()}@example.com`,
|
|
121
|
+
role: 'superadmin',
|
|
122
|
+
passwordHash: 'x',
|
|
123
|
+
});
|
|
124
|
+
expect(await ctx.repos.users.countByRole('superadmin')).toBe(before + 1);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
2
|
+
import { webhooks } from '../src/schema/index.js';
|
|
3
|
+
import { createRepositoryContext, type RepositoryTestContext } from '../src/testing.js';
|
|
4
|
+
|
|
5
|
+
let ctx: RepositoryTestContext;
|
|
6
|
+
|
|
7
|
+
beforeAll(async () => {
|
|
8
|
+
ctx = await createRepositoryContext('webhook');
|
|
9
|
+
});
|
|
10
|
+
afterAll(async () => {
|
|
11
|
+
await ctx?.close();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe('webhooks', () => {
|
|
15
|
+
it('lists only the switched-on webhooks of a space and logs deliveries', async () => {
|
|
16
|
+
const [on, off] = await ctx.handle.db
|
|
17
|
+
.insert(webhooks)
|
|
18
|
+
.values([
|
|
19
|
+
{ spaceId: ctx.spaceId, name: 'On', url: 'https://on.test', events: [] },
|
|
20
|
+
{ spaceId: ctx.spaceId, name: 'Off', url: 'https://off.test', events: [], enabled: false },
|
|
21
|
+
])
|
|
22
|
+
.returning();
|
|
23
|
+
|
|
24
|
+
const enabled = await ctx.repos.webhooks.findEnabled(ctx.spaceId);
|
|
25
|
+
expect(enabled.map((row) => row.id)).toEqual([on?.id]);
|
|
26
|
+
expect(await ctx.repos.webhooks.findById(off?.id as string)).toMatchObject({ enabled: false });
|
|
27
|
+
|
|
28
|
+
await ctx.repos.webhooks.recordDelivery({
|
|
29
|
+
webhookId: on?.id as string,
|
|
30
|
+
event: 'content.published',
|
|
31
|
+
payload: { id: 'x' },
|
|
32
|
+
status: 200,
|
|
33
|
+
error: null,
|
|
34
|
+
});
|
|
35
|
+
await ctx.repos.webhooks.recordDelivery({
|
|
36
|
+
webhookId: on?.id as string,
|
|
37
|
+
event: 'content.published',
|
|
38
|
+
payload: { id: 'y' },
|
|
39
|
+
status: null,
|
|
40
|
+
error: 'ECONNREFUSED',
|
|
41
|
+
});
|
|
42
|
+
const log = await ctx.repos.webhooks.deliveries(on?.id as string);
|
|
43
|
+
expect(log.map((row) => [row.status, row.error])).toEqual([
|
|
44
|
+
[200, null],
|
|
45
|
+
[null, 'ECONNREFUSED'],
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
});
|