fix(platform): enforce atomic store input validation

This commit is contained in:
2026-09-05 02:57:07 +08:00
parent 7acd3ed96e
commit 4ffa59bf8e
3 changed files with 198 additions and 19 deletions
@@ -38,6 +38,8 @@ test('applyLoginSuccess commits the complete login once and notifies once', () =
playerid: 430511,
nickname: '测试号',
avatar: 'http://a',
sex: 0,
bean: 0,
ip: '127.0.0.1',
});
assert.deepEqual(state.room, { kind: 'outside' });
@@ -101,6 +103,8 @@ test('playerJoin atomically adds one canonical entity and its seat id', () => {
playerid: 77,
nickname: '对家',
avatar: 'http://b',
sex: 1,
bean: 90,
ip: '127.0.0.2',
isprepare: 0,
onstate: 0,
@@ -113,7 +117,7 @@ test('playerReady replaces the affected player and room subtrees without prior m
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data));
store.playerJoin(parseOtherJoinRoomPayload({
seat: 2, playerid: 77, nickname: '对家', avatar: 'http://b',
ip: '127.0.0.2', onstate: 0, isprepare: 0,
sex: 1, bean: 90, ip: '127.0.0.2', onstate: 0, isprepare: 0,
}));
const before = store.getState();
let notifications = 0;
@@ -140,7 +144,7 @@ test('playerOffline records protocol and room status with one atomic commit', ()
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data));
store.playerJoin(parseOtherJoinRoomPayload({
seat: 2, playerid: 77, nickname: '对家', avatar: 'http://b',
ip: '127.0.0.2', onstate: 0, isprepare: 0,
sex: 1, bean: 90, ip: '127.0.0.2', onstate: 0, isprepare: 0,
}));
const before = store.getState();
let notifications = 0;
@@ -186,7 +190,7 @@ test('playerExit removes the seat entity and every seat-owned status in one comm
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data));
store.playerJoin(parseOtherJoinRoomPayload({
seat: 2, playerid: 77, nickname: '对家', avatar: 'http://b',
ip: '127.0.0.2', onstate: 1, isprepare: 1,
sex: 1, bean: 90, ip: '127.0.0.2', onstate: 1, isprepare: 1,
}));
const before = store.getState();
let notifications = 0;
@@ -237,7 +241,7 @@ test('clearRoom leaves no fabricated room fields and retains only the self entit
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data));
store.playerJoin(parseOtherJoinRoomPayload({
seat: 2, playerid: 77, nickname: '对家', avatar: 'http://b',
ip: '127.0.0.2', onstate: 0, isprepare: 0,
sex: 1, bean: 90, ip: '127.0.0.2', onstate: 0, isprepare: 0,
}));
const before = store.getState();
const self = before.players.entities[430511];
@@ -278,7 +282,7 @@ test('failed actions keep the exact root, notify nobody, and do not mutate input
assert.throws(
() => store.playerJoin(parseOtherJoinRoomPayload({
seat: 2, playerid: 430511, nickname: '重复', avatar: 'http://duplicate',
ip: '127.0.0.3', onstate: 0, isprepare: 0,
sex: 0, bean: 0, ip: '127.0.0.3', onstate: 0, isprepare: 0,
})),
/duplicate player seat/,
);
@@ -304,3 +308,99 @@ test('failed actions keep the exact root, notify nobody, and do not mutate input
assert.equal(store.getState(), before);
assert.equal(notifications, 0);
});
test('applyLoginSuccess rejects a room login whose parsed room was changed to null', () => {
const store = new PlatformStore();
const parsed = parseLoginResponse(fixture('player-login-room.json').data);
(parsed as { room: null }).room = null;
const before = store.getState();
let notifications = 0;
store.subscribe(() => { notifications += 1; });
assert.throws(() => store.applyLoginSuccess(parsed), /room.*raw/i);
assert.equal(store.getState(), before);
assert.equal(notifications, 0);
});
test('applyLoginSuccess rejects a failed login whose parsed state was changed to success', () => {
const store = new PlatformStore();
const parsed = parseLoginResponse({ state: 1 });
(parsed as { state: number }).state = 0;
const before = store.getState();
let notifications = 0;
store.subscribe(() => { notifications += 1; });
assert.throws(() => store.applyLoginSuccess(parsed), /state.*raw/i);
assert.equal(store.getState(), before);
assert.equal(notifications, 0);
});
test('replaceRoom rejects a successful parsed join whose raw state was changed to failure', () => {
const store = new PlatformStore();
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-success.json').data));
const parsed = parseSelfJoinRoomResponse(fixture('self-join-room.json').data);
(parsed.raw as Record<string, unknown>).state = 9;
const before = store.getState();
let notifications = 0;
store.subscribe(() => { notifications += 1; });
assert.throws(() => store.replaceRoom(parsed), /state.*raw/i);
assert.equal(store.getState(), before);
assert.equal(notifications, 0);
});
test('room self profile conflicts are rejected instead of overwriting login authority', () => {
const conflicts: ReadonlyArray<readonly [string, unknown]> = [
['playerid', 77],
['nickname', '冲突昵称'],
['avatar', 'http://conflict'],
['sex', 2],
['bean', 999],
['ip', '127.0.0.99'],
];
for (const [field, conflict] of conflicts) {
const store = new PlatformStore();
const parsed = parseLoginResponse(fixture('player-login-room.json').data);
const selfRoomPlayer = parsed.room?.players[1] as Record<string, unknown>;
selfRoomPlayer[field] = conflict;
const before = store.getState();
let notifications = 0;
store.subscribe(() => { notifications += 1; });
assert.throws(
() => store.applyLoginSuccess(parsed),
new RegExp(field, 'i'),
`expected conflicting ${field} to fail`,
);
assert.equal(store.getState(), before, `${field} changed the root`);
assert.equal(notifications, 0, `${field} notified subscribers`);
}
});
test('roomtype traversal failure freezes nothing and leaves store state untouched', () => {
const child = { value: 1 };
const throwing = Object.create(null) as Record<string, unknown>;
Object.defineProperty(throwing, 'boom', {
enumerable: true,
get(): never {
throw new Error('roomtype getter boom');
},
});
const roomtype = [child, throwing];
const data = fixture('self-join-room.json').data;
data.roomtype = roomtype;
const parsed = parseSelfJoinRoomResponse(data);
const store = new PlatformStore();
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-success.json').data));
const before = store.getState();
let notifications = 0;
store.subscribe(() => { notifications += 1; });
assert.throws(() => store.replaceRoom(parsed), /roomtype getter boom/);
assert.equal(Object.isFrozen(child), false);
assert.equal(Object.isFrozen(throwing), false);
assert.equal(Object.isFrozen(roomtype), false);
assert.equal(store.getState(), before);
assert.equal(notifications, 0);
});