Files
MetonaSqlark/tests/engine/aria-encryption.test.ts
thzxx 0dba1abf2a test(P0): v0.8.0 验证基座与工程门禁根治
工作流 C-1 / C-3 前半 + 测试代码类型检查。

【故障注入基座】新增 tests/helpers/storage-harness.ts + faulty-backend.ts
- TransactionalFileStore:忠实 OPFS 提交语义(close 才可见)+ 字节级故障注入
  (failNextWrite/Append/Delete、truncateAppendTo 撕裂写、crashPending 真崩溃)
- 删除旧 opfs-mock:读返回内部引用、keepExistingData:false 不截断、close 空实现
  导致"提交前可见"等真实缺陷无法被测出(31 个测试文件迁移至新 harness)
- 删除 aria-opfs-backend 内的第三份重复 mock(含从未被断言使用的 writeCalls 死代码
  与 entry.content.subarray 恒等分支)
- FaultyBackend:包装任意 IStorageBackend 注入故障;crash() 明确区别于 close()
  (后者是优雅停机,会刷完写队列 —— 这正是此前所有"崩溃恢复"测试的真相)
- 16 条基座自测证明注入真的生效(含 close 不能当崩溃的对照组)

【覆盖率口径】jest.config.cjs
- 移除 '!src/**/index.ts'(该 glob 把 AriaEngine 主实现等 15 个实现文件整体
  排除出统计,与 v0.2.6 曾承认过的问题同源),改为只排除纯类型声明文件并附理由
- 新增 coverageThreshold 门禁(此前完全不存在)
- 真实基线:语句 90.66% / 分支 82.94% / 函数 94.36% / 行 93.43%
- 修正 testMatch 使 tests/helpers 下的测试可被发现

【测试代码类型检查】tsconfig.test.json + npm run typecheck:tests
- 修复 103 个测试代码类型错误(此前 babel 剥离类型 + tsconfig 排除 tests,全部隐藏)
- 新增 tests/helpers/assertions.ts:nonNull/decode/rows/object/engineMethod/expectCode
  以断言收窄替代 as any
- 消除 21 个 lint warning(含 v043-hardening 中定义后从未调用的 mockOPFS 死代码)
- parser.test.ts 12 处 toBeDefined() 空断言升级为结构断言(并新增 AND/OR 优先级用例,
  当前红灯,对应总账第 11 项,将在工作流 A 修复)

【版本契约】新增 tests/version-contract.test.ts
- 校验 src VERSION / package.json / dist 三者一致,替代两处硬编码版本字面量

【CI 门禁】.gitea/workflows/ci.yml
- lint 去掉 continue-on-error(此前永远不让 CI 变红)
- 新增 tests 类型检查、--coverage 覆盖率门禁、dist 与源码同步校验
- 版本 0.7.4 升至 0.8.0
2026-09-14 21:03:06 +08:00

354 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* AriaEngine — 全库 AES-256-GCM 加密测试
*
* 覆盖:
* 1. EncryptedBackend 单元:读写往返 / writeMany / delete / clear / 密文落盘验证
* 2. AriaEngine 集成:密码往返(open → 写入 → close → 同密码重开数据完整)
* 3. 错误密码 → ARIA_DECRYPT_ERROR
* 4. 明文旧库 + 加密配置 → ARIA_ENCRYPT_CONFIG_ERROR
* 5. 加密库 + 无密码 → ARIA_ENCRYPT_REQUIRED
* 6. clearAll 后可换新密码重建
* 7. 元数据(迁移版本)加密往返
*/
import { AriaEngine } from '../../src/engine/aria/index';
import { EncryptedBackend } from '../../src/engine/aria/store/encrypted_backend';
import { MemoryBackend } from '../../src/engine/aria/store/backend';
import { createSchema } from '../../src/table/schema';
import { resetOPFSMock } from '../helpers/storage-harness';
import { decode as decodeBytes } from '../helpers/assertions';
beforeEach(() => { resetOPFSMock(); });
let idbCounter = 0;
function uniqueDB(): string {
return `enc-${Date.now()}-${++idbCounter}-${Math.random().toString(36).slice(2, 8)}`;
}
const SCHEMA = () => createSchema('users', {
id: { type: 'string', primaryKey: true },
name: { type: 'string' },
secret: { type: 'string' },
});
// ===================================================================
// EncryptedBackend 单元
// ===================================================================
describe('AriaEngine — EncryptedBackend 单元', () => {
it('open 创建 keymeta,读写往返完整', async () => {
const inner = new MemoryBackend();
const backend = new EncryptedBackend(inner, 's3cret-password');
await backend.open('enc-unit-1');
// keymeta 已创建
const keymeta = await inner.read('__aria_keymeta');
expect(keymeta).not.toBeNull();
// 读写往返
const payload = new TextEncoder().encode('hello encrypted world').buffer;
await backend.write('k1', payload);
const back = await backend.read('k1');
expect(back).not.toBeNull();
expect(decodeBytes(back)).toBe('hello encrypted world');
// 底层是密文(非明文)
const raw = await inner.read('k1');
const rawStr = decodeBytes(raw, 'ciphertext');
expect(rawStr).not.toContain('hello encrypted world');
await backend.close();
});
it('writeMany / deleteMany 往返', async () => {
const inner = new MemoryBackend();
const backend = new EncryptedBackend(inner, 'pw');
await backend.open('enc-unit-2');
const enc = (s: string) => new TextEncoder().encode(s).buffer;
await backend.writeMany({ a: enc('AAA'), b: enc('BBB'), c: enc('CCC') });
expect(decodeBytes(await backend.read('a'))).toBe('AAA');
expect(decodeBytes(await backend.read('c'))).toBe('CCC');
await backend.deleteMany(['a', 'c']);
expect(await backend.exists('a')).toBe(false);
expect(await backend.exists('b')).toBe(true);
await backend.delete('b');
expect(await backend.exists('b')).toBe(false);
expect(await backend.listKeys()).toEqual(['__aria_keymeta']);
await backend.close();
});
it('每个 value 独立随机 IV(相同明文两次加密密文不同)', async () => {
const inner = new MemoryBackend();
const backend = new EncryptedBackend(inner, 'pw');
await backend.open('enc-unit-3');
const payload = new TextEncoder().encode('same plaintext').buffer;
await backend.write('x', payload);
await backend.write('y', payload);
const rawX = await inner.read('x');
const rawY = await inner.read('y');
// Jest toEqual 对 ArrayBuffer 不比较内容 → 字节级比较
expect(new Uint8Array(rawX as ArrayBuffer)).not.toEqual(new Uint8Array(rawY as ArrayBuffer));
// 但 IV 前 12 字节后的长度一致(密文长度 = 明文 + GCM tag 16B
expect((rawX as ArrayBuffer).byteLength).toBe((rawY as ArrayBuffer).byteLength);
// 但解密一致
expect(decodeBytes(await backend.read('x'))).toBe('same plaintext');
expect(decodeBytes(await backend.read('y'))).toBe('same plaintext');
await backend.close();
});
it('clear 清空全部数据但保留密钥元数据(库身份保留)', async () => {
const inner = new MemoryBackend();
const backend = new EncryptedBackend(inner, 'pw');
await backend.open('enc-unit-4');
await backend.write('k', new TextEncoder().encode('v').buffer);
expect((await backend.listKeys()).length).toBe(2);
await backend.clear();
// 数据清空,keymeta 保留
expect(await backend.listKeys()).toEqual(['__aria_keymeta']);
expect(await backend.exists('k')).toBe(false);
await backend.close();
// 重新打开:keymeta 仍在,同密码可用
const backend2 = new EncryptedBackend(inner, 'pw');
await backend2.open('enc-unit-4');
await backend2.write('k2', new TextEncoder().encode('v2').buffer);
expect(decodeBytes(await backend2.read('k2'))).toBe('v2');
// 换密码被拒(keymeta 与旧密码绑定)——注意:MemoryBackend close 清空 store
// 因此在 close 前验证(backend2 仍持有 inner
const backend3 = new EncryptedBackend(inner, 'other-pw');
await expect(backend3.open('enc-unit-4')).rejects.toMatchObject({ code: 'ARIA_DECRYPT_ERROR' });
await backend2.close();
});
it('明文旧库(有数据无 keymeta+ 加密 → ARIA_ENCRYPT_CONFIG_ERROR', async () => {
const inner = new MemoryBackend();
await inner.open('enc-unit-5');
await inner.write('__aria_schemas', new TextEncoder().encode('{}').buffer);
const backend = new EncryptedBackend(inner, 'pw');
await expect(backend.open('enc-unit-5')).rejects.toMatchObject({ code: 'ARIA_ENCRYPT_CONFIG_ERROR' });
});
it('空密码配置直接抛错', () => {
const inner = new MemoryBackend();
try {
new EncryptedBackend(inner, '');
fail('should throw');
} catch (error) {
expect((error as { code?: string }).code).toBe('ARIA_ENCRYPT_CONFIG_ERROR');
}
});
it('getInner / isCryptoReady 访问器', async () => {
const inner = new MemoryBackend();
const backend = new EncryptedBackend(inner, 'pw');
await backend.open('enc-unit-6');
expect(backend.getInner()).toBe(inner);
expect(backend.isCryptoReady()).toBe(true);
await backend.close();
expect(backend.isCryptoReady()).toBe(false);
});
it('密文块被篡改 → 读取抛 ARIA_DECRYPT_ERRORGCM 认证失败)', async () => {
const inner = new MemoryBackend();
const backend = new EncryptedBackend(inner, 'pw');
await backend.open('enc-unit-7');
await backend.write('k', new TextEncoder().encode('integrity-check').buffer);
// 篡改密文一个字节(绕过 IV 区)
const raw = (await inner.read('k')) as ArrayBuffer;
const buf = new Uint8Array(raw);
buf[16] ^= 0xff;
await inner.write('k', buf.buffer as ArrayBuffer);
await expect(backend.read('k')).rejects.toMatchObject({ code: 'ARIA_DECRYPT_ERROR' });
await backend.close();
});
it('过短密文块 → 抛 ARIA_DECRYPT_ERROR', async () => {
const inner = new MemoryBackend();
const backend = new EncryptedBackend(inner, 'pw');
await backend.open('enc-unit-8');
await inner.write('short', new Uint8Array(4).buffer); // 不足 IV(12) + tag
await expect(backend.read('short')).rejects.toMatchObject({ code: 'ARIA_DECRYPT_ERROR' });
await backend.close();
});
});
// ===================================================================
// AriaEngine 集成 — 加密往返
// ===================================================================
describe('AriaEngine — 全库加密(集成)', () => {
it('加密库:写入 → close → 同密码重开数据完整', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({ storageBackend: 'opfs', encryption: { password: 'master-pass' } });
await engine.open(dbName, 1);
await engine.createTable(SCHEMA());
await engine.insert('users', [
{ id: '1', name: 'Alice', secret: 'top-secret-a' },
{ id: '2', name: 'Bob', secret: 'top-secret-b' },
]);
await engine.setMeta('__metona_version', '3');
await engine.close();
// 重开(同密码)
const engine2 = new AriaEngine({ storageBackend: 'opfs', encryption: { password: 'master-pass' } });
await engine2.open(dbName, 1);
const rows = await engine2.find('users', { table: 'users' });
expect(rows).toHaveLength(2);
const byId = Object.fromEntries(rows.map((r) => [r.id, r]));
expect(byId['1'].secret).toBe('top-secret-a');
expect(byId['2'].secret).toBe('top-secret-b');
expect(await engine2.getMeta('__metona_version')).toBe('3');
await engine2.close();
});
it('加密库:错误密码重开 → ARIA_DECRYPT_ERROR', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({ storageBackend: 'opfs', encryption: { password: 'right-pass' } });
await engine.open(dbName, 1);
await engine.createTable(SCHEMA());
await engine.insert('users', [{ id: '1', name: 'A', secret: 'x' }]);
await engine.close();
const engine2 = new AriaEngine({ storageBackend: 'opfs', encryption: { password: 'wrong-pass' } });
await expect(engine2.open(dbName, 1)).rejects.toMatchObject({ code: 'ARIA_DECRYPT_ERROR' });
});
it('加密库:无密码打开 → ARIA_ENCRYPT_REQUIRED', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({ storageBackend: 'opfs', encryption: { password: 'pw' } });
await engine.open(dbName, 1);
await engine.createTable(SCHEMA());
await engine.close();
const engine2 = new AriaEngine({ storageBackend: 'opfs' });
await expect(engine2.open(dbName, 1)).rejects.toMatchObject({ code: 'ARIA_ENCRYPT_REQUIRED' });
});
it('明文库:加密配置打开 → ARIA_ENCRYPT_CONFIG_ERROR', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({ storageBackend: 'opfs' });
await engine.open(dbName, 1);
await engine.createTable(SCHEMA());
await engine.insert('users', [{ id: '1', name: 'A', secret: 'plain' }]);
await engine.close();
const engine2 = new AriaEngine({ storageBackend: 'opfs', encryption: { password: 'pw' } });
await expect(engine2.open(dbName, 1)).rejects.toMatchObject({ code: 'ARIA_ENCRYPT_CONFIG_ERROR' });
});
it('加密库:底层存储全部为密文(SSTable/Schema/WAL 均不可见明文)', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({ storageBackend: 'opfs', encryption: { password: 'pw' } });
await engine.open(dbName, 1);
await engine.createTable(SCHEMA());
await engine.insert('users', [{ id: '1', name: 'Alice', secret: 'needle-in-cipher' }]);
await (engine as any).lsm.flush();
// 强制落盘后 WAL 仍有记录(checkpoint 截断前)→ 全量扫描检查
const backend = (engine as any).backend as EncryptedBackend;
const inner = backend.getInner();
const keys = await inner.listKeys();
expect(keys.length).toBeGreaterThan(0);
// 任何一个底层 key 的内容都不包含明文数据(需解密才可见)
for (const key of keys) {
if (key === '__aria_keymeta') continue;
const raw = await inner.read(key);
const str = new TextDecoder().decode(raw as ArrayBuffer);
expect(str).not.toContain('needle-in-cipher');
expect(str).not.toContain('Alice');
expect(str).not.toContain('users');
}
await engine.close();
});
it('加密 + 压缩组合:往返完整(压缩层先压,加密层后加密)', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({
storageBackend: 'opfs',
compression: true,
encryption: { password: 'pw-compress' },
memtableSizeThreshold: 64 * 1024 * 1024,
});
await engine.open(dbName, 1);
await engine.createTable(SCHEMA());
const rows = [] as Record<string, unknown>[];
for (let i = 0; i < 200; i++) {
rows.push({ id: `u-${i}`, name: `User${i}`, secret: '这是需要加密的敏感内容'.repeat(5) });
}
await engine.insert('users', rows);
await (engine as any).lsm.flush();
await engine.close();
const engine2 = new AriaEngine({
storageBackend: 'opfs',
compression: true,
encryption: { password: 'pw-compress' },
});
await engine2.open(dbName, 1);
expect(await engine2.count('users')).toBe(200);
const one = await engine2.find('users', { table: 'users', where: { id: 'u-42' } });
expect(one[0].secret).toBe('这是需要加密的敏感内容'.repeat(5));
await engine2.close();
});
it('加密库:clearAll 保留密钥(库身份),同密码重开重建,新密码被拒', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({ storageBackend: 'opfs', encryption: { password: 'old-pw' } });
await engine.open(dbName, 1);
await engine.createTable(SCHEMA());
await engine.insert('users', [{ id: '1', name: 'A', secret: 'x' }]);
await engine.clearAll();
await engine.close();
// clearAll 保留 keymeta → 换新密码会被拒绝
const engineWrong = new AriaEngine({ storageBackend: 'opfs', encryption: { password: 'new-pw' } });
await expect(engineWrong.open(dbName, 1)).rejects.toMatchObject({ code: 'ARIA_DECRYPT_ERROR' });
// 同密码重开可重建
const engine2 = new AriaEngine({ storageBackend: 'opfs', encryption: { password: 'old-pw' } });
await engine2.open(dbName, 1);
await engine2.createTable(SCHEMA());
await engine2.insert('users', [{ id: '1', name: 'A', secret: 'new' }]);
expect(await engine2.count('users')).toBe(1);
await engine2.close();
});
it('加密库:WAL 崩溃恢复路径可用(含加密 WAL)', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({
storageBackend: 'opfs',
encryption: { password: 'pw' },
walSyncMode: 'full',
checkpointInterval: 100000,
});
await engine.open(dbName, 1);
await engine.createTable(SCHEMA());
// 写入后不 flush 不 checkpoint,直接"崩溃"close 会 checkpoint,这里模拟崩溃:直接断 backend)
for (let i = 0; i < 10; i++) {
await engine.insert('users', [{ id: `w-${i}`, name: `W${i}`, secret: `s${i}` }]);
}
// 模拟崩溃:不 close,直接断开(真实崩溃时 WAL 留在存储中)
await (engine as any).backend.close();
(engine as any).opened = false;
// 重开:WAL 重放(加密 WAL 解密后解析)
const engine2 = new AriaEngine({
storageBackend: 'opfs',
encryption: { password: 'pw' },
walSyncMode: 'full',
checkpointInterval: 100000,
});
await engine2.open(dbName, 1);
const rows = await engine2.find('users', { table: 'users' });
expect(rows).toHaveLength(10);
await engine2.close();
});
});