Skip to content

Commit af89dbb

Browse files
committed
Add access update flow and bridge access helpers (Plan 27 Phase 5)
- AccessUpdateRequest types and CollectorClient.checkForUpdateRequests/acceptUpdate/refuseUpdate - Collector.requestAccessUpdate and checkInbox extension for update-accept/update-refuse - Contact.hasPendingUpdate/pendingUpdateClients getters - AppClientAccount.getContacts scans for update requests in parallel - bridgeAccess.ts: ensureBridgeAccess/recreateBridgeAccess/getOrCreateBridgeAccess helpers - Tests for access update and bridge access
1 parent 9f13d74 commit af89dbb

10 files changed

Lines changed: 689 additions & 2 deletions

tests/accessUpdate.test.js

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import { assert } from './test-utils/deps-node.js';
2+
import { Contact } from '../ts/appTemplates/Contact.ts';
3+
import { CollectorClient } from '../ts/appTemplates/CollectorClient.ts';
4+
5+
/**
6+
* Unit tests for access update request functionality.
7+
* Tests the data structures and Contact-level getters.
8+
* Integration tests (actual API calls) are in apptemplates.test.js.
9+
*/
10+
11+
// ---- Test helpers ---- //
12+
13+
function makeSource (overrides = {}) {
14+
return {
15+
remoteUsername: 'dr-alice',
16+
displayName: 'Dr. Alice',
17+
chatStreams: null,
18+
appStreamId: null,
19+
permissions: [{ streamId: 'health', level: 'read' }],
20+
status: 'Active',
21+
type: 'collector',
22+
accessId: 'acc-1',
23+
...overrides
24+
};
25+
}
26+
27+
function makeMockCollectorClient (overrides = {}) {
28+
return {
29+
status: 'Active',
30+
pendingUpdate: null,
31+
key: 'dr-alice:a-form1',
32+
requesterUsername: 'dr-alice',
33+
hasChatFeature: false,
34+
...overrides
35+
};
36+
}
37+
38+
describe('[AUPD] Access Update Requests', function () {
39+
// ---- Contact.hasPendingUpdate ---- //
40+
41+
describe('[AUCT] Contact.hasPendingUpdate', function () {
42+
it('[AU01] should return false when no collectorClients', () => {
43+
const c = new Contact('dr-alice', 'Dr. Alice');
44+
c.addSource(makeSource());
45+
assert.equal(c.hasPendingUpdate, false);
46+
});
47+
48+
it('[AU02] should return false when no pending updates', () => {
49+
const c = new Contact('dr-alice', 'Dr. Alice');
50+
c.addSource(makeSource());
51+
c.addCollectorClient(makeMockCollectorClient());
52+
assert.equal(c.hasPendingUpdate, false);
53+
});
54+
55+
it('[AU03] should return true when a collectorClient has pendingUpdate', () => {
56+
const c = new Contact('dr-alice', 'Dr. Alice');
57+
c.addSource(makeSource());
58+
c.addCollectorClient(makeMockCollectorClient({
59+
pendingUpdate: {
60+
eventId: 'evt-1',
61+
content: {
62+
version: 0,
63+
targetAccessName: 'dr-alice:a-form1',
64+
action: 'update-permissions',
65+
permissions: [{ streamId: 'diary', level: 'read' }]
66+
}
67+
}
68+
}));
69+
assert.equal(c.hasPendingUpdate, true);
70+
});
71+
});
72+
73+
// ---- Contact.pendingUpdateClients ---- //
74+
75+
describe('[AUPC] Contact.pendingUpdateClients', function () {
76+
it('[AU04] should return empty array when no pending updates', () => {
77+
const c = new Contact('dr-alice', 'Dr. Alice');
78+
c.addCollectorClient(makeMockCollectorClient());
79+
c.addCollectorClient(makeMockCollectorClient({ key: 'dr-alice:a-form2' }));
80+
assert.equal(c.pendingUpdateClients.length, 0);
81+
});
82+
83+
it('[AU05] should return only clients with pending updates', () => {
84+
const c = new Contact('dr-alice', 'Dr. Alice');
85+
c.addCollectorClient(makeMockCollectorClient());
86+
c.addCollectorClient(makeMockCollectorClient({
87+
key: 'dr-alice:a-form2',
88+
pendingUpdate: {
89+
eventId: 'evt-2',
90+
content: {
91+
version: 0,
92+
targetAccessName: 'dr-alice:a-form2',
93+
action: 'update-permissions',
94+
permissions: [{ streamId: 'diary', level: 'contribute' }]
95+
}
96+
}
97+
}));
98+
assert.equal(c.pendingUpdateClients.length, 1);
99+
assert.equal(c.pendingUpdateClients[0].key, 'dr-alice:a-form2');
100+
});
101+
});
102+
103+
// ---- CollectorClient.pendingUpdate field ---- //
104+
105+
describe('[AUCC] CollectorClient.pendingUpdate', function () {
106+
it('[AU06] should default to null', () => {
107+
// pendingUpdate is a plain field, tested via mock
108+
const cc = makeMockCollectorClient();
109+
assert.equal(cc.pendingUpdate, null);
110+
});
111+
112+
it('[AU07] should hold update request data when set', () => {
113+
const updateReq = {
114+
eventId: 'evt-1',
115+
content: {
116+
version: 0,
117+
targetAccessName: 'dr-alice:a-form1',
118+
action: 'update-permissions',
119+
permissions: [
120+
{ streamId: 'health', level: 'read' },
121+
{ streamId: 'diary', level: 'contribute' }
122+
],
123+
features: { chat: { type: 'user' } },
124+
message: 'I\'d like to also access your diary entries.'
125+
}
126+
};
127+
const cc = makeMockCollectorClient({ pendingUpdate: updateReq });
128+
assert.equal(cc.pendingUpdate.eventId, 'evt-1');
129+
assert.equal(cc.pendingUpdate.content.action, 'update-permissions');
130+
assert.equal(cc.pendingUpdate.content.permissions.length, 2);
131+
assert.equal(cc.pendingUpdate.content.message, 'I\'d like to also access your diary entries.');
132+
assert.deepEqual(cc.pendingUpdate.content.features, { chat: { type: 'user' } });
133+
});
134+
});
135+
136+
// ---- AccessUpdateRequestContent structure ---- //
137+
138+
describe('[AURC] AccessUpdateRequestContent schema', function () {
139+
it('[AU08] should validate minimal update request content', () => {
140+
const content = {
141+
version: 0,
142+
targetAccessName: 'dr-alice:a-form1',
143+
action: 'update-permissions',
144+
permissions: [{ streamId: 'health', level: 'manage' }]
145+
};
146+
assert.equal(content.version, 0);
147+
assert.equal(content.targetAccessName, 'dr-alice:a-form1');
148+
assert.equal(content.action, 'update-permissions');
149+
assert.equal(content.permissions.length, 1);
150+
assert.equal(content.permissions[0].streamId, 'health');
151+
});
152+
153+
it('[AU09] should support add-feature action', () => {
154+
const content = {
155+
version: 0,
156+
targetAccessName: 'dr-alice:a-form1',
157+
action: 'add-feature',
158+
permissions: [{ streamId: 'health', level: 'read' }],
159+
features: { chat: { type: 'user' } },
160+
message: 'Adding chat capability'
161+
};
162+
assert.equal(content.action, 'add-feature');
163+
assert.ok(content.features.chat);
164+
});
165+
});
166+
167+
// ---- Multiple contacts with mixed update states ---- //
168+
169+
describe('[AUMX] Mixed contacts with updates', function () {
170+
it('[AU10] should correctly report updates across multiple contacts', () => {
171+
const alice = new Contact('dr-alice', 'Dr. Alice');
172+
alice.addSource(makeSource());
173+
alice.addCollectorClient(makeMockCollectorClient({
174+
pendingUpdate: {
175+
eventId: 'evt-1',
176+
content: { version: 0, targetAccessName: 'dr-alice:a-form1', action: 'update-permissions', permissions: [] }
177+
}
178+
}));
179+
180+
const bob = new Contact('dr-bob', 'Dr. Bob');
181+
bob.addSource(makeSource({ remoteUsername: 'dr-bob', displayName: 'Dr. Bob' }));
182+
bob.addCollectorClient(makeMockCollectorClient({ key: 'dr-bob:a-form1', pendingUpdate: null }));
183+
184+
assert.equal(alice.hasPendingUpdate, true);
185+
assert.equal(bob.hasPendingUpdate, false);
186+
});
187+
});
188+
});

tests/bridgeAccess.test.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { assert } from './test-utils/deps-node.js';
2+
3+
// We can't easily unit-test the API-calling functions without a real connection,
4+
// but we can test the exported types compile and the permissionsMatch logic indirectly.
5+
// The main integration test is in the bridge-mira tests.
6+
7+
// Import to verify the module loads correctly
8+
import { getOrCreateBridgeAccess, recreateBridgeAccess, ensureBridgeAccess } from '../ts/appTemplates/bridgeAccess.ts';
9+
10+
describe('[BACC] Bridge Access helpers', function () {
11+
it('[BA01] should export all three helper functions', () => {
12+
assert.equal(typeof getOrCreateBridgeAccess, 'function');
13+
assert.equal(typeof recreateBridgeAccess, 'function');
14+
assert.equal(typeof ensureBridgeAccess, 'function');
15+
});
16+
17+
it('[BA02] should be importable from appTemplates', async () => {
18+
const appTemplates = await import('../ts/appTemplates/appTemplates.ts');
19+
assert.equal(typeof appTemplates.getOrCreateBridgeAccess, 'function');
20+
assert.equal(typeof appTemplates.recreateBridgeAccess, 'function');
21+
assert.equal(typeof appTemplates.ensureBridgeAccess, 'function');
22+
});
23+
});

tests/contact.test.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,24 @@ describe('[CTCT] Contact class', function () {
284284
assert.equal(c.eventIsFromContact({ modifiedBy: 'a2' }), true);
285285
assert.equal(c.eventIsFromContact({ modifiedBy: 'other' }), false);
286286
});
287+
288+
it('[CTAQ2] should match events from previous (replaced) accesses via clientData chain', () => {
289+
const c = new Contact('u', 'U');
290+
// Current access (after update) carries previousAccessIds
291+
c.addAccessObject({
292+
id: 'a3-new',
293+
clientData: {
294+
hdsCollectorClient: {
295+
version: 0,
296+
previousAccessIds: ['a1-old', 'a2-older']
297+
}
298+
}
299+
});
300+
assert.equal(c.eventIsFromContact({ modifiedBy: 'a3-new' }), true);
301+
assert.equal(c.eventIsFromContact({ modifiedBy: 'a1-old' }), true);
302+
assert.equal(c.eventIsFromContact({ modifiedBy: 'a2-older' }), true);
303+
assert.equal(c.eventIsFromContact({ modifiedBy: 'unknown' }), false);
304+
});
287305
});
288306

289307
// ---- sourceFromAccess (static) ---- //

ts/appTemplates/AppClientAccount.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,25 @@ export class AppClientAccount extends Application {
104104
* Combines CollectorClients (person-to-person) and bridge/other accesses.
105105
* Multiple forms from the same doctor → one Contact with multiple sources.
106106
* Contacts are enriched with CollectorClient instances and access objects.
107+
* Also checks for pending access update requests on active CollectorClients.
107108
*/
108109
async getContacts (forceRefresh: boolean = false): Promise<Contact[]> {
109110
const collectorClients = await this.getCollectorClients(forceRefresh);
111+
112+
// Check for pending update requests in parallel (with timeout)
113+
const activeClients = collectorClients.filter(cc => cc.status === 'Active');
114+
if (activeClients.length > 0) {
115+
const UPDATE_CHECK_TIMEOUT = 10000;
116+
await Promise.allSettled(
117+
activeClients.map(cc =>
118+
Promise.race([
119+
cc.checkForUpdateRequests(),
120+
new Promise(resolve => setTimeout(resolve, UPDATE_CHECK_TIMEOUT))
121+
])
122+
)
123+
);
124+
}
125+
110126
const sources: ContactSource[] = [];
111127

112128
// Collector clients → person contacts

ts/appTemplates/Collector.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { HDSLibError } from '../errors.ts';
33
import { waitUntilFalse } from '../utils.ts';
44
import { CollectorInvite } from './CollectorInvite.ts';
55
import * as logger from '../logger.ts';
6+
import type { Permission, AccessUpdateAction } from './interfaces.ts';
67

78
const COLLECTOR_STREAMID_SUFFIXES = {
89
archive: 'archive',
@@ -219,6 +220,16 @@ export class Collector {
219220
updateInvite.content.apiEndpoint = responseEvent.content.apiEndpoint;
220221
if (responseEvent.content.chat) updateInvite.content.chat = responseEvent.content.chat;
221222
break;
223+
case 'update-accept':
224+
// Patient accepted an access update — new apiEndpoint, stays active
225+
(updateInvite as any).streamIds = [this.streamIdFor(Collector.STREAMID_SUFFIXES.active)];
226+
updateInvite.content.apiEndpoint = responseEvent.content.apiEndpoint;
227+
if (responseEvent.content.chat) updateInvite.content.chat = responseEvent.content.chat;
228+
break;
229+
case 'update-refuse':
230+
// Patient refused the update — invite stays active with current permissions
231+
// No stream change, just archive the response
232+
break;
222233
case 'refuse':
223234
(updateInvite as any).streamIds = [this.streamIdFor(Collector.STREAMID_SUFFIXES.error)];
224235
updateInvite.content.errorType = 'refused';
@@ -327,6 +338,50 @@ export class Collector {
327338
return newSharingApiEndpoint;
328339
}
329340

341+
/**
342+
* Request an access update for a specific invite.
343+
* Creates a `request/access-update-v1` event in the public stream
344+
* that the patient will discover via their requesterConnection.
345+
*
346+
* @param inviteKey - the invite key (CollectorInvite.key)
347+
* @param permissions - new full permission set
348+
* @param options.action - update action type (default: 'update-permissions')
349+
* @param options.features - optional features to add (e.g. { chat: { type: 'user' } })
350+
* @param options.message - human-readable explanation for the patient
351+
*/
352+
async requestAccessUpdate (
353+
inviteKey: string,
354+
permissions: Permission[],
355+
options: { action?: AccessUpdateAction, features?: Record<string, any>, message?: string } = {}
356+
): Promise<any> {
357+
if (this.statusCode !== Collector.STATUSES.active) {
358+
throw new HDSLibError('Collector must be active to request access update');
359+
}
360+
const invite = await this.getInviteByKey(inviteKey);
361+
if (!invite) throw new HDSLibError(`Cannot find invite with key: ${inviteKey}`);
362+
if (invite.status !== 'active') throw new HDSLibError(`Invite must be active to request update, current: ${invite.status}`);
363+
364+
// targetAccessName matches CollectorClient.key on the patient side
365+
// which is built from accessInfo: username + ':' + accessName
366+
const accessInfo = await invite.checkAndGetAccessInfo();
367+
if (!accessInfo) throw new HDSLibError('Cannot get access info for invite — may have been revoked');
368+
const targetAccessName = accessInfo.user.username + ':' + accessInfo.name;
369+
370+
const eventData = {
371+
type: 'request/access-update-v1',
372+
streamIds: [this.streamIdFor(Collector.STREAMID_SUFFIXES.public)],
373+
content: {
374+
version: 0,
375+
targetAccessName,
376+
action: options.action || 'update-permissions',
377+
permissions,
378+
features: options.features || undefined,
379+
message: options.message || undefined
380+
}
381+
};
382+
return await this.appManaging.connection.apiOne('events.create', eventData, 'event');
383+
}
384+
330385
/**
331386
* @private
332387
* @param {CollectorInvite} invite

0 commit comments

Comments
 (0)