@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/helpers.ts
DELETED
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
import { type Principal, UserService } from '@manablox/auth';
|
|
2
|
-
import { RoleService, SpaceService } from '@manablox/services';
|
|
3
|
-
import { call } from '@orpc/server';
|
|
4
|
-
import { type Mock, vi } from 'vitest';
|
|
5
|
-
import type { RpcContext } from '../src/context.js';
|
|
6
|
-
|
|
7
|
-
export const SPACE_ID = '11111111-1111-4111-8111-111111111111';
|
|
8
|
-
export const OTHER_SPACE_ID = '22222222-2222-4222-8222-222222222222';
|
|
9
|
-
export const USER_ID = '33333333-3333-4333-8333-333333333333';
|
|
10
|
-
export const OTHER_USER_ID = '44444444-4444-4444-8444-444444444444';
|
|
11
|
-
export const TYPE_ID = '55555555-5555-4555-8555-555555555555';
|
|
12
|
-
|
|
13
|
-
export function principal(overrides: Partial<Principal> = {}): Principal {
|
|
14
|
-
return {
|
|
15
|
-
userId: USER_ID,
|
|
16
|
-
email: 'owner@example.com',
|
|
17
|
-
role: 'editor',
|
|
18
|
-
spaces: { [SPACE_ID]: 'owner' },
|
|
19
|
-
...overrides,
|
|
20
|
-
};
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export const superadmin = (): Principal =>
|
|
24
|
-
principal({ role: 'superadmin', email: 'root@example.com', spaces: {} });
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* A context whose repositories are all mocks. Each test wires only the calls it needs;
|
|
28
|
-
* anything else throws, which is the point — a handler reaching for a repository the
|
|
29
|
-
* test did not expect is a behaviour change.
|
|
30
|
-
*/
|
|
31
|
-
export interface StubRepos {
|
|
32
|
-
spaces: Record<
|
|
33
|
-
'all' | 'findManyByIds' | 'findById' | 'findByMachineName' | 'create' | 'update' | 'delete',
|
|
34
|
-
Mock
|
|
35
|
-
>;
|
|
36
|
-
users: Record<
|
|
37
|
-
| 'findById'
|
|
38
|
-
| 'list'
|
|
39
|
-
| 'count'
|
|
40
|
-
| 'countByRole'
|
|
41
|
-
| 'membersOf'
|
|
42
|
-
| 'membershipsWithSpaces'
|
|
43
|
-
| 'roleIn'
|
|
44
|
-
| 'grant'
|
|
45
|
-
| 'revoke'
|
|
46
|
-
| 'setRole'
|
|
47
|
-
| 'create'
|
|
48
|
-
| 'update'
|
|
49
|
-
| 'delete'
|
|
50
|
-
| 'setBanned'
|
|
51
|
-
| 'setPasswordHash'
|
|
52
|
-
| 'revokeSessions',
|
|
53
|
-
Mock
|
|
54
|
-
>;
|
|
55
|
-
content: Record<'findById', Mock>;
|
|
56
|
-
roles: Record<
|
|
57
|
-
| 'listBySpace'
|
|
58
|
-
| 'findById'
|
|
59
|
-
| 'findByMachineName'
|
|
60
|
-
| 'create'
|
|
61
|
-
| 'update'
|
|
62
|
-
| 'delete'
|
|
63
|
-
| 'countMembers'
|
|
64
|
-
| 'pruneContentType',
|
|
65
|
-
Mock
|
|
66
|
-
>;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** The mocks behind a stub context, typed so a test can arrange them without casts. */
|
|
70
|
-
export function mocks(context: RpcContext): StubRepos {
|
|
71
|
-
return context.repos as unknown as StubRepos;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export function stubContext(overrides: Partial<RpcContext> = {}): RpcContext {
|
|
75
|
-
const repos = {
|
|
76
|
-
spaces: {
|
|
77
|
-
all: vi.fn(),
|
|
78
|
-
findManyByIds: vi.fn(),
|
|
79
|
-
findById: vi.fn(),
|
|
80
|
-
findByMachineName: vi.fn(),
|
|
81
|
-
create: vi.fn(),
|
|
82
|
-
update: vi.fn(),
|
|
83
|
-
delete: vi.fn(),
|
|
84
|
-
},
|
|
85
|
-
users: {
|
|
86
|
-
findById: vi.fn(),
|
|
87
|
-
list: vi.fn(),
|
|
88
|
-
count: vi.fn(),
|
|
89
|
-
countByRole: vi.fn(),
|
|
90
|
-
membersOf: vi.fn(),
|
|
91
|
-
membershipsWithSpaces: vi.fn(),
|
|
92
|
-
roleIn: vi.fn(),
|
|
93
|
-
grant: vi.fn(),
|
|
94
|
-
revoke: vi.fn(),
|
|
95
|
-
setRole: vi.fn(),
|
|
96
|
-
create: vi.fn(),
|
|
97
|
-
update: vi.fn(),
|
|
98
|
-
delete: vi.fn(),
|
|
99
|
-
setBanned: vi.fn(),
|
|
100
|
-
setPasswordHash: vi.fn(),
|
|
101
|
-
revokeSessions: vi.fn(),
|
|
102
|
-
},
|
|
103
|
-
content: { findById: vi.fn() },
|
|
104
|
-
roles: {
|
|
105
|
-
listBySpace: vi.fn(),
|
|
106
|
-
findById: vi.fn(),
|
|
107
|
-
findByMachineName: vi.fn(),
|
|
108
|
-
create: vi.fn(),
|
|
109
|
-
update: vi.fn(),
|
|
110
|
-
delete: vi.fn(),
|
|
111
|
-
countMembers: vi.fn(),
|
|
112
|
-
pruneContentType: vi.fn(),
|
|
113
|
-
},
|
|
114
|
-
};
|
|
115
|
-
|
|
116
|
-
const manablox = {
|
|
117
|
-
config: { locales: { default: 'en', available: ['en'] } },
|
|
118
|
-
contentTypes: { forSpace: () => [{ id: TYPE_ID }] },
|
|
119
|
-
};
|
|
120
|
-
const roles = new RoleService(manablox as never, repos as never);
|
|
121
|
-
return {
|
|
122
|
-
manablox,
|
|
123
|
-
repos,
|
|
124
|
-
// The real service over the mocked repositories: the rules are what the tests assert.
|
|
125
|
-
spaces: new SpaceService(manablox as never, repos as never, roles),
|
|
126
|
-
roles,
|
|
127
|
-
users: new UserService(repos as never),
|
|
128
|
-
auth: {},
|
|
129
|
-
apiKeys: { list: vi.fn(), issue: vi.fn(), revoke: vi.fn() },
|
|
130
|
-
media: {},
|
|
131
|
-
content: {},
|
|
132
|
-
contentTypes: {},
|
|
133
|
-
loaders: {},
|
|
134
|
-
principal: principal(),
|
|
135
|
-
headers: new Headers(),
|
|
136
|
-
...overrides,
|
|
137
|
-
} as unknown as RpcContext;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** Calls a procedure the way the transport would, with the mapped error surfaced. */
|
|
141
|
-
export function invoke<T>(procedure: unknown, input: unknown, context: RpcContext): Promise<T> {
|
|
142
|
-
return call(procedure as never, input as never, { context }) as Promise<T>;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
export async function failure(
|
|
146
|
-
promise: Promise<unknown>,
|
|
147
|
-
): Promise<{ code: string; key: string; details?: Array<{ key: string; path?: unknown[] }> }> {
|
|
148
|
-
try {
|
|
149
|
-
await promise;
|
|
150
|
-
} catch (error) {
|
|
151
|
-
const e = error as {
|
|
152
|
-
code: string;
|
|
153
|
-
data?: { key?: string; details?: Array<{ key: string; path?: unknown[] }> };
|
|
154
|
-
message: string;
|
|
155
|
-
};
|
|
156
|
-
return {
|
|
157
|
-
code: e.code,
|
|
158
|
-
key: e.data?.key ?? e.message,
|
|
159
|
-
...(e.data?.details ? { details: e.data.details } : {}),
|
|
160
|
-
};
|
|
161
|
-
}
|
|
162
|
-
throw new Error('expected the call to fail');
|
|
163
|
-
}
|
package/test/menu.test.ts
DELETED
|
@@ -1,192 +0,0 @@
|
|
|
1
|
-
import { ManabloxError } from '@manablox/core';
|
|
2
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
-
import { menuRouter } from '../src/routers/menu.js';
|
|
4
|
-
import { failure, invoke, principal, SPACE_ID, stubContext } from './helpers.js';
|
|
5
|
-
|
|
6
|
-
const MENU_ID = '88888888-8888-4888-8888-888888888888';
|
|
7
|
-
const LOC_ID = '99999999-9999-4999-8999-999999999999';
|
|
8
|
-
|
|
9
|
-
const menu = (overrides = {}) => ({
|
|
10
|
-
id: MENU_ID,
|
|
11
|
-
spaceId: SPACE_ID,
|
|
12
|
-
name: 'Main',
|
|
13
|
-
machineName: 'main',
|
|
14
|
-
description: null,
|
|
15
|
-
...overrides,
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
/** The router is input + permission + one service call; the service itself is a mock. */
|
|
19
|
-
function menus() {
|
|
20
|
-
return {
|
|
21
|
-
list: vi.fn(),
|
|
22
|
-
get: vi.fn(),
|
|
23
|
-
create: vi.fn(),
|
|
24
|
-
update: vi.fn(),
|
|
25
|
-
delete: vi.fn(),
|
|
26
|
-
setItems: vi.fn(),
|
|
27
|
-
usedIn: vi.fn(),
|
|
28
|
-
};
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
describe('menus.list / get', () => {
|
|
32
|
-
it('passes the space and the locale through to the service', async () => {
|
|
33
|
-
const service = menus();
|
|
34
|
-
service.list.mockResolvedValue([menu()]);
|
|
35
|
-
service.get.mockResolvedValue({ menu: menu(), items: [] });
|
|
36
|
-
const ctx = stubContext({ menus: service as never });
|
|
37
|
-
|
|
38
|
-
expect(await invoke(menuRouter.list, { spaceId: SPACE_ID }, ctx)).toEqual([menu()]);
|
|
39
|
-
await invoke(menuRouter.get, { spaceId: SPACE_ID, id: MENU_ID, locale: 'de' }, ctx);
|
|
40
|
-
expect(service.get).toHaveBeenCalledWith(SPACE_ID, MENU_ID, 'de');
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it('needs menu:read, which a member of another space lacks', async () => {
|
|
44
|
-
const ctx = stubContext({
|
|
45
|
-
menus: menus() as never,
|
|
46
|
-
principal: principal({ spaces: {} }),
|
|
47
|
-
});
|
|
48
|
-
expect(await failure(invoke(menuRouter.list, { spaceId: SPACE_ID }, ctx))).toMatchObject({
|
|
49
|
-
code: 'FORBIDDEN',
|
|
50
|
-
});
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('is closed without a principal', async () => {
|
|
54
|
-
const ctx = stubContext({ menus: menus() as never, principal: null });
|
|
55
|
-
expect(await failure(invoke(menuRouter.list, { spaceId: SPACE_ID }, ctx))).toMatchObject({
|
|
56
|
-
code: 'UNAUTHORIZED',
|
|
57
|
-
});
|
|
58
|
-
});
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
describe('menus.create / update / delete', () => {
|
|
62
|
-
it('needs menu:write, which a viewer lacks and an editor holds', async () => {
|
|
63
|
-
const service = menus();
|
|
64
|
-
service.create.mockResolvedValue(menu());
|
|
65
|
-
const input = { spaceId: SPACE_ID, name: 'Main', machineName: 'main' };
|
|
66
|
-
|
|
67
|
-
const viewer = stubContext({
|
|
68
|
-
menus: service as never,
|
|
69
|
-
principal: principal({ spaces: { [SPACE_ID]: 'viewer' } }),
|
|
70
|
-
});
|
|
71
|
-
expect(await failure(invoke(menuRouter.create, input, viewer))).toMatchObject({
|
|
72
|
-
code: 'FORBIDDEN',
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
const editor = stubContext({
|
|
76
|
-
menus: service as never,
|
|
77
|
-
principal: principal({ spaces: { [SPACE_ID]: 'editor' } }),
|
|
78
|
-
});
|
|
79
|
-
expect(await invoke(menuRouter.create, input, editor)).toEqual(menu());
|
|
80
|
-
expect(service.create).toHaveBeenCalledWith(input);
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it('rejects a machine name that is not technical before the service sees it', async () => {
|
|
84
|
-
const service = menus();
|
|
85
|
-
const ctx = stubContext({ menus: service as never });
|
|
86
|
-
const result = await failure(
|
|
87
|
-
invoke(menuRouter.create, { spaceId: SPACE_ID, name: 'Main', machineName: 'Main Nav' }, ctx),
|
|
88
|
-
);
|
|
89
|
-
expect(result.code).toBe('BAD_REQUEST');
|
|
90
|
-
expect(service.create).not.toHaveBeenCalled();
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it('maps a service validation error to BAD_REQUEST with its details', async () => {
|
|
94
|
-
const service = menus();
|
|
95
|
-
service.create.mockRejectedValue(
|
|
96
|
-
ManabloxError.validation(
|
|
97
|
-
[{ key: 'menu.machineName.taken', path: ['machineName'], params: { machineName: 'main' } }],
|
|
98
|
-
'menu.validation.failed',
|
|
99
|
-
),
|
|
100
|
-
);
|
|
101
|
-
const ctx = stubContext({ menus: service as never });
|
|
102
|
-
const result = await failure(
|
|
103
|
-
invoke(menuRouter.create, { spaceId: SPACE_ID, name: 'Main', machineName: 'main' }, ctx),
|
|
104
|
-
);
|
|
105
|
-
expect(result).toMatchObject({
|
|
106
|
-
code: 'BAD_REQUEST',
|
|
107
|
-
key: 'menu.validation.failed',
|
|
108
|
-
details: [{ key: 'menu.machineName.taken', path: ['machineName'] }],
|
|
109
|
-
});
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
it('splits the update into the space, the id and the data', async () => {
|
|
113
|
-
const service = menus();
|
|
114
|
-
service.update.mockResolvedValue(menu({ name: 'Primary' }));
|
|
115
|
-
const ctx = stubContext({ menus: service as never });
|
|
116
|
-
await invoke(menuRouter.update, { spaceId: SPACE_ID, id: MENU_ID, name: 'Primary' }, ctx);
|
|
117
|
-
expect(service.update).toHaveBeenCalledWith(SPACE_ID, MENU_ID, { name: 'Primary' });
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
it('maps a missing menu to NOT_FOUND and answers a delete with ok', async () => {
|
|
121
|
-
const service = menus();
|
|
122
|
-
service.delete.mockRejectedValueOnce(ManabloxError.notFound('menu.notFound', { id: MENU_ID }));
|
|
123
|
-
service.delete.mockResolvedValueOnce(undefined);
|
|
124
|
-
const ctx = stubContext({ menus: service as never });
|
|
125
|
-
expect(
|
|
126
|
-
await failure(invoke(menuRouter.delete, { spaceId: SPACE_ID, id: MENU_ID }, ctx)),
|
|
127
|
-
).toMatchObject({ code: 'NOT_FOUND', key: 'menu.notFound' });
|
|
128
|
-
expect(await invoke(menuRouter.delete, { spaceId: SPACE_ID, id: MENU_ID }, ctx)).toEqual({
|
|
129
|
-
ok: true,
|
|
130
|
-
});
|
|
131
|
-
});
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
describe('menus.setItems', () => {
|
|
135
|
-
it('accepts a nested tree of documents and links and hands it over whole', async () => {
|
|
136
|
-
const service = menus();
|
|
137
|
-
service.setItems.mockResolvedValue([]);
|
|
138
|
-
const ctx = stubContext({ menus: service as never });
|
|
139
|
-
const items = [
|
|
140
|
-
{
|
|
141
|
-
localizationId: LOC_ID,
|
|
142
|
-
children: [{ url: 'https://blog.example', label: 'Blog', children: [] }],
|
|
143
|
-
},
|
|
144
|
-
];
|
|
145
|
-
await invoke(menuRouter.setItems, { spaceId: SPACE_ID, id: MENU_ID, items }, ctx);
|
|
146
|
-
expect(service.setItems).toHaveBeenCalledWith(SPACE_ID, MENU_ID, items);
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it('refuses an entry whose localization id is not a uuid', async () => {
|
|
150
|
-
const service = menus();
|
|
151
|
-
const ctx = stubContext({ menus: service as never });
|
|
152
|
-
const result = await failure(
|
|
153
|
-
invoke(
|
|
154
|
-
menuRouter.setItems,
|
|
155
|
-
{ spaceId: SPACE_ID, id: MENU_ID, items: [{ localizationId: 'nope' }] },
|
|
156
|
-
ctx,
|
|
157
|
-
),
|
|
158
|
-
);
|
|
159
|
-
expect(result.code).toBe('BAD_REQUEST');
|
|
160
|
-
expect(service.setItems).not.toHaveBeenCalled();
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
it('surfaces the service’s positional error paths untouched', async () => {
|
|
164
|
-
const service = menus();
|
|
165
|
-
service.setItems.mockRejectedValue(
|
|
166
|
-
ManabloxError.validation(
|
|
167
|
-
[{ key: 'menu.item.linkNeedsLabel', path: ['items', 0, 'children', 1] }],
|
|
168
|
-
'menu.validation.failed',
|
|
169
|
-
),
|
|
170
|
-
);
|
|
171
|
-
const ctx = stubContext({ menus: service as never });
|
|
172
|
-
const result = await failure(
|
|
173
|
-
invoke(menuRouter.setItems, { spaceId: SPACE_ID, id: MENU_ID, items: [] }, ctx),
|
|
174
|
-
);
|
|
175
|
-
expect(result.details?.[0]?.path).toEqual(['items', 0, 'children', 1]);
|
|
176
|
-
});
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
describe('menus.usedIn', () => {
|
|
180
|
-
it('asks for the menus referencing a localization', async () => {
|
|
181
|
-
const service = menus();
|
|
182
|
-
service.usedIn.mockResolvedValue([menu()]);
|
|
183
|
-
const ctx = stubContext({ menus: service as never });
|
|
184
|
-
const result = await invoke(
|
|
185
|
-
menuRouter.usedIn,
|
|
186
|
-
{ spaceId: SPACE_ID, localizationId: LOC_ID },
|
|
187
|
-
ctx,
|
|
188
|
-
);
|
|
189
|
-
expect(result).toEqual([menu()]);
|
|
190
|
-
expect(service.usedIn).toHaveBeenCalledWith(SPACE_ID, LOC_ID);
|
|
191
|
-
});
|
|
192
|
-
});
|
package/test/role.test.ts
DELETED
|
@@ -1,205 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { contentRouter } from '../src/routers/content.js';
|
|
3
|
-
import { roleRouter } from '../src/routers/role.js';
|
|
4
|
-
import { spaceRouter } from '../src/routers/space.js';
|
|
5
|
-
import {
|
|
6
|
-
failure,
|
|
7
|
-
invoke,
|
|
8
|
-
mocks,
|
|
9
|
-
OTHER_USER_ID,
|
|
10
|
-
principal,
|
|
11
|
-
SPACE_ID,
|
|
12
|
-
stubContext,
|
|
13
|
-
TYPE_ID,
|
|
14
|
-
} from './helpers.js';
|
|
15
|
-
|
|
16
|
-
const ROLE_ID = '66666666-6666-4666-8666-666666666666';
|
|
17
|
-
const OTHER_TYPE_ID = '77777777-7777-4777-8777-777777777777';
|
|
18
|
-
|
|
19
|
-
const row = (overrides = {}) => ({
|
|
20
|
-
id: ROLE_ID,
|
|
21
|
-
spaceId: SPACE_ID,
|
|
22
|
-
name: 'Blogger',
|
|
23
|
-
machineName: 'blogger',
|
|
24
|
-
description: null,
|
|
25
|
-
permissions: ['space:read', `content:write:${TYPE_ID}`],
|
|
26
|
-
createdAt: new Date(),
|
|
27
|
-
updatedAt: new Date(),
|
|
28
|
-
...overrides,
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
describe('roles.list', () => {
|
|
32
|
-
it('lists the built-in roles ahead of the space’s own', async () => {
|
|
33
|
-
const ctx = stubContext();
|
|
34
|
-
mocks(ctx).roles.listBySpace.mockResolvedValue([row()]);
|
|
35
|
-
const list = await invoke<Array<{ machineName: string; builtIn: boolean }>>(
|
|
36
|
-
roleRouter.list,
|
|
37
|
-
{ spaceId: SPACE_ID },
|
|
38
|
-
ctx,
|
|
39
|
-
);
|
|
40
|
-
expect(list.map((role) => role.machineName)).toEqual([
|
|
41
|
-
'owner',
|
|
42
|
-
'admin',
|
|
43
|
-
'editor',
|
|
44
|
-
'author',
|
|
45
|
-
'viewer',
|
|
46
|
-
'blogger',
|
|
47
|
-
]);
|
|
48
|
-
expect(list.filter((role) => role.builtIn)).toHaveLength(5);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it('needs role:read, which an author lacks', async () => {
|
|
52
|
-
const ctx = stubContext({ principal: principal({ spaces: { [SPACE_ID]: 'author' } }) });
|
|
53
|
-
expect(await failure(invoke(roleRouter.list, { spaceId: SPACE_ID }, ctx))).toMatchObject({
|
|
54
|
-
code: 'FORBIDDEN',
|
|
55
|
-
});
|
|
56
|
-
});
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
describe('roles.create', () => {
|
|
60
|
-
it('refuses a built-in name, a bad grant and a type the space lacks — each at its path', async () => {
|
|
61
|
-
const ctx = stubContext();
|
|
62
|
-
const result = await failure(
|
|
63
|
-
invoke(
|
|
64
|
-
roleRouter.create,
|
|
65
|
-
{
|
|
66
|
-
spaceId: SPACE_ID,
|
|
67
|
-
name: 'Owner again',
|
|
68
|
-
machineName: 'owner',
|
|
69
|
-
permissions: ['content:write', 'nope:really', `content:read:${OTHER_TYPE_ID}`],
|
|
70
|
-
},
|
|
71
|
-
ctx,
|
|
72
|
-
),
|
|
73
|
-
);
|
|
74
|
-
expect(result.key).toBe('role.validation.failed');
|
|
75
|
-
expect(result.details?.map((detail) => [detail.key, detail.path])).toEqual([
|
|
76
|
-
['role.machineName.reserved', ['machineName']],
|
|
77
|
-
['role.permission.unknown', ['permissions', 1]],
|
|
78
|
-
['role.permission.unknown', ['permissions', 2]],
|
|
79
|
-
]);
|
|
80
|
-
expect(mocks(ctx).roles.create).not.toHaveBeenCalled();
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it('always includes the reads the admin cannot render without', async () => {
|
|
84
|
-
const ctx = stubContext();
|
|
85
|
-
mocks(ctx).roles.create.mockImplementation(async (_spaceId, data) => row(data));
|
|
86
|
-
const created = await invoke<{ permissions: string[] }>(
|
|
87
|
-
roleRouter.create,
|
|
88
|
-
{
|
|
89
|
-
spaceId: SPACE_ID,
|
|
90
|
-
name: 'Blogger',
|
|
91
|
-
machineName: 'blogger',
|
|
92
|
-
permissions: [`content:write:${TYPE_ID}`],
|
|
93
|
-
},
|
|
94
|
-
ctx,
|
|
95
|
-
);
|
|
96
|
-
expect(created.permissions).toEqual([
|
|
97
|
-
'space:read',
|
|
98
|
-
'contentType:read',
|
|
99
|
-
`content:write:${TYPE_ID}`,
|
|
100
|
-
]);
|
|
101
|
-
});
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
describe('roles.delete', () => {
|
|
105
|
-
it('refuses while a member still holds the role', async () => {
|
|
106
|
-
const ctx = stubContext();
|
|
107
|
-
mocks(ctx).roles.findById.mockResolvedValue(row());
|
|
108
|
-
mocks(ctx).roles.countMembers.mockResolvedValue(2);
|
|
109
|
-
expect(
|
|
110
|
-
await failure(invoke(roleRouter.delete, { spaceId: SPACE_ID, id: ROLE_ID }, ctx)),
|
|
111
|
-
).toMatchObject({ code: 'CONFLICT', key: 'role.inUse' });
|
|
112
|
-
expect(mocks(ctx).roles.delete).not.toHaveBeenCalled();
|
|
113
|
-
});
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
describe('memberships and custom roles', () => {
|
|
117
|
-
it('grants a custom role that exists and refuses one that does not', async () => {
|
|
118
|
-
const ctx = stubContext();
|
|
119
|
-
const repos = mocks(ctx);
|
|
120
|
-
repos.users.roleIn.mockResolvedValue('editor');
|
|
121
|
-
repos.roles.findByMachineName.mockImplementation(async (_space, name) =>
|
|
122
|
-
name === 'blogger' ? row() : null,
|
|
123
|
-
);
|
|
124
|
-
|
|
125
|
-
await invoke(
|
|
126
|
-
spaceRouter.grant,
|
|
127
|
-
{ spaceId: SPACE_ID, userId: OTHER_USER_ID, role: 'blogger' },
|
|
128
|
-
ctx,
|
|
129
|
-
);
|
|
130
|
-
expect(repos.users.grant).toHaveBeenCalledWith(OTHER_USER_ID, SPACE_ID, 'blogger');
|
|
131
|
-
|
|
132
|
-
expect(
|
|
133
|
-
await failure(
|
|
134
|
-
invoke(spaceRouter.grant, { spaceId: SPACE_ID, userId: OTHER_USER_ID, role: 'ghost' }, ctx),
|
|
135
|
-
),
|
|
136
|
-
).toMatchObject({ key: 'space.member.roleNotFound' });
|
|
137
|
-
});
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
describe('content permissions narrowed to a type', () => {
|
|
141
|
-
const blogger = () =>
|
|
142
|
-
principal({
|
|
143
|
-
spaces: { [SPACE_ID]: 'blogger' },
|
|
144
|
-
permissions: {
|
|
145
|
-
[SPACE_ID]: ['space:read', `content:read:${TYPE_ID}`, `content:write:${TYPE_ID}`],
|
|
146
|
-
},
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it('lets the role write its type and refuses another', async () => {
|
|
150
|
-
const ctx = stubContext({
|
|
151
|
-
principal: blogger(),
|
|
152
|
-
content: { create: async (input: unknown) => input } as never,
|
|
153
|
-
});
|
|
154
|
-
const save = { spaceId: SPACE_ID, typeId: TYPE_ID, title: 'Post', fields: {} };
|
|
155
|
-
await expect(invoke(contentRouter.create, save, ctx)).resolves.toMatchObject({ title: 'Post' });
|
|
156
|
-
expect(
|
|
157
|
-
await failure(invoke(contentRouter.create, { ...save, typeId: OTHER_TYPE_ID }, ctx)),
|
|
158
|
-
).toMatchObject({ code: 'FORBIDDEN' });
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
it('checks a document’s own type before deleting it', async () => {
|
|
162
|
-
const ctx = stubContext({
|
|
163
|
-
principal: principal({
|
|
164
|
-
spaces: { [SPACE_ID]: 'blogger' },
|
|
165
|
-
permissions: { [SPACE_ID]: ['space:read', `content:delete:${TYPE_ID}`] },
|
|
166
|
-
}),
|
|
167
|
-
content: { delete: async () => 1 } as never,
|
|
168
|
-
});
|
|
169
|
-
const id = '88888888-8888-4888-8888-888888888888';
|
|
170
|
-
mocks(ctx).content.findById.mockResolvedValue({ id, spaceId: SPACE_ID, typeId: OTHER_TYPE_ID });
|
|
171
|
-
expect(
|
|
172
|
-
await failure(invoke(contentRouter.delete, { spaceId: SPACE_ID, id }, ctx)),
|
|
173
|
-
).toMatchObject({
|
|
174
|
-
code: 'FORBIDDEN',
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
mocks(ctx).content.findById.mockResolvedValue({ id, spaceId: SPACE_ID, typeId: TYPE_ID });
|
|
178
|
-
await expect(invoke(contentRouter.delete, { spaceId: SPACE_ID, id }, ctx)).resolves.toEqual({
|
|
179
|
-
deleted: 1,
|
|
180
|
-
});
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
it('narrows a listing to the readable types, and answers an empty page for the rest', async () => {
|
|
184
|
-
const seen: unknown[] = [];
|
|
185
|
-
const ctx = stubContext({
|
|
186
|
-
principal: blogger(),
|
|
187
|
-
content: {
|
|
188
|
-
list: async (filter: unknown) => {
|
|
189
|
-
seen.push(filter);
|
|
190
|
-
return { items: [], total: 0, limit: 25, offset: 0 };
|
|
191
|
-
},
|
|
192
|
-
} as never,
|
|
193
|
-
});
|
|
194
|
-
await invoke(contentRouter.list, { filter: { spaceId: SPACE_ID } }, ctx);
|
|
195
|
-
expect(seen[0]).toMatchObject({ typeIds: [TYPE_ID] });
|
|
196
|
-
|
|
197
|
-
const empty = await invoke<{ total: number }>(
|
|
198
|
-
contentRouter.list,
|
|
199
|
-
{ filter: { spaceId: SPACE_ID, typeIds: [OTHER_TYPE_ID] } },
|
|
200
|
-
ctx,
|
|
201
|
-
);
|
|
202
|
-
expect(empty.total).toBe(0);
|
|
203
|
-
expect(seen).toHaveLength(1);
|
|
204
|
-
});
|
|
205
|
-
});
|