Files
zoneminder/tests/js/auth-helpers.test.js
T
Isaac ConnorandClaude Opus 5 da99928235 fix: revalidate on every resume rather than trusting a time window
The staleness window was unsound. calculateAuthHash() keys the hash to the
clock hour it was minted in and getAuthUser() accepts the last
ZM_AUTH_HASH_TTL hourly buckets, so a hash dies at the top of an hour rather
than at some age. generateAuthHash() then serves the cached one until it is
half a TTL old, so what arrives can already be nearly spent: on the defaults a
hash minted at 10:59 is still handed out at 11:58 and is refused at 12:00. A
client stamping that arrival as fresh for an hour skips the probe until 12:58
and restarts its streams on a dead hash - the exact failure this was written to
prevent. No fixed window is safe, because the remaining life of a hash we hold
can be anything down to zero, and AUTH_STALE_MS also ignored the configured
ZM_AUTH_HASH_TTL.

So drop AUTH_STALE_MS, authIsStale() and authFreshAt, and have whenAuthFresh()
revalidate. The one case that can still skip the probe is having no hash at all
- authentication off, or a relay form that does not use one - where there is
nothing that can expire and nothing a probe would report. revalidateAuth()
already shares one request between concurrent callers, so a resume that wakes
several of these still costs a single probe, and that is what the montage code
did unconditionally before any of this.

refreshTablesPendingVisibility() now returns as soon as it finds nothing was
deferred. It is bound on every classic page including the unauthenticated ones,
and the version before this ran the whole auth path on an empty queue, so
merely becoming visible could fire a probe with no work behind it.

Tests: two authIsStale cases removed with the function, two whenAuthFresh cases
added - a probe is sent and the callback held until it answers, and no probe is
sent when there is no hash. Reintroducing a fast path fails the first. Full JS
suite green, ESLint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UkQwahn9pi1y4wJe9BTxjM
2026-09-13 10:37:02 -04:00

359 lines
15 KiB
JavaScript

'use strict';
const assert = require('assert');
const path = require('path');
const ZM = require(path.join(__dirname, '../../web/js/auth-helpers.js'));
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(' ok ' + name);
passed++;
} catch (e) {
console.error(' FAIL ' + name);
console.error(' ' + e.message);
failed++;
}
}
console.log('authFailureAction');
test('401 Unauthorized -> login', () => {
assert.strictEqual(ZM.authFailureAction(401), 'login');
});
test('403 Forbidden (stale auth hash, what zms returns) -> login', () => {
assert.strictEqual(ZM.authFailureAction(403), 'login');
});
test('0 network error -> retry', () => {
assert.strictEqual(ZM.authFailureAction(0), 'retry');
});
test('408 timeout -> retry', () => {
assert.strictEqual(ZM.authFailureAction(408), 'retry');
});
test('502 bad gateway -> retry', () => {
assert.strictEqual(ZM.authFailureAction(502), 'retry');
});
test('200 success -> ignore', () => {
assert.strictEqual(ZM.authFailureAction(200), 'ignore');
});
test('404 not found -> ignore', () => {
assert.strictEqual(ZM.authFailureAction(404), 'ignore');
});
console.log('loginRedirectUrl');
test('builds login url preserving console view', () => {
assert.strictEqual(
ZM.loginRedirectUrl('/zm/index.php', 'console'),
'/zm/index.php?view=login&postLoginQuery=view%3Dconsole');
});
test('preserves montage view', () => {
assert.strictEqual(
ZM.loginRedirectUrl('/zm/index.php', 'montage'),
'/zm/index.php?view=login&postLoginQuery=view%3Dmontage');
});
test('defaults to console when view missing', () => {
assert.strictEqual(
ZM.loginRedirectUrl('/zm/index.php', ''),
'/zm/index.php?view=login&postLoginQuery=view%3Dconsole');
});
console.log('rebuildStreamSrc');
test('replaces auth hash in place', () => {
assert.strictEqual(
ZM.rebuildStreamSrc('/zm/cgi-bin/nph-zms?monitor=35&auth=OLD123&connkey=816890&mode=jpeg', 'NEW456', 816890),
'/zm/cgi-bin/nph-zms?monitor=35&auth=NEW456&connkey=816890&mode=jpeg');
});
test('replaces connkey when a fresh one is supplied', () => {
assert.strictEqual(
ZM.rebuildStreamSrc('/zm/cgi-bin/nph-zms?monitor=35&auth=OLD123&connkey=816890&mode=jpeg', 'NEW456', 999999),
'/zm/cgi-bin/nph-zms?monitor=35&auth=NEW456&connkey=999999&mode=jpeg');
});
test('swaps both auth and connkey (the reconnect case)', () => {
// Regression: a broken montage <img> must reconnect with BOTH a fresh hash
// and a fresh connkey, never the stale baked pair that storms zms.
const broken = 'cgi-bin/nph-zms?monitor=35&auth=5c464e95&user=plaza&connkey=816890&scale=25&mode=jpeg';
const out = ZM.rebuildStreamSrc(broken, 'fresh99', 123456);
assert.strictEqual(out.indexOf('auth=5c464e95'), -1, 'stale auth must be gone');
assert.strictEqual(out.indexOf('connkey=816890'), -1, 'stale connkey must be gone');
assert.ok(out.indexOf('auth=fresh99') !== -1);
assert.ok(out.indexOf('connkey=123456') !== -1);
});
test('appends auth when the url has none', () => {
assert.strictEqual(
ZM.rebuildStreamSrc('cgi-bin/nph-zms?monitor=35&mode=jpeg', 'abc', null),
'cgi-bin/nph-zms?monitor=35&mode=jpeg&auth=abc');
});
test('appends auth with ? when no query string present', () => {
assert.strictEqual(
ZM.rebuildStreamSrc('cgi-bin/nph-zms', 'abc', null),
'cgi-bin/nph-zms?auth=abc');
});
test('leaves connkey untouched when none requested', () => {
assert.strictEqual(
ZM.rebuildStreamSrc('cgi-bin/nph-zms?auth=OLD&connkey=42', 'NEW'),
'cgi-bin/nph-zms?auth=NEW&connkey=42');
});
test('handles empty/undefined src safely', () => {
assert.strictEqual(ZM.rebuildStreamSrc('', 'abc', 7), '?auth=abc&connkey=7');
assert.strictEqual(ZM.rebuildStreamSrc(undefined, 'abc', null), '?auth=abc');
});
console.log('authHashFromRelay');
test('extracts the hash from a hashed relay', () => {
assert.strictEqual(ZM.authHashFromRelay('auth=abc123&user=plaza'), 'abc123');
});
test('extracts the hash when auth is not the first parameter', () => {
assert.strictEqual(ZM.authHashFromRelay('user=plaza&auth=abc123'), 'abc123');
});
test('extracts the hash from a relay with no user', () => {
assert.strictEqual(ZM.authHashFromRelay('auth=abc123'), 'abc123');
});
test('does not match a parameter merely ending in auth', () => {
assert.strictEqual(ZM.authHashFromRelay('xauth=abc123'), '');
});
test('returns empty for the plain/none relay forms', () => {
assert.strictEqual(ZM.authHashFromRelay('username=plaza&password=secret'), '');
assert.strictEqual(ZM.authHashFromRelay('username=plaza'), '');
});
test('handles empty/undefined relay safely', () => {
assert.strictEqual(ZM.authHashFromRelay(''), '');
assert.strictEqual(ZM.authHashFromRelay(undefined), '');
assert.strictEqual(ZM.authHashFromRelay(null), '');
});
console.log('appendQuery');
test('joins with ? when the url has no query string', () => {
assert.strictEqual(ZM.appendQuery('/zm/index.php', 'auth=abc'), '/zm/index.php?auth=abc');
});
test('joins with & when the url already has a query string', () => {
assert.strictEqual(ZM.appendQuery('/zm/index.php?view=montage', 'auth=abc'), '/zm/index.php?view=montage&auth=abc');
});
test('leaves the url untouched when auth is off', () => {
// The `x ? '&'+x : ''` dance at every call site existed to avoid a dangling
// separator; appendQuery owns that decision now.
assert.strictEqual(ZM.appendQuery('/zm/index.php?view=montage', ''), '/zm/index.php?view=montage');
assert.strictEqual(ZM.appendQuery('/zm/index.php', undefined), '/zm/index.php');
});
console.log('setUrlParam');
test('replaces an existing parameter in place', () => {
assert.strictEqual(
ZM.setUrlParam('nph-zms?monitor=26&auth=OLD&mode=jpeg', 'auth', 'NEW'),
'nph-zms?monitor=26&auth=NEW&mode=jpeg');
});
test('appends when the parameter is absent', () => {
assert.strictEqual(ZM.setUrlParam('nph-zms?monitor=26', 'auth', 'NEW'), 'nph-zms?monitor=26&auth=NEW');
});
test('does not match a parameter merely ending in the name', () => {
assert.strictEqual(ZM.setUrlParam('nph-zms?xauth=OLD', 'auth', 'NEW'), 'nph-zms?xauth=OLD&auth=NEW');
});
test('replaces an empty-valued parameter', () => {
assert.strictEqual(ZM.setUrlParam('nph-zms?auth=&mode=jpeg', 'auth', 'NEW'), 'nph-zms?auth=NEW&mode=jpeg');
});
console.log('ZMAuth');
test('derives the hash from the relay', () => {
assert.strictEqual(new ZM.ZMAuth('auth=abc123&user=plaza').hash, 'abc123');
});
test('has no hash under the plain relay form', () => {
assert.strictEqual(new ZM.ZMAuth('username=plaza&password=secret').hash, '');
});
test('has no hash when authentication is off', () => {
assert.strictEqual(new ZM.ZMAuth('').hash, '');
assert.strictEqual(new ZM.ZMAuth().hash, '');
});
test('the hash cannot drift from the relay', () => {
// The regression this whole type exists for. ajax/stream.php omitted `auth`
// from its reply whenever it matched the hash the request carried (which came
// from auth_relay), so the separate auth_hash global was never corrected and
// reconnecting streams baked it in. There is now one value, so an update to
// the relay is by construction an update to the hash.
const auth = new ZM.ZMAuth('auth=7361222c&user=plaza');
auth.update({auth_relay: 'auth=5bc52e6d&user=plaza'});
assert.strictEqual(auth.hash, '5bc52e6d');
});
test('update reports whether the credential changed', () => {
const auth = new ZM.ZMAuth('auth=abc&user=plaza');
assert.strictEqual(auth.update({auth_relay: 'auth=abc&user=plaza'}), false, 'same relay is not a change');
assert.strictEqual(auth.update({auth_relay: 'auth=def&user=plaza'}), true);
assert.strictEqual(auth.hash, 'def');
});
test('update accepts a reply carrying only auth', () => {
const auth = new ZM.ZMAuth('auth=abc&user=plaza');
assert.strictEqual(auth.update({auth: 'def'}), true);
assert.strictEqual(auth.relay, 'auth=def&user=plaza', 'user must survive the swap');
assert.strictEqual(auth.update({auth: 'def'}), false);
});
test('update ignores empty and missing payloads', () => {
const auth = new ZM.ZMAuth('auth=abc&user=plaza');
assert.strictEqual(auth.update(null), false);
assert.strictEqual(auth.update({}), false);
assert.strictEqual(auth.relay, 'auth=abc&user=plaza');
});
test('appendTo authenticates a url', () => {
const auth = new ZM.ZMAuth('auth=abc&user=plaza');
assert.strictEqual(
auth.appendTo('/zm/index.php?view=request&request=status'),
'/zm/index.php?view=request&request=status&auth=abc&user=plaza');
});
test('appendTo is a no-op when authentication is off', () => {
assert.strictEqual(new ZM.ZMAuth('').appendTo('/zm/index.php?view=montage'), '/zm/index.php?view=montage');
});
test('applyTo swaps the hash and keeps the other stream options', () => {
const auth = new ZM.ZMAuth('auth=5bc52e6d&user=plaza');
assert.strictEqual(
auth.applyTo('cgi-bin/nph-zms?monitor=26&auth=7361222c&user=plaza&connkey=563525&scale=25&mode=jpeg'),
'cgi-bin/nph-zms?monitor=26&auth=5bc52e6d&user=plaza&connkey=563525&scale=25&mode=jpeg');
});
test('applyTo swaps the connkey too when reconnecting', () => {
const auth = new ZM.ZMAuth('auth=5bc52e6d&user=plaza');
const out = auth.applyTo('cgi-bin/nph-zms?monitor=26&auth=7361222c&user=plaza&connkey=563525&mode=jpeg', 563155);
assert.strictEqual(out.indexOf('auth=7361222c'), -1, 'stale hash must be gone');
assert.strictEqual(out.indexOf('connkey=563525'), -1, 'dead connkey must be gone');
assert.ok(out.indexOf('auth=5bc52e6d') !== -1);
assert.ok(out.indexOf('connkey=563155') !== -1);
});
test('applyTo appends the whole relay when the src carries no auth', () => {
// user= has to come along, otherwise zms falls back to scanning every row.
const auth = new ZM.ZMAuth('auth=abc&user=plaza');
assert.strictEqual(
auth.applyTo('cgi-bin/nph-zms?monitor=26&mode=jpeg'),
'cgi-bin/nph-zms?monitor=26&mode=jpeg&auth=abc&user=plaza');
});
test('applyTo appends the plain relay form, which has no hash to swap', () => {
const auth = new ZM.ZMAuth('username=plaza&password=secret');
assert.strictEqual(
auth.applyTo('cgi-bin/nph-zms?monitor=26&mode=jpeg'),
'cgi-bin/nph-zms?monitor=26&mode=jpeg&username=plaza&password=secret');
});
test('applyTo returns empty for a blank src rather than a bare query string', () => {
// montagereview's loadImage2Monitor treats '' as "nothing to load"; a bare
// '?auth=...' would resolve against the page and load HTML as an image.
const auth = new ZM.ZMAuth('auth=abc&user=plaza');
assert.strictEqual(auth.applyTo(''), '');
assert.strictEqual(auth.applyTo(undefined), '');
assert.strictEqual(auth.applyTo('', 99), '');
});
test('applyTo only sets the connkey when authentication is off', () => {
const auth = new ZM.ZMAuth('');
assert.strictEqual(
auth.applyTo('cgi-bin/nph-zms?monitor=26&connkey=1&mode=jpeg', 99),
'cgi-bin/nph-zms?monitor=26&connkey=99&mode=jpeg');
});
// revalidateAuth() reaches for these as bare globals, the way the browser
// supplies them, so a fake jqXHR here is enough to drive it from node.
function fakeXhr() {
const cbs = {done: [], fail: [], always: []};
const xhr = {
done(fn) {
cbs.done.push(fn); return xhr;
},
fail(fn) {
cbs.fail.push(fn); return xhr;
},
always(fn) {
cbs.always.push(fn); return xhr;
},
resolve(data) {
cbs.done.forEach((f) => f(data)); cbs.always.forEach((f) => f());
},
reject(status) {
cbs.fail.forEach((f) => f({status: status})); cbs.always.forEach((f) => f());
},
};
return xhr;
}
let probeUrls = [];
let pendingXhr = null;
global.thisUrl = '/zm/index.php';
global.setNavBar = function() {};
// Present, and holding the very hash the probe must not send.
global.zmAuth = new ZM.ZMAuth('auth=deadbeef');
global.$j = {
getJSON: function(url) {
probeUrls.push(url);
pendingXhr = fakeXhr();
return pendingXhr;
},
};
console.log('revalidateAuth');
test('the probe carries no credential', () => {
// A stale hash in the URL takes the ZM_AUTH_HASH_LOGINS branch of
// zm_authenticate_request(), which never falls through to userFromSession(),
// so the session cookie would authenticate as nobody and the probe meant to
// renew the credential would be the one request guaranteed to fail.
assert.strictEqual(
ZM.authProbeUrl('/zm/index.php'),
'/zm/index.php?view=request&request=status&entity=navBar');
probeUrls = [];
ZM.revalidateAuth(function() {});
assert.strictEqual(probeUrls.length, 1);
assert.ok(probeUrls[0].indexOf('auth=') === -1, probeUrls[0]);
assert.ok(probeUrls[0].indexOf('password=') === -1, probeUrls[0]);
pendingXhr.resolve({});
});
test('concurrent callers share one request and all run', () => {
probeUrls = [];
let ran = 0;
ZM.revalidateAuth(() => ran++);
ZM.revalidateAuth(() => ran++);
assert.strictEqual(probeUrls.length, 1);
assert.strictEqual(ran, 0);
pendingXhr.resolve({});
assert.strictEqual(ran, 2);
});
test('a transient failure still runs the callbacks', () => {
// A network blip is no reason to leave the page's streams stopped.
let ran = 0;
ZM.revalidateAuth(() => ran++);
pendingXhr.reject(0);
assert.strictEqual(ran, 1);
});
console.log('whenAuthFresh');
test('a hash that exists is always revalidated before the callback runs', () => {
// The remaining life of a held hash is not knowable here: calculateAuthHash()
// keys it to the clock hour it was minted in and generateAuthHash() serves the
// cached one until it is half a TTL old, so one handed over at 11:58 can be
// refused at 12:00. There is no window to trust, so there is no fast path.
probeUrls = [];
let ran = 0;
global.zmAuth = new ZM.ZMAuth('auth=deadbeef');
ZM.whenAuthFresh(() => ran++);
assert.strictEqual(probeUrls.length, 1, 'no probe was sent');
assert.strictEqual(ran, 0, 'callback ran before the server confirmed anything');
pendingXhr.resolve({});
assert.strictEqual(ran, 1);
});
test('no hash means no probe, because nothing can expire', () => {
// Authentication off, or a relay form that carries no hash.
probeUrls = [];
let ran = 0;
global.zmAuth = new ZM.ZMAuth('');
ZM.whenAuthFresh(() => ran++);
assert.strictEqual(probeUrls.length, 0, 'probed with no hash to refresh');
assert.strictEqual(ran, 1, 'callback should run straight away');
global.zmAuth = new ZM.ZMAuth('auth=deadbeef');
});
// Last: goToLogin() latches for the life of the module.
test('a rejected session goes to login and drops the callbacks', () => {
let ran = 0;
let assigned = '';
global.window = {location: {assign: (u) => {
assigned = u;
}}};
global.currentView = 'watch';
ZM.revalidateAuth(() => ran++);
pendingXhr.reject(403);
assert.strictEqual(ran, 0);
assert.ok(assigned.indexOf('view=login') !== -1, assigned);
});
console.log('\n' + passed + ' passed, ' + failed + ' failed');
process.exit(failed ? 1 : 0);