Files
chimera-Public/test/server/ch-1105-ps5-profile-management.test.js

161 lines
11 KiB
JavaScript

'use strict';
const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const test = require('node:test');
const xavatar = require('../../src/xavatarElectronModule');
const {
PINNED_PS5_PROFILE_PROVIDER,
createPs5UploadProfileProvider,
} = require('../../src/services/ps5upload-profile-provider');
const { createPs5ProfileManagementService } = require('../../src/services/ps5-profile-management-service');
const png = label => Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.from(label)]);
const digest = value => crypto.createHash('sha256').update(value).digest('hex');
function fixture(t, overrides = {}) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ps5-profile-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
let username = 'User1';
let avatar = png('before');
let generation = 1;
const calls = [];
const provider = overrides.provider || {
probe: async () => ({ available: true, ...PINNED_PS5_PROFILE_PROVIDER, capabilities: { profileInventory: true, localUsername: true, homeScreenNameSync: true, avatarRead: true, avatarApply: true, avatarClear: false } }),
listUsers: async () => ({ observedAt: 1_900_000_000_000 + generation, foregroundUid: 0x10000001, users: [{ uid: 0x10000001, uidHex: '0x10000001', username, accountId: 'must-not-escape', homePath: '/user/home/10000001' }] }),
currentAvatar: async ({ uid }) => ({ uid, bytes: avatar ? Buffer.from(avatar) : null, sha256: avatar ? digest(avatar) : null }),
renameUser: async input => { calls.push({ op: 'rename', ...input }); username = input.name; generation += 1; return { ok: true, homeScreenNameSynced: true }; },
applyAvatar: async input => { calls.push({ op: 'avatar', uid: input.uid, bytes: Buffer.from(input.imageBytes) }); avatar = Buffer.from(input.imageBytes); generation += 1; return { ok: true, filesCopied: 11, currentAvatarSha256: digest(avatar) }; },
};
const service = createPs5ProfileManagementService({
provider,
stateFile: path.join(root, 'state.json'),
recoveryRoot: path.join(root, 'recovery'),
idFactory: (() => { let n = 0; return () => `profile-op-${++n}`; })(),
now: () => 1_900_000_000_000 + generation,
});
return { calls, provider, root, service, setAvatar(value) { avatar = value; }, setUsername(value) { username = value; generation += 1; } };
}
async function avatarArchive() {
const rgba = Buffer.alloc(32 * 32 * 4, 0xff);
return (await xavatar.convertFromRGBA(rgba, 32, 32, { filename: 'profile' })).buffer;
}
test('inventory exposes only stable local-user identity and current avatar evidence', async t => {
const f = fixture(t);
const result = await f.service.inspect();
assert.equal(result.available, true);
assert.deepEqual(result.users[0], { uid: 0x10000001, uidHex: '0x10000001', username: 'User1', foreground: true, avatar: { available: true, sha256: digest(png('before')) } });
assert.equal(Object.hasOwn(result.users[0], 'accountId'), false);
assert.equal(JSON.stringify(result).includes('/user/home'), false);
assert.equal(result.semantics.accountIdentityMutable, false);
});
test('rename planning validates uid and PS5 16-character local display names', async t => {
const f = fixture(t);
await assert.rejects(() => f.service.prepareRename({ uid: 7, name: 'New name' }), error => error.code === 'PS5_PROFILE_USER_UNKNOWN');
for (const name of ['', ' '.repeat(3), '12345678901234567', 'bad\nname']) {
await assert.rejects(() => f.service.prepareRename({ uid: 0x10000001, name }), error => error.code === 'PS5_PROFILE_NAME');
}
const plan = await f.service.prepareRename({ uid: 0x10000001, name: 'Chimera' });
assert.equal(plan.status, 'prepared');
assert.equal(plan.confirmation, 'RENAME PS5 USER 0x10000001');
assert.equal(plan.snapshot.username, 'User1');
assert.equal(Object.hasOwn(plan, 'accountId'), false);
});
test('stale user evidence blocks rename before a provider mutation', async t => {
const f = fixture(t);
const plan = await f.service.prepareRename({ uid: 0x10000001, name: 'Chimera' });
f.setUsername('ChangedElsewhere');
await assert.rejects(() => f.service.apply({ id: plan.id, confirmation: plan.confirmation }), error => error.code === 'PS5_PROFILE_STALE');
assert.equal(f.calls.length, 0);
});
test('rename requires exact confirmation, verifies post-read and can roll back', async t => {
const f = fixture(t);
const plan = await f.service.prepareRename({ uid: 0x10000001, name: 'Chimera' });
await assert.rejects(() => f.service.apply({ id: plan.id, confirmation: 'RENAME' }), error => error.code === 'PS5_PROFILE_CONFIRMATION');
const applied = await f.service.apply({ id: plan.id, confirmation: plan.confirmation });
assert.equal(applied.status, 'verified');
assert.equal(applied.postRead.username, 'Chimera');
assert.equal(applied.rebootRequired, true);
assert.deepEqual(Object.keys(f.calls[0]).sort(), ['name', 'op', 'uid']);
const rolledBack = await f.service.rollback({ id: plan.id, confirmation: 'ROLLBACK PS5 PROFILE 0x10000001' });
assert.equal(rolledBack.status, 'rolled_back');
assert.equal(rolledBack.postRead.username, 'User1');
});
test('avatar planning accepts only a bounded canonical xavatar archive and requires recovery', async t => {
const f = fixture(t); const archive = await avatarArchive();
const plan = await f.service.prepareAvatar({ uid: 0x10000001, archive });
assert.equal(plan.status, 'prepared');
assert.equal(plan.confirmation, 'APPLY PS5 AVATAR 0x10000001');
assert.match(plan.desired.avatarSha256, /^[a-f0-9]{64}$/);
assert.ok(fs.existsSync(plan.snapshot.avatarBackupPath));
await assert.rejects(() => f.service.prepareAvatar({ uid: 0x10000001, archive: Buffer.alloc(10) }), error => error.code === 'PS5_PROFILE_AVATAR_ARCHIVE');
const recoveryFilesBeforeFailure = fs.readdirSync(path.join(f.root, 'recovery')).sort();
f.setAvatar(null);
await assert.rejects(() => f.service.prepareAvatar({ uid: 0x10000001, archive }), error => error.code === 'PS5_PROFILE_RECOVERY_UNAVAILABLE');
assert.deepEqual(fs.readdirSync(path.join(f.root, 'recovery')).sort(), recoveryFilesBeforeFailure);
});
test('avatar apply verifies provider evidence and rollback restores the previous image', async t => {
const f = fixture(t); const before = png('before'); const plan = await f.service.prepareAvatar({ uid: 0x10000001, archive: await avatarArchive() });
const applied = await f.service.apply({ id: plan.id, confirmation: plan.confirmation });
assert.equal(applied.status, 'verified');
assert.notEqual(applied.postRead.avatar.sha256, digest(before));
assert.equal(f.calls[0].op, 'avatar');
const rolledBack = await f.service.rollback({ id: plan.id, confirmation: 'ROLLBACK PS5 PROFILE 0x10000001' });
assert.equal(rolledBack.status, 'rolled_back');
assert.equal(rolledBack.postRead.avatar.sha256, digest(before));
});
test('pinned PS5Upload provider is loopback-only and calls only profile routes', async t => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-profile-provider-')); t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const calls = [];
const fetch = async (url, options = {}) => {
calls.push({ url: String(url), options });
if (String(url).endsWith('/api/version')) return { ok: true, json: async () => ({ version: PINNED_PS5_PROFILE_PROVIDER.version }) };
if (String(url).includes('/api/profile/info')) return { ok: true, json: async () => ({ ok: true, uid: 0x10000001, users: [{ uid: 0x10000001, uid_hex: '0x10000001', username: 'User1' }], slots: [{ id: 'secret' }] }) };
if (String(url).includes('/api/profile/avatar/current')) return { ok: true, json: async () => ({ data_url: `data:image/png;base64,${png('current').toString('base64')}` }) };
if (String(url).endsWith('/api/profile/local-username')) return { ok: true, json: async () => ({ ok: true }) };
return { ok: true, json: async () => ({ uid: 0x10000001, username: 'Chimera', files_copied: 11 }) };
};
assert.throws(() => createPs5UploadProfileProvider({ engineOrigin: 'http://192.168.1.8:19113', consoleHost: '192.168.1.9', sharedRoot: root, fetch }), error => error.code === 'PS5_PROFILE_ENGINE_ORIGIN');
assert.throws(() => createPs5UploadProfileProvider({ engineOrigin: 'http://127.0.0.1:19113', consoleHost: '192.168.1.9', fetch }), error => error.code === 'PS5_PROFILE_SHARED_ROOT');
const provider = createPs5UploadProfileProvider({ engineOrigin: 'http://127.0.0.1:19113', consoleHost: '192.168.1.9', sharedRoot: root, fetch });
assert.equal((await provider.probe()).available, true);
assert.equal((await provider.listUsers()).users[0].username, 'User1');
assert.equal((await provider.currentAvatar({ uid: 0x10000001 })).bytes.subarray(0, 8).equals(png('').subarray(0, 8)), true);
await provider.renameUser({ uid: 0x10000001, name: 'Chimera' });
await provider.applyAvatar({ uid: 0x10000001, username: 'Chimera', imageBytes: png('next') });
assert.equal(calls.every(call => new URL(call.url).hostname === '127.0.0.1'), true);
assert.equal(calls.some(call => ['/api/profile/activate', '/api/profile/clear-slot', '/api/profile/username'].includes(new URL(call.url).pathname)), false);
assert.equal(calls.some(call => new URL(call.url).pathname === '/api/profile/local-username'), true);
});
test('server, Electron, web and UI expose only guarded local profile operations', () => {
const registry = fs.readFileSync(path.join(__dirname, '../../src/server/create-registry.js'), 'utf8');
const main = fs.readFileSync(path.join(__dirname, '../../main.js'), 'utf8');
const preload = fs.readFileSync(path.join(__dirname, '../../preload.js'), 'utf8');
const web = fs.readFileSync(path.join(__dirname, '../../renderer/pork-web-shim.js'), 'utf8');
const html = fs.readFileSync(path.join(__dirname, '../../renderer/index.html'), 'utf8');
const renderer = fs.readFileSync(path.join(__dirname, '../../renderer/modules/xavatar.js'), 'utf8');
for (const command of ['ps5-profile:inspect', 'ps5-profile:prepare-rename', 'ps5-profile:prepare-avatar', 'ps5-profile:apply', 'ps5-profile:rollback', 'ps5-profile:list-operations']) {
assert.match(registry, new RegExp(command)); assert.match(main, new RegExp(command)); assert.match(preload, new RegExp(command)); assert.match(web, new RegExp(command));
}
assert.match(html, /ps5-profile-workspace/);
assert.match(html, /PS5Upload 5\.4\.19/);
assert.match(renderer, /loadPs5LocalProfiles/);
assert.match(renderer, /result\.version \|\| '5\.4\.19'/);
assert.doesNotMatch(renderer, /PS5Upload 5\.2\.1/);
assert.match(renderer, /ROLLBACK PS5 PROFILE/);
assert.doesNotMatch(renderer, /account.?id/i);
});