@oxyhq/core 7.0.0 → 7.1.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/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/index.js +17 -2
- package/dist/cjs/session/SessionClient.js +181 -10
- package/dist/cjs/session/accountDialogController.js +541 -0
- package/dist/cjs/session/accountProjection.js +131 -0
- package/dist/cjs/session/createSessionClient.js +9 -2
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/index.js +11 -0
- package/dist/esm/session/SessionClient.js +181 -10
- package/dist/esm/session/accountDialogController.js +536 -0
- package/dist/esm/session/accountProjection.js +127 -0
- package/dist/esm/session/createSessionClient.js +9 -2
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +4 -0
- package/dist/types/session/SessionClient.d.ts +51 -0
- package/dist/types/session/accountDialogController.d.ts +246 -0
- package/dist/types/session/accountProjection.d.ts +142 -0
- package/dist/types/session/createSessionClient.d.ts +9 -2
- package/package.json +1 -1
- package/src/index.ts +31 -0
- package/src/session/SessionClient.ts +201 -11
- package/src/session/__tests__/SessionClient.signedOut.test.ts +224 -0
- package/src/session/__tests__/accountDialogController.test.ts +469 -0
- package/src/session/__tests__/accountProjection.test.ts +181 -0
- package/src/session/accountDialogController.ts +682 -0
- package/src/session/accountProjection.ts +263 -0
- package/src/session/createSessionClient.ts +9 -2
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
import type { DeviceSessionState } from '@oxyhq/contracts';
|
|
2
|
+
import type { OxyServices } from '../../OxyServices';
|
|
3
|
+
import type { User } from '../../models/interfaces';
|
|
4
|
+
import type { SessionLoginResponse, MinimalUserData } from '../../models/session';
|
|
5
|
+
import type { AccountNode } from '../../mixins/OxyServices.accounts';
|
|
6
|
+
import { SessionClient, type SessionClientHost } from '../SessionClient';
|
|
7
|
+
import {
|
|
8
|
+
AccountDialogController,
|
|
9
|
+
createAccountDialogController,
|
|
10
|
+
} from '../accountDialogController';
|
|
11
|
+
|
|
12
|
+
// A SessionClient whose applied state can be driven directly (applyState is
|
|
13
|
+
// protected on the base) — mirrors the existing TestClient pattern.
|
|
14
|
+
class TestSessionClient extends SessionClient {
|
|
15
|
+
set(state: DeviceSessionState): void {
|
|
16
|
+
this.applyState(state);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function host(): SessionClientHost {
|
|
21
|
+
return {
|
|
22
|
+
makeRequest: jest.fn(),
|
|
23
|
+
getBaseURL: () => 'http://test.invalid',
|
|
24
|
+
getAccessToken: () => 'token',
|
|
25
|
+
onTokensChanged: () => () => undefined,
|
|
26
|
+
setTokens: jest.fn(),
|
|
27
|
+
getCurrentAccountId: () => null,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function state(
|
|
32
|
+
accounts: Array<{ accountId: string; sessionId: string }>,
|
|
33
|
+
activeAccountId: string | null,
|
|
34
|
+
revision = 1,
|
|
35
|
+
): DeviceSessionState {
|
|
36
|
+
return {
|
|
37
|
+
deviceId: 'device-1',
|
|
38
|
+
accounts: accounts.map((a) => ({ accountId: a.accountId, sessionId: a.sessionId, authuser: 0 })),
|
|
39
|
+
activeAccountId,
|
|
40
|
+
revision,
|
|
41
|
+
updatedAt: 1_720_000_000_000,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function user(id: string, over: Partial<User> = {}): User {
|
|
46
|
+
return {
|
|
47
|
+
id,
|
|
48
|
+
publicKey: `pk_${id}`,
|
|
49
|
+
username: `user_${id}`,
|
|
50
|
+
name: { displayName: `User ${id}` },
|
|
51
|
+
...over,
|
|
52
|
+
} as User;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function graphNode(id: string, over: Partial<AccountNode> = {}): AccountNode {
|
|
56
|
+
return {
|
|
57
|
+
accountId: id,
|
|
58
|
+
kind: 'organization',
|
|
59
|
+
parentAccountId: null,
|
|
60
|
+
account: user(id),
|
|
61
|
+
relationship: 'owner',
|
|
62
|
+
callerMembership: null,
|
|
63
|
+
...over,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface OxyMock {
|
|
68
|
+
listAccounts: jest.Mock;
|
|
69
|
+
getUsersByIds: jest.Mock;
|
|
70
|
+
getFileDownloadUrl: jest.Mock;
|
|
71
|
+
switchToAccount: jest.Mock;
|
|
72
|
+
startCommonsSignIn: jest.Mock;
|
|
73
|
+
pollCommonsSignIn: jest.Mock;
|
|
74
|
+
claimSessionByToken: jest.Mock;
|
|
75
|
+
signInWithSharedIdentity: jest.Mock;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function makeOxy(): OxyMock {
|
|
79
|
+
return {
|
|
80
|
+
listAccounts: jest.fn().mockResolvedValue([]),
|
|
81
|
+
getUsersByIds: jest.fn().mockResolvedValue([]),
|
|
82
|
+
getFileDownloadUrl: jest.fn((id: string) => `https://cdn/${id}`),
|
|
83
|
+
switchToAccount: jest.fn(),
|
|
84
|
+
startCommonsSignIn: jest.fn(),
|
|
85
|
+
pollCommonsSignIn: jest.fn(),
|
|
86
|
+
claimSessionByToken: jest.fn(),
|
|
87
|
+
signInWithSharedIdentity: jest.fn().mockResolvedValue(null),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface Harness {
|
|
92
|
+
controller: AccountDialogController;
|
|
93
|
+
oxy: OxyMock;
|
|
94
|
+
sc: TestSessionClient;
|
|
95
|
+
commitSession: jest.Mock;
|
|
96
|
+
onSignedIn: jest.Mock;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function makeHarness(over: Partial<{ clientId: string | null }> = {}): Harness {
|
|
100
|
+
const oxy = makeOxy();
|
|
101
|
+
const sc = new TestSessionClient(host());
|
|
102
|
+
const commitSession = jest.fn().mockResolvedValue(undefined);
|
|
103
|
+
const onSignedIn = jest.fn();
|
|
104
|
+
const controller = createAccountDialogController({
|
|
105
|
+
oxyServices: oxy as unknown as OxyServices,
|
|
106
|
+
sessionClient: sc,
|
|
107
|
+
clientId: 'clientId' in over ? over.clientId : 'oxy_dk_test',
|
|
108
|
+
commitSession,
|
|
109
|
+
onSignedIn,
|
|
110
|
+
pollIntervalMs: 1000,
|
|
111
|
+
});
|
|
112
|
+
return { controller, oxy, sc, commitSession, onSignedIn };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
describe('AccountDialogController — initial + views', () => {
|
|
116
|
+
it('starts on the accounts view with an empty list and idle sign-in', () => {
|
|
117
|
+
const { controller } = makeHarness();
|
|
118
|
+
const snap = controller.getSnapshot();
|
|
119
|
+
expect(snap.view).toBe('accounts');
|
|
120
|
+
expect(snap.accounts).toEqual([]);
|
|
121
|
+
expect(snap.activeAccountId).toBeNull();
|
|
122
|
+
expect(snap.loading).toBe(false);
|
|
123
|
+
expect(snap.switchingAccountId).toBeNull();
|
|
124
|
+
expect(snap.signIn.phase).toBe('idle');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('setView / add / close move between views and notify subscribers', () => {
|
|
128
|
+
const { controller } = makeHarness();
|
|
129
|
+
const seen: string[] = [];
|
|
130
|
+
controller.subscribe((s) => seen.push(s.view));
|
|
131
|
+
|
|
132
|
+
controller.add();
|
|
133
|
+
expect(controller.getSnapshot().view).toBe('add');
|
|
134
|
+
controller.setView('signin');
|
|
135
|
+
expect(controller.getSnapshot().view).toBe('signin');
|
|
136
|
+
controller.close();
|
|
137
|
+
expect(controller.getSnapshot().view).toBe('accounts');
|
|
138
|
+
|
|
139
|
+
expect(seen).toEqual(['add', 'signin', 'accounts']);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('getSnapshot returns a stable reference until a change occurs', () => {
|
|
143
|
+
const { controller } = makeHarness();
|
|
144
|
+
const a = controller.getSnapshot();
|
|
145
|
+
expect(controller.getSnapshot()).toBe(a);
|
|
146
|
+
controller.setView('add');
|
|
147
|
+
expect(controller.getSnapshot()).not.toBe(a);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('AccountDialogController — account list', () => {
|
|
152
|
+
it('refresh loads graph + profiles and projects the unified list', async () => {
|
|
153
|
+
const { controller, oxy, sc } = makeHarness();
|
|
154
|
+
sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
|
|
155
|
+
oxy.getUsersByIds.mockResolvedValue([user('a1'), user('org1')]);
|
|
156
|
+
oxy.listAccounts.mockResolvedValue([graphNode('org1')]);
|
|
157
|
+
|
|
158
|
+
await controller.refresh();
|
|
159
|
+
|
|
160
|
+
const snap = controller.getSnapshot();
|
|
161
|
+
expect(oxy.getUsersByIds).toHaveBeenCalledWith(['a1', 'org1']);
|
|
162
|
+
expect(snap.accounts.map((r) => r.accountId)).toEqual(['a1', 'org1']);
|
|
163
|
+
expect(snap.activeAccountId).toBe('a1');
|
|
164
|
+
expect(snap.accounts[0].isCurrent).toBe(true);
|
|
165
|
+
expect(snap.accounts[1].onDevice).toBe(false);
|
|
166
|
+
expect(snap.loading).toBe(false);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('start subscribes to SessionClient so a device-state change re-projects', async () => {
|
|
170
|
+
const { controller, oxy, sc } = makeHarness();
|
|
171
|
+
oxy.getUsersByIds.mockResolvedValue([user('a1')]);
|
|
172
|
+
controller.start();
|
|
173
|
+
await Promise.resolve();
|
|
174
|
+
await Promise.resolve();
|
|
175
|
+
|
|
176
|
+
sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
|
|
177
|
+
// The subscription re-projects synchronously from the new device state.
|
|
178
|
+
expect(controller.getSnapshot().activeAccountId).toBe('a1');
|
|
179
|
+
controller.destroy();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('keeps device rows and surfaces the error when listAccounts fails', async () => {
|
|
183
|
+
const { controller, oxy, sc } = makeHarness();
|
|
184
|
+
sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
|
|
185
|
+
oxy.getUsersByIds.mockResolvedValue([user('a1')]);
|
|
186
|
+
oxy.listAccounts.mockRejectedValue(new Error('graph boom'));
|
|
187
|
+
|
|
188
|
+
await controller.refresh();
|
|
189
|
+
|
|
190
|
+
const snap = controller.getSnapshot();
|
|
191
|
+
expect(snap.error).toBe('graph boom');
|
|
192
|
+
expect(snap.accounts.map((r) => r.accountId)).toEqual(['a1']);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe('AccountDialogController — switchTo (uniform switch)', () => {
|
|
197
|
+
it('uses SessionClient.switchAccount for an account already on the device', async () => {
|
|
198
|
+
const { controller, oxy, sc } = makeHarness();
|
|
199
|
+
sc.set(state([{ accountId: 'a1', sessionId: 's1' }, { accountId: 'a2', sessionId: 's2' }], 'a1'));
|
|
200
|
+
const switchSpy = jest.spyOn(sc, 'switchAccount').mockResolvedValue(undefined);
|
|
201
|
+
oxy.getUsersByIds.mockResolvedValue([user('a1'), user('a2')]);
|
|
202
|
+
|
|
203
|
+
await controller.switchTo('a2');
|
|
204
|
+
|
|
205
|
+
expect(switchSpy).toHaveBeenCalledWith('a2');
|
|
206
|
+
expect(oxy.switchToAccount).not.toHaveBeenCalled();
|
|
207
|
+
expect(controller.getSnapshot().switchingAccountId).toBeNull();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('mints via oxyServices.switchToAccount + commitSession on first entry into a graph account', async () => {
|
|
211
|
+
const { controller, oxy, sc, commitSession } = makeHarness();
|
|
212
|
+
sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
|
|
213
|
+
const switchSpy = jest.spyOn(sc, 'switchAccount').mockResolvedValue(undefined);
|
|
214
|
+
oxy.switchToAccount.mockResolvedValue({
|
|
215
|
+
sessionId: 'sess-org',
|
|
216
|
+
deviceId: 'device-1',
|
|
217
|
+
expiresAt: '2030-01-01T00:00:00Z',
|
|
218
|
+
accessToken: 'access-org',
|
|
219
|
+
user: user('org1'),
|
|
220
|
+
});
|
|
221
|
+
oxy.getUsersByIds.mockResolvedValue([user('a1'), user('org1')]);
|
|
222
|
+
|
|
223
|
+
await controller.switchTo('org1');
|
|
224
|
+
|
|
225
|
+
expect(oxy.switchToAccount).toHaveBeenCalledWith('org1');
|
|
226
|
+
expect(switchSpy).not.toHaveBeenCalled();
|
|
227
|
+
expect(commitSession).toHaveBeenCalledTimes(1);
|
|
228
|
+
expect(commitSession.mock.calls[0][0]).toMatchObject({ sessionId: 'sess-org', accessToken: 'access-org' });
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('falls back to SessionClient.registerAndActivate when no commitSession is supplied', async () => {
|
|
232
|
+
const oxy = makeOxy();
|
|
233
|
+
const sc = new TestSessionClient(host());
|
|
234
|
+
sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
|
|
235
|
+
const registerSpy = jest.spyOn(sc, 'registerAndActivate').mockResolvedValue(undefined);
|
|
236
|
+
oxy.switchToAccount.mockResolvedValue({
|
|
237
|
+
sessionId: 'sess-org',
|
|
238
|
+
deviceId: 'device-1',
|
|
239
|
+
expiresAt: '2030-01-01T00:00:00Z',
|
|
240
|
+
accessToken: 'access-org',
|
|
241
|
+
user: user('org1'),
|
|
242
|
+
});
|
|
243
|
+
const controller = new AccountDialogController({
|
|
244
|
+
oxyServices: oxy as unknown as OxyServices,
|
|
245
|
+
sessionClient: sc,
|
|
246
|
+
clientId: 'oxy_dk_test',
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
await controller.switchTo('org1');
|
|
250
|
+
expect(registerSpy).toHaveBeenCalledWith('org1');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('ignores a concurrent switch while one is in flight', async () => {
|
|
254
|
+
const { controller, oxy, sc } = makeHarness();
|
|
255
|
+
sc.set(state([{ accountId: 'a1', sessionId: 's1' }, { accountId: 'a2', sessionId: 's2' }], 'a1'));
|
|
256
|
+
let release: () => void = () => undefined;
|
|
257
|
+
jest.spyOn(sc, 'switchAccount').mockImplementation(
|
|
258
|
+
() => new Promise<void>((resolve) => { release = resolve; }),
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
const first = controller.switchTo('a2');
|
|
262
|
+
expect(controller.getSnapshot().switchingAccountId).toBe('a2');
|
|
263
|
+
await controller.switchTo('a1'); // ignored — a switch is in flight
|
|
264
|
+
expect(sc.switchAccount).toHaveBeenCalledTimes(1);
|
|
265
|
+
|
|
266
|
+
release();
|
|
267
|
+
await first;
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe('AccountDialogController — sign in with Oxy', () => {
|
|
272
|
+
it('completes silently when a shared identity mints a session', async () => {
|
|
273
|
+
const { controller, oxy, commitSession, onSignedIn } = makeHarness();
|
|
274
|
+
const session: SessionLoginResponse = {
|
|
275
|
+
sessionId: 'sess-shared',
|
|
276
|
+
deviceId: 'device-1',
|
|
277
|
+
expiresAt: '2030-01-01T00:00:00Z',
|
|
278
|
+
accessToken: 'access-shared',
|
|
279
|
+
user: { id: 'a1', username: 'user_a1', name: { displayName: 'User a1' } },
|
|
280
|
+
};
|
|
281
|
+
oxy.signInWithSharedIdentity.mockResolvedValue(session);
|
|
282
|
+
|
|
283
|
+
await controller.signInWithOxy();
|
|
284
|
+
|
|
285
|
+
expect(commitSession).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'sess-shared' }));
|
|
286
|
+
expect(onSignedIn).toHaveBeenCalledWith(expect.objectContaining({ id: 'a1' }));
|
|
287
|
+
expect(controller.getSnapshot().view).toBe('accounts');
|
|
288
|
+
expect(controller.getSnapshot().signIn.phase).toBe('idle');
|
|
289
|
+
expect(oxy.startCommonsSignIn).not.toHaveBeenCalled();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('falls through to the QR handoff when no shared identity is present', async () => {
|
|
293
|
+
const { controller, oxy } = makeHarness();
|
|
294
|
+
oxy.signInWithSharedIdentity.mockResolvedValue(null);
|
|
295
|
+
oxy.startCommonsSignIn.mockResolvedValue({
|
|
296
|
+
sessionToken: 'secret-tok',
|
|
297
|
+
authorizeCode: 'AUTH-CODE',
|
|
298
|
+
qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
|
|
299
|
+
expiresAt: Date.now() + 300_000,
|
|
300
|
+
status: 'pending',
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
await controller.signInWithOxy();
|
|
304
|
+
|
|
305
|
+
expect(oxy.startCommonsSignIn).toHaveBeenCalledWith({ clientId: 'oxy_dk_test' });
|
|
306
|
+
const snap = controller.getSnapshot();
|
|
307
|
+
expect(snap.view).toBe('qr');
|
|
308
|
+
expect(snap.signIn.phase).toBe('waiting');
|
|
309
|
+
expect(snap.signIn.authorizeCode).toBe('AUTH-CODE');
|
|
310
|
+
expect(snap.signIn.qrPayload).toBe('oxycommons://approve?v=1&code=AUTH-CODE');
|
|
311
|
+
controller.cancelSignIn();
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('errors when showQr is called without a clientId', async () => {
|
|
315
|
+
const { controller } = makeHarness({ clientId: null });
|
|
316
|
+
await controller.showQr();
|
|
317
|
+
const snap = controller.getSnapshot();
|
|
318
|
+
expect(snap.signIn.phase).toBe('error');
|
|
319
|
+
expect(snap.signIn.error).toMatch(/clientId/);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('polls, claims, and commits when the QR flow is authorized', async () => {
|
|
323
|
+
jest.useFakeTimers();
|
|
324
|
+
try {
|
|
325
|
+
const { controller, oxy, commitSession, onSignedIn } = makeHarness();
|
|
326
|
+
oxy.startCommonsSignIn.mockResolvedValue({
|
|
327
|
+
sessionToken: 'secret-tok',
|
|
328
|
+
authorizeCode: 'AUTH-CODE',
|
|
329
|
+
qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
|
|
330
|
+
expiresAt: Date.now() + 600_000,
|
|
331
|
+
status: 'pending',
|
|
332
|
+
});
|
|
333
|
+
oxy.pollCommonsSignIn
|
|
334
|
+
.mockResolvedValueOnce({ authorized: false, status: 'pending' })
|
|
335
|
+
.mockResolvedValueOnce({ authorized: true, sessionId: 'sess-1', status: 'authorized' });
|
|
336
|
+
oxy.claimSessionByToken.mockResolvedValue({
|
|
337
|
+
accessToken: 'access-1',
|
|
338
|
+
sessionId: 'sess-1',
|
|
339
|
+
deviceId: 'device-1',
|
|
340
|
+
expiresAt: '2030-01-01T00:00:00Z',
|
|
341
|
+
user: user('a1'),
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
await controller.showQr();
|
|
345
|
+
expect(controller.getSnapshot().signIn.phase).toBe('waiting');
|
|
346
|
+
|
|
347
|
+
await jest.advanceTimersByTimeAsync(1000); // first poll → pending
|
|
348
|
+
expect(oxy.pollCommonsSignIn).toHaveBeenCalledTimes(1);
|
|
349
|
+
|
|
350
|
+
await jest.advanceTimersByTimeAsync(1000); // second poll → authorized → claim
|
|
351
|
+
expect(oxy.claimSessionByToken).toHaveBeenCalledWith('secret-tok');
|
|
352
|
+
expect(commitSession).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'sess-1', accessToken: 'access-1' }));
|
|
353
|
+
expect(onSignedIn).toHaveBeenCalledWith(expect.objectContaining({ id: 'a1' }));
|
|
354
|
+
expect(controller.getSnapshot().view).toBe('accounts');
|
|
355
|
+
} finally {
|
|
356
|
+
jest.useRealTimers();
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('surfaces a denied QR authorization as an error and stops polling', async () => {
|
|
361
|
+
jest.useFakeTimers();
|
|
362
|
+
try {
|
|
363
|
+
const { controller, oxy } = makeHarness();
|
|
364
|
+
oxy.startCommonsSignIn.mockResolvedValue({
|
|
365
|
+
sessionToken: 'secret-tok',
|
|
366
|
+
authorizeCode: 'AUTH-CODE',
|
|
367
|
+
qrPayload: 'oxycommons://approve',
|
|
368
|
+
expiresAt: Date.now() + 600_000,
|
|
369
|
+
status: 'pending',
|
|
370
|
+
});
|
|
371
|
+
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'cancelled' });
|
|
372
|
+
|
|
373
|
+
await controller.showQr();
|
|
374
|
+
await jest.advanceTimersByTimeAsync(1000);
|
|
375
|
+
|
|
376
|
+
expect(controller.getSnapshot().signIn.phase).toBe('error');
|
|
377
|
+
expect(controller.getSnapshot().signIn.error).toMatch(/denied/i);
|
|
378
|
+
|
|
379
|
+
// No further polls after the terminal error.
|
|
380
|
+
await jest.advanceTimersByTimeAsync(5000);
|
|
381
|
+
expect(oxy.pollCommonsSignIn).toHaveBeenCalledTimes(1);
|
|
382
|
+
} finally {
|
|
383
|
+
jest.useRealTimers();
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('cancelSignIn stops the poll and resets to idle', async () => {
|
|
388
|
+
jest.useFakeTimers();
|
|
389
|
+
try {
|
|
390
|
+
const { controller, oxy } = makeHarness();
|
|
391
|
+
oxy.startCommonsSignIn.mockResolvedValue({
|
|
392
|
+
sessionToken: 'secret-tok',
|
|
393
|
+
authorizeCode: 'AUTH-CODE',
|
|
394
|
+
qrPayload: 'oxycommons://approve',
|
|
395
|
+
expiresAt: Date.now() + 600_000,
|
|
396
|
+
status: 'pending',
|
|
397
|
+
});
|
|
398
|
+
oxy.pollCommonsSignIn.mockResolvedValue({ authorized: false, status: 'pending' });
|
|
399
|
+
|
|
400
|
+
await controller.showQr();
|
|
401
|
+
controller.cancelSignIn();
|
|
402
|
+
expect(controller.getSnapshot().signIn.phase).toBe('idle');
|
|
403
|
+
|
|
404
|
+
await jest.advanceTimersByTimeAsync(5000);
|
|
405
|
+
expect(oxy.pollCommonsSignIn).not.toHaveBeenCalled();
|
|
406
|
+
} finally {
|
|
407
|
+
jest.useRealTimers();
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
describe('AccountDialogController — openPasswordAtOxyAuth', () => {
|
|
413
|
+
it('builds the IdP sign-in URL with redirect_uri + client_id and invokes openUrl', () => {
|
|
414
|
+
const oxy = makeOxy();
|
|
415
|
+
const sc = new TestSessionClient(host());
|
|
416
|
+
const openUrl = jest.fn();
|
|
417
|
+
const controller = new AccountDialogController({
|
|
418
|
+
oxyServices: oxy as unknown as OxyServices,
|
|
419
|
+
sessionClient: sc,
|
|
420
|
+
clientId: 'oxy_dk_test',
|
|
421
|
+
openUrl,
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
const url = controller.openPasswordAtOxyAuth({ returnUrl: 'https://mention.earth/', state: 'xyz' });
|
|
425
|
+
const parsed = new URL(url);
|
|
426
|
+
expect(parsed.origin).toBe('https://auth.oxy.so');
|
|
427
|
+
expect(parsed.pathname).toBe('/login');
|
|
428
|
+
expect(parsed.searchParams.get('redirect_uri')).toBe('https://mention.earth/');
|
|
429
|
+
expect(parsed.searchParams.get('client_id')).toBe('oxy_dk_test');
|
|
430
|
+
expect(parsed.searchParams.get('state')).toBe('xyz');
|
|
431
|
+
expect(openUrl).toHaveBeenCalledWith(url);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it('honors an idpApex override', () => {
|
|
435
|
+
const oxy = makeOxy();
|
|
436
|
+
const sc = new TestSessionClient(host());
|
|
437
|
+
const controller = new AccountDialogController({
|
|
438
|
+
oxyServices: oxy as unknown as OxyServices,
|
|
439
|
+
sessionClient: sc,
|
|
440
|
+
idpApex: 'alia.onl',
|
|
441
|
+
});
|
|
442
|
+
const url = controller.openPasswordAtOxyAuth({ returnUrl: 'https://alia.onl/' });
|
|
443
|
+
expect(new URL(url).origin).toBe('https://auth.alia.onl');
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
describe('AccountDialogController — lifecycle', () => {
|
|
448
|
+
it('destroy unsubscribes so later device-state changes do not notify', async () => {
|
|
449
|
+
const { controller, oxy, sc } = makeHarness();
|
|
450
|
+
oxy.getUsersByIds.mockResolvedValue([user('a1')]);
|
|
451
|
+
controller.start();
|
|
452
|
+
await Promise.resolve();
|
|
453
|
+
const seen: string[] = [];
|
|
454
|
+
controller.subscribe((s) => seen.push(s.view));
|
|
455
|
+
controller.destroy();
|
|
456
|
+
// destroy clears all listeners; a subsequent state push notifies nobody.
|
|
457
|
+
sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
|
|
458
|
+
expect(seen).toEqual([]);
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it('createAccountDialogController returns an AccountDialogController instance', () => {
|
|
463
|
+
const { controller } = makeHarness();
|
|
464
|
+
expect(controller).toBeInstanceOf(AccountDialogController);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
// Ensure the exported type surface is reachable at compile time for binders.
|
|
468
|
+
const _typecheck: MinimalUserData | null = null;
|
|
469
|
+
void _typecheck;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import type { DeviceSessionState } from '@oxyhq/contracts';
|
|
2
|
+
import type { User } from '../../models/interfaces';
|
|
3
|
+
import type { AccountNode } from '../../mixins/OxyServices.accounts';
|
|
4
|
+
import {
|
|
5
|
+
projectSwitchableAccounts,
|
|
6
|
+
switchableAccountIds,
|
|
7
|
+
} from '../accountProjection';
|
|
8
|
+
|
|
9
|
+
function user(id: string, over: Partial<User> = {}): User {
|
|
10
|
+
return {
|
|
11
|
+
id,
|
|
12
|
+
publicKey: `pk_${id}`,
|
|
13
|
+
username: `user_${id}`,
|
|
14
|
+
name: { displayName: `User ${id}` },
|
|
15
|
+
...over,
|
|
16
|
+
} as User;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function state(
|
|
20
|
+
accounts: Array<{ accountId: string; sessionId: string; authuser?: number }>,
|
|
21
|
+
activeAccountId: string | null,
|
|
22
|
+
): DeviceSessionState {
|
|
23
|
+
return {
|
|
24
|
+
deviceId: 'device-1',
|
|
25
|
+
accounts: accounts.map((a) => ({ accountId: a.accountId, sessionId: a.sessionId, authuser: a.authuser ?? 0 })),
|
|
26
|
+
activeAccountId,
|
|
27
|
+
revision: 1,
|
|
28
|
+
updatedAt: 1_720_000_000_000,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function graphNode(id: string, over: Partial<AccountNode> = {}): AccountNode {
|
|
33
|
+
return {
|
|
34
|
+
accountId: id,
|
|
35
|
+
kind: 'organization',
|
|
36
|
+
parentAccountId: null,
|
|
37
|
+
account: user(id),
|
|
38
|
+
relationship: 'owner',
|
|
39
|
+
callerMembership: null,
|
|
40
|
+
...over,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const mapOf = (...users: User[]): Map<string, User> => {
|
|
45
|
+
const map = new Map<string, User>();
|
|
46
|
+
for (const u of users) map.set(u.id, u);
|
|
47
|
+
return map;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const noAvatar = (): undefined => undefined;
|
|
51
|
+
|
|
52
|
+
describe('projectSwitchableAccounts', () => {
|
|
53
|
+
it('returns [] for null state and empty graph', () => {
|
|
54
|
+
expect(
|
|
55
|
+
projectSwitchableAccounts({ state: null, graph: [], profilesById: new Map(), resolveAvatarUrl: noAvatar }),
|
|
56
|
+
).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('projects device rows and flags the active account current', () => {
|
|
60
|
+
const rows = projectSwitchableAccounts({
|
|
61
|
+
state: state([{ accountId: 'a1', sessionId: 's1', authuser: 0 }, { accountId: 'a2', sessionId: 's2', authuser: 1 }], 'a2'),
|
|
62
|
+
graph: [],
|
|
63
|
+
profilesById: mapOf(user('a1'), user('a2')),
|
|
64
|
+
resolveAvatarUrl: noAvatar,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(rows.map((r) => r.accountId)).toEqual(['a1', 'a2']);
|
|
68
|
+
expect(rows.map((r) => r.isCurrent)).toEqual([false, true]);
|
|
69
|
+
expect(rows.every((r) => r.onDevice)).toBe(true);
|
|
70
|
+
expect(rows[1].sessionId).toBe('s2');
|
|
71
|
+
expect(rows[1].authuser).toBe(1);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('omits device accounts whose profile is not resolved (except the active one via activeUser)', () => {
|
|
75
|
+
const rows = projectSwitchableAccounts({
|
|
76
|
+
state: state([{ accountId: 'a1', sessionId: 's1' }, { accountId: 'a2', sessionId: 's2' }], 'a1'),
|
|
77
|
+
graph: [],
|
|
78
|
+
// a2 has no resolved profile; a1 is active and provided via activeUser.
|
|
79
|
+
profilesById: new Map(),
|
|
80
|
+
activeUser: user('a1', { name: { displayName: 'Fresh A1' } }),
|
|
81
|
+
resolveAvatarUrl: noAvatar,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(rows.map((r) => r.accountId)).toEqual(['a1']);
|
|
85
|
+
expect(rows[0].displayName).toBe('Fresh A1');
|
|
86
|
+
expect(rows[0].isCurrent).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('prefers activeUser over profilesById for the active row (freshness)', () => {
|
|
90
|
+
const rows = projectSwitchableAccounts({
|
|
91
|
+
state: state([{ accountId: 'a1', sessionId: 's1' }], 'a1'),
|
|
92
|
+
graph: [],
|
|
93
|
+
profilesById: mapOf(user('a1', { name: { displayName: 'Stale' } })),
|
|
94
|
+
activeUser: user('a1', { name: { displayName: 'Fresh' } }),
|
|
95
|
+
resolveAvatarUrl: noAvatar,
|
|
96
|
+
});
|
|
97
|
+
expect(rows[0].displayName).toBe('Fresh');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('merges graph-only accounts after device rows, carrying graph metadata', () => {
|
|
101
|
+
const rows = projectSwitchableAccounts({
|
|
102
|
+
state: state([{ accountId: 'a1', sessionId: 's1' }], 'a1'),
|
|
103
|
+
graph: [graphNode('org1', { kind: 'organization', relationship: 'owner', parentAccountId: 'a1' })],
|
|
104
|
+
profilesById: mapOf(user('a1')),
|
|
105
|
+
resolveAvatarUrl: noAvatar,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
expect(rows.map((r) => r.accountId)).toEqual(['a1', 'org1']);
|
|
109
|
+
const org = rows[1];
|
|
110
|
+
expect(org.onDevice).toBe(false);
|
|
111
|
+
expect(org.isCurrent).toBe(false);
|
|
112
|
+
expect(org.sessionId).toBeUndefined();
|
|
113
|
+
expect(org.kind).toBe('organization');
|
|
114
|
+
expect(org.relationship).toBe('owner');
|
|
115
|
+
expect(org.parentAccountId).toBe('a1');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('dedups an account present as BOTH device session and graph node into ONE enriched row', () => {
|
|
119
|
+
const rows = projectSwitchableAccounts({
|
|
120
|
+
state: state([{ accountId: 'a1', sessionId: 's1', authuser: 0 }], 'a1'),
|
|
121
|
+
graph: [graphNode('a1', { kind: 'personal', relationship: 'self', callerMembership: null })],
|
|
122
|
+
profilesById: mapOf(user('a1')),
|
|
123
|
+
resolveAvatarUrl: noAvatar,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
expect(rows).toHaveLength(1);
|
|
127
|
+
const row = rows[0];
|
|
128
|
+
// Keeps the device sessionId + active flag, gains the graph metadata.
|
|
129
|
+
expect(row.sessionId).toBe('s1');
|
|
130
|
+
expect(row.onDevice).toBe(true);
|
|
131
|
+
expect(row.isCurrent).toBe(true);
|
|
132
|
+
expect(row.kind).toBe('personal');
|
|
133
|
+
expect(row.relationship).toBe('self');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('resolves avatar url via the injected resolver and falls back email to @handle', () => {
|
|
137
|
+
const rows = projectSwitchableAccounts({
|
|
138
|
+
state: state([{ accountId: 'a1', sessionId: 's1' }], 'a1'),
|
|
139
|
+
graph: [],
|
|
140
|
+
profilesById: mapOf(user('a1', { avatar: 'file123', email: undefined, username: 'nate' })),
|
|
141
|
+
resolveAvatarUrl: (avatar) => (avatar ? `https://cdn/${avatar}` : undefined),
|
|
142
|
+
});
|
|
143
|
+
expect(rows[0].avatarUrl).toBe('https://cdn/file123');
|
|
144
|
+
// No real email → `@handle` secondary line, never synthesized.
|
|
145
|
+
expect(rows[0].email).toBe('@nate');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('uses a real email when present', () => {
|
|
149
|
+
const rows = projectSwitchableAccounts({
|
|
150
|
+
state: state([{ accountId: 'a1', sessionId: 's1' }], 'a1'),
|
|
151
|
+
graph: [],
|
|
152
|
+
profilesById: mapOf(user('a1', { email: 'real@oxy.so' })),
|
|
153
|
+
resolveAvatarUrl: noAvatar,
|
|
154
|
+
});
|
|
155
|
+
expect(rows[0].email).toBe('real@oxy.so');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('marks no row current when activeAccountId is null', () => {
|
|
159
|
+
const rows = projectSwitchableAccounts({
|
|
160
|
+
state: state([{ accountId: 'a1', sessionId: 's1' }], null),
|
|
161
|
+
graph: [],
|
|
162
|
+
profilesById: mapOf(user('a1')),
|
|
163
|
+
resolveAvatarUrl: noAvatar,
|
|
164
|
+
});
|
|
165
|
+
expect(rows.every((r) => !r.isCurrent)).toBe(true);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe('switchableAccountIds', () => {
|
|
170
|
+
it('unions device + graph ids, deduped and sorted', () => {
|
|
171
|
+
const ids = switchableAccountIds(
|
|
172
|
+
state([{ accountId: 'b', sessionId: 's1' }, { accountId: 'a', sessionId: 's2' }], 'a'),
|
|
173
|
+
[graphNode('c'), graphNode('a')],
|
|
174
|
+
);
|
|
175
|
+
expect(ids).toEqual(['a', 'b', 'c']);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('returns [] for null state and empty graph', () => {
|
|
179
|
+
expect(switchableAccountIds(null, [])).toEqual([]);
|
|
180
|
+
});
|
|
181
|
+
});
|