The service mints /api/alarm/a/{alertId}#{token} with no host, so the QR renders against the configured instance and keeps working when the instance moves or is reached by another name. The scanner now matches path shape and fragment and never the host, so legacy alarm.dcntrlized.org codes and other instances codes still join; error copy distinguishes a malformed code from a wrong-alert token.
158 lines
5.5 KiB
TypeScript
158 lines
5.5 KiB
TypeScript
/**
|
|
* QR payload tests — instance-relative contract.
|
|
*
|
|
* The service mints `/api/alarm/a/{alertId}#{token}` (no host) and the client
|
|
* composes `{configuredOrigin}{qrPayload}`. The parser matches path shape +
|
|
* fragment, never host, so legacy `alarm.dcntrlized.org` codes and other
|
|
* instances' codes still work.
|
|
*/
|
|
import {
|
|
buildQrPayload,
|
|
buildQrSchemePayload,
|
|
parseManualToken,
|
|
parseQrPayload,
|
|
qrErrorMessage,
|
|
truncateToken,
|
|
} from '../src/lib/qrPayload';
|
|
|
|
const ID = 'alr_7f3c9a1b2d';
|
|
const TOKEN = 'sub_9Qx2mZp4Lk7VeR1sTn0';
|
|
const ORIGIN = 'https://instance.example';
|
|
|
|
describe('buildQrPayload', () => {
|
|
it('composes the service path under the configured origin', () => {
|
|
expect(buildQrPayload(ID, TOKEN, ORIGIN)).toBe(`${ORIGIN}/api/alarm/a/${ID}#${TOKEN}`);
|
|
});
|
|
|
|
it('strips a trailing slash on the origin', () => {
|
|
expect(buildQrPayload(ID, TOKEN, `${ORIGIN}/`)).toBe(`${ORIGIN}/api/alarm/a/${ID}#${TOKEN}`);
|
|
});
|
|
|
|
it('renders a host-free relative path when no origin is configured', () => {
|
|
expect(buildQrPayload(ID, TOKEN)).toBe(`/api/alarm/a/${ID}#${TOKEN}`);
|
|
});
|
|
|
|
it('keeps the pre-existing scheme builder for old codes but mints no new host', () => {
|
|
expect(buildQrSchemePayload(ID, TOKEN)).toBe(`dcntrlized-alarm://a/${ID}#${TOKEN}`);
|
|
});
|
|
});
|
|
|
|
describe('parseQrPayload — path shape, never host', () => {
|
|
it('parses the composed payload', () => {
|
|
const res = parseQrPayload(buildQrPayload(ID, TOKEN, ORIGIN));
|
|
expect(res).toEqual({
|
|
ok: true,
|
|
payload: { alertId: ID, subscribeToken: TOKEN, source: 'url' },
|
|
});
|
|
});
|
|
|
|
it('accepts a relative /api/alarm/a/{id}#{token}', () => {
|
|
const res = parseQrPayload(`/api/alarm/a/${ID}#${TOKEN}`);
|
|
expect(res.ok && res.payload.alertId).toBe(ID);
|
|
expect(res.ok && res.payload.source).toBe('relative');
|
|
});
|
|
|
|
it('accepts a legacy https://alarm.dcntrlized.org/a/{id}#{token}', () => {
|
|
const res = parseQrPayload(`https://alarm.dcntrlized.org/a/${ID}#${TOKEN}`);
|
|
expect(res.ok && res.payload.alertId).toBe(ID);
|
|
expect(res.ok && res.payload.subscribeToken).toBe(TOKEN);
|
|
});
|
|
|
|
it('accepts a full http(s) URL on any host (host grants nothing)', () => {
|
|
expect(parseQrPayload(`https://evil.example.com/a/${ID}#${TOKEN}`).ok).toBe(true);
|
|
expect(parseQrPayload(`http://127.0.0.1:8080/api/alarm/a/${ID}#${TOKEN}`).ok).toBe(true);
|
|
expect(parseQrPayload(`https://staging.alarm.dcntrlized.org/a/${ID}#${TOKEN}`).ok).toBe(true);
|
|
});
|
|
|
|
it('rejects a URL whose path is not an alarm path', () => {
|
|
expect(parseQrPayload(`https://example.com/foo/bar#${TOKEN}`)).toEqual({
|
|
ok: false,
|
|
error: 'not-an-alarm-code',
|
|
});
|
|
});
|
|
|
|
it('rejects a foreign scheme', () => {
|
|
expect(parseQrPayload(`ftp://example.com/a/${ID}#${TOKEN}`)).toEqual({
|
|
ok: false,
|
|
error: 'not-an-alarm-code',
|
|
});
|
|
});
|
|
|
|
it('parses the pre-existing custom scheme', () => {
|
|
const res = parseQrPayload(buildQrSchemePayload(ID, TOKEN));
|
|
expect(res.ok && res.payload.source).toBe('scheme');
|
|
expect(res.ok && res.payload.alertId).toBe(ID);
|
|
});
|
|
|
|
it('accepts a relative a/{id}#{token} and raw {id}#{token}', () => {
|
|
expect(parseQrPayload(`a/${ID}#${TOKEN}`).ok).toBe(true);
|
|
const raw = parseQrPayload(`${ID}#${TOKEN}`);
|
|
expect(raw.ok && raw.payload.source).toBe('raw');
|
|
});
|
|
|
|
it('tolerates whitespace, a trailing slash and query noise', () => {
|
|
const res = parseQrPayload(` https://alarm.dcntrlized.org/a/${ID}/?utm=x#${TOKEN} `);
|
|
expect(res.ok && res.payload.alertId).toBe(ID);
|
|
expect(res.ok && res.payload.subscribeToken).toBe(TOKEN);
|
|
});
|
|
|
|
it('reports empty, missing token and missing alert id', () => {
|
|
expect(parseQrPayload(' ')).toEqual({ ok: false, error: 'empty' });
|
|
expect(parseQrPayload(`https://alarm.dcntrlized.org/a/${ID}`)).toEqual({
|
|
ok: false,
|
|
error: 'missing-token',
|
|
});
|
|
expect(parseQrPayload(`https://alarm.dcntrlized.org/a/#${TOKEN}`)).toEqual({
|
|
ok: false,
|
|
error: 'missing-alert-id',
|
|
});
|
|
});
|
|
|
|
it('never throws on garbage', () => {
|
|
for (const junk of ['###', 'https://', '://a/b#c', '\u0000', '{"json":true}']) {
|
|
expect(() => parseQrPayload(junk)).not.toThrow();
|
|
}
|
|
});
|
|
|
|
it('round-trips ids that need encoding', () => {
|
|
const weird = 'alr/with space';
|
|
const res = parseQrPayload(buildQrPayload(weird, TOKEN, ORIGIN));
|
|
expect(res.ok && res.payload.alertId).toBe(weird);
|
|
});
|
|
});
|
|
|
|
describe('parseManualToken', () => {
|
|
it('accepts a bare token when the alert id is known', () => {
|
|
expect(parseManualToken(TOKEN, ID)).toEqual({
|
|
ok: true,
|
|
payload: { alertId: ID, subscribeToken: TOKEN, source: 'raw' },
|
|
});
|
|
});
|
|
|
|
it('accepts a full payload even when an id is also supplied', () => {
|
|
const res = parseManualToken(buildQrPayload(ID, TOKEN, ORIGIN), 'other');
|
|
expect(res.ok && res.payload.alertId).toBe(ID);
|
|
});
|
|
|
|
it('fails a bare token with no known alert id', () => {
|
|
expect(parseManualToken(TOKEN, '')).toEqual({ ok: false, error: 'missing-alert-id' });
|
|
});
|
|
|
|
it('fails empty input', () => {
|
|
expect(parseManualToken('', ID)).toEqual({ ok: false, error: 'empty' });
|
|
});
|
|
});
|
|
|
|
describe('copy helpers', () => {
|
|
it('truncates tokens to 12 chars + ellipsis', () => {
|
|
expect(truncateToken('0123456789abcdef')).toBe('0123456789ab…');
|
|
expect(truncateToken('short')).toBe('short');
|
|
});
|
|
|
|
it('has a message for every error', () => {
|
|
for (const err of ['empty', 'not-an-alarm-code', 'missing-alert-id', 'missing-token'] as const) {
|
|
expect(qrErrorMessage(err).length).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
});
|