@oceanum/oceanumlab 4.7.0 → 4.7.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 +102 -11
- package/lib/DatameshUI.d.ts +1 -6
- package/lib/DatameshUI.js +1 -9
- package/lib/DatameshWidget.d.ts +18 -2
- package/lib/DatameshWidget.js +80 -12
- package/lib/StoredNotebooks.d.ts +20 -0
- package/lib/StoredNotebooks.js +112 -0
- package/lib/Tabs.d.ts +26 -0
- package/lib/Tabs.js +45 -0
- package/lib/aiBackend.d.ts +1 -1
- package/lib/aiBackend.js +1 -1
- package/lib/auth/accountWidget.d.ts +35 -0
- package/lib/auth/accountWidget.js +95 -0
- package/lib/auth/claims.d.ts +3 -0
- package/lib/auth/claims.js +25 -0
- package/lib/auth/plugin.d.ts +9 -0
- package/lib/auth/plugin.js +126 -0
- package/lib/auth/serverAuth.d.ts +79 -0
- package/lib/auth/serverAuth.js +254 -0
- package/lib/auth/startSignIn.d.ts +11 -0
- package/lib/auth/startSignIn.js +16 -0
- package/lib/auth/tokens.d.ts +102 -0
- package/lib/auth/tokens.js +9 -0
- package/lib/chatRouter.d.ts +13 -1
- package/lib/chatRouter.js +9 -2
- package/lib/constants.d.ts +1 -1
- package/lib/constants.js +1 -1
- package/lib/datameshUiUrl.d.ts +2 -2
- package/lib/datameshUiUrl.js +6 -6
- package/lib/index.d.ts +8 -1
- package/lib/index.js +58 -17
- package/lib/share/client.d.ts +84 -0
- package/lib/share/client.js +205 -0
- package/lib/share/events.d.ts +7 -0
- package/lib/share/events.js +9 -0
- package/lib/share/notebook.d.ts +184 -0
- package/lib/share/notebook.js +277 -0
- package/lib/share/oceanumOnly.d.ts +49 -0
- package/lib/share/oceanumOnly.js +90 -0
- package/lib/share/plugin.d.ts +21 -0
- package/lib/share/plugin.js +883 -0
- package/lib/share/widgets.d.ts +25 -0
- package/lib/share/widgets.js +154 -0
- package/package.json +13 -1
- package/schema/datamesh-connect.json +3 -12
- package/src/DatameshUI.tsx +1 -10
- package/src/DatameshWidget.tsx +143 -19
- package/src/StoredNotebooks.tsx +206 -0
- package/src/Tabs.tsx +102 -0
- package/src/__tests__/DatameshWidget.spec.tsx +33 -20
- package/src/__tests__/OceanumPanel.spec.tsx +157 -0
- package/src/__tests__/StoredNotebooks.spec.tsx +157 -0
- package/src/__tests__/Tabs.spec.tsx +168 -0
- package/src/__tests__/aiBackend.spec.ts +5 -6
- package/src/__tests__/chatRouter.spec.ts +9 -15
- package/src/__tests__/datameshUiUrl.spec.ts +4 -3
- package/src/__tests__/oceanumOnly.spec.ts +193 -0
- package/src/__tests__/pluginArity.spec.ts +24 -0
- package/src/__tests__/serverAuth.spec.ts +277 -0
- package/src/__tests__/settingsSchema.spec.ts +73 -0
- package/src/__tests__/shareClient.spec.ts +314 -0
- package/src/__tests__/shareNotebook.spec.ts +451 -0
- package/src/__tests__/sharePlugin.spec.ts +426 -0
- package/src/aiBackend.ts +1 -1
- package/src/auth/accountWidget.ts +113 -0
- package/src/auth/claims.ts +30 -0
- package/src/auth/plugin.ts +155 -0
- package/src/auth/serverAuth.ts +342 -0
- package/src/auth/startSignIn.ts +23 -0
- package/src/auth/tokens.ts +111 -0
- package/src/chatRouter.ts +8 -4
- package/src/constants.ts +1 -1
- package/src/datameshUiUrl.ts +6 -6
- package/src/index.ts +87 -36
- package/src/share/client.ts +288 -0
- package/src/share/events.ts +10 -0
- package/src/share/notebook.ts +416 -0
- package/src/share/oceanumOnly.ts +126 -0
- package/src/share/plugin.ts +1096 -0
- package/src/share/widgets.ts +217 -0
- package/style/index.css +266 -13
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AuthRequest,
|
|
3
|
+
readServerAuthOption,
|
|
4
|
+
SERVER_AUTH_OPTION,
|
|
5
|
+
ServerAuth
|
|
6
|
+
} from '../auth/serverAuth';
|
|
7
|
+
import { IOceanumEnvironment } from '../auth/tokens';
|
|
8
|
+
|
|
9
|
+
const OPTION = JSON.stringify({
|
|
10
|
+
auth0Domain: 'auth.oceanum.io',
|
|
11
|
+
clientId: 'a-client',
|
|
12
|
+
oceanumDomain: 'oceanum.io',
|
|
13
|
+
urls: {
|
|
14
|
+
datamesh: 'https://datamesh.oceanum.io/',
|
|
15
|
+
specs: 'https://specs.oceanum.io',
|
|
16
|
+
manage: 'https://manage.oceanum.io',
|
|
17
|
+
ai: 'https://ai.oceanum.io'
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const ENVIRONMENT = readServerAuthOption(
|
|
22
|
+
OPTION,
|
|
23
|
+
'localhost'
|
|
24
|
+
) as IOceanumEnvironment;
|
|
25
|
+
|
|
26
|
+
const CLAIMS = { sub: 'auth0|abc', email: 'ada@example.org', name: 'Ada' };
|
|
27
|
+
const PENDING = {
|
|
28
|
+
userCode: 'WXYZ-1234',
|
|
29
|
+
verificationUri: 'https://auth.oceanum.io/activate',
|
|
30
|
+
verificationUriComplete:
|
|
31
|
+
'https://auth.oceanum.io/activate?user_code=WXYZ-1234',
|
|
32
|
+
expiresIn: 900
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const SIGNED_OUT = {
|
|
36
|
+
state: 'signedOut',
|
|
37
|
+
claims: null as unknown,
|
|
38
|
+
pending: null as unknown,
|
|
39
|
+
error: null as string | null
|
|
40
|
+
};
|
|
41
|
+
const WAITING = { ...SIGNED_OUT, state: 'pending', pending: PENDING };
|
|
42
|
+
const SIGNED_IN = { ...SIGNED_OUT, state: 'signedIn', claims: CLAIMS };
|
|
43
|
+
|
|
44
|
+
/** A Jupyter server whose answers can be changed as the test goes. */
|
|
45
|
+
function server(initial: Record<string, unknown>) {
|
|
46
|
+
const calls: string[] = [];
|
|
47
|
+
const state = { status: initial, token: 'jwt-1' as string | null };
|
|
48
|
+
const request: AuthRequest = async (action, method) => {
|
|
49
|
+
calls.push(`${method} ${action}`);
|
|
50
|
+
if (action === 'token') {
|
|
51
|
+
const signedIn = state.status.state === 'signedIn';
|
|
52
|
+
return { ...state.status, accessToken: signedIn ? state.token : null };
|
|
53
|
+
}
|
|
54
|
+
return state.status;
|
|
55
|
+
};
|
|
56
|
+
return { calls, state, request };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const tick = (ms = 0): Promise<void> =>
|
|
60
|
+
new Promise(resolve => setTimeout(resolve, ms));
|
|
61
|
+
|
|
62
|
+
async function until(check: () => void, timeout = 2000): Promise<void> {
|
|
63
|
+
const end = Date.now() + timeout;
|
|
64
|
+
for (;;) {
|
|
65
|
+
try {
|
|
66
|
+
check();
|
|
67
|
+
return;
|
|
68
|
+
} catch (err) {
|
|
69
|
+
if (Date.now() > end) {
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
await tick(10);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe('readServerAuthOption', () => {
|
|
78
|
+
it('reads the page option name the server publishes under', () => {
|
|
79
|
+
// oceanumlab/__init__.py writes this literal (SERVER_AUTH_OPTION there, pinned by its
|
|
80
|
+
// own test). Renaming either side alone would silently turn sign-in off everywhere.
|
|
81
|
+
expect(SERVER_AUTH_OPTION).toBe('oceanumServerAuth');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('builds the environment, normalising URLs and dropping absent optional ones', () => {
|
|
85
|
+
expect(ENVIRONMENT.urls).toEqual({
|
|
86
|
+
datamesh: 'https://datamesh.oceanum.io',
|
|
87
|
+
specs: 'https://specs.oceanum.io',
|
|
88
|
+
manage: 'https://manage.oceanum.io',
|
|
89
|
+
ai: 'https://ai.oceanum.io'
|
|
90
|
+
});
|
|
91
|
+
// The device grant is indifferent to the page's address, so there is nothing to match.
|
|
92
|
+
expect(ENVIRONMENT.hosts).toEqual(['localhost']);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('treats a missing or unusable option as no sign-in on offer', () => {
|
|
96
|
+
expect(readServerAuthOption(undefined, 'localhost')).toBeNull();
|
|
97
|
+
expect(readServerAuthOption('', 'localhost')).toBeNull();
|
|
98
|
+
expect(readServerAuthOption('not json', 'localhost')).toBeNull();
|
|
99
|
+
expect(readServerAuthOption('[]', 'localhost')).toBeNull();
|
|
100
|
+
const noSpecs = JSON.parse(OPTION);
|
|
101
|
+
delete noSpecs.urls.specs;
|
|
102
|
+
expect(
|
|
103
|
+
readServerAuthOption(JSON.stringify(noSpecs), 'localhost')
|
|
104
|
+
).toBeNull();
|
|
105
|
+
const script = JSON.parse(OPTION);
|
|
106
|
+
script.urls.specs = 'javascript:alert(1)';
|
|
107
|
+
expect(
|
|
108
|
+
readServerAuthOption(JSON.stringify(script), 'localhost')
|
|
109
|
+
).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('ServerAuth', () => {
|
|
114
|
+
it('is inert, and ready, when the server offers no sign-in', async () => {
|
|
115
|
+
const jupyter = server(SIGNED_OUT);
|
|
116
|
+
const auth = new ServerAuth({
|
|
117
|
+
environment: null,
|
|
118
|
+
request: jupyter.request
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
await auth.ready;
|
|
122
|
+
await auth.signIn();
|
|
123
|
+
|
|
124
|
+
expect(auth.user).toBeNull();
|
|
125
|
+
expect(await auth.getAccessToken()).toBeNull();
|
|
126
|
+
expect(jupyter.calls).toEqual([]);
|
|
127
|
+
auth.dispose();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('finds a session the server already holds, before it says it is ready', async () => {
|
|
131
|
+
// A page reload, or a second tab: the server signed in earlier and still is.
|
|
132
|
+
const jupyter = server(SIGNED_IN);
|
|
133
|
+
const auth = new ServerAuth({
|
|
134
|
+
environment: ENVIRONMENT,
|
|
135
|
+
request: jupyter.request
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
await auth.ready;
|
|
139
|
+
|
|
140
|
+
expect(auth.user?.email).toBe('ada@example.org');
|
|
141
|
+
expect(await auth.getAccessToken()).toBe('jwt-1');
|
|
142
|
+
auth.dispose();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('starts a sign-in, waits on the user, then reports the user and the token', async () => {
|
|
146
|
+
const jupyter = server(SIGNED_OUT);
|
|
147
|
+
const auth = new ServerAuth({
|
|
148
|
+
environment: ENVIRONMENT,
|
|
149
|
+
request: jupyter.request,
|
|
150
|
+
pendingPollMs: 10
|
|
151
|
+
});
|
|
152
|
+
await auth.ready;
|
|
153
|
+
const users: (string | null)[] = [];
|
|
154
|
+
const tokens: (string | null)[] = [];
|
|
155
|
+
auth.userChanged.connect((_, user) => users.push(user?.email ?? null));
|
|
156
|
+
auth.tokenChanged.connect((_, token) => tokens.push(token));
|
|
157
|
+
|
|
158
|
+
jupyter.state.status = WAITING;
|
|
159
|
+
await auth.signIn();
|
|
160
|
+
expect(auth.pending?.userCode).toBe('WXYZ-1234');
|
|
161
|
+
expect(auth.user).toBeNull();
|
|
162
|
+
|
|
163
|
+
// The user confirms the code in their browser; the server's next answer says so.
|
|
164
|
+
jupyter.state.status = SIGNED_IN;
|
|
165
|
+
await until(() => expect(auth.user?.email).toBe('ada@example.org'));
|
|
166
|
+
|
|
167
|
+
expect(auth.pending).toBeNull();
|
|
168
|
+
expect(users).toEqual(['ada@example.org']);
|
|
169
|
+
expect(tokens).toEqual(['jwt-1']);
|
|
170
|
+
auth.dispose();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('passes on a refreshed token while signed in', async () => {
|
|
174
|
+
const jupyter = server(SIGNED_IN);
|
|
175
|
+
const auth = new ServerAuth({
|
|
176
|
+
environment: ENVIRONMENT,
|
|
177
|
+
request: jupyter.request,
|
|
178
|
+
tokenPollMs: 10
|
|
179
|
+
});
|
|
180
|
+
await auth.ready;
|
|
181
|
+
const tokens: (string | null)[] = [];
|
|
182
|
+
auth.tokenChanged.connect((_, token) => tokens.push(token));
|
|
183
|
+
|
|
184
|
+
jupyter.state.token = 'jwt-2';
|
|
185
|
+
await until(() => expect(tokens).toEqual(['jwt-2']));
|
|
186
|
+
auth.dispose();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('signs out without calling it a session that ended', async () => {
|
|
190
|
+
const jupyter = server(SIGNED_IN);
|
|
191
|
+
const ended: string[] = [];
|
|
192
|
+
const auth = new ServerAuth({
|
|
193
|
+
environment: ENVIRONMENT,
|
|
194
|
+
request: jupyter.request,
|
|
195
|
+
onSessionEnded: message => ended.push(message)
|
|
196
|
+
});
|
|
197
|
+
await auth.ready;
|
|
198
|
+
const tokens: (string | null)[] = [];
|
|
199
|
+
auth.tokenChanged.connect((_, token) => tokens.push(token));
|
|
200
|
+
|
|
201
|
+
jupyter.state.status = SIGNED_OUT;
|
|
202
|
+
await auth.signOut();
|
|
203
|
+
|
|
204
|
+
expect(jupyter.calls).toContain('POST signout');
|
|
205
|
+
expect(auth.user).toBeNull();
|
|
206
|
+
expect(tokens).toEqual([null]);
|
|
207
|
+
expect(ended).toEqual([]);
|
|
208
|
+
auth.dispose();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('says so when the server ends the session itself', async () => {
|
|
212
|
+
const jupyter = server(SIGNED_IN);
|
|
213
|
+
const ended: string[] = [];
|
|
214
|
+
const auth = new ServerAuth({
|
|
215
|
+
environment: ENVIRONMENT,
|
|
216
|
+
request: jupyter.request,
|
|
217
|
+
onSessionEnded: message => ended.push(message),
|
|
218
|
+
tokenPollMs: 10
|
|
219
|
+
});
|
|
220
|
+
await auth.ready;
|
|
221
|
+
|
|
222
|
+
// The refresh token was revoked: the server's next answer is signed out, with why.
|
|
223
|
+
jupyter.state.status = {
|
|
224
|
+
...SIGNED_OUT,
|
|
225
|
+
error: 'Your Oceanum.io session has ended.'
|
|
226
|
+
};
|
|
227
|
+
await until(() => expect(auth.user).toBeNull());
|
|
228
|
+
|
|
229
|
+
expect(ended).toEqual(['Your Oceanum.io session has ended.']);
|
|
230
|
+
auth.dispose();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('keeps the session when the Jupyter server is briefly unreachable', async () => {
|
|
234
|
+
const jupyter = server(SIGNED_IN);
|
|
235
|
+
let failing = false;
|
|
236
|
+
const warn = jest
|
|
237
|
+
.spyOn(console, 'warn')
|
|
238
|
+
.mockImplementation(() => undefined);
|
|
239
|
+
const auth = new ServerAuth({
|
|
240
|
+
environment: ENVIRONMENT,
|
|
241
|
+
request: async (action, method) => {
|
|
242
|
+
if (failing) {
|
|
243
|
+
throw new Error('connection refused');
|
|
244
|
+
}
|
|
245
|
+
return jupyter.request(action, method);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
await auth.ready;
|
|
249
|
+
|
|
250
|
+
failing = true;
|
|
251
|
+
await auth.refresh();
|
|
252
|
+
|
|
253
|
+
// One failed request is not a sign-out.
|
|
254
|
+
expect(auth.user?.email).toBe('ada@example.org');
|
|
255
|
+
auth.dispose();
|
|
256
|
+
warn.mockRestore();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('cancels a sign-in that is waiting', async () => {
|
|
260
|
+
const jupyter = server(SIGNED_OUT);
|
|
261
|
+
const auth = new ServerAuth({
|
|
262
|
+
environment: ENVIRONMENT,
|
|
263
|
+
request: jupyter.request,
|
|
264
|
+
pendingPollMs: 10000
|
|
265
|
+
});
|
|
266
|
+
await auth.ready;
|
|
267
|
+
jupyter.state.status = WAITING;
|
|
268
|
+
await auth.signIn();
|
|
269
|
+
|
|
270
|
+
jupyter.state.status = SIGNED_OUT;
|
|
271
|
+
await auth.cancelSignIn();
|
|
272
|
+
|
|
273
|
+
expect(jupyter.calls).toContain('POST cancel');
|
|
274
|
+
expect(auth.pending).toBeNull();
|
|
275
|
+
auth.dispose();
|
|
276
|
+
});
|
|
277
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
|
|
4
|
+
import { ISettingRegistry, SettingRegistry } from '@jupyterlab/settingregistry';
|
|
5
|
+
|
|
6
|
+
const ID = '@oceanum/oceanumlab:datamesh-connect';
|
|
7
|
+
const schema = JSON.parse(
|
|
8
|
+
readFileSync(join(__dirname, '../../schema/datamesh-connect.json'), 'utf8')
|
|
9
|
+
) as ISettingRegistry.ISchema;
|
|
10
|
+
|
|
11
|
+
/** A registry whose stored user settings for the plugin are `user`. */
|
|
12
|
+
function registryWith(user: Record<string, unknown>): SettingRegistry {
|
|
13
|
+
const plugin: ISettingRegistry.IPlugin = {
|
|
14
|
+
id: ID,
|
|
15
|
+
data: { user: {}, composite: {} },
|
|
16
|
+
raw: JSON.stringify(user),
|
|
17
|
+
schema,
|
|
18
|
+
version: 'test'
|
|
19
|
+
};
|
|
20
|
+
const connector = {
|
|
21
|
+
fetch: async (): Promise<ISettingRegistry.IPlugin> => plugin,
|
|
22
|
+
list: async () => ({ ids: [ID], values: [plugin] }),
|
|
23
|
+
save: async (): Promise<void> => undefined,
|
|
24
|
+
remove: async (): Promise<void> => undefined
|
|
25
|
+
};
|
|
26
|
+
return new SettingRegistry({ connector: connector as any });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `datameshUiUrl` and `aiBackendUrl` were user settings until 4.7.1. They are deployment
|
|
31
|
+
* configuration now: a user-editable service address is somewhere to send the user's
|
|
32
|
+
* credential.
|
|
33
|
+
*
|
|
34
|
+
* Taking them out of the schema is not enough on its own, and would have been a breaking
|
|
35
|
+
* release. The schema is `additionalProperties: false`, the registry REJECTS a load whose
|
|
36
|
+
* stored settings fail validation, and these two keys are stored for every
|
|
37
|
+
* notebook.oceanum.io user: until this release that site wrote each environment's addresses
|
|
38
|
+
* into oceanumlab's settings. Every one of them would have lost the whole settings load --
|
|
39
|
+
* the Datamesh token with it.
|
|
40
|
+
*/
|
|
41
|
+
describe('the datamesh-connect settings schema', () => {
|
|
42
|
+
it('no longer offers the two service addresses as settings', () => {
|
|
43
|
+
expect(Object.keys(schema.properties ?? {})).toEqual([
|
|
44
|
+
'datameshToken',
|
|
45
|
+
'injectToken',
|
|
46
|
+
'autoRunCode',
|
|
47
|
+
'iterate'
|
|
48
|
+
]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('still loads settings stored when they were', async () => {
|
|
52
|
+
const registry = registryWith({
|
|
53
|
+
datameshToken: 'a-token',
|
|
54
|
+
datameshUiUrl: 'https://ui.datamesh.oceanum.tech',
|
|
55
|
+
aiBackendUrl: 'https://ai.oceanum.tech'
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const settings = await registry.load(ID);
|
|
59
|
+
|
|
60
|
+
expect(settings.get('datameshToken').composite).toBe('a-token');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('stays strict about everything else', async () => {
|
|
64
|
+
// Tolerating those two must not turn into tolerating typos.
|
|
65
|
+
const warn = jest
|
|
66
|
+
.spyOn(console, 'warn')
|
|
67
|
+
.mockImplementation(() => undefined);
|
|
68
|
+
const registry = registryWith({ datameshTokn: 'a-typo' });
|
|
69
|
+
|
|
70
|
+
await expect(registry.load(ID)).rejects.toBeDefined();
|
|
71
|
+
warn.mockRestore();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import type { INotebookContent } from '@jupyterlab/nbformat';
|
|
2
|
+
|
|
3
|
+
import { SpecStoreClient, SpecStoreError } from '../share/client';
|
|
4
|
+
import type { ISpecBody } from '../share/notebook';
|
|
5
|
+
|
|
6
|
+
const ID = '6f1c1a52-4b8e-4c0f-9a57-1d2e3f4a5b6c';
|
|
7
|
+
const SERVER_ID = '0b7d9f2e-1111-4a2b-8c3d-9e8f7a6b5c4d';
|
|
8
|
+
|
|
9
|
+
const notebook: INotebookContent = {
|
|
10
|
+
nbformat: 4,
|
|
11
|
+
nbformat_minor: 5,
|
|
12
|
+
metadata: {},
|
|
13
|
+
cells: []
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function record(id: string = ID) {
|
|
17
|
+
return {
|
|
18
|
+
id,
|
|
19
|
+
name: 'Waves',
|
|
20
|
+
description: null as string | null,
|
|
21
|
+
modified: '2026-09-12T10:00:00',
|
|
22
|
+
creator: 'a@example.com',
|
|
23
|
+
spec: notebook
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function json(data: unknown, status = 200): Response {
|
|
28
|
+
return {
|
|
29
|
+
ok: status >= 200 && status < 300,
|
|
30
|
+
status,
|
|
31
|
+
json: async () => data
|
|
32
|
+
} as unknown as Response;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function noContent(): Response {
|
|
36
|
+
return {
|
|
37
|
+
ok: true,
|
|
38
|
+
status: 204,
|
|
39
|
+
json: async () => {
|
|
40
|
+
throw new Error('no body');
|
|
41
|
+
}
|
|
42
|
+
} as unknown as Response;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function setup(
|
|
46
|
+
response: () => Response | Promise<Response>,
|
|
47
|
+
token: string | null = 'tok'
|
|
48
|
+
) {
|
|
49
|
+
const fetch = jest.fn(
|
|
50
|
+
async (_input: RequestInfo | URL, _init?: RequestInit) => response()
|
|
51
|
+
);
|
|
52
|
+
const client = new SpecStoreClient({
|
|
53
|
+
specsUrl: 'https://specs.example.com/',
|
|
54
|
+
getAccessToken: async () => token,
|
|
55
|
+
fetch: fetch as unknown as typeof globalThis.fetch
|
|
56
|
+
});
|
|
57
|
+
const call = (i = 0) => {
|
|
58
|
+
const [url, init] = fetch.mock.calls[i];
|
|
59
|
+
return {
|
|
60
|
+
url: String(url),
|
|
61
|
+
method: init?.method,
|
|
62
|
+
headers: (init?.headers ?? {}) as Record<string, string>,
|
|
63
|
+
body: init?.body === undefined ? undefined : JSON.parse(String(init.body))
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
return { client, fetch, call };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const body: ISpecBody = { name: 'Waves', description: null, spec: notebook };
|
|
70
|
+
|
|
71
|
+
describe('SpecStoreClient', () => {
|
|
72
|
+
it('lists with the bearer token', async () => {
|
|
73
|
+
const { client, call } = setup(() => json([record()]));
|
|
74
|
+
const items = await client.list();
|
|
75
|
+
expect(items).toHaveLength(1);
|
|
76
|
+
expect(call()).toEqual({
|
|
77
|
+
url: 'https://specs.example.com/specs/notebook',
|
|
78
|
+
method: 'GET',
|
|
79
|
+
headers: { Authorization: 'Bearer tok' },
|
|
80
|
+
body: undefined
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('requires sign-in to list, without calling the server', async () => {
|
|
85
|
+
const { client, fetch } = setup(() => json([]), null);
|
|
86
|
+
await expect(client.list()).rejects.toMatchObject({ kind: 'signed-out' });
|
|
87
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('gets a record anonymously when signed out', async () => {
|
|
91
|
+
const { client, call } = setup(() => json(record()), null);
|
|
92
|
+
const result = await client.get(ID);
|
|
93
|
+
expect(result.id).toBe(ID);
|
|
94
|
+
expect(call().url).toBe(`https://specs.example.com/specs/notebook/${ID}`);
|
|
95
|
+
expect(call().headers).toEqual({});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('refuses ids that are not UUIDs before building a URL', async () => {
|
|
99
|
+
const { client, fetch } = setup(() => json(record()));
|
|
100
|
+
await expect(client.get('../eidos')).rejects.toMatchObject({
|
|
101
|
+
kind: 'invalid'
|
|
102
|
+
});
|
|
103
|
+
await expect(client.update('x/permissions', body)).rejects.toBeInstanceOf(
|
|
104
|
+
SpecStoreError
|
|
105
|
+
);
|
|
106
|
+
await expect(client.remove('x')).rejects.toMatchObject({
|
|
107
|
+
kind: 'invalid'
|
|
108
|
+
});
|
|
109
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('creates with a JSON body, never sends an id, and uses the server id', async () => {
|
|
113
|
+
const { client, call } = setup(() => json(record(SERVER_ID)));
|
|
114
|
+
const withId = { ...body, id: ID } as ISpecBody;
|
|
115
|
+
const result = await client.create(withId);
|
|
116
|
+
expect(result.id).toBe(SERVER_ID);
|
|
117
|
+
expect(call()).toEqual({
|
|
118
|
+
url: 'https://specs.example.com/specs/notebook',
|
|
119
|
+
method: 'POST',
|
|
120
|
+
headers: {
|
|
121
|
+
Authorization: 'Bearer tok',
|
|
122
|
+
'Content-Type': 'application/json'
|
|
123
|
+
},
|
|
124
|
+
body: { name: 'Waves', description: null, spec: notebook }
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('updates with PUT to the record URL', async () => {
|
|
129
|
+
const { client, call } = setup(() => json(record()));
|
|
130
|
+
await client.update(ID, body);
|
|
131
|
+
expect(call().method).toBe('PUT');
|
|
132
|
+
expect(call().url).toBe(`https://specs.example.com/specs/notebook/${ID}`);
|
|
133
|
+
expect(call().body).toEqual(body);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('patches only the fields it is given', async () => {
|
|
137
|
+
const { client, call } = setup(() => json(record()));
|
|
138
|
+
await client.patch(ID, { name: 'Renamed' });
|
|
139
|
+
expect(call()).toEqual({
|
|
140
|
+
url: `https://specs.example.com/specs/notebook/${ID}`,
|
|
141
|
+
method: 'PATCH',
|
|
142
|
+
headers: {
|
|
143
|
+
Authorization: 'Bearer tok',
|
|
144
|
+
'Content-Type': 'application/json'
|
|
145
|
+
},
|
|
146
|
+
body: { name: 'Renamed' }
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('refuses a patch that carries nothing, without calling the server', async () => {
|
|
151
|
+
// The store answers an empty patch with 400, and there is nothing to send.
|
|
152
|
+
const { client, fetch } = setup(() => json(record()));
|
|
153
|
+
await expect(client.patch(ID, {})).rejects.toMatchObject({
|
|
154
|
+
kind: 'invalid'
|
|
155
|
+
});
|
|
156
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('renames with a single patch, leaving the notebook alone', async () => {
|
|
160
|
+
const { client, call, fetch } = setup(() => json(record()));
|
|
161
|
+
await client.rename(ID, 'Renamed');
|
|
162
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
163
|
+
expect(call().method).toBe('PATCH');
|
|
164
|
+
expect(call().body).toEqual({ name: 'Renamed' });
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('renames by reading and putting back where the store has no PATCH', async () => {
|
|
168
|
+
// Production served no PATCH when this was written; a store without it answers 405.
|
|
169
|
+
const responses: Record<string, () => Response> = {
|
|
170
|
+
PATCH: () => json('Method Not Allowed', 405),
|
|
171
|
+
GET: () => json({ ...record(), description: 'Wave stats' }),
|
|
172
|
+
PUT: () => json(record())
|
|
173
|
+
};
|
|
174
|
+
const fetch = jest.fn(
|
|
175
|
+
async (_input: RequestInfo | URL, init?: RequestInit) =>
|
|
176
|
+
responses[init?.method ?? 'GET']()
|
|
177
|
+
);
|
|
178
|
+
const client = new SpecStoreClient({
|
|
179
|
+
specsUrl: 'https://specs.example.com',
|
|
180
|
+
getAccessToken: async () => 'tok',
|
|
181
|
+
fetch: fetch as unknown as typeof globalThis.fetch
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
await client.rename(ID, 'Renamed');
|
|
185
|
+
|
|
186
|
+
expect(fetch.mock.calls.map(([, init]) => init?.method)).toEqual([
|
|
187
|
+
'PATCH',
|
|
188
|
+
'GET',
|
|
189
|
+
'PUT'
|
|
190
|
+
]);
|
|
191
|
+
const put = JSON.parse(String(fetch.mock.calls[2][1]?.body));
|
|
192
|
+
// The name changes; everything else goes back as it was read.
|
|
193
|
+
expect(put).toEqual({
|
|
194
|
+
name: 'Renamed',
|
|
195
|
+
description: 'Wave stats',
|
|
196
|
+
spec: notebook
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('does not fall back for a failure that is not a missing method', async () => {
|
|
201
|
+
const { client, fetch } = setup(() => json('Forbidden', 403));
|
|
202
|
+
await expect(client.rename(ID, 'Renamed')).rejects.toMatchObject({
|
|
203
|
+
kind: 'forbidden'
|
|
204
|
+
});
|
|
205
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('deletes with DELETE to the record URL, and needs sign-in', async () => {
|
|
209
|
+
const { client, call } = setup(() => noContent());
|
|
210
|
+
await client.remove(ID);
|
|
211
|
+
expect(call().method).toBe('DELETE');
|
|
212
|
+
expect(call().url).toBe(`https://specs.example.com/specs/notebook/${ID}`);
|
|
213
|
+
expect(call().body).toBeUndefined();
|
|
214
|
+
|
|
215
|
+
const signedOut = setup(() => noContent(), null);
|
|
216
|
+
await expect(signedOut.client.remove(ID)).rejects.toMatchObject({
|
|
217
|
+
kind: 'signed-out'
|
|
218
|
+
});
|
|
219
|
+
expect(signedOut.fetch).not.toHaveBeenCalled();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('posts permission grants', async () => {
|
|
223
|
+
const { client, call } = setup(() => json([]));
|
|
224
|
+
await client.addPermission(ID, {
|
|
225
|
+
type: 'user',
|
|
226
|
+
entity: 'b@example.com',
|
|
227
|
+
permission: 'write'
|
|
228
|
+
});
|
|
229
|
+
await client.addPermission(ID, {
|
|
230
|
+
type: 'public',
|
|
231
|
+
entity: '',
|
|
232
|
+
permission: 'read'
|
|
233
|
+
});
|
|
234
|
+
expect(call(0).url).toBe(
|
|
235
|
+
`https://specs.example.com/specs/notebook/${ID}/permissions`
|
|
236
|
+
);
|
|
237
|
+
expect(call(0).method).toBe('POST');
|
|
238
|
+
expect(call(0).body).toEqual({
|
|
239
|
+
type: 'user',
|
|
240
|
+
entity: 'b@example.com',
|
|
241
|
+
permission: 'write'
|
|
242
|
+
});
|
|
243
|
+
expect(call(1).body).toEqual({
|
|
244
|
+
type: 'public',
|
|
245
|
+
entity: '',
|
|
246
|
+
permission: 'read'
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('deletes a permission by repeating the exact grant', async () => {
|
|
251
|
+
const { client, call } = setup(() => json([]));
|
|
252
|
+
await client.removePermission(ID, {
|
|
253
|
+
type: 'public',
|
|
254
|
+
entity: '',
|
|
255
|
+
permission: 'read'
|
|
256
|
+
});
|
|
257
|
+
expect(call(0).url).toBe(
|
|
258
|
+
`https://specs.example.com/specs/notebook/${ID}/permissions`
|
|
259
|
+
);
|
|
260
|
+
expect(call(0).method).toBe('DELETE');
|
|
261
|
+
expect(call(0).body).toEqual({
|
|
262
|
+
type: 'public',
|
|
263
|
+
entity: '',
|
|
264
|
+
permission: 'read'
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it.each([
|
|
269
|
+
[400, 'expired', 'Your Oceanum session has expired, sign in again.'],
|
|
270
|
+
[403, 'forbidden', /another organisation/],
|
|
271
|
+
[404, 'not-found', /no longer exists/],
|
|
272
|
+
[405, 'unsupported', /does not offer that operation/],
|
|
273
|
+
[413, 'too-large', /too large/],
|
|
274
|
+
[500, 'server', 'Oceanum.io returned an error (HTTP 500).']
|
|
275
|
+
])('maps HTTP %i to %s', async (status, kind, message) => {
|
|
276
|
+
const { client } = setup(() => json('Forbidden', status as number));
|
|
277
|
+
const error = await client.get(ID).catch(e => e);
|
|
278
|
+
expect(error).toBeInstanceOf(SpecStoreError);
|
|
279
|
+
expect(error.kind).toBe(kind);
|
|
280
|
+
expect(error.status).toBe(status);
|
|
281
|
+
expect(error.message).toMatch(message as string | RegExp);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('maps a network failure', async () => {
|
|
285
|
+
const { client } = setup(() =>
|
|
286
|
+
Promise.reject(new TypeError('Failed to fetch'))
|
|
287
|
+
);
|
|
288
|
+
await expect(client.create(body)).rejects.toMatchObject({
|
|
289
|
+
kind: 'network',
|
|
290
|
+
message: expect.stringMatching(/Could not reach Oceanum.io/)
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('rejects malformed responses', async () => {
|
|
295
|
+
const { client } = setup(() => json({ id: 'nope', name: 1 }));
|
|
296
|
+
await expect(client.get(ID)).rejects.toMatchObject({ kind: 'invalid' });
|
|
297
|
+
const list = setup(() => json({ detail: 'x' }));
|
|
298
|
+
await expect(list.client.list()).rejects.toMatchObject({
|
|
299
|
+
kind: 'invalid'
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('asks for a fresh token on every request', async () => {
|
|
304
|
+
const getAccessToken = jest.fn(async () => 'tok');
|
|
305
|
+
const client = new SpecStoreClient({
|
|
306
|
+
specsUrl: 'https://specs.example.com',
|
|
307
|
+
getAccessToken,
|
|
308
|
+
fetch: async () => json(record())
|
|
309
|
+
});
|
|
310
|
+
await client.get(ID);
|
|
311
|
+
await client.get(ID);
|
|
312
|
+
expect(getAccessToken).toHaveBeenCalledTimes(2);
|
|
313
|
+
});
|
|
314
|
+
});
|