fix(platform): enforce atomic store input validation
This commit is contained in:
+91
-14
@@ -3,6 +3,7 @@ import {
|
|||||||
hasOwn,
|
hasOwn,
|
||||||
requireArray,
|
requireArray,
|
||||||
requireInteger,
|
requireInteger,
|
||||||
|
requireNumber,
|
||||||
requireRecord,
|
requireRecord,
|
||||||
requireString,
|
requireString,
|
||||||
type ParsedLoginResponse,
|
type ParsedLoginResponse,
|
||||||
@@ -21,6 +22,8 @@ interface RoomBuildResult {
|
|||||||
readonly room: Extract<PlatformState['room'], { readonly kind: 'inside' }>;
|
readonly room: Extract<PlatformState['room'], { readonly kind: 'inside' }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LOGIN_ROOM_FIELDS = ['roomcode', 'seat', 'roomtype', 'players'] as const;
|
||||||
|
|
||||||
function requirePositiveInteger(value: unknown, path: string): number {
|
function requirePositiveInteger(value: unknown, path: string): number {
|
||||||
const parsed = requireInteger(value, path);
|
const parsed = requireInteger(value, path);
|
||||||
if (parsed <= 0) throw new RangeError(`${path}: expected positive integer`);
|
if (parsed <= 0) throw new RangeError(`${path}: expected positive integer`);
|
||||||
@@ -55,6 +58,8 @@ function parseRoomPlayer(value: unknown, path: string): {
|
|||||||
playerid: requirePositiveInteger(raw.playerid, `${path}.playerid`),
|
playerid: requirePositiveInteger(raw.playerid, `${path}.playerid`),
|
||||||
nickname: requireString(raw.nickname, `${path}.nickname`),
|
nickname: requireString(raw.nickname, `${path}.nickname`),
|
||||||
avatar: requireString(raw.avatar, `${path}.avatar`),
|
avatar: requireString(raw.avatar, `${path}.avatar`),
|
||||||
|
sex: requireInteger(raw.sex, `${path}.sex`),
|
||||||
|
bean: requireNumber(raw.bean, `${path}.bean`),
|
||||||
...(hasOwn(raw, 'ip') ? { ip: requireString(raw.ip, `${path}.ip`) } : {}),
|
...(hasOwn(raw, 'ip') ? { ip: requireString(raw.ip, `${path}.ip`) } : {}),
|
||||||
isprepare,
|
isprepare,
|
||||||
onstate,
|
onstate,
|
||||||
@@ -62,12 +67,67 @@ function parseRoomPlayer(value: unknown, path: string): {
|
|||||||
return { player, isprepare, onstate };
|
return { player, isprepare, onstate };
|
||||||
}
|
}
|
||||||
|
|
||||||
function recursivelyFreeze<T>(value: T, seen = new WeakSet<object>()): T {
|
function collectFreezableGraph(value: unknown): object[] {
|
||||||
if (typeof value !== 'object' || value === null) return value;
|
const graph: object[] = [];
|
||||||
if (seen.has(value)) return value;
|
const seen = new WeakSet<object>();
|
||||||
seen.add(value);
|
|
||||||
for (const child of Object.values(value)) recursivelyFreeze(child, seen);
|
const visit = (current: unknown): void => {
|
||||||
return Object.freeze(value);
|
if (typeof current !== 'object' || current === null || seen.has(current)) return;
|
||||||
|
seen.add(current);
|
||||||
|
graph.push(current);
|
||||||
|
|
||||||
|
const prototype = Object.getPrototypeOf(current);
|
||||||
|
if (!Array.isArray(current) && prototype !== Object.prototype && prototype !== null) {
|
||||||
|
throw new TypeError('roomtype: expected JSON array or plain object container');
|
||||||
|
}
|
||||||
|
Object.isExtensible(current);
|
||||||
|
for (const key of Reflect.ownKeys(current)) {
|
||||||
|
const descriptor = Reflect.getOwnPropertyDescriptor(current, key);
|
||||||
|
if (descriptor === undefined) {
|
||||||
|
throw new TypeError('roomtype: unstable property structure');
|
||||||
|
}
|
||||||
|
visit(Reflect.get(current, key));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
visit(value);
|
||||||
|
return graph;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recursivelyFreeze<T>(value: T): T {
|
||||||
|
const graph = collectFreezableGraph(value);
|
||||||
|
for (let index = graph.length - 1; index >= 0; index -= 1) {
|
||||||
|
Object.freeze(graph[index]);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSelfProfileMatches(
|
||||||
|
loginPlayer: PlatformPlayer,
|
||||||
|
roomPlayer: PlatformPlayer,
|
||||||
|
path: string,
|
||||||
|
): void {
|
||||||
|
for (const field of ['playerid', 'nickname', 'avatar', 'sex', 'bean'] as const) {
|
||||||
|
if (roomPlayer[field] !== loginPlayer[field]) {
|
||||||
|
throw new RangeError(`${path}.${field}: conflicts with authoritative login profile`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (roomPlayer.ip !== undefined && roomPlayer.ip !== loginPlayer.ip) {
|
||||||
|
throw new RangeError(`${path}.ip: conflicts with authoritative login profile`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireMatchingState(
|
||||||
|
parsedState: unknown,
|
||||||
|
raw: Readonly<Record<string, unknown>>,
|
||||||
|
path: string,
|
||||||
|
): number {
|
||||||
|
const state = requireInteger(parsedState, `${path}.state`);
|
||||||
|
const rawState = requireInteger(raw.state, `${path}.raw.state`);
|
||||||
|
if (state !== rawState) {
|
||||||
|
throw new RangeError(`${path}.state: expected parsed state to equal raw.state`);
|
||||||
|
}
|
||||||
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRoom(
|
function buildRoom(
|
||||||
@@ -129,15 +189,21 @@ function buildRoom(
|
|||||||
if (parsed.onstate === 1) offlineSeats.push(seat);
|
if (parsed.onstate === 1) offlineSeats.push(seat);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (seatPlayerIds[selfSeat] !== selfPlayer.playerid) {
|
const selfRoomPlayer = parsedPlayers.get(selfPlayer.playerid);
|
||||||
throw new RangeError('$.room.players: self seat must contain the authenticated player');
|
if (seatPlayerIds[selfSeat] !== selfPlayer.playerid || selfRoomPlayer === undefined) {
|
||||||
|
throw new RangeError(`$.room.players[${selfSeat}].playerid: conflicts with authenticated player`);
|
||||||
}
|
}
|
||||||
|
assertSelfProfileMatches(selfPlayer, selfRoomPlayer, `$.room.players[${selfSeat}]`);
|
||||||
|
|
||||||
recursivelyFreeze(roomtype);
|
recursivelyFreeze(roomtype);
|
||||||
const entities: Record<number, PlatformPlayer> = {};
|
const entities: Record<number, PlatformPlayer> = {};
|
||||||
for (const [playerid, parsedPlayer] of parsedPlayers) {
|
for (const [playerid, parsedPlayer] of parsedPlayers) {
|
||||||
const player = playerid === selfPlayer.playerid
|
const player = playerid === selfPlayer.playerid
|
||||||
? { ...selfPlayer, ...parsedPlayer }
|
? {
|
||||||
|
...selfPlayer,
|
||||||
|
isprepare: parsedPlayer.isprepare,
|
||||||
|
onstate: parsedPlayer.onstate,
|
||||||
|
}
|
||||||
: parsedPlayer;
|
: parsedPlayer;
|
||||||
entities[playerid] = Object.freeze(player);
|
entities[playerid] = Object.freeze(player);
|
||||||
}
|
}
|
||||||
@@ -217,7 +283,12 @@ export class PlatformStore {
|
|||||||
|
|
||||||
applyLoginSuccess(parsed: ParsedLoginResponse): void {
|
applyLoginSuccess(parsed: ParsedLoginResponse): void {
|
||||||
const input = requireRecord(parsed, '$.login');
|
const input = requireRecord(parsed, '$.login');
|
||||||
const state = requireInteger(input.state, '$.login.state');
|
const raw = requireRecord(input.raw, '$.login.raw');
|
||||||
|
const state = requireMatchingState(input.state, raw, '$.login');
|
||||||
|
const rawHasRoom = LOGIN_ROOM_FIELDS.some((field) => hasOwn(raw, field));
|
||||||
|
if (input.room === undefined || (input.room !== null) !== rawHasRoom) {
|
||||||
|
throw new TypeError('$.login.room: parsed room presence conflicts with raw room fields');
|
||||||
|
}
|
||||||
if (state !== 0) {
|
if (state !== 0) {
|
||||||
throw new RangeError('$.login.state: expected successful state 0');
|
throw new RangeError('$.login.state: expected successful state 0');
|
||||||
}
|
}
|
||||||
@@ -225,7 +296,6 @@ export class PlatformStore {
|
|||||||
if (playerid <= 0) {
|
if (playerid <= 0) {
|
||||||
throw new RangeError('$.login.playerid: expected positive integer');
|
throw new RangeError('$.login.playerid: expected positive integer');
|
||||||
}
|
}
|
||||||
const raw = requireRecord(input.raw, '$.login.raw');
|
|
||||||
if (requireInteger(raw.playerid, '$.login.raw.playerid') !== playerid) {
|
if (requireInteger(raw.playerid, '$.login.raw.playerid') !== playerid) {
|
||||||
throw new RangeError('$.login.raw.playerid: expected the parsed playerid');
|
throw new RangeError('$.login.raw.playerid: expected the parsed playerid');
|
||||||
}
|
}
|
||||||
@@ -233,6 +303,8 @@ export class PlatformStore {
|
|||||||
playerid,
|
playerid,
|
||||||
nickname: requireString(raw.nickname, '$.login.raw.nickname'),
|
nickname: requireString(raw.nickname, '$.login.raw.nickname'),
|
||||||
avatar: requireString(raw.avatar, '$.login.raw.avatar'),
|
avatar: requireString(raw.avatar, '$.login.raw.avatar'),
|
||||||
|
sex: requireInteger(raw.sex, '$.login.raw.sex'),
|
||||||
|
bean: requireNumber(raw.bean, '$.login.raw.bean'),
|
||||||
ip: requireString(raw.ip, '$.login.raw.ip'),
|
ip: requireString(raw.ip, '$.login.raw.ip'),
|
||||||
});
|
});
|
||||||
const roomResult = input.room === null
|
const roomResult = input.room === null
|
||||||
@@ -253,10 +325,15 @@ export class PlatformStore {
|
|||||||
|
|
||||||
replaceRoom(parsed: ParsedRoomResponse): void {
|
replaceRoom(parsed: ParsedRoomResponse): void {
|
||||||
const input = requireRecord(parsed, '$.response');
|
const input = requireRecord(parsed, '$.response');
|
||||||
if (requireInteger(input.state, '$.response.state') !== 0) {
|
const raw = requireRecord(input.raw, '$.response.raw');
|
||||||
|
const state = requireMatchingState(input.state, raw, '$.response');
|
||||||
|
if (input.room === undefined || (input.room !== null) !== (state === 0)) {
|
||||||
|
throw new TypeError('$.response.room: parsed room presence conflicts with raw state');
|
||||||
|
}
|
||||||
|
if (state !== 0) {
|
||||||
throw new RangeError('$.response.state: expected successful state 0');
|
throw new RangeError('$.response.state: expected successful state 0');
|
||||||
}
|
}
|
||||||
if (input.room === null || input.room === undefined) {
|
if (input.room === null) {
|
||||||
throw new TypeError('$.response.room: expected parsed room snapshot');
|
throw new TypeError('$.response.room: expected parsed room snapshot');
|
||||||
}
|
}
|
||||||
const previous = this.getState();
|
const previous = this.getState();
|
||||||
@@ -268,7 +345,7 @@ export class PlatformStore {
|
|||||||
if (selfPlayer === undefined) {
|
if (selfPlayer === undefined) {
|
||||||
throw new Error('players.entities: missing authenticated player');
|
throw new Error('players.entities: missing authenticated player');
|
||||||
}
|
}
|
||||||
const roomResult = buildRoom(input.room, input.raw, selfPlayer);
|
const roomResult = buildRoom(input.room, raw, selfPlayer);
|
||||||
this.stateCell.value = Object.freeze({
|
this.stateCell.value = Object.freeze({
|
||||||
app: previous.app,
|
app: previous.app,
|
||||||
players: roomResult.players,
|
players: roomResult.players,
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export interface PlatformPlayer {
|
|||||||
readonly playerid: number;
|
readonly playerid: number;
|
||||||
readonly nickname: string;
|
readonly nickname: string;
|
||||||
readonly avatar: string;
|
readonly avatar: string;
|
||||||
|
readonly sex: number;
|
||||||
|
readonly bean: number;
|
||||||
readonly ip?: string;
|
readonly ip?: string;
|
||||||
readonly isprepare?: number;
|
readonly isprepare?: number;
|
||||||
readonly onstate?: number;
|
readonly onstate?: number;
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ test('applyLoginSuccess commits the complete login once and notifies once', () =
|
|||||||
playerid: 430511,
|
playerid: 430511,
|
||||||
nickname: '测试号',
|
nickname: '测试号',
|
||||||
avatar: 'http://a',
|
avatar: 'http://a',
|
||||||
|
sex: 0,
|
||||||
|
bean: 0,
|
||||||
ip: '127.0.0.1',
|
ip: '127.0.0.1',
|
||||||
});
|
});
|
||||||
assert.deepEqual(state.room, { kind: 'outside' });
|
assert.deepEqual(state.room, { kind: 'outside' });
|
||||||
@@ -101,6 +103,8 @@ test('playerJoin atomically adds one canonical entity and its seat id', () => {
|
|||||||
playerid: 77,
|
playerid: 77,
|
||||||
nickname: '对家',
|
nickname: '对家',
|
||||||
avatar: 'http://b',
|
avatar: 'http://b',
|
||||||
|
sex: 1,
|
||||||
|
bean: 90,
|
||||||
ip: '127.0.0.2',
|
ip: '127.0.0.2',
|
||||||
isprepare: 0,
|
isprepare: 0,
|
||||||
onstate: 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.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data));
|
||||||
store.playerJoin(parseOtherJoinRoomPayload({
|
store.playerJoin(parseOtherJoinRoomPayload({
|
||||||
seat: 2, playerid: 77, nickname: '对家', avatar: 'http://b',
|
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 before = store.getState();
|
||||||
let notifications = 0;
|
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.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data));
|
||||||
store.playerJoin(parseOtherJoinRoomPayload({
|
store.playerJoin(parseOtherJoinRoomPayload({
|
||||||
seat: 2, playerid: 77, nickname: '对家', avatar: 'http://b',
|
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 before = store.getState();
|
||||||
let notifications = 0;
|
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.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data));
|
||||||
store.playerJoin(parseOtherJoinRoomPayload({
|
store.playerJoin(parseOtherJoinRoomPayload({
|
||||||
seat: 2, playerid: 77, nickname: '对家', avatar: 'http://b',
|
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();
|
const before = store.getState();
|
||||||
let notifications = 0;
|
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.applyLoginSuccess(parseLoginResponse(fixture('player-login-room.json').data));
|
||||||
store.playerJoin(parseOtherJoinRoomPayload({
|
store.playerJoin(parseOtherJoinRoomPayload({
|
||||||
seat: 2, playerid: 77, nickname: '对家', avatar: 'http://b',
|
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 before = store.getState();
|
||||||
const self = before.players.entities[430511];
|
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(
|
assert.throws(
|
||||||
() => store.playerJoin(parseOtherJoinRoomPayload({
|
() => store.playerJoin(parseOtherJoinRoomPayload({
|
||||||
seat: 2, playerid: 430511, nickname: '重复', avatar: 'http://duplicate',
|
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/,
|
/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(store.getState(), before);
|
||||||
assert.equal(notifications, 0);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user