feat(agent): preview public replies in push notifications
TestFlight / Build and upload (push) Successful in 1m53s
TestFlight / Build and upload (push) Successful in 1m53s
This commit is contained in:
@@ -40,12 +40,13 @@ async function fixture(options: { warmupMs?: number; config?: PushConfig } = {})
|
||||
let context: Context = { version: 1, sourceId: source, conversationId: '1', cursor: '10', hasMore: false,
|
||||
latestUserMessageAt: new Date(now - 30_000).toISOString(), nativeReadCursor: '0', uncertain: false,
|
||||
webPresence: { active: false, unknown: false, observedAt: new Date(now).toISOString() }, candidates: [] };
|
||||
const sent: { registration: Registration; identity: any }[] = [], reads: any[] = [];
|
||||
const sent: { registration: Registration; identity: any; messageText?: string }[] = [], reads: any[] = [];
|
||||
let result: SendResult = { outcome: 'accepted' }, failure = false;
|
||||
const manager = new AgentPushCoordinator({ db: prisma, config: options.config || config, now: () => now,
|
||||
warmupMs: options.warmupMs ?? 0, context: async after => { if (failure) throw new Error('upstream secret must not leak');
|
||||
return { ...context, webPresence: { ...context.webPresence, observedAt: new Date(now).toISOString() }, candidates: after === undefined ? [] : context.candidates.filter(c => Number(c.cursor) > Number(after)) }; },
|
||||
send: async (registration, identity) => { sent.push({ registration, identity }); return result; }, broadcast: value => reads.push(value) });
|
||||
messageText: async () => 'The Agent finished the requested work.',
|
||||
send: async (registration, identity, _expiresAt, _now, messageText) => { sent.push({ registration, identity, messageText }); return result; }, broadcast: value => reads.push(value) });
|
||||
await manager.initialize();
|
||||
const installationId = randomUUID(); await manager.register(installationId, apns); await manager.tick();
|
||||
return { manager, installationId, source, sent, reads, get now() { return now; }, advance(ms: number) { now += ms; },
|
||||
@@ -66,10 +67,50 @@ test('first source bootstrap never alerts history; later durable candidates debo
|
||||
f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 1);
|
||||
await f.manager.tick(); assert.equal(f.sent.length, 1);
|
||||
assert.deepEqual(f.sent[0].identity, { version: 1, sourceId: f.source, kind: 'message', messageId: 'outbound:11', cursor: '11' });
|
||||
assert.equal(f.sent[0].messageText, 'The Agent finished the requested work.');
|
||||
assert.equal((await prisma.agentPushDelivery.findFirst())?.status, 'accepted');
|
||||
const restarted = new AgentPushCoordinator({ ...f.manager.options, warmupMs: 0 }); await restarted.tick(); assert.equal(f.sent.length, 1);
|
||||
});
|
||||
|
||||
test('preview resolves only the coalesced eligible root candidate after attention clears and survives a pending restart', async () => {
|
||||
const f = await fixture(), tab = randomUUID(), lookups: unknown[] = [];
|
||||
f.manager.options.messageText = async candidate => { lookups.push(candidate); return `Public reply ${candidate.messageId}`; };
|
||||
f.candidate('11', 'unknown'); await f.manager.tick(); f.advance(2_000); await f.manager.tick();
|
||||
assert.equal(lookups.length, 0);
|
||||
await f.manager.presence(tab, { sequence: 1, visibility: 'visible', focused: true, interacted: false });
|
||||
f.candidate('12', 'worker'); await f.manager.tick(); f.candidate('13', 'timer'); await f.manager.tick(); f.advance(2_000); await f.manager.tick();
|
||||
assert.equal(lookups.length, 0, 'focused clients do not cause repeated message fetches');
|
||||
await f.manager.retirePresence(tab); f.advance(6_000);
|
||||
const restarted = new AgentPushCoordinator({ ...f.manager.options, warmupMs: 0 }); await restarted.tick();
|
||||
assert.equal(f.sent.length, 1); assert.equal(f.sent[0].messageText, 'Public reply outbound:13');
|
||||
assert.equal((lookups[0] as any).conversationId, '1'); assert.equal((lookups[0] as any).messageId, 'outbound:13');
|
||||
assert.ok(!JSON.stringify(await prisma.agentPushDelivery.findMany()).includes('Public reply'), 'receipts contain no preview text');
|
||||
});
|
||||
|
||||
for (const change of ['read', 'focus', 'source', 'revoke']) test(`preview lookup cannot bypass ${change} changes before the send intent`, async () => {
|
||||
const f = await fixture(); f.candidate(); await f.manager.tick(); f.advance(2_000);
|
||||
f.manager.options.messageText = async () => {
|
||||
if (change === 'read') f.context({ nativeReadCursor: '11' });
|
||||
if (change === 'source') f.context({ sourceId: randomUUID() });
|
||||
if (change === 'focus') await f.manager.presence(randomUUID(), { sequence: 1, visibility: 'visible', focused: true, interacted: false });
|
||||
if (change === 'revoke') await f.manager.revoke(f.installationId);
|
||||
return 'Public text that must not be sent now';
|
||||
};
|
||||
await f.manager.tick(); assert.equal(f.sent.length, 0);
|
||||
assert.equal((await prisma.agentPushDelivery.findFirstOrThrow()).attempts, 0);
|
||||
});
|
||||
|
||||
test('unavailable public text suppresses without claiming a provider send; the next valid lookup sends actual text', async () => {
|
||||
const f = await fixture(); f.candidate(); await f.manager.tick(); f.advance(2_000);
|
||||
f.manager.options.messageText = async () => { throw new Error('private upstream detail'); };
|
||||
await f.manager.tick(); assert.equal(f.sent.length, 0);
|
||||
const pending = await prisma.agentPushDelivery.findFirstOrThrow();
|
||||
assert.equal(pending.status, 'pending'); assert.equal(pending.attempts, 0);
|
||||
assert.equal(pending.lastSuppressionReason, 'source_or_coordinator_unavailable');
|
||||
f.manager.options.messageText = async () => 'Recovered public reply';
|
||||
await f.manager.tick(); assert.equal(f.sent[0].messageText, 'Recovered public reply');
|
||||
});
|
||||
|
||||
test('all tabs veto while focused; hidden heartbeats and stale sequences cannot extend short transition grace', async () => {
|
||||
const f = await fixture(), tab1 = randomUUID(), tab2 = randomUUID();
|
||||
await f.manager.presence(tab1, { sequence: 1, visibility: 'hidden', focused: false, interacted: false });
|
||||
@@ -243,20 +284,20 @@ test('provider endpoint validation rejects SSRF targets and private/reserved DNS
|
||||
assert.equal(isPublicAddress('8.8.8.8'), true); assert.equal(isPublicAddress('2606:4700::1111'), true);
|
||||
});
|
||||
|
||||
test('real APNs ES256 request construction uses generic alerts, explicit environment and background clear headers through injected HTTP2 boundary', async () => {
|
||||
test('real APNs ES256 request construction uses message text, explicit environment and background clear headers through injected HTTP2 boundary', async () => {
|
||||
const key = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
||||
const requests: any[] = []; const now = Date.now(), sourceId = randomUUID();
|
||||
const sender = createPushSender({ ...config, apns: { ...config.apns!, key: key.privateKey.export({ format: 'pem', type: 'pkcs8' }).toString() } },
|
||||
{ apns: async request => { requests.push(request); return { status: 200 }; } });
|
||||
const identity = { version: 1 as const, sourceId, kind: 'message' as const, messageId: 'outbound:12', cursor: '12' };
|
||||
assert.equal((await sender(apns, identity, now + 300_000, now)).outcome, 'accepted');
|
||||
assert.equal((await sender(apns, identity, now + 300_000, now, 'Your **report** is ready.')).outcome, 'accepted');
|
||||
assert.equal(requests[0].endpoint, `https://api.sandbox.push.apple.com/3/device/${apns.token}`);
|
||||
assert.equal(requests[0].headers['apns-push-type'], 'alert'); assert.equal(requests[0].headers['apns-topic'], apns.topic);
|
||||
const [header, claims, signature] = requests[0].headers.authorization.slice(7).split('.');
|
||||
assert.equal(JSON.parse(Buffer.from(header, 'base64url').toString()).kid, 'ABCDEFGHIJ');
|
||||
assert.equal(JSON.parse(Buffer.from(claims, 'base64url').toString()).iss, 'DQQH5H6GBD');
|
||||
assert.equal(verify('sha256', Buffer.from(`${header}.${claims}`), { key: key.publicKey, dsaEncoding: 'ieee-p1363' }, Buffer.from(signature, 'base64url')), true);
|
||||
assert.deepEqual(JSON.parse(requests[0].body).aps.alert, { title: 'Sybil Agent', body: 'You have a new message.' });
|
||||
assert.deepEqual(JSON.parse(requests[0].body).aps.alert, { title: 'Sybil Agent', body: 'Your report is ready.' });
|
||||
await sender(apns, { version: 1, sourceId, kind: 'read_state', readCursor: '12' }, now + 60_000, now);
|
||||
assert.equal(requests[1].headers['apns-push-type'], 'background'); assert.equal(requests[1].headers['apns-priority'], '5');
|
||||
assert.deepEqual(JSON.parse(requests[1].body).aps, { 'content-available': 1 });
|
||||
@@ -277,7 +318,7 @@ test('standard encrypted aes128gcm + VAPID Web Push uses library request constru
|
||||
|
||||
test('production-scoped APNs configuration refuses sandbox registration and cannot send it', async () => {
|
||||
const production = { ...config, apns: { ...config.apns!, environment: 'production' as const } };
|
||||
const manager = new AgentPushCoordinator({ db: prisma, config: production, context: async () => { throw new Error(); }, send: async () => { throw new Error('must not send'); } });
|
||||
const manager = new AgentPushCoordinator({ db: prisma, config: production, context: async () => { throw new Error(); }, messageText: async () => { throw new Error(); }, send: async () => { throw new Error('must not send'); } });
|
||||
await assert.rejects(manager.register(randomUUID(), apns), { statusCode: 400 });
|
||||
let requests = 0;
|
||||
const sender = createPushSender(production, { apns: async () => { requests++; throw new Error('must not send'); } });
|
||||
|
||||
Reference in New Issue
Block a user