import test from 'node:test'; import assert from 'node:assert/strict'; import { registerHooks } from 'node:module'; import { readFileSync } from 'node:fs'; import { currentVersion, versions, checkClientVersion, compareVersions, validatePolicy, openidWhitelist, shouldCheckPlayerVersion } from '../functions/versionPolicy.ts'; const identity = { users: [], usersAd: [], reads: 0, fail: false }; globalThis.__versionCloud = { database: () => ({ collection: name => ({ where: query => ({ async getOne() { identity.reads++; if (identity.fail) throw new Error('database unavailable'); assert.equal(typeof query.token, 'string'); return { data: identity[name].find(user => user.token === query.token) || null }; }, }) }) }) }; const hooks = registerHooks({ resolve(specifier, context, next) { if (specifier === '@/versionPolicy') return { url: new URL('../functions/versionPolicy.ts', import.meta.url).href, shortCircuit: true }; if (specifier === '@lafjs/cloud') return { url: 'data:text/javascript,export default globalThis.__versionCloud', shortCircuit: true }; return next(specifier, context); } }); const { default: interceptor } = await import('../functions/__interceptor__.ts'); hooks.deregister(); const policy = (overrides = {}) => ({ force: false, minVersion: '1.0.83', apiBlacklist: ['gatedPlayer', 'gatedPlayerData', 'gatedPlayer/index'], ...overrides }); const check = (name, version, overrides = {}) => checkClientVersion(name, version, '1.0.84', { '1.0.84': policy(overrides) }); test('checked-in current policy is valid and an explicitly disabled release accepts legacy clients', () => { assert.equal(validatePolicy(currentVersion, versions[currentVersion]), true); const disabled = { '1.0.82': policy({ minVersion: null, apiBlacklist: [] }) }; assert.equal(checkClientVersion('gatedPlayer', undefined, '1.0.82', disabled), null); }); test('numeric comparison handles component boundaries and rejects ambiguous or malformed versions', () => { for (const [a, b, result] of [['1.0.10', '1.0.9', 1], ['1.0.83', '1.0.83', 0], ['1.9.99', '2.0.0', -1], ['1.0.99', '1.1.0', -1]]) { assert.equal(compareVersions(a, b), result); } for (const value of [undefined, null, 123, '', '1.0', '1.0.83-beta', ' 1.0.83', '01.0.83', ['1.0.83'], {}, '9007199254740992.0.0']) { assert.equal(compareVersions(value, '1.0.83'), null); assert.equal(check('gatedPlayer', value).errorCode, 'CLIENT_UPDATE_REQUIRED'); } }); test('ordinary releases retain the minimum even when force is false; future versions are accepted', () => { for (const name of ['gatedPlayer', 'gatedPlayerData', 'gatedPlayer/index']) { const denied = check(name, '1.0.82'); assert.equal(denied.errorCode, 'CLIENT_UPDATE_REQUIRED'); assert.deepEqual(denied.data, { version: '1.0.84', minVersion: '1.0.83' }); for (const version of ['1.0.83', '1.0.84', '2.0.0']) assert.equal(check(name, version), null); } assert.equal(check('userCoin', undefined), null, 'unlisted endpoints remain outside the gate'); assert.equal(check('gatedPlayer/indexExtra', undefined), null, 'blacklist matching is exact'); assert.equal(check('gatedPlayer', undefined, { apiBlacklist: [] }), null); }); test('server release selects the policy; client version cannot select an old or missing policy', () => { const policies = { '1.0.82': policy({ minVersion: null }), '1.0.84': policy() }; assert.equal(checkClientVersion('gatedPlayer', '1.0.82', '1.0.84', policies).errorCode, 'CLIENT_UPDATE_REQUIRED'); assert.equal(checkClientVersion('gatedPlayer', '9.9.99', '1.0.84', policies), null); }); test('invalid current configuration fails closed as service unavailable, never as upgrade required', () => { for (const override of [ { force: true }, { minVersion: '2.0.0' }, { minVersion: undefined }, { minVersion: '1.0' }, { force: 'false' }, { force: true, minVersion: null }, { apiBlacklist: null }, { apiBlacklist: ['/userData'] }, ]) assert.equal(check('gatedPlayer', '9.9.99', override).errorCode, 'CLIENT_UPDATE_CONFIG_UNAVAILABLE'); assert.equal(check('gatedPlayer', '1.0.83', { force: true, minVersion: '1.0.84' }).errorCode, 'CLIENT_UPDATE_REQUIRED'); assert.equal(checkClientVersion('gatedPlayer', '1.0.83', 'missing', {}).errorCode, 'CLIENT_UPDATE_CONFIG_UNAVAILABLE'); assert.equal(check('gatedPlayer', undefined, { minVersion: null }), null); }); test('server callbacks, jobs, and administrative functions remain outside player checks', () => { for (const name of ['wx/payCallBack', 'wx/iosPayCall', 'wx/KeFuInfo', 'goldMiner/merchantNotify', 'wxPerformanceCall', 'suRecall', 'passCheckJobs', 'pyramid/jobs', 'cloudRise/admin', 'abConfigAdmin', 'signInTestAdmin', '__init__']) { assert.equal(check(name, undefined, { apiBlacklist: [name] }), null); assert.equal(checkClientVersion(name, undefined, 'bad', {}), null, 'even invalid policy must not stop server callbacks'); } }); test('Laf interceptor blocks before invoking business logic and preserves next response/context', async () => { assert.equal(interceptor.length, 2, 'Laf selects middleware semantics by parameter count'); const original = versions[currentVersion]; versions[currentVersion] = policy({ minVersion: currentVersion }); let calls = 0; const next = async ctx => { calls++; return { business: true, context: ctx }; }; try { for (const name of ['gatedPlayer', 'gatedPlayerData', 'gatedPlayer/index']) { const response = await interceptor({ __function_name: name, method: 'POST', body: { version: '1.0.1', action: 'read' } }, next); assert.equal(response.errorCode, 'CLIENT_UPDATE_REQUIRED'); } assert.equal(calls, 0, 'no business side effects before an upgrade'); const ctx = { __function_name: 'gatedPlayerData', method: 'POST', body: { version: currentVersion } }; const response = await interceptor(ctx, next); assert.equal(response.context, ctx); assert.equal(calls, 1); await interceptor({ __function_name: 'userCoin', method: 'POST', body: {} }, next); await interceptor({ __function_name: 'gatedPlayer', method: 'OPTIONS' }, next); await interceptor({ __function_name: 'gatedPlayer', method: 'call' }, next); assert.equal(calls, 4); } finally { versions[currentVersion] = original; } }); test('uses runtime target name and correct body/query source, with nested path fallback', async () => { const original = versions[currentVersion]; versions[currentVersion] = policy({ minVersion: currentVersion }); const next = async () => ({ passed: true }); try { const blocked = await interceptor({ __function_name: 'gatedPlayer', method: 'POST', body: { __function_name: 'unlisted' }, query: { version: currentVersion } }, next); assert.equal(blocked.errorCode, 'CLIENT_UPDATE_REQUIRED'); assert.deepEqual(await interceptor({ __function_name: 'gatedPlayer', method: 'GET', query: { version: currentVersion } }, next), { passed: true }); assert.equal((await interceptor({ request: { path: '/gatedPlayer/index', method: 'POST' }, body: {} }, next)).errorCode, 'CLIENT_UPDATE_REQUIRED'); assert.equal((await interceptor({ method: 'POST', body: {} }, next)).errorCode, 'CLIENT_UPDATE_CONFIG_UNAVAILABLE'); } finally { versions[currentVersion] = original; } }); test('policy module and interceptor have no direct HTTP methods', () => { for (const name of ['versionPolicy', '__interceptor__']) { const yaml = readFileSync(new URL(`../functions/${name}.yaml`, import.meta.url), 'utf8'); assert.match(yaml, /methods: \[\]/); } }); test('openid whitelist is exact, empty means all players, and malformed entries fail closed', () => { const original = [...openidWhitelist]; try { openidWhitelist.splice(0); assert.equal(shouldCheckPlayerVersion(undefined), true); openidWhitelist.push('openid-A'); assert.equal(shouldCheckPlayerVersion('openid-A'), true); for (const openid of ['openid-a', 'other', undefined, { $ne: null }]) { assert.equal(shouldCheckPlayerVersion(openid), false); } for (const invalid of ['', ' openid-A', null, 123]) { openidWhitelist.splice(0, openidWhitelist.length, invalid); assert.equal(check('gatedPlayer', '1.0.82').errorCode, 'CLIENT_UPDATE_CONFIG_UNAVAILABLE'); assert.equal(check('wx/payCallBack', undefined), null); } } finally { openidWhitelist.splice(0, openidWhitelist.length, ...original); } }); test('scoped interceptor resolves tokens for nonexempt APIs and never trusts claimed openid', async () => { const original = versions[currentVersion], originalWhitelist = [...openidWhitelist]; versions[currentVersion] = policy({ minVersion: currentVersion }); openidWhitelist.splice(0, openidWhitelist.length, 'openid-target'); identity.users = [{ token: 'target-token', openid: 'openid-target' }, { token: 'other-token', openid: 'openid-other' }]; identity.usersAd = [{ token: 'ad-token', openid: 'openid-target' }]; identity.reads = 0; let calls = 0; const next = async () => { calls++; return { passed: true }; }; const post = (body = {}, name = 'gatedPlayerData') => interceptor({ __function_name: name, method: 'POST', body: { version: '1.0.1', ...body } }, next); try { assert.deepEqual(await post({ openid: 'openid-target' }, 'login'), { passed: true }); assert.equal(identity.reads, 0, 'exempt login needs no identity lookup in interceptor'); calls = 0; for (const token of ['target-token', 'ad-token']) { assert.equal((await post({ token, openid: 'other', uid: 'forged', gameName: 'fake' })).errorCode, 'CLIENT_UPDATE_REQUIRED'); } assert.equal(calls, 0, 'targeted old requests cannot enter business code'); for (const token of ['other-token', 'unknown', '', undefined, { $ne: null }]) { assert.deepEqual(await post({ token, openid: 'openid-target' }), { passed: true }); } assert.equal((await interceptor({ __function_name: 'gatedPlayerData', method: 'GET', query: { version: '1.0.1', token: 'target-token' }, body: { token: 'other-token' } }, next)).errorCode, 'CLIENT_UPDATE_REQUIRED'); const beforeReads = identity.reads; await post({ token: 'target-token', version: currentVersion }); await post({ token: 'target-token' }, 'userCoin'); await post({ token: 'target-token' }, 'wx/payCallBack'); assert.equal(identity.reads, beforeReads, 'compatible/unlisted/callback requests need no identity lookup'); const businessError = new Error('business error'); await assert.rejects(interceptor({ __function_name: 'gatedPlayerData', method: 'POST', body: { token: 'other-token', version: '1.0.1' } }, async () => { throw businessError; }), error => error === businessError); identity.fail = true; const beforeCalls = calls; assert.equal((await post({ token: 'target-token' })).errorCode, 'CLIENT_UPDATE_CONFIG_UNAVAILABLE'); assert.equal(calls, beforeCalls, 'identity lookup failure cannot bypass the gate'); openidWhitelist.splice(0); assert.equal((await post()).errorCode, 'CLIENT_UPDATE_REQUIRED', 'empty list restores full rollout without identity lookup'); } finally { identity.fail = false; versions[currentVersion] = original; openidWhitelist.splice(0, openidWhitelist.length, ...originalWhitelist); } });