From bec0df1dc2333aa602da37c5b538f900c75081fa Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 8 May 2026 23:37:13 -0400 Subject: [PATCH] test(01-03): add failing tests for Base64 codec + full round-trip [RED] - round-trip.test.ts (3 tests): full pipeline EXPORT -> IMPORT -> MIGRATE -> WRAP -> UNWRAP -> IDB PUT -> IDB GET exercising every save layer file end-to-end (CORE-09 + CORE-04 + CORE-06 + CORE-07); plus DoS-cap rejection at MAX_IMPORT_BYTES + 1; plus malformed-Base64 rejection RED phase per TDD plan-level gate. Tests fail because codec.ts does not exist yet. --- src/save/round-trip.test.ts | 87 +++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 src/save/round-trip.test.ts diff --git a/src/save/round-trip.test.ts b/src/save/round-trip.test.ts new file mode 100644 index 0000000..b12034a --- /dev/null +++ b/src/save/round-trip.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import 'fake-indexeddb/auto'; +import { wrap, unwrap } from './envelope'; +import { migrate, CURRENT_SCHEMA_VERSION } from './migrations'; +import { exportToBase64, importFromBase64 } from './codec'; +import { openSaveDB } from './db'; + +// CORE-09 + CORE-04 + CORE-06 + CORE-07: full save round-trip exercising +// every save layer file end-to-end. This is the load-bearing integration +// test for Phase 1 — if this passes, Phase 2 can reasonably trust that +// the save subsystem is wired correctly. + +beforeEach(async () => { + // Same store-contents reset pattern as the unit tests — see db.test.ts + // and snapshots.test.ts for why we don't deleteDatabase. + const db = await openSaveDB(); + for (const store of ['saves', 'save_snapshots'] as const) { + const all = await db.getAll(store); + for (const e of all) { + await db.delete(store, e.id); + } + } +}); + +describe('CORE-09 + CORE-04 + CORE-06 + CORE-07: full save round-trip', () => { + it('synthetic v0 envelope migrates, round-trips through Base64, validates, persists', async () => { + // Pretend a player had an old v0 save lying around (CONTEXT D-05 synthetic v0). + const v0Payload = { garden: [{ id: 'tile-1' }, { id: 'tile-2' }] }; + // v0 envelope: schemaVersion 0, with a placeholder checksum that we won't + // verify (the v0 era didn't have our checksum scheme, but the schema + // accepts it because checksum just has to be 8 hex chars). + const v0Envelope = { + schemaVersion: 0, + payload: v0Payload, + checksum: '00000000', // 8-char hex placeholder + }; + + // EXPORT through Base64 codec + const exported = exportToBase64(v0Envelope); + expect(exported.length).toBeGreaterThan(0); + + // IMPORT (simulating a fresh browser) — note: import returns a parsed + // envelope that PASSES our SaveEnvelopeSchema (schemaVersion 0 is allowed + // since z.number().nonnegative()). + const imported = importFromBase64(exported); + expect(imported.schemaVersion).toBe(0); + + // MIGRATE the imported payload + const { payload, toVersion } = migrate(imported.payload, imported.schemaVersion); + expect(toVersion).toBe(CURRENT_SCHEMA_VERSION); + expect(payload).toMatchObject({ + garden: { tiles: [{ id: 'tile-1' }, { id: 'tile-2' }] }, + plants: [], + harvestedFragmentIds: [], + lastTickAt: expect.any(Number), + settings: { + musicVolume: expect.any(Number), + ambientVolume: expect.any(Number), + sfxVolume: expect.any(Number), + }, + }); + + // WRAP with current version and a valid checksum, UNWRAP to verify + const v1Envelope = wrap(payload, toVersion); + expect(unwrap(v1Envelope)).toEqual(payload); + + // PERSIST to IDB and read back (CORE-04) + const db = await openSaveDB(); + await db.put('saves', { + id: 'main', + envelope: v1Envelope, + savedAt: new Date().toISOString(), + }); + const retrieved = await db.get('saves', 'main'); + expect(retrieved?.envelope).toEqual(v1Envelope); + expect(unwrap(retrieved!.envelope)).toEqual(payload); + }); + + it('rejects oversized Base64 import (DoS cap)', () => { + const huge = 'A'.repeat(50 * 1024 * 1024 + 1); + expect(() => importFromBase64(huge)).toThrow(/exceeds/); + }); + + it('rejects malformed Base64', () => { + expect(() => importFromBase64('not-valid-base64-)(*&^%$')).toThrow(); + }); +});