@oceanum/oceanumlab 4.6.1 → 4.7.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/lib/DatameshUI.d.ts +10 -0
- package/lib/DatameshUI.js +20 -2
- package/lib/DatameshWidget.d.ts +11 -1
- package/lib/DatameshWidget.js +116 -43
- package/lib/aiBackend.d.ts +66 -0
- package/lib/aiBackend.js +101 -0
- package/lib/chatRouter.d.ts +5 -1
- package/lib/chatRouter.js +27 -9
- package/lib/constants.d.ts +1 -1
- package/lib/constants.js +1 -1
- package/lib/datameshUiUrl.d.ts +15 -0
- package/lib/datameshUiUrl.js +35 -0
- package/lib/httpUrl.d.ts +9 -0
- package/lib/httpUrl.js +20 -0
- package/lib/index.js +28 -7
- package/package.json +1 -1
- package/schema/datamesh-connect.json +12 -0
- package/src/DatameshUI.tsx +21 -2
- package/src/DatameshWidget.tsx +200 -54
- package/src/__tests__/DatameshWidget.spec.tsx +380 -0
- package/src/__tests__/aiBackend.spec.ts +272 -0
- package/src/__tests__/chatRouter.spec.ts +259 -17
- package/src/__tests__/datameshUiUrl.spec.ts +154 -0
- package/src/aiBackend.ts +150 -0
- package/src/chatRouter.ts +36 -8
- package/src/constants.ts +1 -1
- package/src/datameshUiUrl.ts +44 -0
- package/src/httpUrl.ts +23 -0
- package/src/index.ts +38 -10
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sidebar against a fake host and a fake backend: whether the Oceanum AI
|
|
3
|
+
* chat appears, what the capabilities request is signed with, and -- above all
|
|
4
|
+
* -- that watching for a sign-in runs no commands.
|
|
5
|
+
*
|
|
6
|
+
* Every `CommandRegistry.execute` emits `commandExecuted`, and JupyterLab's
|
|
7
|
+
* modal command palette closes itself on any of those. So the sidebar reads
|
|
8
|
+
* `isVisible`/`isEnabled` and listens for `commandChanged`, and only asks for
|
|
9
|
+
* the token when a request needs one.
|
|
10
|
+
*/
|
|
11
|
+
import { ModalCommandPalette } from '@jupyterlab/apputils';
|
|
12
|
+
import { addIcon } from '@jupyterlab/ui-components';
|
|
13
|
+
import { CommandRegistry } from '@lumino/commands';
|
|
14
|
+
import { CommandPalette, Widget } from '@lumino/widgets';
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
ACCESS_TOKEN_COMMAND,
|
|
18
|
+
SIGN_IN_COMMAND,
|
|
19
|
+
SIGN_OUT_COMMAND
|
|
20
|
+
} from '../aiBackend';
|
|
21
|
+
import { DatameshConnectWidget } from '../DatameshWidget';
|
|
22
|
+
import { Signal } from '@lumino/signaling';
|
|
23
|
+
|
|
24
|
+
interface IRequest {
|
|
25
|
+
url: string;
|
|
26
|
+
headers: Record<string, string>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let requests: IRequest[];
|
|
30
|
+
let widget: DatameshConnectWidget | null;
|
|
31
|
+
let palette: ModalCommandPalette | null;
|
|
32
|
+
const settingsChanged = new Signal<unknown, void>({});
|
|
33
|
+
|
|
34
|
+
beforeAll(() => {
|
|
35
|
+
// The chat scrolls to its last message; jsdom has no scrollIntoView, and
|
|
36
|
+
// the error would come back as React unmounting the sidebar.
|
|
37
|
+
(Element.prototype as any).scrollIntoView = (): void => undefined;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
requests = [];
|
|
42
|
+
window.datameshToken = undefined as unknown as string;
|
|
43
|
+
(globalThis as any).fetch = jest.fn(
|
|
44
|
+
async (url: string, init: { headers: Record<string, string> }) => {
|
|
45
|
+
requests.push({ url, headers: init.headers });
|
|
46
|
+
return { ok: true, json: async () => ({ code: true }) };
|
|
47
|
+
}
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
if (widget) {
|
|
53
|
+
Widget.detach(widget);
|
|
54
|
+
widget.dispose();
|
|
55
|
+
widget = null;
|
|
56
|
+
}
|
|
57
|
+
if (palette) {
|
|
58
|
+
palette.dispose();
|
|
59
|
+
palette = null;
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
interface IHost {
|
|
64
|
+
commands: CommandRegistry;
|
|
65
|
+
signIn: jest.Mock;
|
|
66
|
+
accessToken: jest.Mock;
|
|
67
|
+
/** Every command the registry reported as executed, in order. */
|
|
68
|
+
executed: string[];
|
|
69
|
+
setSignedIn: (signedIn: boolean) => void;
|
|
70
|
+
setToken: (token: string | null) => void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A host like Oceanum Notebook: an access-token command, a sign-in command
|
|
75
|
+
* enabled where the site has sign-in, and a sign-out command visible only
|
|
76
|
+
* while signed in. `auth: false` is plain JupyterLab, which has none of them.
|
|
77
|
+
*/
|
|
78
|
+
function host({
|
|
79
|
+
auth = true,
|
|
80
|
+
signInEnabled = true,
|
|
81
|
+
signedIn = false,
|
|
82
|
+
signInRejects = false
|
|
83
|
+
} = {}): IHost {
|
|
84
|
+
const state = { signedIn, token: 'a-jwt' as string | null };
|
|
85
|
+
const commands = new CommandRegistry();
|
|
86
|
+
const executed: string[] = [];
|
|
87
|
+
commands.commandExecuted.connect((_, args) => executed.push(args.id));
|
|
88
|
+
const signIn = jest.fn(() =>
|
|
89
|
+
signInRejects
|
|
90
|
+
? Promise.reject(new Error('the sign-in window was blocked'))
|
|
91
|
+
: undefined
|
|
92
|
+
);
|
|
93
|
+
const accessToken = jest.fn(() => (state.signedIn ? state.token : null));
|
|
94
|
+
if (auth) {
|
|
95
|
+
commands.addCommand(ACCESS_TOKEN_COMMAND, {
|
|
96
|
+
execute: () => accessToken() as any
|
|
97
|
+
});
|
|
98
|
+
commands.addCommand(SIGN_IN_COMMAND, {
|
|
99
|
+
execute: signIn,
|
|
100
|
+
isEnabled: () => signInEnabled
|
|
101
|
+
});
|
|
102
|
+
commands.addCommand(SIGN_OUT_COMMAND, {
|
|
103
|
+
execute: () => undefined,
|
|
104
|
+
isVisible: () => state.signedIn
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
commands,
|
|
109
|
+
signIn,
|
|
110
|
+
accessToken,
|
|
111
|
+
executed,
|
|
112
|
+
setSignedIn: (value: boolean) => {
|
|
113
|
+
state.signedIn = value;
|
|
114
|
+
// What the host does on every sign-in and sign-out.
|
|
115
|
+
commands.notifyCommandChanged();
|
|
116
|
+
},
|
|
117
|
+
setToken: (token: string | null) => {
|
|
118
|
+
state.token = token;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function mount(
|
|
124
|
+
commands: CommandRegistry,
|
|
125
|
+
{ backend = '', pasted = '' } = {}
|
|
126
|
+
): HTMLElement {
|
|
127
|
+
widget = new DatameshConnectWidget({
|
|
128
|
+
app: {} as any,
|
|
129
|
+
name: 'Datamesh Connect',
|
|
130
|
+
icon: addIcon,
|
|
131
|
+
openDatameshUI: (): void => undefined,
|
|
132
|
+
datameshUiUrl: () => '',
|
|
133
|
+
datameshUiFrame: () => null,
|
|
134
|
+
datameshToken: () => pasted,
|
|
135
|
+
aiBackendUrl: () => backend,
|
|
136
|
+
settingsChanged,
|
|
137
|
+
commands,
|
|
138
|
+
getCurrentWidget: () => null as unknown as Widget
|
|
139
|
+
});
|
|
140
|
+
Widget.attach(widget, document.body);
|
|
141
|
+
widget.update();
|
|
142
|
+
return widget.node;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** JupyterLab's modal command palette, open with something typed in it. */
|
|
146
|
+
function openPalette(commands: CommandRegistry): ModalCommandPalette {
|
|
147
|
+
palette = new ModalCommandPalette({
|
|
148
|
+
commandPalette: new CommandPalette({ commands })
|
|
149
|
+
});
|
|
150
|
+
palette.attach();
|
|
151
|
+
palette.palette.inputNode.value = 'notebook';
|
|
152
|
+
palette.show();
|
|
153
|
+
return palette;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Waits for `check` to pass: the sidebar renders asynchronously. */
|
|
157
|
+
async function until(check: () => void, timeout = 4000): Promise<void> {
|
|
158
|
+
const end = Date.now() + timeout;
|
|
159
|
+
for (;;) {
|
|
160
|
+
try {
|
|
161
|
+
check();
|
|
162
|
+
return;
|
|
163
|
+
} catch (err) {
|
|
164
|
+
if (Date.now() > end) {
|
|
165
|
+
throw err;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const settle = (ms = 150): Promise<void> =>
|
|
173
|
+
new Promise(resolve => setTimeout(resolve, ms));
|
|
174
|
+
|
|
175
|
+
const chat = (node: HTMLElement) => node.querySelector('.oceanum-ai-chat');
|
|
176
|
+
const notice = (node: HTMLElement) =>
|
|
177
|
+
node.querySelector('.oceanum-token-config')?.textContent ?? null;
|
|
178
|
+
|
|
179
|
+
describe('DatameshConnectWidget: Oceanum AI with the host sign-in', () => {
|
|
180
|
+
it('asks to sign in, then shows the chat once signed in, and hides it on sign-out', async () => {
|
|
181
|
+
const oceanum = host();
|
|
182
|
+
const node = mount(oceanum.commands, {
|
|
183
|
+
backend: 'https://ai.example.test/'
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
await until(() =>
|
|
187
|
+
expect(notice(node)).toBe(
|
|
188
|
+
'Sign in to Oceanum.io to use Oceanum AI, or set a Datamesh token.'
|
|
189
|
+
)
|
|
190
|
+
);
|
|
191
|
+
expect(chat(node)).toBeNull();
|
|
192
|
+
expect(requests).toEqual([]);
|
|
193
|
+
// Signed out, nothing was run: not even to find out there is no token.
|
|
194
|
+
expect(oceanum.executed).toEqual([]);
|
|
195
|
+
|
|
196
|
+
(node.querySelector('.oceanum-token-config a') as HTMLElement).click();
|
|
197
|
+
expect(oceanum.signIn).toHaveBeenCalled();
|
|
198
|
+
|
|
199
|
+
oceanum.setSignedIn(true);
|
|
200
|
+
await until(() => expect(chat(node)).not.toBeNull());
|
|
201
|
+
expect(notice(node)).toBeNull();
|
|
202
|
+
expect(requests).toEqual([
|
|
203
|
+
{
|
|
204
|
+
url: 'https://ai.example.test/api/capabilities',
|
|
205
|
+
headers: { Authorization: 'Bearer a-jwt' }
|
|
206
|
+
}
|
|
207
|
+
]);
|
|
208
|
+
// One token request for the capabilities, however many components watch.
|
|
209
|
+
expect(oceanum.accessToken).toHaveBeenCalledTimes(1);
|
|
210
|
+
|
|
211
|
+
oceanum.setSignedIn(false);
|
|
212
|
+
await until(() => expect(chat(node)).toBeNull());
|
|
213
|
+
expect(notice(node)).toContain('Sign in to Oceanum.io');
|
|
214
|
+
expect(requests).toHaveLength(1);
|
|
215
|
+
// The sign-in token never becomes the pasted one.
|
|
216
|
+
expect(window.datameshToken).toBeUndefined();
|
|
217
|
+
}, 15000);
|
|
218
|
+
|
|
219
|
+
it('runs no command while it watches, so the command palette stays open', async () => {
|
|
220
|
+
// The regression this test exists for: a one-second poll that executed the
|
|
221
|
+
// access-token command closed the palette within half a second, because
|
|
222
|
+
// ModalCommandPalette hides itself on any commandExecuted.
|
|
223
|
+
const oceanum = host();
|
|
224
|
+
const node = mount(oceanum.commands);
|
|
225
|
+
await until(() => expect(notice(node)).toContain('Sign in to Oceanum.io'));
|
|
226
|
+
|
|
227
|
+
const modal = openPalette(oceanum.commands);
|
|
228
|
+
expect(modal.isHidden).toBe(false);
|
|
229
|
+
|
|
230
|
+
await settle(3000);
|
|
231
|
+
|
|
232
|
+
expect(oceanum.executed).toEqual([]);
|
|
233
|
+
expect(oceanum.accessToken).not.toHaveBeenCalled();
|
|
234
|
+
expect(modal.isHidden).toBe(false);
|
|
235
|
+
expect(modal.palette.inputNode.value).toBe('notebook');
|
|
236
|
+
|
|
237
|
+
// Signing in is a change, not a poll: it may run the token command once.
|
|
238
|
+
oceanum.setSignedIn(true);
|
|
239
|
+
await until(() => expect(chat(node)).not.toBeNull());
|
|
240
|
+
expect(oceanum.executed).toEqual([ACCESS_TOKEN_COMMAND]);
|
|
241
|
+
|
|
242
|
+
await settle(2000);
|
|
243
|
+
expect(oceanum.executed).toEqual([ACCESS_TOKEN_COMMAND]);
|
|
244
|
+
}, 20000);
|
|
245
|
+
|
|
246
|
+
it('does not ask again when only the sign-in token has refreshed', async () => {
|
|
247
|
+
const oceanum = host({ signedIn: true });
|
|
248
|
+
const node = mount(oceanum.commands);
|
|
249
|
+
|
|
250
|
+
await until(() => expect(chat(node)).not.toBeNull());
|
|
251
|
+
expect(requests).toHaveLength(1);
|
|
252
|
+
|
|
253
|
+
// The host refreshed the token and told the registry, as it does.
|
|
254
|
+
oceanum.setToken('refreshed-jwt');
|
|
255
|
+
oceanum.commands.notifyCommandChanged();
|
|
256
|
+
await settle(500);
|
|
257
|
+
|
|
258
|
+
expect(requests).toHaveLength(1);
|
|
259
|
+
expect(oceanum.accessToken).toHaveBeenCalledTimes(1);
|
|
260
|
+
}, 15000);
|
|
261
|
+
|
|
262
|
+
it('signs with a pasted token rather than the sign-in, running nothing', async () => {
|
|
263
|
+
const oceanum = host({ signedIn: true });
|
|
264
|
+
const node = mount(oceanum.commands, { pasted: 'pasted-token' });
|
|
265
|
+
|
|
266
|
+
await until(() => expect(chat(node)).not.toBeNull());
|
|
267
|
+
expect(requests).toEqual([
|
|
268
|
+
{
|
|
269
|
+
url: 'https://ai.oceanum.io/api/capabilities',
|
|
270
|
+
headers: { 'X-Datamesh-Token': 'pasted-token' }
|
|
271
|
+
}
|
|
272
|
+
]);
|
|
273
|
+
expect(oceanum.executed).toEqual([]);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('uses the sign-in once the pasted token is cleared, as a chat request does', async () => {
|
|
277
|
+
// window.datameshToken keeps a token after the setting is cleared, until
|
|
278
|
+
// reload. Signing the capabilities with it would disagree with the chat
|
|
279
|
+
// requests, which read the setting -- and an expired one would hide the
|
|
280
|
+
// chat from a user who is signed in.
|
|
281
|
+
window.datameshToken = 'cleared-token';
|
|
282
|
+
const oceanum = host({ signedIn: true });
|
|
283
|
+
const node = mount(oceanum.commands, { pasted: '' });
|
|
284
|
+
|
|
285
|
+
await until(() => expect(chat(node)).not.toBeNull());
|
|
286
|
+
expect(requests).toEqual([
|
|
287
|
+
{
|
|
288
|
+
url: 'https://ai.oceanum.io/api/capabilities',
|
|
289
|
+
headers: { Authorization: 'Bearer a-jwt' }
|
|
290
|
+
}
|
|
291
|
+
]);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('keeps the chat hidden when the capabilities say there is no code', async () => {
|
|
295
|
+
(globalThis as any).fetch = jest.fn(async () => ({
|
|
296
|
+
ok: true,
|
|
297
|
+
json: async () => ({ code: false })
|
|
298
|
+
}));
|
|
299
|
+
const oceanum = host({ signedIn: true });
|
|
300
|
+
const node = mount(oceanum.commands);
|
|
301
|
+
|
|
302
|
+
await until(() => expect((globalThis as any).fetch).toHaveBeenCalled());
|
|
303
|
+
await settle();
|
|
304
|
+
expect(chat(node)).toBeNull();
|
|
305
|
+
expect(notice(node)).toBeNull();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('asks for a Datamesh token where the host has no sign-in', async () => {
|
|
309
|
+
const oceanum = host({ auth: false });
|
|
310
|
+
const node = mount(oceanum.commands);
|
|
311
|
+
|
|
312
|
+
await until(() =>
|
|
313
|
+
expect(notice(node)).toBe(
|
|
314
|
+
'Set your Datamesh token to enable Oceanum.io services Get token here'
|
|
315
|
+
)
|
|
316
|
+
);
|
|
317
|
+
expect(chat(node)).toBeNull();
|
|
318
|
+
expect(requests).toEqual([]);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('asks for a Datamesh token where sign-in is not enabled for the site', async () => {
|
|
322
|
+
const oceanum = host({ signInEnabled: false });
|
|
323
|
+
const node = mount(oceanum.commands);
|
|
324
|
+
|
|
325
|
+
await until(() =>
|
|
326
|
+
expect(notice(node)).toBe(
|
|
327
|
+
'Set your Datamesh token to enable Oceanum.io services Get token here'
|
|
328
|
+
)
|
|
329
|
+
);
|
|
330
|
+
expect(chat(node)).toBeNull();
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('says so, rather than failing, when signing in cannot start', async () => {
|
|
334
|
+
// Node's process, without @types/node in the test tsconfig.
|
|
335
|
+
const node_ = (globalThis as any).process as {
|
|
336
|
+
on: (event: string, handler: (reason: unknown) => void) => void;
|
|
337
|
+
off: (event: string, handler: (reason: unknown) => void) => void;
|
|
338
|
+
};
|
|
339
|
+
const rejections: unknown[] = [];
|
|
340
|
+
const onRejection = (reason: unknown) => rejections.push(reason);
|
|
341
|
+
node_.on('unhandledRejection', onRejection);
|
|
342
|
+
const warn = jest
|
|
343
|
+
.spyOn(console, 'warn')
|
|
344
|
+
.mockImplementation(() => undefined);
|
|
345
|
+
const oceanum = host({ signInRejects: true });
|
|
346
|
+
const node = mount(oceanum.commands);
|
|
347
|
+
|
|
348
|
+
try {
|
|
349
|
+
await until(() =>
|
|
350
|
+
expect(notice(node)).toContain('Sign in to Oceanum.io')
|
|
351
|
+
);
|
|
352
|
+
(node.querySelector('.oceanum-token-config a') as HTMLElement).click();
|
|
353
|
+
await settle();
|
|
354
|
+
|
|
355
|
+
expect(warn).toHaveBeenCalledWith(
|
|
356
|
+
'Oceanum AI: could not start signing in to Oceanum.io.'
|
|
357
|
+
);
|
|
358
|
+
expect(rejections).toEqual([]);
|
|
359
|
+
} finally {
|
|
360
|
+
node_.off('unhandledRejection', onRejection);
|
|
361
|
+
warn.mockRestore();
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('stops watching once it is disposed', async () => {
|
|
366
|
+
const oceanum = host();
|
|
367
|
+
const node = mount(oceanum.commands);
|
|
368
|
+
await until(() => expect(notice(node)).toContain('Sign in to Oceanum.io'));
|
|
369
|
+
|
|
370
|
+
Widget.detach(widget!);
|
|
371
|
+
widget!.dispose();
|
|
372
|
+
widget = null;
|
|
373
|
+
|
|
374
|
+
oceanum.setSignedIn(true);
|
|
375
|
+
await settle(500);
|
|
376
|
+
|
|
377
|
+
expect(oceanum.executed).toEqual([]);
|
|
378
|
+
expect(requests).toEqual([]);
|
|
379
|
+
});
|
|
380
|
+
});
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import schema from '../../schema/datamesh-connect.json';
|
|
2
|
+
import {
|
|
3
|
+
ACCESS_TOKEN_COMMAND,
|
|
4
|
+
SIGN_IN_COMMAND,
|
|
5
|
+
SIGN_OUT_COMMAND,
|
|
6
|
+
aiBackendUrl,
|
|
7
|
+
aiCredentialSource,
|
|
8
|
+
canSignIn,
|
|
9
|
+
isSignedIn,
|
|
10
|
+
resolveAiCredential,
|
|
11
|
+
signInToken,
|
|
12
|
+
type AuthCommands
|
|
13
|
+
} from '../aiBackend';
|
|
14
|
+
import { OCEANUM_AI_BACKEND_URL } from '../constants';
|
|
15
|
+
import { validHttpUrl } from '../httpUrl';
|
|
16
|
+
|
|
17
|
+
describe('aiBackendUrl', () => {
|
|
18
|
+
it('is the default the settings schema offers', () => {
|
|
19
|
+
expect(schema.properties.aiBackendUrl.default).toBe(OCEANUM_AI_BACKEND_URL);
|
|
20
|
+
expect(aiBackendUrl(schema.properties.aiBackendUrl.default)).toBe(
|
|
21
|
+
'https://ai.oceanum.io'
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it.each([
|
|
26
|
+
['as it is', 'https://ai.oceanum.tech', 'https://ai.oceanum.tech'],
|
|
27
|
+
[
|
|
28
|
+
'without a trailing slash',
|
|
29
|
+
'https://ai.oceanum.tech/',
|
|
30
|
+
'https://ai.oceanum.tech'
|
|
31
|
+
],
|
|
32
|
+
['trimmed', ' https://ai.oceanum.tech/ ', 'https://ai.oceanum.tech'],
|
|
33
|
+
[
|
|
34
|
+
'over plain http, with a port',
|
|
35
|
+
'http://localhost:8000/',
|
|
36
|
+
'http://localhost:8000'
|
|
37
|
+
],
|
|
38
|
+
[
|
|
39
|
+
'with a path prefix',
|
|
40
|
+
'https://example.test/ai//',
|
|
41
|
+
'https://example.test/ai'
|
|
42
|
+
],
|
|
43
|
+
[
|
|
44
|
+
'without a query or fragment',
|
|
45
|
+
'https://ai.oceanum.tech/?x=1#y',
|
|
46
|
+
'https://ai.oceanum.tech'
|
|
47
|
+
]
|
|
48
|
+
])('takes an http(s) address %s', (_, value, expected) => {
|
|
49
|
+
expect(aiBackendUrl(value)).toBe(expected);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it.each([
|
|
53
|
+
['empty', ''],
|
|
54
|
+
['blank', ' '],
|
|
55
|
+
['missing', undefined],
|
|
56
|
+
['null', null],
|
|
57
|
+
['not a URL', 'not a url'],
|
|
58
|
+
['a relative path', '/api'],
|
|
59
|
+
['a javascript: URL', 'javascript:alert(1)'],
|
|
60
|
+
['a file: URL', 'file:///etc/passwd']
|
|
61
|
+
])('falls back to the default for %s', (_, value) => {
|
|
62
|
+
expect(aiBackendUrl(value)).toBe('https://ai.oceanum.io');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('validHttpUrl', () => {
|
|
67
|
+
it('falls back to the fallback it is given', () => {
|
|
68
|
+
expect(validHttpUrl('ftp://x', 'https://fallback.test').href).toBe(
|
|
69
|
+
'https://fallback.test/'
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('resolveAiCredential', () => {
|
|
75
|
+
const never = jest.fn(async (): Promise<unknown> => 'unused');
|
|
76
|
+
|
|
77
|
+
beforeEach(() => never.mockClear());
|
|
78
|
+
|
|
79
|
+
it('uses a pasted token, trimmed, without asking for the sign-in', async () => {
|
|
80
|
+
expect(await resolveAiCredential(' a-token \n', never)).toEqual({
|
|
81
|
+
source: 'datamesh-token',
|
|
82
|
+
headers: { 'X-Datamesh-Token': 'a-token' }
|
|
83
|
+
});
|
|
84
|
+
expect(never).not.toHaveBeenCalled();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it.each([
|
|
88
|
+
['no', undefined],
|
|
89
|
+
['a null', null],
|
|
90
|
+
['an empty', ''],
|
|
91
|
+
['a blank', ' \t ']
|
|
92
|
+
])(
|
|
93
|
+
'uses the sign-in as a bearer token with %s pasted token',
|
|
94
|
+
async (_, pasted) => {
|
|
95
|
+
expect(await resolveAiCredential(pasted, async () => ' a-jwt ')).toEqual({
|
|
96
|
+
source: 'sign-in',
|
|
97
|
+
headers: { Authorization: 'Bearer a-jwt' }
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
it.each([
|
|
103
|
+
['signed out', async (): Promise<unknown> => null],
|
|
104
|
+
['an empty token', async (): Promise<unknown> => ''],
|
|
105
|
+
['a blank token', async (): Promise<unknown> => ' '],
|
|
106
|
+
['a non-string', async (): Promise<unknown> => ({ token: 'a-jwt' })],
|
|
107
|
+
['a number', async (): Promise<unknown> => 42],
|
|
108
|
+
[
|
|
109
|
+
'a rejection',
|
|
110
|
+
(): Promise<unknown> => Promise.reject(new Error('sign-in failed'))
|
|
111
|
+
],
|
|
112
|
+
[
|
|
113
|
+
'a throw',
|
|
114
|
+
(): Promise<unknown> => {
|
|
115
|
+
throw new Error('sign-in failed');
|
|
116
|
+
}
|
|
117
|
+
]
|
|
118
|
+
])('has no credential without a pasted token and %s', async (_, get) => {
|
|
119
|
+
expect(await resolveAiCredential('', get)).toBeNull();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A host registry: `commands` are registered, `enabled`/`visible` say what its
|
|
125
|
+
* queries answer. Queries must never execute anything.
|
|
126
|
+
*/
|
|
127
|
+
const host = (
|
|
128
|
+
commands: Record<string, () => unknown>,
|
|
129
|
+
{ enabled = [] as string[], visible = [] as string[] } = {}
|
|
130
|
+
) => ({
|
|
131
|
+
hasCommand: jest.fn((id: string) => id in commands),
|
|
132
|
+
execute: jest.fn(async (id: string) => commands[id]()),
|
|
133
|
+
isEnabled: jest.fn((id: string) => enabled.includes(id)),
|
|
134
|
+
isVisible: jest.fn((id: string) => visible.includes(id))
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const asCommands = (commands: ReturnType<typeof host>): AuthCommands =>
|
|
138
|
+
commands as unknown as AuthCommands;
|
|
139
|
+
|
|
140
|
+
describe('canSignIn and isSignedIn', () => {
|
|
141
|
+
const all = {
|
|
142
|
+
[ACCESS_TOKEN_COMMAND]: () => 'a-jwt',
|
|
143
|
+
[SIGN_IN_COMMAND]: (): void => undefined,
|
|
144
|
+
[SIGN_OUT_COMMAND]: (): void => undefined
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
it('reads the queries, and runs nothing', () => {
|
|
148
|
+
const commands = host(all, {
|
|
149
|
+
enabled: [SIGN_IN_COMMAND],
|
|
150
|
+
visible: [SIGN_OUT_COMMAND]
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
expect(canSignIn(asCommands(commands))).toBe(true);
|
|
154
|
+
expect(isSignedIn(asCommands(commands))).toBe(true);
|
|
155
|
+
expect(commands.execute).not.toHaveBeenCalled();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('is signed out while the sign-out command is hidden', () => {
|
|
159
|
+
const commands = host(all, { enabled: [SIGN_IN_COMMAND] });
|
|
160
|
+
|
|
161
|
+
expect(canSignIn(asCommands(commands))).toBe(true);
|
|
162
|
+
expect(isSignedIn(asCommands(commands))).toBe(false);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('cannot sign in where the site has sign-in disabled', () => {
|
|
166
|
+
const commands = host(all, { visible: [SIGN_OUT_COMMAND] });
|
|
167
|
+
|
|
168
|
+
expect(canSignIn(asCommands(commands))).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('is false where the host has no such commands', () => {
|
|
172
|
+
const commands = host({});
|
|
173
|
+
|
|
174
|
+
expect(canSignIn(asCommands(commands))).toBe(false);
|
|
175
|
+
expect(isSignedIn(asCommands(commands))).toBe(false);
|
|
176
|
+
expect(commands.execute).not.toHaveBeenCalled();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('is false when a query throws', () => {
|
|
180
|
+
const commands = {
|
|
181
|
+
hasCommand: () => true,
|
|
182
|
+
execute: jest.fn(),
|
|
183
|
+
isEnabled: () => {
|
|
184
|
+
throw new Error('no');
|
|
185
|
+
},
|
|
186
|
+
isVisible: () => {
|
|
187
|
+
throw new Error('no');
|
|
188
|
+
}
|
|
189
|
+
} as unknown as AuthCommands;
|
|
190
|
+
|
|
191
|
+
expect(canSignIn(commands)).toBe(false);
|
|
192
|
+
expect(isSignedIn(commands)).toBe(false);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe('aiCredentialSource', () => {
|
|
197
|
+
const signedIn = {
|
|
198
|
+
enabled: [SIGN_IN_COMMAND],
|
|
199
|
+
visible: [SIGN_OUT_COMMAND]
|
|
200
|
+
};
|
|
201
|
+
const all = {
|
|
202
|
+
[ACCESS_TOKEN_COMMAND]: () => 'a-jwt',
|
|
203
|
+
[SIGN_IN_COMMAND]: (): void => undefined,
|
|
204
|
+
[SIGN_OUT_COMMAND]: (): void => undefined
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
it('is the pasted token when there is one', () => {
|
|
208
|
+
const commands = host(all, signedIn);
|
|
209
|
+
|
|
210
|
+
expect(aiCredentialSource(' a-token ', asCommands(commands))).toBe(
|
|
211
|
+
'datamesh-token'
|
|
212
|
+
);
|
|
213
|
+
expect(commands.execute).not.toHaveBeenCalled();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('is the sign-in while signed in, and nothing once signed out', () => {
|
|
217
|
+
const commands = host(all, signedIn);
|
|
218
|
+
expect(aiCredentialSource('', asCommands(commands))).toBe('sign-in');
|
|
219
|
+
expect(commands.execute).not.toHaveBeenCalled();
|
|
220
|
+
|
|
221
|
+
const out = host(all, { enabled: [SIGN_IN_COMMAND] });
|
|
222
|
+
expect(aiCredentialSource('', asCommands(out))).toBeNull();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('is nothing where the host has no access-token command', () => {
|
|
226
|
+
const commands = host(
|
|
227
|
+
{ [SIGN_OUT_COMMAND]: (): void => undefined },
|
|
228
|
+
{ visible: [SIGN_OUT_COMMAND] }
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
expect(aiCredentialSource('', asCommands(commands))).toBeNull();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it.each([
|
|
235
|
+
['blank', ' '],
|
|
236
|
+
['missing', undefined],
|
|
237
|
+
['not a string', 42]
|
|
238
|
+
])('treats a %s pasted token as none', (_, value) => {
|
|
239
|
+
const commands = host(all, signedIn);
|
|
240
|
+
|
|
241
|
+
expect(aiCredentialSource(value, asCommands(commands))).toBe('sign-in');
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe('signInToken', () => {
|
|
246
|
+
it('is null, without running anything, when the host has no access-token command', async () => {
|
|
247
|
+
const commands = host({});
|
|
248
|
+
|
|
249
|
+
expect(await signInToken(asCommands(commands))()).toBeNull();
|
|
250
|
+
expect(commands.execute).not.toHaveBeenCalled();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("is what the host's access-token command answers", async () => {
|
|
254
|
+
let token: string | null = 'a-jwt';
|
|
255
|
+
const commands = host({ [ACCESS_TOKEN_COMMAND]: () => token });
|
|
256
|
+
const get = signInToken(asCommands(commands));
|
|
257
|
+
|
|
258
|
+
expect(await get()).toBe('a-jwt');
|
|
259
|
+
token = null;
|
|
260
|
+
expect(await get()).toBeNull();
|
|
261
|
+
expect(commands.execute).toHaveBeenCalledWith(ACCESS_TOKEN_COMMAND);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('checks for the command on every call, so a host registering it late is found', async () => {
|
|
265
|
+
const commands: Record<string, () => unknown> = {};
|
|
266
|
+
const get = signInToken(asCommands(host(commands)));
|
|
267
|
+
|
|
268
|
+
expect(await get()).toBeNull();
|
|
269
|
+
commands[ACCESS_TOKEN_COMMAND] = () => 'a-jwt';
|
|
270
|
+
expect(await get()).toBe('a-jwt');
|
|
271
|
+
});
|
|
272
|
+
});
|