feat: initial implementation of content-addressed incremental backup engine
Rust-centric architecture with TypeScript facade following smartproxy/smartstorage pattern. Core engine in Rust (FastCDC chunking, SHA-256, gzip, AES-256-GCM + Argon2id, binary pack files, global index, snapshots, locking, verification, pruning, repair). TypeScript provides npm interface via @push.rocks/smartrust RustBridge IPC with Unix socket streaming for ingest/restore. All 14 integration tests pass.
This commit is contained in:
215
test/test.ts
Normal file
215
test/test.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs';
|
||||
import * as stream from 'node:stream';
|
||||
import { ContainerArchive } from '../ts/index.js';
|
||||
|
||||
const testRepoPath = path.resolve('.nogit/test-repo');
|
||||
const testRepoEncryptedPath = path.resolve('.nogit/test-repo-encrypted');
|
||||
|
||||
// Clean up test directories before tests
|
||||
tap.preTask('cleanup test directories', async () => {
|
||||
for (const p of [testRepoPath, testRepoEncryptedPath]) {
|
||||
if (fs.existsSync(p)) {
|
||||
fs.rmSync(p, { recursive: true });
|
||||
}
|
||||
}
|
||||
fs.mkdirSync('.nogit', { recursive: true });
|
||||
});
|
||||
|
||||
// ==================== Basic Repository Lifecycle ====================
|
||||
|
||||
let repo: ContainerArchive;
|
||||
|
||||
tap.test('should initialize a new repository', async () => {
|
||||
repo = await ContainerArchive.init(testRepoPath);
|
||||
expect(repo).toBeTruthy();
|
||||
|
||||
// Verify directory structure was created
|
||||
expect(fs.existsSync(path.join(testRepoPath, 'config.json'))).toBeTrue();
|
||||
expect(fs.existsSync(path.join(testRepoPath, 'packs', 'data'))).toBeTrue();
|
||||
expect(fs.existsSync(path.join(testRepoPath, 'snapshots'))).toBeTrue();
|
||||
expect(fs.existsSync(path.join(testRepoPath, 'index'))).toBeTrue();
|
||||
});
|
||||
|
||||
// ==================== Ingest ====================
|
||||
|
||||
tap.test('should ingest data and create a snapshot', async () => {
|
||||
// Create a 512KB buffer with deterministic content
|
||||
const testData = Buffer.alloc(512 * 1024);
|
||||
for (let i = 0; i < testData.length; i++) {
|
||||
testData[i] = i % 256;
|
||||
}
|
||||
|
||||
const inputStream = stream.Readable.from(testData);
|
||||
const snapshot = await repo.ingest(inputStream, {
|
||||
tags: { service: 'test', type: 'unit-test' },
|
||||
items: [{ name: 'test-data.bin', type: 'binary' }],
|
||||
});
|
||||
|
||||
expect(snapshot).toBeTruthy();
|
||||
expect(snapshot.id).toBeTruthy();
|
||||
expect(snapshot.originalSize).toEqual(512 * 1024);
|
||||
expect(snapshot.newChunks).toBeGreaterThan(0);
|
||||
expect(snapshot.items.length).toEqual(1);
|
||||
expect(snapshot.items[0].name).toEqual('test-data.bin');
|
||||
});
|
||||
|
||||
// ==================== Dedup ====================
|
||||
|
||||
tap.test('should deduplicate on second ingest of same data', async () => {
|
||||
// Ingest the exact same data again
|
||||
const testData = Buffer.alloc(512 * 1024);
|
||||
for (let i = 0; i < testData.length; i++) {
|
||||
testData[i] = i % 256;
|
||||
}
|
||||
|
||||
const inputStream = stream.Readable.from(testData);
|
||||
const snapshot = await repo.ingest(inputStream, {
|
||||
tags: { service: 'test', type: 'dedup-test' },
|
||||
items: [{ name: 'test-data-dup.bin', type: 'binary' }],
|
||||
});
|
||||
|
||||
expect(snapshot).toBeTruthy();
|
||||
expect(snapshot.newChunks).toEqual(0);
|
||||
expect(snapshot.reusedChunks).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// ==================== List Snapshots ====================
|
||||
|
||||
tap.test('should list snapshots', async () => {
|
||||
const snapshots = await repo.listSnapshots();
|
||||
expect(snapshots.length).toEqual(2);
|
||||
});
|
||||
|
||||
tap.test('should filter snapshots by tag', async () => {
|
||||
const snapshots = await repo.listSnapshots({
|
||||
tags: { type: 'dedup-test' },
|
||||
});
|
||||
expect(snapshots.length).toEqual(1);
|
||||
expect(snapshots[0].tags.type).toEqual('dedup-test');
|
||||
});
|
||||
|
||||
// ==================== Restore ====================
|
||||
|
||||
tap.test('should restore data byte-for-byte', async () => {
|
||||
const snapshots = await repo.listSnapshots();
|
||||
const snapshotId = snapshots[snapshots.length - 1].id; // oldest
|
||||
|
||||
const restoreStream = await repo.restore(snapshotId);
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
restoreStream.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
restoreStream.on('end', resolve);
|
||||
restoreStream.on('error', reject);
|
||||
});
|
||||
|
||||
const restored = Buffer.concat(chunks);
|
||||
|
||||
// Create expected data
|
||||
const expected = Buffer.alloc(512 * 1024);
|
||||
for (let i = 0; i < expected.length; i++) {
|
||||
expected[i] = i % 256;
|
||||
}
|
||||
|
||||
expect(restored.length).toEqual(expected.length);
|
||||
expect(restored.equals(expected)).toBeTrue();
|
||||
});
|
||||
|
||||
// ==================== Verify ====================
|
||||
|
||||
tap.test('should verify repository at quick level', async () => {
|
||||
const result = await repo.verify({ level: 'quick' });
|
||||
expect(result.ok).toBeTrue();
|
||||
expect(result.errors.length).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('should verify repository at standard level', async () => {
|
||||
const result = await repo.verify({ level: 'standard' });
|
||||
expect(result.ok).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('should verify repository at full level', async () => {
|
||||
const result = await repo.verify({ level: 'full' });
|
||||
expect(result.ok).toBeTrue();
|
||||
expect(result.stats.chunksChecked).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// ==================== Prune ====================
|
||||
|
||||
tap.test('should prune with keepLast=1', async () => {
|
||||
const result = await repo.prune({ keepLast: 1 });
|
||||
expect(result.removedSnapshots).toEqual(1);
|
||||
expect(result.dryRun).toBeFalse();
|
||||
|
||||
// Verify only 1 snapshot remains
|
||||
const snapshots = await repo.listSnapshots();
|
||||
expect(snapshots.length).toEqual(1);
|
||||
});
|
||||
|
||||
// ==================== Close ====================
|
||||
|
||||
tap.test('should close repository', async () => {
|
||||
await repo.close();
|
||||
});
|
||||
|
||||
// ==================== Reopen ====================
|
||||
|
||||
tap.test('should reopen repository', async () => {
|
||||
repo = await ContainerArchive.open(testRepoPath);
|
||||
const snapshots = await repo.listSnapshots();
|
||||
expect(snapshots.length).toEqual(1);
|
||||
await repo.close();
|
||||
});
|
||||
|
||||
// ==================== Encrypted Repository ====================
|
||||
|
||||
tap.test('should create and use encrypted repository', async () => {
|
||||
const encRepo = await ContainerArchive.init(testRepoEncryptedPath, {
|
||||
passphrase: 'test-password-123',
|
||||
});
|
||||
|
||||
// Verify key file was created
|
||||
const keysDir = path.join(testRepoEncryptedPath, 'keys');
|
||||
const keyFiles = fs.readdirSync(keysDir).filter((f: string) => f.endsWith('.key'));
|
||||
expect(keyFiles.length).toEqual(1);
|
||||
|
||||
// Ingest data
|
||||
const testData = Buffer.alloc(128 * 1024, 'encrypted-test-data');
|
||||
const inputStream = stream.Readable.from(testData);
|
||||
const snapshot = await encRepo.ingest(inputStream, {
|
||||
tags: { encrypted: 'true' },
|
||||
items: [{ name: 'secret.bin' }],
|
||||
});
|
||||
|
||||
expect(snapshot.newChunks).toBeGreaterThan(0);
|
||||
|
||||
// Restore and verify
|
||||
const restoreStream = await encRepo.restore(snapshot.id);
|
||||
const chunks: Buffer[] = [];
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
restoreStream.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
restoreStream.on('end', resolve);
|
||||
restoreStream.on('error', reject);
|
||||
});
|
||||
|
||||
const restored = Buffer.concat(chunks);
|
||||
expect(restored.length).toEqual(testData.length);
|
||||
expect(restored.equals(testData)).toBeTrue();
|
||||
|
||||
await encRepo.close();
|
||||
});
|
||||
|
||||
tap.test('should open encrypted repository with correct passphrase', async () => {
|
||||
const encRepo = await ContainerArchive.open(testRepoEncryptedPath, {
|
||||
passphrase: 'test-password-123',
|
||||
});
|
||||
|
||||
const snapshots = await encRepo.listSnapshots();
|
||||
expect(snapshots.length).toEqual(1);
|
||||
|
||||
await encRepo.close();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
Reference in New Issue
Block a user