@oceanum/oceanumlab 4.7.0 → 4.7.1
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 +70 -11
- package/lib/StoredNotebooks.d.ts +16 -0
- package/lib/StoredNotebooks.js +84 -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 +253 -0
- package/lib/auth/tokens.d.ts +95 -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 +56 -17
- package/lib/specStore.d.ts +37 -0
- package/lib/specStore.js +57 -0
- package/package.json +9 -1
- package/schema/datamesh-connect.json +3 -12
- package/src/DatameshUI.tsx +1 -10
- package/src/DatameshWidget.tsx +124 -19
- package/src/StoredNotebooks.tsx +166 -0
- package/src/Tabs.tsx +102 -0
- package/src/__tests__/DatameshWidget.spec.tsx +33 -20
- package/src/__tests__/OceanumPanel.spec.tsx +101 -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__/pluginArity.spec.ts +24 -0
- package/src/__tests__/serverAuth.spec.ts +277 -0
- package/src/__tests__/settingsSchema.spec.ts +73 -0
- package/src/__tests__/specStore.spec.ts +126 -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 +341 -0
- package/src/auth/tokens.ts +104 -0
- package/src/chatRouter.ts +8 -4
- package/src/constants.ts +1 -1
- package/src/datameshUiUrl.ts +6 -6
- package/src/index.ts +85 -36
- package/src/specStore.ts +82 -0
- package/style/index.css +158 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { ReactWidget } from '@jupyterlab/ui-components';
|
|
2
|
+
import { Widget } from '@lumino/widgets';
|
|
3
|
+
import { readFileSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import React from 'react';
|
|
6
|
+
|
|
7
|
+
import { ITab, Tabs } from '../Tabs';
|
|
8
|
+
|
|
9
|
+
/** A widget whose sole job is to render the tab strip so the DOM can be inspected. */
|
|
10
|
+
class Harness extends ReactWidget {
|
|
11
|
+
selected = 'one';
|
|
12
|
+
readonly selections: string[] = [];
|
|
13
|
+
|
|
14
|
+
constructor(private readonly _tabs: ITab[]) {
|
|
15
|
+
super();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
render(): React.ReactElement {
|
|
19
|
+
return (
|
|
20
|
+
<Tabs
|
|
21
|
+
tabs={this._tabs}
|
|
22
|
+
selected={this.selected}
|
|
23
|
+
onSelect={id => {
|
|
24
|
+
this.selections.push(id);
|
|
25
|
+
this.selected = id;
|
|
26
|
+
this.update();
|
|
27
|
+
}}
|
|
28
|
+
/>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const TABS: ITab[] = [
|
|
34
|
+
{ id: 'one', label: 'One', render: () => <p>first pane</p> },
|
|
35
|
+
{ id: 'two', label: 'Two', render: () => <p>second pane</p> },
|
|
36
|
+
{ id: 'three', label: 'Three', render: () => <p>third pane</p> }
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
async function mount(): Promise<Harness> {
|
|
40
|
+
const widget = new Harness(TABS);
|
|
41
|
+
Widget.attach(widget, document.body);
|
|
42
|
+
// ReactWidget renders on an animation frame, so wait for the first pane rather than
|
|
43
|
+
// sleeping a fixed 50ms and hoping. That wait was enough on a warm run and not on a cold
|
|
44
|
+
// one: the first run after a fresh install, with an empty ts-jest cache, rendered nothing
|
|
45
|
+
// in time and every test here failed at once with zero panes -- which reads like a broken
|
|
46
|
+
// component rather than a slow one.
|
|
47
|
+
const deadline = Date.now() + 4000;
|
|
48
|
+
for (;;) {
|
|
49
|
+
if (widget.node.querySelector('[role="tabpanel"]')) {
|
|
50
|
+
return widget;
|
|
51
|
+
}
|
|
52
|
+
if (Date.now() > deadline) {
|
|
53
|
+
throw new Error('Tabs rendered no panes within 4s');
|
|
54
|
+
}
|
|
55
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function strip(widget: Harness): HTMLElement[] {
|
|
60
|
+
return Array.from(widget.node.querySelectorAll<HTMLElement>('[role="tab"]'));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function panels(widget: Harness): HTMLElement[] {
|
|
64
|
+
return Array.from(
|
|
65
|
+
widget.node.querySelectorAll<HTMLElement>('[role="tabpanel"]')
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe('Tabs', () => {
|
|
70
|
+
it('marks only the selected tab, and puts only it in the tab order', async () => {
|
|
71
|
+
const widget = await mount();
|
|
72
|
+
const tabs = strip(widget);
|
|
73
|
+
|
|
74
|
+
expect(tabs.map(t => t.textContent)).toEqual(['One', 'Two', 'Three']);
|
|
75
|
+
expect(tabs.map(t => t.getAttribute('aria-selected'))).toEqual([
|
|
76
|
+
'true',
|
|
77
|
+
'false',
|
|
78
|
+
'false'
|
|
79
|
+
]);
|
|
80
|
+
// A single tab stop for the strip: Tab moves past it, arrows move within it.
|
|
81
|
+
expect(tabs.map(t => t.tabIndex)).toEqual([0, -1, -1]);
|
|
82
|
+
|
|
83
|
+
widget.dispose();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('keeps every pane mounted and hides the inactive ones', async () => {
|
|
87
|
+
// Unmounting would discard the AI conversation whenever the user switches away.
|
|
88
|
+
const widget = await mount();
|
|
89
|
+
const rendered = panels(widget);
|
|
90
|
+
|
|
91
|
+
expect(rendered).toHaveLength(3);
|
|
92
|
+
expect(rendered.map(p => p.hidden)).toEqual([false, true, true]);
|
|
93
|
+
expect(widget.node.textContent).toContain('second pane');
|
|
94
|
+
|
|
95
|
+
widget.dispose();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('the stylesheet actually hides them', async () => {
|
|
99
|
+
// `hidden` is only a hint: an author `display` on .oceanum-tabpanel overrides the
|
|
100
|
+
// user agent's `[hidden] { display: none }`, and asserting the IDL property above
|
|
101
|
+
// cannot see that. This renders the real stylesheet and asks for the computed value.
|
|
102
|
+
const css = readFileSync(
|
|
103
|
+
join(__dirname, '..', '..', 'style', 'index.css'),
|
|
104
|
+
'utf8'
|
|
105
|
+
);
|
|
106
|
+
const style = document.createElement('style');
|
|
107
|
+
style.textContent = css;
|
|
108
|
+
document.head.appendChild(style);
|
|
109
|
+
|
|
110
|
+
const widget = await mount();
|
|
111
|
+
const [active, inactive] = panels(widget);
|
|
112
|
+
expect(getComputedStyle(active).display).not.toBe('none');
|
|
113
|
+
expect(getComputedStyle(inactive).display).toBe('none');
|
|
114
|
+
|
|
115
|
+
widget.dispose();
|
|
116
|
+
style.remove();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('moves with the arrow keys, wrapping at both ends', async () => {
|
|
120
|
+
const widget = await mount();
|
|
121
|
+
// ReactWidget re-renders on an animation frame, so each key has to land on the
|
|
122
|
+
// render that the previous one produced.
|
|
123
|
+
const settle = (): Promise<void> =>
|
|
124
|
+
new Promise(resolve => setTimeout(resolve, 50));
|
|
125
|
+
|
|
126
|
+
strip(widget)[0].dispatchEvent(
|
|
127
|
+
new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })
|
|
128
|
+
);
|
|
129
|
+
expect(widget.selections.pop()).toBe('two');
|
|
130
|
+
await settle();
|
|
131
|
+
|
|
132
|
+
strip(widget)[1].dispatchEvent(
|
|
133
|
+
new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })
|
|
134
|
+
);
|
|
135
|
+
expect(widget.selections.pop()).toBe('one');
|
|
136
|
+
await settle();
|
|
137
|
+
|
|
138
|
+
// Wrapping: left from the first tab reaches the last.
|
|
139
|
+
strip(widget)[0].dispatchEvent(
|
|
140
|
+
new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })
|
|
141
|
+
);
|
|
142
|
+
expect(widget.selections.pop()).toBe('three');
|
|
143
|
+
|
|
144
|
+
widget.dispose();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('jumps to the ends with Home and End', async () => {
|
|
148
|
+
const widget = await mount();
|
|
149
|
+
|
|
150
|
+
strip(widget)[0].dispatchEvent(
|
|
151
|
+
new KeyboardEvent('keydown', { key: 'End', bubbles: true })
|
|
152
|
+
);
|
|
153
|
+
expect(widget.selections.pop()).toBe('three');
|
|
154
|
+
|
|
155
|
+
widget.dispose();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('falls back to the first tab when the selection names no tab', async () => {
|
|
159
|
+
const widget = new Harness(TABS);
|
|
160
|
+
widget.selected = 'gone';
|
|
161
|
+
Widget.attach(widget, document.body);
|
|
162
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
163
|
+
|
|
164
|
+
expect(strip(widget)[0].getAttribute('aria-selected')).toBe('true');
|
|
165
|
+
|
|
166
|
+
widget.dispose();
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import schema from '../../schema/datamesh-connect.json';
|
|
2
1
|
import {
|
|
3
2
|
ACCESS_TOKEN_COMMAND,
|
|
4
3
|
SIGN_IN_COMMAND,
|
|
@@ -15,11 +14,11 @@ import { OCEANUM_AI_BACKEND_URL } from '../constants';
|
|
|
15
14
|
import { validHttpUrl } from '../httpUrl';
|
|
16
15
|
|
|
17
16
|
describe('aiBackendUrl', () => {
|
|
18
|
-
it('is
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
);
|
|
17
|
+
it('is production when the deployment names no address', () => {
|
|
18
|
+
// What index.ts passes when the sign-in environment has no `ai` URL, or there is no
|
|
19
|
+
// sign-in at all.
|
|
20
|
+
expect(aiBackendUrl('')).toBe(OCEANUM_AI_BACKEND_URL);
|
|
21
|
+
expect(aiBackendUrl(undefined)).toBe('https://ai.oceanum.io');
|
|
23
22
|
});
|
|
24
23
|
|
|
25
24
|
it.each([
|
|
@@ -417,12 +417,10 @@ describe('ChatRouter: where a request goes, and what it is signed with', () => {
|
|
|
417
417
|
|
|
418
418
|
it('sends to the configured address, without a double slash', async () => {
|
|
419
419
|
const router = new ChatRouter(
|
|
420
|
-
|
|
421
|
-
datameshToken: 'a-token',
|
|
422
|
-
aiBackendUrl: ' https://ai.oceanum.tech/ '
|
|
423
|
-
}),
|
|
420
|
+
settings,
|
|
424
421
|
noSignIn,
|
|
425
|
-
noNotebook
|
|
422
|
+
noNotebook,
|
|
423
|
+
() => ' https://ai.oceanum.tech/ '
|
|
426
424
|
);
|
|
427
425
|
|
|
428
426
|
await router.route('hello');
|
|
@@ -434,12 +432,10 @@ describe('ChatRouter: where a request goes, and what it is signed with', () => {
|
|
|
434
432
|
|
|
435
433
|
it('sends to the default for an invalid address', async () => {
|
|
436
434
|
const router = new ChatRouter(
|
|
437
|
-
|
|
438
|
-
datameshToken: 'a-token',
|
|
439
|
-
aiBackendUrl: 'javascript:alert(1)'
|
|
440
|
-
}),
|
|
435
|
+
settings,
|
|
441
436
|
noSignIn,
|
|
442
|
-
noNotebook
|
|
437
|
+
noNotebook,
|
|
438
|
+
() => 'javascript:alert(1)'
|
|
443
439
|
);
|
|
444
440
|
|
|
445
441
|
await router.route('hello');
|
|
@@ -542,12 +538,10 @@ describe('ChatRouter: where a request goes, and what it is signed with', () => {
|
|
|
542
538
|
throw new TypeError('Failed to fetch');
|
|
543
539
|
});
|
|
544
540
|
const router = new ChatRouter(
|
|
545
|
-
|
|
546
|
-
datameshToken: 'a-token',
|
|
547
|
-
aiBackendUrl: 'http://localhost:8000/'
|
|
548
|
-
}),
|
|
541
|
+
settings,
|
|
549
542
|
noSignIn,
|
|
550
|
-
noNotebook
|
|
543
|
+
noNotebook,
|
|
544
|
+
() => 'http://localhost:8000/'
|
|
551
545
|
);
|
|
552
546
|
|
|
553
547
|
await expect(router.route('hello')).rejects.toThrow(
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import schema from '../../schema/datamesh-connect.json';
|
|
2
1
|
import { DATAMESH_UI_SERVICE } from '../constants';
|
|
3
2
|
import {
|
|
4
3
|
datameshUiSrc,
|
|
@@ -7,8 +6,10 @@ import {
|
|
|
7
6
|
} from '../datameshUiUrl';
|
|
8
7
|
|
|
9
8
|
describe('validDatameshUiUrl', () => {
|
|
10
|
-
it('is
|
|
11
|
-
expect(
|
|
9
|
+
it('is production when the deployment names no address', () => {
|
|
10
|
+
expect(validDatameshUiUrl(undefined).href).toBe(
|
|
11
|
+
new URL(DATAMESH_UI_SERVICE).href
|
|
12
|
+
);
|
|
12
13
|
});
|
|
13
14
|
|
|
14
15
|
it('takes an http(s) address as it is', () => {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import plugins from '../index';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* JupyterLab passes `requires` then `optional` to `activate`, positionally, after the
|
|
5
|
+
* application. Omitting a parameter for a dependency you do not use does not skip it —
|
|
6
|
+
* it shifts every later argument along by one, silently handing the wrong object to the
|
|
7
|
+
* wrong parameter. TypeScript cannot see this: `activate` is typed loosely enough that
|
|
8
|
+
* the mismatch compiles, and unit tests that never activate the plugin never notice.
|
|
9
|
+
*
|
|
10
|
+
* This bit exactly once: adding an optional IOceanumAuth to a plugin whose signature
|
|
11
|
+
* had quietly omitted its IStateDB meant `auth` received the state database, and the
|
|
12
|
+
* panel crashed on `auth.userChanged.connect` at application start.
|
|
13
|
+
*/
|
|
14
|
+
describe('plugin activate signatures', () => {
|
|
15
|
+
it.each(plugins.map(plugin => [plugin.id, plugin] as const))(
|
|
16
|
+
'%s takes one parameter per declared dependency',
|
|
17
|
+
(_id, plugin) => {
|
|
18
|
+
const declared =
|
|
19
|
+
(plugin.requires?.length ?? 0) + (plugin.optional?.length ?? 0);
|
|
20
|
+
// +1 for the JupyterFrontEnd itself.
|
|
21
|
+
expect(plugin.activate.length).toBe(declared + 1);
|
|
22
|
+
}
|
|
23
|
+
);
|
|
24
|
+
});
|
|
@@ -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
|
+
});
|