@manablox/api-rpc 0.2.0 → 0.4.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/dist/index.d.ts +8010 -0
- package/dist/index.js +1227 -0
- package/package.json +18 -11
- package/src/base.ts +0 -59
- package/src/context.ts +0 -70
- package/src/index.ts +0 -28
- package/src/routers/asset.ts +0 -96
- package/src/routers/content-type.ts +0 -117
- package/src/routers/content.ts +0 -266
- package/src/routers/menu.ts +0 -77
- package/src/routers/role.ts +0 -51
- package/src/routers/space.ts +0 -149
- package/src/routers/user.ts +0 -175
- package/src/routers/workflow.ts +0 -253
- package/src/schemas.ts +0 -40
- package/test/asset.test.ts +0 -144
- package/test/content.test.ts +0 -256
- package/test/helpers.ts +0 -163
- package/test/menu.test.ts +0 -192
- package/test/role.test.ts +0 -205
- package/test/space.test.ts +0 -170
- package/test/user.test.ts +0 -212
- package/test/workflow.test.ts +0 -259
- package/tsconfig.json +0 -1
- package/vitest.config.ts +0 -8
package/test/space.test.ts
DELETED
|
@@ -1,170 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { spaceRouter } from '../src/routers/space.js';
|
|
3
|
-
import {
|
|
4
|
-
failure,
|
|
5
|
-
invoke,
|
|
6
|
-
mocks,
|
|
7
|
-
OTHER_SPACE_ID,
|
|
8
|
-
OTHER_USER_ID,
|
|
9
|
-
principal,
|
|
10
|
-
SPACE_ID,
|
|
11
|
-
stubContext,
|
|
12
|
-
superadmin,
|
|
13
|
-
USER_ID,
|
|
14
|
-
} from './helpers.js';
|
|
15
|
-
|
|
16
|
-
const space = (overrides = {}) => ({
|
|
17
|
-
id: SPACE_ID,
|
|
18
|
-
name: 'Site',
|
|
19
|
-
machineName: 'site',
|
|
20
|
-
url: 'http://localhost:3002',
|
|
21
|
-
defaultLocale: 'en',
|
|
22
|
-
locales: ['en', 'de'],
|
|
23
|
-
settings: {},
|
|
24
|
-
...overrides,
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
describe('spaces.list', () => {
|
|
28
|
-
it('shows a member only their spaces and a superadmin every space', async () => {
|
|
29
|
-
const ctx = stubContext();
|
|
30
|
-
const repos = mocks(ctx);
|
|
31
|
-
repos.spaces.all.mockResolvedValue([space(), space({ id: OTHER_SPACE_ID })]);
|
|
32
|
-
repos.spaces.findManyByIds.mockImplementation(async (ids: string[]) =>
|
|
33
|
-
[space(), space({ id: OTHER_SPACE_ID })].filter((s) => ids.includes(s.id)),
|
|
34
|
-
);
|
|
35
|
-
|
|
36
|
-
const own = await invoke<unknown[]>(spaceRouter.list, undefined, ctx);
|
|
37
|
-
expect(own).toHaveLength(1);
|
|
38
|
-
// Bounded by membership: the member's call never lists the whole instance.
|
|
39
|
-
expect(repos.spaces.findManyByIds).toHaveBeenCalledWith([SPACE_ID]);
|
|
40
|
-
expect(repos.spaces.all).not.toHaveBeenCalled();
|
|
41
|
-
|
|
42
|
-
const all = await invoke<unknown[]>(spaceRouter.list, undefined, {
|
|
43
|
-
...ctx,
|
|
44
|
-
principal: superadmin(),
|
|
45
|
-
});
|
|
46
|
-
expect(all).toHaveLength(2);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it('confines a space-restricted key even for a superadmin', async () => {
|
|
50
|
-
const ctx = stubContext({
|
|
51
|
-
principal: superadmin(),
|
|
52
|
-
});
|
|
53
|
-
ctx.principal!.allowedSpaceIds = [OTHER_SPACE_ID];
|
|
54
|
-
mocks(ctx).spaces.all.mockResolvedValue([space(), space({ id: OTHER_SPACE_ID })]);
|
|
55
|
-
const visible = await invoke<Array<{ id: string }>>(spaceRouter.list, undefined, ctx);
|
|
56
|
-
expect(visible.map((s) => s.id)).toEqual([OTHER_SPACE_ID]);
|
|
57
|
-
});
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
describe('spaces.create', () => {
|
|
61
|
-
it('requires a superadmin', async () => {
|
|
62
|
-
const ctx = stubContext();
|
|
63
|
-
const result = await failure(invoke(spaceRouter.create, space(), ctx));
|
|
64
|
-
expect(result.code).toBe('FORBIDDEN');
|
|
65
|
-
expect(result.key).toBe('auth.superadminRequired');
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it('rejects a default locale outside the locale list', async () => {
|
|
69
|
-
const ctx = stubContext({ principal: superadmin() });
|
|
70
|
-
const result = await failure(
|
|
71
|
-
invoke(spaceRouter.create, { ...space(), defaultLocale: 'fr' }, ctx),
|
|
72
|
-
);
|
|
73
|
-
expect(result.code).toBe('BAD_REQUEST');
|
|
74
|
-
expect(result.key).toBe('space.validation.failed');
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it('makes the creator an owner', async () => {
|
|
78
|
-
const ctx = stubContext({ principal: superadmin() });
|
|
79
|
-
const repos = mocks(ctx);
|
|
80
|
-
repos.spaces.create!.mockResolvedValue(space());
|
|
81
|
-
|
|
82
|
-
await invoke(spaceRouter.create, space(), ctx);
|
|
83
|
-
expect(repos.users.grant).toHaveBeenCalledWith(USER_ID, SPACE_ID, 'owner');
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it('turns a taken machine name into a validation error', async () => {
|
|
87
|
-
const ctx = stubContext({ principal: superadmin() });
|
|
88
|
-
const repos = mocks(ctx);
|
|
89
|
-
repos.spaces.create!.mockRejectedValue(
|
|
90
|
-
Object.assign(new Error('Failed query'), {
|
|
91
|
-
cause: { code: '23505', message: 'duplicate key value violates "spaces_machine_name_key"' },
|
|
92
|
-
}),
|
|
93
|
-
);
|
|
94
|
-
|
|
95
|
-
const result = await failure(invoke(spaceRouter.create, space(), ctx));
|
|
96
|
-
expect(result.code).toBe('BAD_REQUEST');
|
|
97
|
-
expect(result.key).toBe('space.validation.failed');
|
|
98
|
-
});
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
describe('spaces.grant / revoke', () => {
|
|
102
|
-
it('refuses to demote or remove the last owner', async () => {
|
|
103
|
-
const ctx = stubContext();
|
|
104
|
-
const repos = mocks(ctx);
|
|
105
|
-
repos.users.roleIn!.mockResolvedValue('owner');
|
|
106
|
-
repos.users.membersOf!.mockResolvedValue([{ userId: USER_ID, role: 'owner' }]);
|
|
107
|
-
|
|
108
|
-
const demote = await failure(
|
|
109
|
-
invoke(spaceRouter.grant, { spaceId: SPACE_ID, userId: USER_ID, role: 'editor' }, ctx),
|
|
110
|
-
);
|
|
111
|
-
expect(demote.key).toBe('space.member.lastOwner');
|
|
112
|
-
|
|
113
|
-
const remove = await failure(
|
|
114
|
-
invoke(spaceRouter.revoke, { spaceId: SPACE_ID, userId: USER_ID }, ctx),
|
|
115
|
-
);
|
|
116
|
-
expect(remove.key).toBe('space.member.lastOwner');
|
|
117
|
-
expect(repos.users.revoke).not.toHaveBeenCalled();
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
it('lets an owner be demoted once another owner exists', async () => {
|
|
121
|
-
const ctx = stubContext();
|
|
122
|
-
const repos = mocks(ctx);
|
|
123
|
-
repos.users.roleIn!.mockResolvedValue('owner');
|
|
124
|
-
repos.users.membersOf!.mockResolvedValue([
|
|
125
|
-
{ userId: USER_ID, role: 'owner' },
|
|
126
|
-
{ userId: OTHER_USER_ID, role: 'owner' },
|
|
127
|
-
]);
|
|
128
|
-
|
|
129
|
-
await invoke(spaceRouter.grant, { spaceId: SPACE_ID, userId: USER_ID, role: 'editor' }, ctx);
|
|
130
|
-
expect(repos.users.grant).toHaveBeenCalledWith(USER_ID, SPACE_ID, 'editor');
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it('needs user:write in that space', async () => {
|
|
134
|
-
const ctx = stubContext({ principal: principal({ spaces: { [SPACE_ID]: 'editor' } }) });
|
|
135
|
-
const result = await failure(
|
|
136
|
-
invoke(spaceRouter.grant, { spaceId: SPACE_ID, userId: OTHER_USER_ID, role: 'viewer' }, ctx),
|
|
137
|
-
);
|
|
138
|
-
expect(result.code).toBe('FORBIDDEN');
|
|
139
|
-
});
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
describe('spaces.addMembers', () => {
|
|
143
|
-
it('skips users who are already members', async () => {
|
|
144
|
-
const ctx = stubContext();
|
|
145
|
-
const repos = mocks(ctx);
|
|
146
|
-
repos.users.membersOf!.mockResolvedValue([{ userId: OTHER_USER_ID, role: 'editor' }]);
|
|
147
|
-
|
|
148
|
-
const result = await invoke<{ added: number }>(
|
|
149
|
-
spaceRouter.addMembers,
|
|
150
|
-
{ spaceId: SPACE_ID, userIds: [USER_ID, OTHER_USER_ID], role: 'viewer' },
|
|
151
|
-
ctx,
|
|
152
|
-
);
|
|
153
|
-
expect(result.added).toBe(1);
|
|
154
|
-
expect(repos.users.grant).toHaveBeenCalledTimes(1);
|
|
155
|
-
});
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
describe('spaces.setHome', () => {
|
|
159
|
-
it('rejects a document from another space', async () => {
|
|
160
|
-
const ctx = stubContext();
|
|
161
|
-
const repos = mocks(ctx);
|
|
162
|
-
repos.spaces.findById!.mockResolvedValue(space());
|
|
163
|
-
repos.content.findById!.mockResolvedValue({ id: 'x', spaceId: OTHER_SPACE_ID });
|
|
164
|
-
|
|
165
|
-
const result = await failure(
|
|
166
|
-
invoke(spaceRouter.setHome, { spaceId: SPACE_ID, contentId: OTHER_USER_ID }, ctx),
|
|
167
|
-
);
|
|
168
|
-
expect(result.key).toBe('content.notInSpace');
|
|
169
|
-
});
|
|
170
|
-
});
|
package/test/user.test.ts
DELETED
|
@@ -1,212 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, type vi } from 'vitest';
|
|
2
|
-
import { userRouter } from '../src/routers/user.js';
|
|
3
|
-
import {
|
|
4
|
-
failure,
|
|
5
|
-
invoke,
|
|
6
|
-
mocks,
|
|
7
|
-
OTHER_USER_ID,
|
|
8
|
-
SPACE_ID,
|
|
9
|
-
stubContext,
|
|
10
|
-
superadmin,
|
|
11
|
-
TYPE_ID,
|
|
12
|
-
USER_ID,
|
|
13
|
-
} from './helpers.js';
|
|
14
|
-
|
|
15
|
-
const user = (overrides = {}) => ({
|
|
16
|
-
id: OTHER_USER_ID,
|
|
17
|
-
name: 'Sam',
|
|
18
|
-
email: 'sam@example.com',
|
|
19
|
-
emailVerified: false,
|
|
20
|
-
image: null,
|
|
21
|
-
role: 'editor',
|
|
22
|
-
banned: false,
|
|
23
|
-
banReason: null,
|
|
24
|
-
createdAt: new Date('2026-01-01'),
|
|
25
|
-
updatedAt: new Date('2026-01-01'),
|
|
26
|
-
...overrides,
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
describe('users.setupNeeded', () => {
|
|
30
|
-
it('is public and true only while the instance has no account', async () => {
|
|
31
|
-
const ctx = stubContext({ principal: null });
|
|
32
|
-
const repos = mocks(ctx);
|
|
33
|
-
repos.users.count.mockResolvedValue(0);
|
|
34
|
-
expect(await invoke(userRouter.setupNeeded, undefined, ctx)).toEqual({ setupNeeded: true });
|
|
35
|
-
repos.users.count.mockResolvedValue(3);
|
|
36
|
-
expect(await invoke(userRouter.setupNeeded, undefined, ctx)).toEqual({ setupNeeded: false });
|
|
37
|
-
});
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
describe('users.list', () => {
|
|
41
|
-
it('is superadmin-only', async () => {
|
|
42
|
-
const result = await failure(invoke(userRouter.list, {}, stubContext()));
|
|
43
|
-
expect(result.code).toBe('FORBIDDEN');
|
|
44
|
-
expect(result.key).toBe('auth.superadminRequired');
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it('strips the row down to a summary', async () => {
|
|
48
|
-
const ctx = stubContext({ principal: superadmin() });
|
|
49
|
-
mocks(ctx).users.list.mockResolvedValue({
|
|
50
|
-
items: [user()],
|
|
51
|
-
total: 1,
|
|
52
|
-
limit: 25,
|
|
53
|
-
offset: 0,
|
|
54
|
-
});
|
|
55
|
-
const page = await invoke<{ items: Record<string, unknown>[] }>(userRouter.list, {}, ctx);
|
|
56
|
-
expect(page.items[0]).not.toHaveProperty('emailVerified');
|
|
57
|
-
expect(page.items[0]).toMatchObject({ id: OTHER_USER_ID, email: 'sam@example.com' });
|
|
58
|
-
});
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
describe('users.create', () => {
|
|
62
|
-
it('hashes the password and normalises the email', async () => {
|
|
63
|
-
const ctx = stubContext({ principal: superadmin() });
|
|
64
|
-
const repos = mocks(ctx);
|
|
65
|
-
repos.users.create.mockImplementation(async (data) => user({ email: data.email }));
|
|
66
|
-
|
|
67
|
-
const created = await invoke<{ email: string }>(
|
|
68
|
-
userRouter.create,
|
|
69
|
-
{ name: ' Sam ', email: 'Sam@Example.com', password: 'correct horse battery staple' },
|
|
70
|
-
ctx,
|
|
71
|
-
);
|
|
72
|
-
|
|
73
|
-
expect(created.email).toBe('sam@example.com');
|
|
74
|
-
const [data] = repos.users.create.mock.calls[0] as [Record<string, string>];
|
|
75
|
-
expect(data.name).toBe('Sam');
|
|
76
|
-
expect(data.role).toBe('editor');
|
|
77
|
-
expect(data.passwordHash).toMatch(/^\$argon2id\$/);
|
|
78
|
-
expect(data.passwordHash).not.toContain('correct horse');
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it('refuses a short password before touching the database', async () => {
|
|
82
|
-
const ctx = stubContext({ principal: superadmin() });
|
|
83
|
-
const result = await failure(
|
|
84
|
-
invoke(userRouter.create, { name: 'Sam', email: 'sam@example.com', password: 'short' }, ctx),
|
|
85
|
-
);
|
|
86
|
-
expect(result.code).toBe('BAD_REQUEST');
|
|
87
|
-
expect(mocks(ctx).users.create).not.toHaveBeenCalled();
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it('turns a duplicate email into a validation error', async () => {
|
|
91
|
-
const ctx = stubContext({ principal: superadmin() });
|
|
92
|
-
const violation = Object.assign(new Error('Failed query: insert into "users"'), {
|
|
93
|
-
cause: { code: '23505', message: 'duplicate key value violates "users_email_key"' },
|
|
94
|
-
});
|
|
95
|
-
mocks(ctx).users.create.mockRejectedValue(violation);
|
|
96
|
-
const result = await failure(
|
|
97
|
-
invoke(
|
|
98
|
-
userRouter.create,
|
|
99
|
-
{ name: 'Sam', email: 'sam@example.com', password: 'correct horse battery staple' },
|
|
100
|
-
ctx,
|
|
101
|
-
),
|
|
102
|
-
);
|
|
103
|
-
// The error is the validation envelope; the field-level key rides in its details.
|
|
104
|
-
expect(result.key).toBe('user.validation.failed');
|
|
105
|
-
expect(result.details?.[0]).toMatchObject({ key: 'user.email.taken', path: ['email'] });
|
|
106
|
-
});
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
describe('users.setRole', () => {
|
|
110
|
-
it('keeps the last superadmin', async () => {
|
|
111
|
-
const ctx = stubContext({ principal: superadmin() });
|
|
112
|
-
const repos = mocks(ctx);
|
|
113
|
-
repos.users.findById.mockResolvedValue(user({ id: USER_ID, role: 'superadmin' }));
|
|
114
|
-
repos.users.countByRole.mockResolvedValue(1);
|
|
115
|
-
const result = await failure(
|
|
116
|
-
invoke(userRouter.setRole, { userId: USER_ID, role: 'editor' }, ctx),
|
|
117
|
-
);
|
|
118
|
-
expect(result.key).toBe('user.lastSuperadmin');
|
|
119
|
-
expect(repos.users.setRole).not.toHaveBeenCalled();
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it('demotes a superadmin when another remains', async () => {
|
|
123
|
-
const ctx = stubContext({ principal: superadmin() });
|
|
124
|
-
const repos = mocks(ctx);
|
|
125
|
-
repos.users.findById.mockResolvedValue(user({ role: 'superadmin' }));
|
|
126
|
-
repos.users.countByRole.mockResolvedValue(2);
|
|
127
|
-
repos.users.setRole.mockResolvedValue(user());
|
|
128
|
-
await invoke(userRouter.setRole, { userId: OTHER_USER_ID, role: 'editor' }, ctx);
|
|
129
|
-
expect(repos.users.setRole).toHaveBeenCalledWith(OTHER_USER_ID, 'editor');
|
|
130
|
-
});
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
describe('users.ban / users.delete', () => {
|
|
134
|
-
it('will not act on the caller', async () => {
|
|
135
|
-
const ctx = stubContext({ principal: superadmin() });
|
|
136
|
-
const repos = mocks(ctx);
|
|
137
|
-
for (const procedure of [userRouter.ban, userRouter.delete]) {
|
|
138
|
-
const result = await failure(invoke(procedure, { userId: USER_ID }, ctx));
|
|
139
|
-
expect(result.key).toBe('user.self.protected');
|
|
140
|
-
}
|
|
141
|
-
expect(repos.users.setBanned).not.toHaveBeenCalled();
|
|
142
|
-
expect(repos.users.delete).not.toHaveBeenCalled();
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
it('bans and signs the account out everywhere', async () => {
|
|
146
|
-
const ctx = stubContext({ principal: superadmin() });
|
|
147
|
-
const repos = mocks(ctx);
|
|
148
|
-
repos.users.findById.mockResolvedValue(user());
|
|
149
|
-
repos.users.setBanned.mockResolvedValue(user({ banned: true, banReason: 'left' }));
|
|
150
|
-
const banned = await invoke<{ banned: boolean }>(
|
|
151
|
-
userRouter.ban,
|
|
152
|
-
{ userId: OTHER_USER_ID, reason: 'left' },
|
|
153
|
-
ctx,
|
|
154
|
-
);
|
|
155
|
-
expect(banned.banned).toBe(true);
|
|
156
|
-
expect(repos.users.setBanned).toHaveBeenCalledWith(OTHER_USER_ID, true, 'left');
|
|
157
|
-
expect(repos.users.revokeSessions).toHaveBeenCalledWith(OTHER_USER_ID);
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
it('deletes an editor outright', async () => {
|
|
161
|
-
const ctx = stubContext({ principal: superadmin() });
|
|
162
|
-
const repos = mocks(ctx);
|
|
163
|
-
repos.users.findById.mockResolvedValue(user());
|
|
164
|
-
expect(await invoke(userRouter.delete, { userId: OTHER_USER_ID }, ctx)).toEqual({ ok: true });
|
|
165
|
-
expect(repos.users.delete).toHaveBeenCalledWith(OTHER_USER_ID);
|
|
166
|
-
});
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
describe('users.setPassword', () => {
|
|
170
|
-
it('stores a hash and revokes every session', async () => {
|
|
171
|
-
const ctx = stubContext({ principal: superadmin() });
|
|
172
|
-
const repos = mocks(ctx);
|
|
173
|
-
repos.users.findById.mockResolvedValue(user());
|
|
174
|
-
await invoke(
|
|
175
|
-
userRouter.setPassword,
|
|
176
|
-
{ userId: OTHER_USER_ID, password: 'correct horse battery staple' },
|
|
177
|
-
ctx,
|
|
178
|
-
);
|
|
179
|
-
const [, hash] = repos.users.setPasswordHash.mock.calls[0] as [string, string];
|
|
180
|
-
expect(hash).toMatch(/^\$argon2id\$/);
|
|
181
|
-
expect(repos.users.revokeSessions).toHaveBeenCalledWith(OTHER_USER_ID);
|
|
182
|
-
});
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
describe('users.issueApiKey', () => {
|
|
186
|
-
it('stores a permission restriction it has checked, and refuses one it cannot', async () => {
|
|
187
|
-
const ctx = stubContext();
|
|
188
|
-
const apiKeys = ctx.apiKeys as unknown as { issue: ReturnType<typeof vi.fn> };
|
|
189
|
-
apiKeys.issue.mockResolvedValue({ id: 'k', name: 'site', key: 'mbx_x_y', prefix: 'x' });
|
|
190
|
-
|
|
191
|
-
await invoke(
|
|
192
|
-
userRouter.issueApiKey,
|
|
193
|
-
{
|
|
194
|
-
name: 'site',
|
|
195
|
-
spaceIds: [SPACE_ID],
|
|
196
|
-
permissions: ['content:read', `content:write:${TYPE_ID}`],
|
|
197
|
-
},
|
|
198
|
-
ctx,
|
|
199
|
-
);
|
|
200
|
-
expect(apiKeys.issue).toHaveBeenCalledWith(USER_ID, 'site', {
|
|
201
|
-
expiresAt: undefined,
|
|
202
|
-
spaceIds: [SPACE_ID],
|
|
203
|
-
permissions: ['content:read', `content:write:${TYPE_ID}`],
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
const refused = await failure(
|
|
207
|
-
invoke(userRouter.issueApiKey, { name: 'site', permissions: ['content:read:nowhere'] }, ctx),
|
|
208
|
-
);
|
|
209
|
-
expect(refused.key).toBe('apiKey.validation.failed');
|
|
210
|
-
expect(refused.details?.[0]?.path).toEqual(['permissions', 0]);
|
|
211
|
-
});
|
|
212
|
-
});
|
package/test/workflow.test.ts
DELETED
|
@@ -1,259 +0,0 @@
|
|
|
1
|
-
import { ManabloxError } from '@manablox/core';
|
|
2
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
-
import { workflowRouter } from '../src/routers/workflow.js';
|
|
4
|
-
import { failure, invoke, principal, SPACE_ID, stubContext, USER_ID } from './helpers.js';
|
|
5
|
-
|
|
6
|
-
const WORKFLOW_ID = '88888888-8888-4888-8888-888888888888';
|
|
7
|
-
const CONTENT_ID = '99999999-9999-4999-8999-999999999999';
|
|
8
|
-
|
|
9
|
-
function workflows() {
|
|
10
|
-
return {
|
|
11
|
-
catalog: vi.fn(),
|
|
12
|
-
list: vi.fn(),
|
|
13
|
-
get: vi.fn(),
|
|
14
|
-
create: vi.fn(),
|
|
15
|
-
update: vi.fn(),
|
|
16
|
-
setEnabled: vi.fn(),
|
|
17
|
-
delete: vi.fn(),
|
|
18
|
-
runs: vi.fn(),
|
|
19
|
-
run: vi.fn(),
|
|
20
|
-
runNow: vi.fn(),
|
|
21
|
-
subscriptions: vi.fn(),
|
|
22
|
-
subscribe: vi.fn(),
|
|
23
|
-
unsubscribe: vi.fn(),
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const draft = () => ({
|
|
28
|
-
spaceId: SPACE_ID,
|
|
29
|
-
name: 'Ping',
|
|
30
|
-
trigger: { kind: 'event' as const, events: ['content.saved' as const] },
|
|
31
|
-
steps: [{ type: 'http' as const, url: 'https://example.test/hook' }],
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
describe('workflows.catalog', () => {
|
|
35
|
-
it('is open to any signed-in user and closed otherwise', async () => {
|
|
36
|
-
const service = workflows();
|
|
37
|
-
service.catalog.mockReturnValue({ events: [], steps: [], operators: [] });
|
|
38
|
-
const ctx = stubContext({ workflows: service as never, principal: principal({ spaces: {} }) });
|
|
39
|
-
expect(await invoke(workflowRouter.catalog, undefined, ctx)).toEqual({
|
|
40
|
-
events: [],
|
|
41
|
-
steps: [],
|
|
42
|
-
operators: [],
|
|
43
|
-
});
|
|
44
|
-
expect(
|
|
45
|
-
await failure(invoke(workflowRouter.catalog, undefined, { ...ctx, principal: null })),
|
|
46
|
-
).toMatchObject({ code: 'UNAUTHORIZED' });
|
|
47
|
-
});
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
describe('workflows.list / get', () => {
|
|
51
|
-
it('needs workflow:read, which an editor holds and a viewer lacks', async () => {
|
|
52
|
-
const service = workflows();
|
|
53
|
-
service.list.mockResolvedValue([]);
|
|
54
|
-
const ctx = stubContext({
|
|
55
|
-
workflows: service as never,
|
|
56
|
-
principal: principal({ spaces: { [SPACE_ID]: 'editor' } }),
|
|
57
|
-
});
|
|
58
|
-
expect(await invoke(workflowRouter.list, { spaceId: SPACE_ID }, ctx)).toEqual([]);
|
|
59
|
-
expect(service.list).toHaveBeenCalledWith(SPACE_ID);
|
|
60
|
-
const viewer = { ...ctx, principal: principal({ spaces: { [SPACE_ID]: 'viewer' } }) };
|
|
61
|
-
expect(await failure(invoke(workflowRouter.list, { spaceId: SPACE_ID }, viewer))).toMatchObject(
|
|
62
|
-
{ code: 'FORBIDDEN' },
|
|
63
|
-
);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it('maps a missing workflow to NOT_FOUND', async () => {
|
|
67
|
-
const service = workflows();
|
|
68
|
-
service.get.mockRejectedValue(ManabloxError.notFound('workflow.notFound', { id: WORKFLOW_ID }));
|
|
69
|
-
const ctx = stubContext({ workflows: service as never });
|
|
70
|
-
expect(
|
|
71
|
-
await failure(invoke(workflowRouter.get, { spaceId: SPACE_ID, id: WORKFLOW_ID }, ctx)),
|
|
72
|
-
).toMatchObject({ code: 'NOT_FOUND', key: 'workflow.notFound' });
|
|
73
|
-
});
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
describe('workflows.create / update', () => {
|
|
77
|
-
it('needs workflow:write, which an editor lacks and an admin holds', async () => {
|
|
78
|
-
const service = workflows();
|
|
79
|
-
service.create.mockResolvedValue({ id: WORKFLOW_ID });
|
|
80
|
-
|
|
81
|
-
const editor = stubContext({
|
|
82
|
-
workflows: service as never,
|
|
83
|
-
principal: principal({ spaces: { [SPACE_ID]: 'editor' } }),
|
|
84
|
-
});
|
|
85
|
-
expect(await failure(invoke(workflowRouter.create, draft(), editor))).toMatchObject({
|
|
86
|
-
code: 'FORBIDDEN',
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
const admin = stubContext({
|
|
90
|
-
workflows: service as never,
|
|
91
|
-
principal: principal({ spaces: { [SPACE_ID]: 'admin' } }),
|
|
92
|
-
});
|
|
93
|
-
expect(await invoke(workflowRouter.create, draft(), admin)).toEqual({ id: WORKFLOW_ID });
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it('fills in every step and trigger default so the service sees the whole shape', async () => {
|
|
97
|
-
const service = workflows();
|
|
98
|
-
service.create.mockResolvedValue({ id: WORKFLOW_ID });
|
|
99
|
-
const ctx = stubContext({ workflows: service as never });
|
|
100
|
-
await invoke(workflowRouter.create, draft(), ctx);
|
|
101
|
-
|
|
102
|
-
const [spaceId, data] = service.create.mock.calls[0] as [string, Record<string, unknown>];
|
|
103
|
-
expect(spaceId).toBe(SPACE_ID);
|
|
104
|
-
expect(data.spaceId).toBeUndefined();
|
|
105
|
-
expect(data.trigger).toEqual({
|
|
106
|
-
kind: 'event',
|
|
107
|
-
events: ['content.saved'],
|
|
108
|
-
typeIds: [],
|
|
109
|
-
locales: [],
|
|
110
|
-
});
|
|
111
|
-
expect(data.steps).toEqual([
|
|
112
|
-
{
|
|
113
|
-
id: '',
|
|
114
|
-
name: '',
|
|
115
|
-
enabled: true,
|
|
116
|
-
continueOnError: false,
|
|
117
|
-
type: 'http',
|
|
118
|
-
method: 'POST',
|
|
119
|
-
url: 'https://example.test/hook',
|
|
120
|
-
headers: [],
|
|
121
|
-
body: { mode: 'event', template: '' },
|
|
122
|
-
secret: null,
|
|
123
|
-
timeoutMs: 10_000,
|
|
124
|
-
},
|
|
125
|
-
]);
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
it('accepts a fork whose sides carry steps of their own', async () => {
|
|
129
|
-
const service = workflows();
|
|
130
|
-
service.update.mockResolvedValue({ id: WORKFLOW_ID });
|
|
131
|
-
const ctx = stubContext({ workflows: service as never });
|
|
132
|
-
await invoke(
|
|
133
|
-
workflowRouter.update,
|
|
134
|
-
{
|
|
135
|
-
...draft(),
|
|
136
|
-
id: WORKFLOW_ID,
|
|
137
|
-
steps: [
|
|
138
|
-
{
|
|
139
|
-
type: 'branch',
|
|
140
|
-
rules: [{ field: 'content.status', operator: 'equals', value: 'published' }],
|
|
141
|
-
then: [{ type: 'delay', minutes: 5 }],
|
|
142
|
-
},
|
|
143
|
-
],
|
|
144
|
-
},
|
|
145
|
-
ctx,
|
|
146
|
-
);
|
|
147
|
-
const [, , data] = service.update.mock.calls[0] as [string, string, { steps: unknown[] }];
|
|
148
|
-
expect(data.steps[0]).toMatchObject({
|
|
149
|
-
type: 'branch',
|
|
150
|
-
match: 'all',
|
|
151
|
-
then: [{ type: 'delay', minutes: 5, enabled: true }],
|
|
152
|
-
else: [],
|
|
153
|
-
});
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
it('refuses an unknown event and an unknown step type before the service', async () => {
|
|
157
|
-
const service = workflows();
|
|
158
|
-
const ctx = stubContext({ workflows: service as never });
|
|
159
|
-
expect(
|
|
160
|
-
(
|
|
161
|
-
await failure(
|
|
162
|
-
invoke(
|
|
163
|
-
workflowRouter.create,
|
|
164
|
-
{ ...draft(), trigger: { kind: 'event', events: ['content.exploded'] } },
|
|
165
|
-
ctx,
|
|
166
|
-
),
|
|
167
|
-
)
|
|
168
|
-
).code,
|
|
169
|
-
).toBe('BAD_REQUEST');
|
|
170
|
-
expect(
|
|
171
|
-
(await failure(invoke(workflowRouter.create, { ...draft(), steps: [{ type: 'fax' }] }, ctx)))
|
|
172
|
-
.code,
|
|
173
|
-
).toBe('BAD_REQUEST');
|
|
174
|
-
expect(service.create).not.toHaveBeenCalled();
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
it('maps a service validation error to BAD_REQUEST with the step path', async () => {
|
|
178
|
-
const service = workflows();
|
|
179
|
-
service.create.mockRejectedValue(
|
|
180
|
-
ManabloxError.validation(
|
|
181
|
-
[{ key: 'workflow.step.http.url.invalid', path: ['steps', 0, 'url'] }],
|
|
182
|
-
'workflow.validation.failed',
|
|
183
|
-
),
|
|
184
|
-
);
|
|
185
|
-
const ctx = stubContext({ workflows: service as never });
|
|
186
|
-
expect(await failure(invoke(workflowRouter.create, draft(), ctx))).toMatchObject({
|
|
187
|
-
code: 'BAD_REQUEST',
|
|
188
|
-
key: 'workflow.validation.failed',
|
|
189
|
-
details: [{ path: ['steps', 0, 'url'] }],
|
|
190
|
-
});
|
|
191
|
-
});
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
describe('workflows.setEnabled / delete / runNow', () => {
|
|
195
|
-
it('routes each to its service call with workflow:write', async () => {
|
|
196
|
-
const service = workflows();
|
|
197
|
-
service.setEnabled.mockResolvedValue({ id: WORKFLOW_ID, enabled: true });
|
|
198
|
-
service.delete.mockResolvedValue(undefined);
|
|
199
|
-
service.runNow.mockResolvedValue({ id: 'run' });
|
|
200
|
-
const ctx = stubContext({ workflows: service as never });
|
|
201
|
-
|
|
202
|
-
await invoke(
|
|
203
|
-
workflowRouter.setEnabled,
|
|
204
|
-
{ spaceId: SPACE_ID, id: WORKFLOW_ID, enabled: true },
|
|
205
|
-
ctx,
|
|
206
|
-
);
|
|
207
|
-
expect(service.setEnabled).toHaveBeenCalledWith(SPACE_ID, WORKFLOW_ID, true);
|
|
208
|
-
|
|
209
|
-
expect(
|
|
210
|
-
await invoke(workflowRouter.delete, { spaceId: SPACE_ID, id: WORKFLOW_ID }, ctx),
|
|
211
|
-
).toEqual({ ok: true });
|
|
212
|
-
|
|
213
|
-
await invoke(workflowRouter.runNow, { spaceId: SPACE_ID, id: WORKFLOW_ID }, ctx);
|
|
214
|
-
expect(service.runNow).toHaveBeenCalledWith(SPACE_ID, WORKFLOW_ID, null);
|
|
215
|
-
await invoke(
|
|
216
|
-
workflowRouter.runNow,
|
|
217
|
-
{ spaceId: SPACE_ID, id: WORKFLOW_ID, contentId: CONTENT_ID },
|
|
218
|
-
ctx,
|
|
219
|
-
);
|
|
220
|
-
expect(service.runNow).toHaveBeenLastCalledWith(SPACE_ID, WORKFLOW_ID, CONTENT_ID);
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
it('lets an editor read runs but not start one', async () => {
|
|
224
|
-
const service = workflows();
|
|
225
|
-
service.runs.mockResolvedValue([]);
|
|
226
|
-
const ctx = stubContext({
|
|
227
|
-
workflows: service as never,
|
|
228
|
-
principal: principal({ spaces: { [SPACE_ID]: 'editor' } }),
|
|
229
|
-
});
|
|
230
|
-
await invoke(workflowRouter.runs, { spaceId: SPACE_ID, id: WORKFLOW_ID }, ctx);
|
|
231
|
-
expect(service.runs).toHaveBeenCalledWith(SPACE_ID, WORKFLOW_ID, 50);
|
|
232
|
-
expect(
|
|
233
|
-
await failure(invoke(workflowRouter.runNow, { spaceId: SPACE_ID, id: WORKFLOW_ID }, ctx)),
|
|
234
|
-
).toMatchObject({ code: 'FORBIDDEN' });
|
|
235
|
-
});
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
describe('push subscriptions', () => {
|
|
239
|
-
it('are always the caller’s own, whatever the input says', async () => {
|
|
240
|
-
const service = workflows();
|
|
241
|
-
service.subscriptions.mockResolvedValue([]);
|
|
242
|
-
service.subscribe.mockResolvedValue({ endpoint: 'https://push.example/1' });
|
|
243
|
-
service.unsubscribe.mockResolvedValue(undefined);
|
|
244
|
-
const headers = new Headers({ 'user-agent': 'Test/1.0' });
|
|
245
|
-
const ctx = stubContext({ workflows: service as never, headers });
|
|
246
|
-
|
|
247
|
-
await invoke(workflowRouter.pushSubscriptions, undefined, ctx);
|
|
248
|
-
expect(service.subscriptions).toHaveBeenCalledWith(USER_ID);
|
|
249
|
-
|
|
250
|
-
const subscription = { endpoint: 'https://push.example/1', keys: { p256dh: 'p', auth: 'a' } };
|
|
251
|
-
await invoke(workflowRouter.pushSubscribe, subscription, ctx);
|
|
252
|
-
expect(service.subscribe).toHaveBeenCalledWith(USER_ID, subscription, 'Test/1.0');
|
|
253
|
-
|
|
254
|
-
expect(
|
|
255
|
-
await invoke(workflowRouter.pushUnsubscribe, { endpoint: subscription.endpoint }, ctx),
|
|
256
|
-
).toEqual({ ok: true });
|
|
257
|
-
expect(service.unsubscribe).toHaveBeenCalledWith(USER_ID, subscription.endpoint);
|
|
258
|
-
});
|
|
259
|
-
});
|
package/tsconfig.json
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{ "extends": "@manablox/config-typescript/library.json", "include": ["src", "test"] }
|