feat(local): compose login account and device sources

This commit is contained in:
2026-09-05 14:51:24 +08:00
parent 562f58dd2d
commit e00d051112
4 changed files with 306 additions and 6 deletions
@@ -0,0 +1,173 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { BUILD_IDENTITY } from '../../../YouleNexus/assets/framework/config/sources/defaults.ts';
import { PROFILES } from '../../../YouleNexus/assets/framework/config/profiles.ts';
import type { LocalStartupContext } from '../../../YouleNexus/assets/framework/config/local-startup.ts';
import { LOCAL_MACHINE_KEY, localAccountKey, type LocalStoragePort } from '../../../YouleNexus/assets/framework/adapters/local/local-storage.ts';
import { createLocalLoginSources, type LocalHostSnapshot } from '../../../YouleNexus/assets/framework/adapters/local/login-sources.ts';
const context: LocalStartupContext = { hostKind: 'h5', profile: 'local', config: {
mode: 'debug', isDebugger: true, source: 'direct', gameserver: null, rawConfig: null,
servers: PROFILES.local!.servers!, identity: BUILD_IDENTITY,
} };
class MapStorage implements LocalStoragePort {
readonly values = new Map<string, string>();
getItem(key: string): string | null { return this.values.get(key) ?? null; }
setItem(key: string, value: string): void { this.values.set(key, value); }
}
function fixture(overrides: Partial<Parameters<typeof createLocalLoginSources>[0]> = {}) {
return createLocalLoginSources({ context, storage: new MapStorage(), random: () => 0.5,
now: () => 123456, sampleHost: () => ({ location: null }), isSessionActive: () => false, ...overrides });
}
test('one host sample supplies frozen account and device without rewriting account cache', async () => {
const storage = new MapStorage();
let samples = 0;
const city = { ip: '10.0.0.2', province: 'P', city: 'C' };
const location = { unknownProtocolField: [1] };
const sources = fixture({ storage, sampleHost: () => { samples++; return { location, returnCitySN: city }; } });
assert.throws(() => sources.getLoginDeviceSnapshot(), /prepare/i);
const account = await sources.prepareLogin();
const device = sources.getLoginDeviceSnapshot();
assert.equal(samples, 1);
assert.equal(account.province, 'P');
assert.equal(account.city, 'C');
assert.equal(device.machineid, '123456-5499');
assert.strictEqual(device.location, location);
assert.equal(device.ip, '10.0.0.2');
assert.equal(device.machineroom, '');
assert.deepEqual(device.deviceLogin, { enabled: false });
assert.deepEqual(device.loginPlayerId, { enabled: false });
for (const value of [account, device, device.deviceLogin, device.loginPlayerId]) assert.ok(Object.isFrozen(value));
city.ip = 'changed'; city.province = 'changed'; city.city = 'changed';
assert.equal(device.ip, '10.0.0.2');
assert.equal(account.province, 'P');
assert.equal(account.city, 'C');
assert.equal(JSON.parse(storage.values.get(localAccountKey(context))!).account.province, 'jiangxi');
});
test('undefined city omits ip and retains generated province and city', async () => {
const sources = fixture();
const account = await sources.prepareLogin();
assert.equal(account.province, 'jiangxi');
assert.equal(account.city, 'nanchang');
assert.equal(Object.prototype.hasOwnProperty.call(sources.getLoginDeviceSnapshot(), 'ip'), false);
});
test('malformed host input fails and leaves no device', async () => {
for (const host of [{}, { location: undefined }, { location: [] }, { location: 0 },
{ location: null, returnCitySN: null }, { location: null, returnCitySN: [] },
...['ip', 'province', 'city'].map(field => ({ location: null, returnCitySN: { ip: '', province: '', city: '', [field]: 3 } }))]) {
const sources = fixture({ sampleHost: () => host as LocalHostSnapshot });
await assert.rejects(sources.prepareLogin(), /location|returnCitySN/);
assert.throws(() => sources.getLoginDeviceSnapshot(), /prepare/i);
}
});
test('machine cache is persisted and reused across source instances without now or random', async () => {
const storage = new MapStorage();
await fixture({ storage }).prepareLogin();
assert.deepEqual(JSON.parse(storage.values.get(LOCAL_MACHINE_KEY)!), { schemaVersion: 1, machineid: '123456-5499' });
const sources = fixture({ storage, now: () => { throw Error('unexpected now'); }, random: () => { throw Error('unexpected random'); } });
await sources.prepareLogin();
assert.equal(sources.getLoginDeviceSnapshot().machineid, '123456-5499');
});
test('damaged machine records fail without replacing the stored value', async () => {
for (const raw of ['bad json', 'null', '[]', '{}', ...[
{ schemaVersion: 2, machineid: '1-1000' }, { schemaVersion: 1, machineid: '' },
{ schemaVersion: 1, machineid: 3 }, { schemaVersion: 1, machineid: 'bad' },
{ schemaVersion: 1, machineid: '-1-1000' }, { schemaVersion: 1, machineid: '1-999' },
{ schemaVersion: 1, machineid: '1-9999' }, { schemaVersion: 1, machineid: '9007199254740992-1000' },
{ schemaVersion: 1, machineid: '1-1000', extra: true },
].map(value => JSON.stringify(value))]) {
const storage = new MapStorage(); storage.values.set(LOCAL_MACHINE_KEY, raw);
const sources = fixture({ storage });
await assert.rejects(sources.prepareLogin(), /youle\.local-test\.machine\.v1/);
assert.equal(storage.values.get(LOCAL_MACHINE_KEY), raw);
assert.throws(() => sources.getLoginDeviceSnapshot(), /prepare/i);
}
});
test('machine generation validates timestamp and random boundaries', async () => {
const storage = new MapStorage(); await fixture({ storage }).prepareLogin();
storage.values.delete(LOCAL_MACHINE_KEY);
for (const now of [-1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
await assert.rejects(fixture({ storage, now: () => now }).prepareLogin(), /now|timestamp/);
assert.equal(storage.values.has(LOCAL_MACHINE_KEY), false);
}
for (const random of [-0.1, 1, NaN, Infinity]) {
await assert.rejects(fixture({ storage, random: () => random }).prepareLogin(), /random/);
assert.equal(storage.values.has(LOCAL_MACHINE_KEY), false);
}
for (const [random, expected] of [[0, '0-1000'], [0.999999, '0-9998']] as const) {
storage.values.delete(LOCAL_MACHINE_KEY);
const sources = fixture({ storage, random: () => random, now: () => 0 });
await sources.prepareLogin();
assert.equal(sources.getLoginDeviceSnapshot().machineid, expected);
}
});
test('machine storage and generation failures preserve the original cause', async () => {
for (const operation of ['read', 'write', 'now', 'random'] as const) {
const stored = new MapStorage(); await fixture({ storage: stored }).prepareLogin(); stored.values.delete(LOCAL_MACHINE_KEY);
const cause = new Error(operation);
const sources = fixture({ storage: {
getItem(key) { if (key === LOCAL_MACHINE_KEY && operation === 'read') throw cause; return stored.getItem(key); },
setItem(key, value) { if (key === LOCAL_MACHINE_KEY && operation === 'write') throw cause; stored.setItem(key, value); },
}, now: () => { if (operation === 'now') throw cause; return 0; },
random: () => { if (operation === 'random') throw cause; return 0; } });
await assert.rejects(sources.prepareLogin(), (error: unknown) => {
assert.ok(error instanceof Error);
assert.match(error.message, /youle\.local-test\.machine\.v1/);
assert.strictEqual((error as Error & { cause?: unknown }).cause, cause); return true;
});
assert.throws(() => sources.getLoginDeviceSnapshot(), /prepare/i);
}
});
test('concurrent prepares merge and reset conflicts in both directions', async () => {
let samples = 0;
const sources = fixture({ sampleHost: () => { samples++; return { location: null }; } });
const first = sources.prepareLogin(); const second = sources.prepareLogin();
await assert.rejects(sources.resetAccount(), /pending|prepare/i);
const [a, b] = await Promise.all([first, second]);
assert.strictEqual(a, b); assert.equal(samples, 1);
const reset = sources.resetAccount();
assert.throws(() => sources.getLoginDeviceSnapshot(), /prepare/i);
const conflictingPrepare = sources.prepareLogin();
const conflictingReset = sources.resetAccount();
await assert.rejects(conflictingPrepare, /reset/i);
await assert.rejects(conflictingReset, /pending|reset/i);
await reset;
});
test('reset invalidates device and changes account while preserving the machine key', async () => {
const storage = new MapStorage(); let random = 0.5;
const sources = fixture({ storage, random: () => random });
const old = await sources.prepareLogin(); const machine = storage.values.get(LOCAL_MACHINE_KEY);
random = 0;
const account = await sources.resetAccount();
assert.notEqual(account.openid, old.openid);
assert.equal(storage.values.get(LOCAL_MACHINE_KEY), machine);
assert.throws(() => sources.getLoginDeviceSnapshot(), /prepare/i);
assert.equal((await sources.prepareLogin()).openid, account.openid);
});
test('prepare starts by invalidating prior device and failure cannot expose it', async () => {
let fail = false;
const sources = fixture({ sampleHost: () => { if (fail) throw Error('host failed'); return { location: null }; } });
await sources.prepareLogin(); fail = true;
const pending = sources.prepareLogin();
assert.throws(() => sources.getLoginDeviceSnapshot(), /prepare/i);
await assert.rejects(pending, /host failed/);
assert.throws(() => sources.getLoginDeviceSnapshot(), /prepare/i);
});
test('active-session reset rejects and invalidates the device without overwriting account', async () => {
const storage = new MapStorage(); const sources = fixture({ storage, isSessionActive: () => true });
await sources.prepareLogin(); const old = storage.values.get(localAccountKey(context));
await assert.rejects(sources.resetAccount(), /active session/i);
assert.equal(storage.values.get(localAccountKey(context)), old);
assert.throws(() => sources.getLoginDeviceSnapshot(), /prepare/i);
});