diablo2-web/tests/e2e-module-split/tier5-adversarial-hardening...

499 lines
20 KiB
TypeScript

/**
* Tier 5 — Adversarial Hardening & Challenger Regression Suite
* (`tests/e2e-module-split/tier5-adversarial-hardening.test.ts`).
*
* Covers white-box regression guards and stress probes from Iteration 1:
* - adv_BUG_01: `src/client/scene/arena-overlay.ts` zero dynamic/cross-module server imports & fail-fast WorldArenaFactory DI
* - adv_BUG_02: zero `.test.ts`/`.spec.ts` files under `src/**` & `tests/frontend/flow-state.test.ts` relocation
* - adv_BUG_03: `src/netproto/transport/endpoint.ts` zero `new Function` dynamic imports & fail-fast `NetprotoError` when TCP opener is unregistered
* - adv_F1_01..06: `D2DataRegistry` & `getSharedDataRegistry` fail-fast & cache-poisoning guards
* - adv_F2_01..04: `validateUniversalDt1Libraries` & `validatePackManifest` mutation rejection
* - adv_F4_01..03: `runBakerCli` & `runBakerVerify` adversarial target/directory checks
* - adv_server_determinism & hardening: 200-step twin `ServerWorld` & `GameEngine` fuzzing, `NaN`/`Infinity` movement sanitization, `[0, 0xffff]` coordinate clamping, and `computeLockstepHash` `Map` monster sensitivity
* - adv_online_session: `OnlineSession` + `LocalLoopbackAdapter` + `ClientWorld` entity lifecycle, post-`leaveGame()` command guard, and 20x rapid reconnect cycles
*/
import { describe, expect, it } from 'vitest'
import { runBakerCli, runBakerVerify } from '../../src/baker/cli.ts'
import {
initBrowserArenaMode,
registerWorldArenaFactory,
} from '../../src/client/scene/arena-overlay.ts'
import { OnlineSession } from '../../src/client/session/online-session.ts'
import { ClientWorld } from '../../src/client/world/client-world.ts'
import {
D2DataRegistry,
getSharedDataRegistry,
getSharedMountedArchives,
resetSharedDataRegistryCache,
} from '../../src/common/data/data-registry.ts'
import {
UNIVERSAL_DT1_PATHS,
validatePackManifest,
validateUniversalDt1Libraries,
} from '../../src/common/pack-contract/index.ts'
import {
NetprotoError,
createTcpResolver,
registerDefaultTcpOpener,
type ClientCommand,
type ServerEvent,
} from '../../src/netproto/index.ts'
import { LocalLoopbackAdapter } from '../../src/server/adapter/LocalLoopbackAdapter.ts'
import { GameEngine, type GameEngineOptions } from '../../src/server/engine/engine.ts'
import { computeLockstepHash, computeStateHash } from '../../src/server/engine/state-hash.ts'
import { ServerWorld } from '../../src/server/world/server-world.ts'
import {
ROOT_DIR,
collectTsFiles,
existsInRepo,
readJsonFile,
readRepoFile,
stripComments,
} from './helpers.ts'
describe('Tier 5 — Adversarial Hardening & Challenger Regression Suite', () => {
describe('1. White-Box Boundary & Architecture Regression Guards (adv_BUG_01..03)', () => {
it('adv_BUG_01: src/client/scene/arena-overlay.ts has zero dynamic imports or server references and fails fast without WorldArenaFactory', async () => {
const code = stripComments(readRepoFile('src/client/scene/arena-overlay.ts'))
expect(code).not.toContain('import(')
expect(code).not.toContain('world-arena')
expect(code).not.toContain("'server'")
expect(typeof registerWorldArenaFactory).toBe('function')
registerWorldArenaFactory(null)
await expect(initBrowserArenaMode()).rejects.toThrow(
/No WorldArenaFactory registered; registerWorldArenaFactory must be called/i,
)
})
it('adv_BUG_02: zero .test.ts or .spec.ts files exist under src/** and tests/frontend/flow-state.test.ts is present', () => {
const srcTestFiles = collectTsFiles('src').filter(
(f) => f.endsWith('.test.ts') || f.endsWith('.spec.ts') || f.includes('__tests__'),
)
expect(srcTestFiles).toEqual([])
expect(existsInRepo('src/client/frontend/__tests__/flow-state.test.ts')).toBe(false)
expect(existsInRepo('tests/frontend/flow-state.test.ts')).toBe(true)
for (const cfgName of [
'tsconfig.common.json',
'tsconfig.netproto.json',
'tsconfig.server.json',
'tsconfig.client.json',
'tsconfig.baker.json',
]) {
const cfg = readJsonFile(cfgName)
const ex = (cfg.exclude as string[]) ?? []
expect(ex).not.toContain('**/*.test.ts')
expect(ex).not.toContain('src/netproto/transport/node-tcp-stream.ts')
}
})
it('adv_BUG_03: src/netproto/transport/endpoint.ts has zero new Function/node-tcp-stream references and throws NetprotoError when unregistered', async () => {
const code = stripComments(readRepoFile('src/netproto/transport/endpoint.ts'))
expect(code).not.toContain('new Function')
expect(code).not.toContain('node-tcp-stream')
registerDefaultTcpOpener(null)
const resolver = createTcpResolver()
let caught: unknown = null
try {
await resolver.open('bnet', '127.0.0.1', 6112)
} catch (err) {
caught = err
}
expect(caught).toBeInstanceOf(NetprotoError)
const netErr = caught as NetprotoError
expect(netErr.code).toBe('ERR_TRANSPORT')
expect(netErr.channel).toBe('bnet')
expect(netErr.message).toMatch(/No TCP opener registered/i)
})
})
describe('2. D2DataRegistry & ArchiveSource Adversarial Stress (adv_F1_01..06)', () => {
it('adv_F1_01..03: getSharedDataRegistry and getSharedMountedArchives reject missing, null, and primitive inputs fail-fast', () => {
resetSharedDataRegistryCache()
expect(() => getSharedDataRegistry()).toThrow(/ArchiveSource/i)
expect(() => getSharedMountedArchives()).toThrow(/ArchiveSource/i)
for (const bad of [null, 0, 42, '', 'd2data.mpq', false, true]) {
expect(() => getSharedDataRegistry(bad as never)).toThrow(/ArchiveSource/i)
expect(() => getSharedMountedArchives(bad as never)).toThrow(/ArchiveSource/i)
}
})
it('adv_F1_04..06: D2DataRegistry rejects empty/null-returning/corrupt ArchiveSources without poisoning shared cache', async () => {
resetSharedDataRegistryCache()
expect(() => new D2DataRegistry(null as never)).toThrow(/ArchiveSource/i)
expect(() => D2DataRegistry.fromArchiveSourceSync({} as never)).toThrow(
/synchronous reading/i,
)
await expect(D2DataRegistry.fromMountedArchives(null as never)).rejects.toThrow(
/ArchiveSource/i,
)
const nullReturningSource = {
readBytes: () => null,
readText: () => null,
}
expect(() => getSharedDataRegistry(nullReturningSource)).toThrow(/synchronous reading/i)
const corruptAnimSource = {
readBytes(p: string): Uint8Array | null {
if (p.toLowerCase().endsWith('animdata.d2')) {
return new Uint8Array([1, 2, 3])
}
return new Uint8Array(0)
},
readText(): string | null {
return 'Id\tskill\n0\tAttack\n'
},
}
expect(() => getSharedDataRegistry(corruptAnimSource)).toThrow()
// Shared cache must remain unpoisoned after failed initialization
expect(() => getSharedDataRegistry()).toThrow(/ArchiveSource/i)
})
})
describe('3. Pack Contract & Baker CLI Adversarial Stress (adv_F2_01..04, adv_F4_01..03)', () => {
const validBlank = {
path: UNIVERSAL_DT1_PATHS[0],
tiles: [{ type: 0, style: 0, sequence: 0 }],
}
const validInvisWal = {
path: UNIVERSAL_DT1_PATHS[1],
tiles: [{ type: 0, style: 49, sequence: 7 }],
}
const validWarp = {
path: UNIVERSAL_DT1_PATHS[2],
tiles: [{ type: 0, style: 0, sequence: 0 }],
}
it('adv_F2_01..03: validateUniversalDt1Libraries rejects missing universal DT1s and style/sequence/orientation mutations', () => {
expect(() => validateUniversalDt1Libraries([])).toThrow(/Blank\.dt1/i)
expect(() => validateUniversalDt1Libraries(null as never)).toThrow(/Blank\.dt1/i)
expect(() => validateUniversalDt1Libraries([validInvisWal, validWarp])).toThrow(/Blank\.dt1/i)
expect(() => validateUniversalDt1Libraries([validBlank, validWarp])).toThrow(/InvisWal\.dt1/i)
expect(() =>
validateUniversalDt1Libraries([
validBlank,
{ path: UNIVERSAL_DT1_PATHS[1], tiles: [{ type: 0, style: 48, sequence: 7 }] },
validWarp,
]),
).toThrow(/style=49, sequence=7/i)
expect(() =>
validateUniversalDt1Libraries([
validBlank,
{ path: UNIVERSAL_DT1_PATHS[1], tiles: [{ type: 0, style: 49, sequence: 6 }] },
validWarp,
]),
).toThrow(/style=49, sequence=7/i)
expect(() =>
validateUniversalDt1Libraries([
validBlank,
{ path: UNIVERSAL_DT1_PATHS[1], tiles: [{ type: 1, style: 49, sequence: 7 }] },
validWarp,
]),
).toThrow(/style=49, sequence=7/i)
expect(() => validateUniversalDt1Libraries([validBlank, validInvisWal])).toThrow(/Warp\.dt1/i)
expect(() =>
validateUniversalDt1Libraries([
validBlank,
validInvisWal,
{ path: UNIVERSAL_DT1_PATHS[2], tiles: [] },
]),
).toThrow(/Warp\.dt1 has 0 decoded tiles/i)
})
it('adv_F2_04: validatePackManifest rejects null, missingFiles, missingTiles, invisWalFloorVerified=false, and incomplete libraries', () => {
expect(() => validatePackManifest(null as never)).toThrow(/Invalid pack manifest/i)
expect(() => validatePackManifest({ missingFiles: 1, missingTiles: 0 })).toThrow(
/missingFiles=1/i,
)
expect(() =>
validatePackManifest({ missingFiles: ['Act1\\Town\\foo.dt1'], missingTiles: 0 }),
).toThrow(/missingFiles=1/i)
expect(() => validatePackManifest({ missingFiles: 0, missingTiles: 2 })).toThrow(
/missingTiles=2/i,
)
expect(() =>
validatePackManifest({
missingFiles: 0,
missingTiles: 0,
invisWalFloorVerified: false,
}),
).toThrow(/invisWalFloorVerified is false/i)
expect(() =>
validatePackManifest({
missingFiles: 0,
missingTiles: 0,
invisWalFloorVerified: true,
libraries: { onlyBlank: validBlank },
}),
).toThrow(/InvisWal\.dt1/i)
})
it('adv_F4_01..03: runBakerCli handles help/invalid targets and runBakerVerify validates 136 levels and fails fast on missing catalog', async () => {
await expect(runBakerCli(['--help'])).resolves.toBeUndefined()
await expect(runBakerCli(['-h'])).resolves.toBeUndefined()
await expect(runBakerCli([])).resolves.toBeUndefined()
await expect(runBakerCli(['invalid-target-xyz'])).rejects.toThrow(
/Unknown bake target "invalid-target-xyz"/i,
)
const summary = await runBakerVerify(ROOT_DIR)
expect(summary.tileManifestVerified).toBe(true)
expect(summary.levelsVerified).toBe(136)
expect(summary.dropParityVerified).toBe(true)
await expect(runBakerVerify('/tmp/nonexistent-root-dir-12345')).rejects.toThrow(
/Tile catalog index\.json not found/i,
)
})
})
describe('4. ServerWorld & GameEngine Hardening & 200-Step Twin Determinism', () => {
const SEED = 0x12345678
function makeEngineOpts(): GameEngineOptions {
return {
spawn: { x: 200, y: 200 },
stats: [
{
id: 'fallen1',
name: 'Fallen',
hp: 30,
damage: 4,
cooldownTicks: 25,
reach: 40,
aggroRadius: 220,
speed: 90,
xp: 15,
},
],
xpTable: [0, 0, 500, 1500, 3750],
skills: [
{
id: '0',
name: 'Attack',
manaCost: 0,
cooldownTicks: 5,
range: 48,
projectile: false,
speed: 0,
baseMinDamage: 10,
baseMaxDamage: 10,
damagePerLevel: 0,
radius: 48,
},
{
id: '36',
name: 'Fire Bolt',
manaCost: 2,
cooldownTicks: 8,
range: 300,
projectile: true,
speed: 200,
baseMinDamage: 12,
baseMaxDamage: 12,
damagePerLevel: 0,
radius: 20,
},
],
npcDefs: [],
questDefs: [],
combatOptions: {
playerSpeed: 170,
playerReach: 48,
playerCooldownTicks: 5,
playerDamage: 10,
playerManaPerAttack: 0,
respawnTicks: 50,
},
lootSeed: SEED,
talkRadius: 64,
pickupRadius: 64,
inventoryCols: 10,
inventoryRows: 4,
monsterCount: 6,
}
}
it('adv_server_hardening: clamps ServerWorld target coordinates to [0, 0xffff], sanitizes NaN/Infinity in GameEngine.tick, and hashes Map monsters in computeLockstepHash', () => {
// 1. ServerWorld [0, 0xffff] coordinate clamping
const coordWorld = new ServerWorld({ act: 1, levelId: 1, seed: SEED, spawnX: 100, spawnY: 100 })
coordWorld.handleCommand({ type: 'WalkToLocation', x: -500, y: -500 })
expect(coordWorld.player.targetX).toBe(0)
expect(coordWorld.player.targetY).toBe(0)
coordWorld.handleCommand({ type: 'WalkToLocation', x: 1e9, y: 1e9 })
expect(coordWorld.player.targetX).toBe(0xffff)
expect(coordWorld.player.targetY).toBe(0xffff)
// 2. computeLockstepHash sensitivity to ServerWorld.monsters (Map)
const probeWorld1 = new ServerWorld({ act: 1, levelId: 1, seed: SEED, spawnX: 100, spawnY: 100 })
const probeWorld2 = new ServerWorld({ act: 1, levelId: 1, seed: SEED, spawnX: 100, spawnY: 100 })
probeWorld1.monsters.set(100, {
unitId: 100,
classId: 0,
code: 'fallen1',
name: 'Fallen',
x: 110,
y: 110,
hp: 50,
maxHp: 50,
})
probeWorld2.monsters.set(100, {
unitId: 100,
classId: 0,
code: 'fallen1',
name: 'Fallen',
x: 110,
y: 110,
hp: 50,
maxHp: 50,
})
probeWorld2.handleCommand({ type: 'PlayerAttack', targetId: 100 })
expect(probeWorld2.monsters.get(100)?.hp).toBeLessThan(50)
const lockstep1 = computeLockstepHash(probeWorld1 as unknown as Parameters<typeof computeLockstepHash>[0])
const lockstep2 = computeLockstepHash(probeWorld2 as unknown as Parameters<typeof computeLockstepHash>[0])
expect(lockstep1).not.toBe(lockstep2)
// 3. GameEngine.tick NaN/Infinity movement sanitization
const nanEngine = new GameEngine('sor', makeEngineOpts())
nanEngine.tick({
movement: { x: Number.NaN, y: Number.POSITIVE_INFINITY },
attacking: false,
pickingUp: false,
talking: false,
digits: [],
saving: false,
loading: false,
})
expect(Number.isFinite(nanEngine.world.player.x)).toBe(true)
expect(Number.isFinite(nanEngine.world.player.y)).toBe(true)
expect(Number.isFinite(nanEngine.world.player.facing)).toBe(true)
expect(nanEngine.world.player.x).toBe(200)
expect(nanEngine.world.player.y).toBe(200)
})
it('adv_server_world_200_step_determinism: twin ServerWorld + LocalLoopbackAdapter instances produce identical hashes across 200 valid + adversarial steps', async () => {
const worldA = new ServerWorld({ act: 1, levelId: 1, seed: SEED, spawnX: 100, spawnY: 100 })
const worldB = new ServerWorld({ act: 1, levelId: 1, seed: SEED, spawnX: 100, spawnY: 100 })
for (const w of [worldA, worldB]) {
w.monsters.set(100, { unitId: 100, classId: 0, code: 'fallen1', name: 'Fallen', x: 115, y: 115, hp: 50, maxHp: 50 })
w.monsters.set(101, { unitId: 101, classId: 1, code: 'zombie1', name: 'Zombie', x: 130, y: 125, hp: 80, maxHp: 80 })
}
const adapterA = new LocalLoopbackAdapter(worldA)
const adapterB = new LocalLoopbackAdapter(worldB)
await adapterA.connect()
await adapterB.connect()
for (let i = 0; i < 200; i += 1) {
const mode = i % 10
let cmd: Record<string, unknown> = { type: 'WalkToLocation', x: 100 + (i % 30), y: 100 + ((i * 3) % 30) }
if (mode === 1) cmd = { type: 'WalkToLocation', x: -500 - i, y: -999999 }
else if (mode === 2) cmd = { type: 'RunToLocation', x: Number.NaN, y: Number.POSITIVE_INFINITY }
else if (mode === 3) cmd = { type: 'SelectSkill', skillId: -1, hand: 'right' }
else if (mode === 4) cmd = { type: 'CastSkill', skillId: 99999, x: 105, y: 105 }
else if (mode === 5) cmd = { type: 'WalkToUnit', unitType: 1, unitId: 999999 }
else if (mode === 6) cmd = { type: 'PlayerAttack', targetId: i < 100 ? 100 : 101, skillId: 0, x: 115, y: 115 }
else if (mode === 7) cmd = { type: 'PlayerAttack', targetId: 555555, skillId: -1, x: -50, y: Number.NaN }
else if (mode === 8) {
adapterA.disconnect()
adapterB.disconnect()
await adapterA.connect()
await adapterB.connect()
}
adapterA.send(cmd as ClientCommand)
adapterB.send(cmd as ClientCommand)
adapterA.stepTick(40)
adapterB.stepTick(40)
const hashA = computeStateHash(worldA as unknown as Parameters<typeof computeStateHash>[0])
const hashB = computeStateHash(worldB as unknown as Parameters<typeof computeStateHash>[0])
expect(hashA).toBe(hashB)
}
})
it('adv_game_engine_200_step_determinism: twin GameEngine instances produce identical state hashes across 200 valid + adversarial steps', () => {
const engineA = new GameEngine('sor', makeEngineOpts())
const engineB = new GameEngine('sor', makeEngineOpts())
for (let i = 0; i < 200; i += 1) {
const mode = i % 6
let mx = mode === 0 ? (i % 3) - 1 : mode === 1 ? Number.NaN : 1
let my = mode === 0 ? ((i * 2) % 3) - 1 : mode === 1 ? Number.NEGATIVE_INFINITY : 0
const digits = mode === 2 ? [2] : mode === 3 ? [-1, 99999] : []
if (mode === 4) {
engineA.enqueueCommand({ type: 'allocateStat', stat: 'nonexistent_stat' })
engineB.enqueueCommand({ type: 'allocateStat', stat: 'nonexistent_stat' })
engineA.enqueueCommand({ type: 'useBeltSlot', col: -5 })
engineB.enqueueCommand({ type: 'useBeltSlot', col: -5 })
} else if (mode === 5) {
engineA.updateActiveAura(-1, 3)
engineB.updateActiveAura(-1, 3)
}
const input = {
movement: { x: mx, y: my },
attacking: mode === 2 || mode === 3,
pickingUp: i % 5 === 0,
talking: i % 15 === 0,
digits,
saving: false,
loading: false,
}
engineA.tick(input)
engineB.tick(input)
expect(Number.isFinite(engineA.world.player.x)).toBe(true)
expect(Number.isFinite(engineA.world.player.y)).toBe(true)
expect(engineA.computeStateHash()).toBe(engineB.computeStateHash())
}
})
})
describe('5. OnlineSession + LocalLoopbackAdapter + ClientWorld Adversarial Lifecycle', () => {
it('adv_online_session_lifecycle: handles adversarial ClientWorld events, post-leaveGame() command guard, and 20x rapid reconnect cycles', async () => {
const serverWorld = new ServerWorld({ act: 1, levelId: 1, seed: 0x12345678, spawnX: 100, spawnY: 100 })
const adapter = new LocalLoopbackAdapter(serverWorld)
const clientWorld = new ClientWorld()
const session = new OnlineSession({ world: clientWorld })
session.attachGameServer(adapter)
expect(session.phase).toBe('ingame')
expect(clientWorld.selfId).toBe(1)
// Adversarial events for nonexistent units must not throw
expect(() => {
session.handleServerEvent({ type: 'EntityRemove', unitType: 1, unitId: 999999 } as unknown as ServerEvent)
session.handleServerEvent({ type: 'UnitMove', unitType: 1, unitId: 999999, x: 50, y: 60 } as unknown as ServerEvent)
}).not.toThrow()
// Post-leaveGame() command guard
const packetsOutBeforeLeave = adapter.stats.packetsOut
await session.leaveGame()
expect(session.phase).toBe('idle')
expect(adapter.isConnected).toBe(false)
let clockMs = 2000
session.dispatchCommand({ type: 'PlayerMove', targetX: 200, targetY: 200 }, (clockMs += 200))
session.dispatchCommand({ type: 'RunToLocation', x: 210, y: 210 }, (clockMs += 200))
expect(adapter.stats.packetsOut).toBe(packetsOutBeforeLeave)
// 20x rapid reconnect cycles
for (let c = 0; c < 20; c += 1) {
await adapter.connect()
session.attachGameServer(adapter)
expect(session.phase).toBe('ingame')
session.dispatchCommand({ type: 'RunToLocation', x: 100 + c, y: 100 + c }, (clockMs += 200))
adapter.stepTick(40)
await session.leaveGame()
expect(session.phase).toBe('idle')
}
})
})
})