@oxyhq/core 11.0.1 → 12.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 +7 -5
- package/dist/cjs/mixins/OxyServices.auth.js +6 -117
- package/dist/cjs/mixins/OxyServices.identity.js +16 -12
- package/dist/cjs/session/accountDialogController.js +84 -75
- package/dist/cjs/utils/officialOrigins.js +6 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/index.js +6 -5
- package/dist/esm/mixins/OxyServices.auth.js +6 -117
- package/dist/esm/mixins/OxyServices.identity.js +16 -12
- package/dist/esm/session/accountDialogController.js +84 -75
- package/dist/esm/utils/officialOrigins.js +6 -1
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +3 -3
- package/dist/types/mixins/OxyServices.auth.d.ts +4 -48
- package/dist/types/mixins/OxyServices.identity.d.ts +13 -9
- package/dist/types/session/accountDialogController.d.ts +84 -49
- package/dist/types/utils/officialOrigins.d.ts +6 -0
- package/package.json +2 -2
- package/src/index.ts +7 -4
- package/src/mixins/OxyServices.auth.ts +6 -144
- package/src/mixins/OxyServices.identity.ts +19 -15
- package/src/mixins/__tests__/OxyServices.identity.test.ts +26 -14
- package/src/mixins/__tests__/webauthnAuth.test.ts +1 -1
- package/src/session/__tests__/accountDialogController.test.ts +75 -64
- package/src/session/__tests__/accountProjection.test.ts +30 -0
- package/src/session/accountDialogController.ts +131 -104
- package/src/utils/__tests__/officialOrigins.test.ts +14 -0
- package/src/utils/officialOrigins.ts +6 -1
- package/src/mixins/__tests__/passwordSignIn.test.ts +0 -115
|
@@ -53,7 +53,7 @@ const OXY_IDENTITY_APEX = 'oxy.so';
|
|
|
53
53
|
export type IdentityRecordType = OxySignedRecordType;
|
|
54
54
|
|
|
55
55
|
/** Auth-method types that can be unlinked via {@link OxyServicesIdentityMixin}. */
|
|
56
|
-
export type UnlinkableAuthMethodType = 'identity' | '
|
|
56
|
+
export type UnlinkableAuthMethodType = 'identity' | 'webauthn';
|
|
57
57
|
|
|
58
58
|
/**
|
|
59
59
|
* Result of a link/unlink auth-method mutation (`POST /auth/link`,
|
|
@@ -207,18 +207,18 @@ export function OxyServicesIdentityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
207
207
|
}
|
|
208
208
|
|
|
209
209
|
/**
|
|
210
|
-
*
|
|
211
|
-
*
|
|
210
|
+
* Unlink an authentication method from the current account. The server
|
|
211
|
+
* refuses to remove the last remaining method (the account would become
|
|
212
|
+
* inaccessible). Unlinking `identity` downgrades the account to custodial.
|
|
212
213
|
*
|
|
213
|
-
* @param
|
|
214
|
-
* @param password - The new password (server enforces strength rules).
|
|
214
|
+
* @param type - The auth-method type to remove.
|
|
215
215
|
*/
|
|
216
|
-
async
|
|
216
|
+
async unlinkAuthMethod(type: UnlinkableAuthMethodType): Promise<LinkAuthMethodResult> {
|
|
217
217
|
try {
|
|
218
218
|
const result = await this.makeRequest<LinkAuthMethodResult>(
|
|
219
|
-
'
|
|
220
|
-
|
|
221
|
-
|
|
219
|
+
'DELETE',
|
|
220
|
+
`/auth/link/${encodeURIComponent(type)}`,
|
|
221
|
+
undefined,
|
|
222
222
|
{ cache: false },
|
|
223
223
|
);
|
|
224
224
|
this._invalidateIdentityCaches(this.getCurrentUserId());
|
|
@@ -229,17 +229,21 @@ export function OxyServicesIdentityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
229
229
|
}
|
|
230
230
|
|
|
231
231
|
/**
|
|
232
|
-
*
|
|
233
|
-
* refuses to remove the last remaining method (the account would become
|
|
234
|
-
* inaccessible). Unlinking `identity` downgrades the account to custodial.
|
|
232
|
+
* Remove ONE passkey (WebAuthn credential) from the current account.
|
|
235
233
|
*
|
|
236
|
-
*
|
|
234
|
+
* Passkeys are per-credential, so unlike {@link unlinkAuthMethod} (which
|
|
235
|
+
* removes an auth method by type) this targets a specific credential id.
|
|
236
|
+
* The server refuses to remove the last remaining auth method (the account
|
|
237
|
+
* would become inaccessible) and deletes the stored `WebauthnCredential`.
|
|
238
|
+
*
|
|
239
|
+
* @param credentialId - The passkey's public credential id
|
|
240
|
+
* (`AuthMethodEntry.credentialId`).
|
|
237
241
|
*/
|
|
238
|
-
async
|
|
242
|
+
async removePasskey(credentialId: string): Promise<LinkAuthMethodResult> {
|
|
239
243
|
try {
|
|
240
244
|
const result = await this.makeRequest<LinkAuthMethodResult>(
|
|
241
245
|
'DELETE',
|
|
242
|
-
`/auth/link/${encodeURIComponent(
|
|
246
|
+
`/auth/link/webauthn/${encodeURIComponent(credentialId)}`,
|
|
243
247
|
undefined,
|
|
244
248
|
{ cache: false },
|
|
245
249
|
);
|
|
@@ -186,37 +186,49 @@ describe('OxyServices.identity', () => {
|
|
|
186
186
|
});
|
|
187
187
|
});
|
|
188
188
|
|
|
189
|
-
describe('
|
|
190
|
-
it('
|
|
191
|
-
makeRequestSpy.mockResolvedValue({ success: true, message: '
|
|
189
|
+
describe('unlinkAuthMethod', () => {
|
|
190
|
+
it('DELETEs /auth/link/:type and sweeps cache', async () => {
|
|
191
|
+
makeRequestSpy.mockResolvedValue({ success: true, message: 'identity auth unlinked successfully' });
|
|
192
192
|
|
|
193
|
-
await oxy.
|
|
193
|
+
await oxy.unlinkAuthMethod('identity');
|
|
194
194
|
|
|
195
195
|
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
196
|
-
'
|
|
197
|
-
'/auth/link',
|
|
198
|
-
|
|
196
|
+
'DELETE',
|
|
197
|
+
'/auth/link/identity',
|
|
198
|
+
undefined,
|
|
199
199
|
expect.objectContaining({ cache: false }),
|
|
200
200
|
);
|
|
201
|
-
expect(
|
|
202
|
-
expect(clearEntrySpy).toHaveBeenCalledWith('GET:/auth/methods');
|
|
201
|
+
expect(clearEntrySpy).toHaveBeenCalledWith('GET:/u/user-123/did.json');
|
|
203
202
|
});
|
|
204
203
|
});
|
|
205
204
|
|
|
206
|
-
describe('
|
|
207
|
-
it('DELETEs /auth/link/:
|
|
208
|
-
makeRequestSpy.mockResolvedValue({ success: true, message: '
|
|
205
|
+
describe('removePasskey', () => {
|
|
206
|
+
it('DELETEs /auth/link/webauthn/:credentialID and sweeps cache', async () => {
|
|
207
|
+
makeRequestSpy.mockResolvedValue({ success: true, message: 'Passkey unlinked successfully' });
|
|
209
208
|
|
|
210
|
-
await oxy.
|
|
209
|
+
await oxy.removePasskey('cred-abc');
|
|
211
210
|
|
|
212
211
|
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
213
212
|
'DELETE',
|
|
214
|
-
'/auth/link/
|
|
213
|
+
'/auth/link/webauthn/cred-abc',
|
|
215
214
|
undefined,
|
|
216
215
|
expect.objectContaining({ cache: false }),
|
|
217
216
|
);
|
|
218
217
|
expect(clearEntrySpy).toHaveBeenCalledWith('GET:/u/user-123/did.json');
|
|
219
218
|
});
|
|
219
|
+
|
|
220
|
+
it('URL-encodes the credential id', async () => {
|
|
221
|
+
makeRequestSpy.mockResolvedValue({ success: true, message: 'Passkey unlinked successfully' });
|
|
222
|
+
|
|
223
|
+
await oxy.removePasskey('a/b+c=');
|
|
224
|
+
|
|
225
|
+
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
226
|
+
'DELETE',
|
|
227
|
+
'/auth/link/webauthn/a%2Fb%2Bc%3D',
|
|
228
|
+
undefined,
|
|
229
|
+
expect.objectContaining({ cache: false }),
|
|
230
|
+
);
|
|
231
|
+
});
|
|
220
232
|
});
|
|
221
233
|
|
|
222
234
|
describe('signRecord (client-only)', () => {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* (`webauthnRegisterOptions` / `webauthnRegisterVerify` /
|
|
4
4
|
* `webauthnLoginOptions` / `webauthnLoginVerify`).
|
|
5
5
|
*
|
|
6
|
-
* Stubs `makeRequest` (HTTP-mock style
|
|
6
|
+
* Stubs `makeRequest` (HTTP-mock style) and
|
|
7
7
|
* asserts: each method hits the right endpoint with the right body; the opaque
|
|
8
8
|
* ceremony `response` is forwarded verbatim; the login/verify + register/verify
|
|
9
9
|
* (signup) paths parse the login contract and plant the access token on the
|
|
@@ -152,6 +152,7 @@ describe('AccountDialogController — initial + views', () => {
|
|
|
152
152
|
expect(snap.loading).toBe(false);
|
|
153
153
|
expect(snap.switchingAccountId).toBeNull();
|
|
154
154
|
expect(snap.signIn.phase).toBe('idle');
|
|
155
|
+
expect(snap.commonsAvailability).toBe('unknown');
|
|
155
156
|
});
|
|
156
157
|
|
|
157
158
|
it('setView / add / close move between views and notify subscribers', () => {
|
|
@@ -169,6 +170,19 @@ describe('AccountDialogController — initial + views', () => {
|
|
|
169
170
|
expect(seen).toEqual(['add', 'signin', 'accounts']);
|
|
170
171
|
});
|
|
171
172
|
|
|
173
|
+
it('startSignup moves to the signup view and notifies subscribers', () => {
|
|
174
|
+
const { controller } = makeHarness();
|
|
175
|
+
const seen: string[] = [];
|
|
176
|
+
controller.subscribe((s) => seen.push(s.view));
|
|
177
|
+
|
|
178
|
+
controller.startSignup();
|
|
179
|
+
expect(controller.getSnapshot().view).toBe('signup');
|
|
180
|
+
controller.close();
|
|
181
|
+
expect(controller.getSnapshot().view).toBe('accounts');
|
|
182
|
+
|
|
183
|
+
expect(seen).toEqual(['signup', 'accounts']);
|
|
184
|
+
});
|
|
185
|
+
|
|
172
186
|
it('getSnapshot returns a stable reference until a change occurs', () => {
|
|
173
187
|
const { controller } = makeHarness();
|
|
174
188
|
const a = controller.getSnapshot();
|
|
@@ -391,6 +405,54 @@ describe('AccountDialogController — switchTo (uniform switch)', () => {
|
|
|
391
405
|
expect(commitSession.mock.calls[0][0]).toMatchObject({ sessionId: 'sess-org', accessToken: 'access-org' });
|
|
392
406
|
});
|
|
393
407
|
|
|
408
|
+
it('commits a graph switch via the IN-PLACE commitSwitchedSession — never the hub-syncing commitSession', async () => {
|
|
409
|
+
// PROBLEM 2: an account switch must not run the cross-origin hub-sync
|
|
410
|
+
// full-page redirect. When both funnels are wired, the mint-switch must use
|
|
411
|
+
// commitSwitchedSession (in-place) and NEVER commitSession (which may
|
|
412
|
+
// redirect on an official web origin).
|
|
413
|
+
const oxy = makeOxy();
|
|
414
|
+
const sc = new TestSessionClient(host());
|
|
415
|
+
sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
|
|
416
|
+
jest.spyOn(sc, 'switchAccount').mockResolvedValue(undefined);
|
|
417
|
+
oxy.switchToAccount.mockResolvedValue({
|
|
418
|
+
sessionId: 'sess-org',
|
|
419
|
+
deviceId: 'device-1',
|
|
420
|
+
expiresAt: '2030-01-01T00:00:00Z',
|
|
421
|
+
accessToken: 'access-org',
|
|
422
|
+
user: user('org1'),
|
|
423
|
+
});
|
|
424
|
+
const commitSession = jest.fn().mockResolvedValue(undefined);
|
|
425
|
+
const commitSwitchedSession = jest.fn().mockResolvedValue(undefined);
|
|
426
|
+
const controller = new AccountDialogController({
|
|
427
|
+
oxyServices: oxy as unknown as OxyServices,
|
|
428
|
+
sessionClient: sc,
|
|
429
|
+
clientId: 'oxy_dk_test',
|
|
430
|
+
commitSession,
|
|
431
|
+
commitSwitchedSession,
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
await controller.switchTo('org1');
|
|
435
|
+
|
|
436
|
+
expect(commitSwitchedSession).toHaveBeenCalledTimes(1);
|
|
437
|
+
expect(commitSwitchedSession.mock.calls[0][0]).toMatchObject({ sessionId: 'sess-org', accessToken: 'access-org' });
|
|
438
|
+
expect(commitSession).not.toHaveBeenCalled();
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it('surfaces a failed switch as snapshot.error instead of silently no-op\'ing', async () => {
|
|
442
|
+
// PROBLEM 1: switching into an account the server refuses (e.g. a 403 for a
|
|
443
|
+
// personal-kind target) must tell the user why — the error is recorded on
|
|
444
|
+
// the snapshot, the switch does not throw, and switchingAccountId resets.
|
|
445
|
+
const { controller, oxy, sc } = makeHarness();
|
|
446
|
+
sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
|
|
447
|
+
oxy.switchToAccount.mockRejectedValue(new Error('Cannot switch into a personal account'));
|
|
448
|
+
|
|
449
|
+
await expect(controller.switchTo('org1')).resolves.toBeUndefined();
|
|
450
|
+
|
|
451
|
+
const snap = controller.getSnapshot();
|
|
452
|
+
expect(snap.error).toBe('Cannot switch into a personal account');
|
|
453
|
+
expect(snap.switchingAccountId).toBeNull();
|
|
454
|
+
});
|
|
455
|
+
|
|
394
456
|
it('falls back to SessionClient.registerAndActivate when no commitSession is supplied', async () => {
|
|
395
457
|
const oxy = makeOxy();
|
|
396
458
|
const sc = new TestSessionClient(host());
|
|
@@ -579,7 +641,7 @@ describe('AccountDialogController — sign in with Oxy', () => {
|
|
|
579
641
|
});
|
|
580
642
|
});
|
|
581
643
|
|
|
582
|
-
describe('AccountDialogController — Commons
|
|
644
|
+
describe('AccountDialogController — Commons availability (canOpenApp)', () => {
|
|
583
645
|
const START_HANDLE = {
|
|
584
646
|
sessionToken: 'secret-tok',
|
|
585
647
|
authorizeCode: 'AUTH-CODE',
|
|
@@ -621,6 +683,7 @@ describe('AccountDialogController — Commons deep-link (canOpenApp)', () => {
|
|
|
621
683
|
expect(snap.view).toBe('qr');
|
|
622
684
|
expect(snap.signIn.phase).toBe('waiting');
|
|
623
685
|
expect(snap.signIn.qrPayload).toBe('oxycommons://approve?v=1&code=AUTH-CODE');
|
|
686
|
+
expect(snap.commonsAvailability).toBe('available');
|
|
624
687
|
controller.cancelSignIn();
|
|
625
688
|
});
|
|
626
689
|
|
|
@@ -635,6 +698,7 @@ describe('AccountDialogController — Commons deep-link (canOpenApp)', () => {
|
|
|
635
698
|
expect(canOpenApp).toHaveBeenCalledWith('oxycommons://');
|
|
636
699
|
expect(openUrl).not.toHaveBeenCalled();
|
|
637
700
|
expect(controller.getSnapshot().signIn.phase).toBe('waiting');
|
|
701
|
+
expect(controller.getSnapshot().commonsAvailability).toBe('unavailable');
|
|
638
702
|
controller.cancelSignIn();
|
|
639
703
|
});
|
|
640
704
|
|
|
@@ -647,10 +711,11 @@ describe('AccountDialogController — Commons deep-link (canOpenApp)', () => {
|
|
|
647
711
|
|
|
648
712
|
expect(openUrl).not.toHaveBeenCalled();
|
|
649
713
|
expect(controller.getSnapshot().signIn.qrPayload).toBe('oxycommons://approve?v=1&code=AUTH-CODE');
|
|
714
|
+
expect(controller.getSnapshot().commonsAvailability).toBe('unknown');
|
|
650
715
|
controller.cancelSignIn();
|
|
651
716
|
});
|
|
652
717
|
|
|
653
|
-
it('swallows a canOpenApp probe rejection
|
|
718
|
+
it('swallows a canOpenApp probe rejection, keeps the QR fallback, and records unavailable', async () => {
|
|
654
719
|
const openUrl = jest.fn();
|
|
655
720
|
const canOpenApp = jest.fn().mockRejectedValue(new Error('probe boom'));
|
|
656
721
|
const { controller } = makeController({ openUrl, canOpenApp });
|
|
@@ -660,73 +725,19 @@ describe('AccountDialogController — Commons deep-link (canOpenApp)', () => {
|
|
|
660
725
|
|
|
661
726
|
expect(openUrl).not.toHaveBeenCalled();
|
|
662
727
|
expect(controller.getSnapshot().signIn.phase).toBe('waiting');
|
|
728
|
+
expect(controller.getSnapshot().commonsAvailability).toBe('unavailable');
|
|
663
729
|
controller.cancelSignIn();
|
|
664
730
|
});
|
|
665
|
-
});
|
|
666
|
-
|
|
667
|
-
describe('AccountDialogController — openPasswordAtOxyAuth', () => {
|
|
668
|
-
beforeEach(() => {
|
|
669
|
-
const store = new Map<string, string>();
|
|
670
|
-
Object.defineProperty(globalThis, 'sessionStorage', {
|
|
671
|
-
value: {
|
|
672
|
-
getItem: (key: string) => store.get(key) ?? null,
|
|
673
|
-
setItem: (key: string, value: string) => {
|
|
674
|
-
store.set(key, value);
|
|
675
|
-
},
|
|
676
|
-
removeItem: (key: string) => {
|
|
677
|
-
store.delete(key);
|
|
678
|
-
},
|
|
679
|
-
},
|
|
680
|
-
configurable: true,
|
|
681
|
-
});
|
|
682
|
-
});
|
|
683
|
-
|
|
684
|
-
it('builds the IdP sign-in URL with redirect_uri + client_id and invokes openUrl', async () => {
|
|
685
|
-
const oxy = makeOxy();
|
|
686
|
-
const sc = new TestSessionClient(host());
|
|
687
|
-
const openUrl = jest.fn();
|
|
688
|
-
const controller = new AccountDialogController({
|
|
689
|
-
oxyServices: oxy as unknown as OxyServices,
|
|
690
|
-
sessionClient: sc,
|
|
691
|
-
clientId: 'oxy_dk_test',
|
|
692
|
-
openUrl,
|
|
693
|
-
});
|
|
694
|
-
|
|
695
|
-
const url = await controller.openPasswordAtOxyAuth({ returnUrl: 'https://mention.earth/dashboard' });
|
|
696
|
-
const parsed = new URL(url);
|
|
697
|
-
expect(parsed.origin).toBe('https://auth.oxy.so');
|
|
698
|
-
expect(parsed.pathname).toBe('/login');
|
|
699
|
-
expect(parsed.searchParams.get('redirect_uri')).toBe('https://mention.earth');
|
|
700
|
-
expect(parsed.searchParams.get('client_id')).toBe('oxy_dk_test');
|
|
701
|
-
expect(parsed.searchParams.get('state')).toBeTruthy();
|
|
702
|
-
expect(parsed.searchParams.get('code_challenge')).toBeTruthy();
|
|
703
|
-
expect(parsed.searchParams.get('code_challenge_method')).toBe('S256');
|
|
704
|
-
expect(openUrl).toHaveBeenCalledWith(url);
|
|
705
|
-
});
|
|
706
731
|
|
|
707
|
-
it('
|
|
708
|
-
const
|
|
709
|
-
const
|
|
710
|
-
const controller = new AccountDialogController({
|
|
711
|
-
oxyServices: oxy as unknown as OxyServices,
|
|
712
|
-
sessionClient: sc,
|
|
713
|
-
authRedirectUri: 'https://inbox.oxy.so',
|
|
714
|
-
});
|
|
732
|
+
it('start() eagerly resolves commonsAvailability without requiring a QR flow', async () => {
|
|
733
|
+
const canOpenApp = jest.fn().mockResolvedValue(true);
|
|
734
|
+
const { controller } = makeController({ canOpenApp });
|
|
715
735
|
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
});
|
|
736
|
+
controller.start();
|
|
737
|
+
await flush();
|
|
719
738
|
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
const sc = new TestSessionClient(host());
|
|
723
|
-
const controller = new AccountDialogController({
|
|
724
|
-
oxyServices: oxy as unknown as OxyServices,
|
|
725
|
-
sessionClient: sc,
|
|
726
|
-
idpApex: 'alia.onl',
|
|
727
|
-
});
|
|
728
|
-
const url = await controller.openPasswordAtOxyAuth({ returnUrl: 'https://alia.onl/' });
|
|
729
|
-
expect(new URL(url).origin).toBe('https://auth.alia.onl');
|
|
739
|
+
expect(canOpenApp).toHaveBeenCalledWith('oxycommons://');
|
|
740
|
+
expect(controller.getSnapshot().commonsAvailability).toBe('available');
|
|
730
741
|
});
|
|
731
742
|
});
|
|
732
743
|
|
|
@@ -164,6 +164,36 @@ describe('projectSwitchableAccounts', () => {
|
|
|
164
164
|
});
|
|
165
165
|
expect(rows.every((r) => !r.isCurrent)).toBe(true);
|
|
166
166
|
});
|
|
167
|
+
|
|
168
|
+
it('keeps the OPERATOR full set when acting-as a sub-account (no collapse to the active account)', () => {
|
|
169
|
+
// nate operates albert: albert is the active on-device account, nate is also
|
|
170
|
+
// on-device, and the graph is OPERATOR-anchored (the server returns nate's
|
|
171
|
+
// full forest regardless of which account is active). The projection must
|
|
172
|
+
// faithfully union — switching in only changes which row is `isCurrent`,
|
|
173
|
+
// never which accounts are listed. This is the switcher-collapse regression.
|
|
174
|
+
const rows = projectSwitchableAccounts({
|
|
175
|
+
state: state([{ accountId: 'nate', sessionId: 's-nate' }, { accountId: 'albert', sessionId: 's-albert' }], 'albert'),
|
|
176
|
+
graph: [
|
|
177
|
+
graphNode('nate', { kind: 'personal', relationship: 'self' }),
|
|
178
|
+
graphNode('albert', { relationship: 'owner' }),
|
|
179
|
+
graphNode('oxy', { relationship: 'owner' }),
|
|
180
|
+
graphNode('faircoin', { relationship: 'owner' }),
|
|
181
|
+
],
|
|
182
|
+
profilesById: mapOf(user('nate'), user('albert'), user('oxy'), user('faircoin')),
|
|
183
|
+
resolveAvatarUrl: noAvatar,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// The full operable set is present even though a leaf sub-account is active.
|
|
187
|
+
expect(rows.map((r) => r.accountId).sort()).toEqual(['albert', 'faircoin', 'nate', 'oxy']);
|
|
188
|
+
// Exactly the active sub-account is flagged current; operator + siblings stay.
|
|
189
|
+
expect(rows.find((r) => r.accountId === 'albert')?.isCurrent).toBe(true);
|
|
190
|
+
expect(rows.filter((r) => r.isCurrent)).toHaveLength(1);
|
|
191
|
+
// The operator's own personal account is not dropped by acting-as.
|
|
192
|
+
expect(rows.find((r) => r.accountId === 'nate')).toBeDefined();
|
|
193
|
+
// Graph-only siblings (operable but not yet on this device) still appear.
|
|
194
|
+
expect(rows.find((r) => r.accountId === 'oxy')?.onDevice).toBe(false);
|
|
195
|
+
expect(rows.find((r) => r.accountId === 'faircoin')?.onDevice).toBe(false);
|
|
196
|
+
});
|
|
167
197
|
});
|
|
168
198
|
|
|
169
199
|
describe('switchableAccountIds', () => {
|