工作流 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
354 lines
15 KiB
TypeScript
354 lines
15 KiB
TypeScript
/**
|
||
* 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_ERROR(GCM 认证失败)', 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();
|
||
});
|
||
});
|