Files
server/laf-cloud/tests/client-version-policy.test.mjs
T

181 lines
11 KiB
JavaScript

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);
}
});