fix: route APNs delivery per-token by registered environment
Issue: after defaulting APNs delivery to production (#2381), development builds installed from Xcode stopped receiving notifications entirely: their sandbox device tokens were sent to the production APNs endpoint, rejected as BadDeviceToken, and dropped as dead. Fix: the iOS shell reads the aps-environment entitlement from the embedded provisioning profile and exposes it to the web layer as a document-start user script (added in capacitorDidLoad, since Capacitor replaces the userContentController after webViewConfiguration(for:)). Token registration reports the environment to the server, which stores it per token and groups delivery by environment for both relay and direct APNs sends. OPENCHAMBER_APNS_ENVIRONMENT remains as an explicit override forcing every send to one environment. TestFlight/App Store builds and older clients without the field default to production, preserving released behavior; the relay already accepts env per send request.
This commit is contained in:
@@ -146,20 +146,40 @@ describe('apns runtime relay mode (default)', () => {
|
||||
expect(deps.writeSettingsToDisk).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('honors an explicit sandbox environment', async () => {
|
||||
it('honors an explicit sandbox environment override for every token', async () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
|
||||
process.env.OPENCHAMBER_APNS_ENVIRONMENT = 'sandbox';
|
||||
|
||||
const runtime = createApnsRuntime(makeDeps());
|
||||
await runtime.addOrUpdateApnsToken('s1', 'tokenA');
|
||||
await runtime.addOrUpdateApnsToken('s1', 'tokenA', undefined, 'ios', 'production');
|
||||
await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' });
|
||||
|
||||
const sent = JSON.parse(fetchMock.mock.calls.find(isSend)[1].body);
|
||||
expect(sent.env).toBe('sandbox');
|
||||
});
|
||||
|
||||
it('routes each token to its registered environment (dev build sandbox, release production)', async () => {
|
||||
const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send';
|
||||
|
||||
const runtime = createApnsRuntime(makeDeps());
|
||||
await runtime.addOrUpdateApnsToken('s1', 'tokenXcode', undefined, 'ios', 'sandbox');
|
||||
await runtime.addOrUpdateApnsToken('s2', 'tokenStore', undefined, 'ios', 'production');
|
||||
await runtime.addOrUpdateApnsToken('s3', 'tokenLegacy'); // no environment → production
|
||||
|
||||
fetchMock.mockClear();
|
||||
await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' });
|
||||
|
||||
const sends = fetchMock.mock.calls.filter(isSend).map(([, init]) => JSON.parse(init.body));
|
||||
expect(sends).toHaveLength(2);
|
||||
const byEnv = Object.fromEntries(sends.map((s) => [s.env, new Set(s.tokens)]));
|
||||
expect(byEnv.sandbox).toEqual(new Set(['tokenXcode']));
|
||||
expect(byEnv.production).toEqual(new Set(['tokenStore', 'tokenLegacy']));
|
||||
});
|
||||
|
||||
it('no-ops (no relay call) when no tokens are registered', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
@@ -170,13 +190,54 @@ describe('apns runtime relay mode (default)', () => {
|
||||
});
|
||||
|
||||
describe('apns runtime direct fallback (relay disabled)', () => {
|
||||
it('defaults direct APNs configuration to production', async () => {
|
||||
it('leaves direct APNs environment unset without an explicit override (per-token routing)', async () => {
|
||||
const { environment: _environment, ...configWithoutEnvironment } = APNS_CONFIG;
|
||||
const runtime = createApnsRuntime(
|
||||
makeDeps({ readSettingsFromDiskMigrated: vi.fn(async () => ({ apnsConfig: configWithoutEnvironment })) }),
|
||||
);
|
||||
|
||||
await expect(runtime.resolveApnsConfig()).resolves.toMatchObject({ environment: 'production' });
|
||||
await expect(runtime.resolveApnsConfig()).resolves.toMatchObject({ environment: null });
|
||||
});
|
||||
|
||||
it('sends each token to the APNs host of its registered environment', async () => {
|
||||
process.env.OPENCHAMBER_PUSH_RELAY_DISABLED = 'true';
|
||||
const { environment: _environment, ...configWithoutEnvironment } = APNS_CONFIG;
|
||||
const hosts = [];
|
||||
const http2 = {
|
||||
connect: (host) => {
|
||||
const targeted = [];
|
||||
hosts.push({ host, targeted });
|
||||
return {
|
||||
on: () => {},
|
||||
close: () => {},
|
||||
request: (headers) => {
|
||||
targeted.push(String(headers[':path']).replace('/3/device/', ''));
|
||||
const listeners = {};
|
||||
const req = {
|
||||
on: (event, cb) => { listeners[event] = cb; return req; },
|
||||
setEncoding: () => req,
|
||||
end: () => {
|
||||
queueMicrotask(() => {
|
||||
listeners.response?.({ ':status': '200' });
|
||||
listeners.end?.();
|
||||
});
|
||||
},
|
||||
};
|
||||
return req;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
const runtime = createApnsRuntime(
|
||||
makeDeps({ http2, readSettingsFromDiskMigrated: vi.fn(async () => ({ apnsConfig: configWithoutEnvironment })) }),
|
||||
);
|
||||
await runtime.addOrUpdateApnsToken('s1', 'tokenXcode', undefined, 'ios', 'sandbox');
|
||||
await runtime.addOrUpdateApnsToken('s2', 'tokenStore', undefined, 'ios', 'production');
|
||||
await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b', tag: 'ready-x' });
|
||||
|
||||
const byHost = Object.fromEntries(hosts.map(({ host, targeted }) => [host, targeted]));
|
||||
expect(byHost['https://api.sandbox.push.apple.com']).toEqual(['tokenXcode']);
|
||||
expect(byHost['https://api.push.apple.com']).toEqual(['tokenStore']);
|
||||
});
|
||||
|
||||
it('signs an ES256 JWT and sends over http2 when relay is disabled', async () => {
|
||||
|
||||
Reference in New Issue
Block a user