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.
This commit is contained in:
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user