Files
waggle-os/packages/server/tests/data-export.test.ts
Oleg Maslov b20b138fe4 moving
2026-09-02 10:14:22 +02:00

210 lines
8.4 KiB
TypeScript

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { Readable } from 'node:stream';
import { MindDB, FrameStore, SessionStore, WorkspaceManager, WaggleConfig, VaultStore } from '@waggle/core';
import { buildLocalServer } from '../src/local/index.js';
import type { FastifyInstance } from 'fastify';
import { injectWithAuth } from './test-utils.js';
/**
* Minimal ZIP parser — reads the central directory to extract file names.
* ZIP files have a central directory at the end with all entry metadata.
*/
function extractZipFileNames(buffer: Buffer): string[] {
const names: string[] = [];
// Scan for central directory file headers (signature 0x02014b50)
for (let i = 0; i < buffer.length - 46; i++) {
if (
buffer[i] === 0x50 &&
buffer[i + 1] === 0x4b &&
buffer[i + 2] === 0x01 &&
buffer[i + 3] === 0x02
) {
const nameLen = buffer.readUInt16LE(i + 28);
const extraLen = buffer.readUInt16LE(i + 30);
const commentLen = buffer.readUInt16LE(i + 32);
const nameStart = i + 46;
if (nameStart + nameLen <= buffer.length) {
const name = buffer.toString('utf-8', nameStart, nameStart + nameLen);
names.push(name);
}
// Skip past this entry
i += 45 + nameLen + extraLen + commentLen;
}
}
return names;
}
/**
* Extract a specific file's content from a ZIP buffer.
* Reads local file headers (signature 0x04034b50) for uncompressed entries.
*/
function extractZipFileContent(buffer: Buffer, targetName: string): string | null {
for (let i = 0; i < buffer.length - 30; i++) {
if (
buffer[i] === 0x50 &&
buffer[i + 1] === 0x4b &&
buffer[i + 2] === 0x03 &&
buffer[i + 3] === 0x04
) {
const compressionMethod = buffer.readUInt16LE(i + 8);
const compressedSize = buffer.readUInt32LE(i + 18);
const uncompressedSize = buffer.readUInt32LE(i + 22);
const nameLen = buffer.readUInt16LE(i + 26);
const extraLen = buffer.readUInt16LE(i + 28);
const nameStart = i + 30;
if (nameStart + nameLen <= buffer.length) {
const name = buffer.toString('utf-8', nameStart, nameStart + nameLen);
if (name === targetName && compressionMethod === 0) {
// Stored (uncompressed) — read directly
const dataStart = nameStart + nameLen + extraLen;
if (dataStart + uncompressedSize <= buffer.length) {
return buffer.toString('utf-8', dataStart, dataStart + uncompressedSize);
}
}
}
}
}
return null;
}
describe('Data Export (GDPR)', () => {
let server: FastifyInstance;
let tmpDir: string;
beforeAll(async () => {
// Create a temp directory for test data
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-export-test-'));
// Create personal.mind with test data
const personalPath = path.join(tmpDir, 'personal.mind');
const mind = new MindDB(personalPath);
const sessions = new SessionStore(mind);
const frames = new FrameStore(mind);
const s1 = sessions.create('test-export');
frames.createIFrame(s1.gop_id, 'Export test memory frame', 'normal');
frames.createIFrame(s1.gop_id, 'Another memory for export', 'important');
mind.close();
// Create a workspace with a session
const wsManager = new WorkspaceManager(tmpDir);
const ws = wsManager.create({ name: 'Test Export WS', group: 'Testing' });
const sessionsDir = path.join(tmpDir, 'workspaces', ws.id, 'sessions');
fs.mkdirSync(sessionsDir, { recursive: true });
fs.writeFileSync(
path.join(sessionsDir, 'test-session.jsonl'),
[
JSON.stringify({ type: 'meta', title: 'Export Test Session', created: new Date().toISOString() }),
JSON.stringify({ role: 'user', content: 'Hello, test export!', timestamp: new Date().toISOString() }),
JSON.stringify({ role: 'assistant', content: 'This is a test response for export.', timestamp: new Date().toISOString() }),
].join('\n') + '\n',
'utf-8',
);
// Create config.json with a test provider
const config = new WaggleConfig(tmpDir);
config.setDefaultModel('claude-sonnet-4-6');
config.setProvider('anthropic', { apiKey: 'sk-ant-secret-key-12345678', models: ['claude-sonnet-4-6'] });
config.save();
// Create vault with a test entry
const vault = new VaultStore(tmpDir);
vault.set('anthropic', 'sk-ant-secret-key-12345678', { models: ['claude-sonnet-4-6'] });
// Build the local server
server = await buildLocalServer({ dataDir: tmpDir });
}, 60_000);
afterAll(async () => {
await server?.close();
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('POST /api/export returns a ZIP with correct Content-Type', async () => {
const res = await injectWithAuth(server, { method: 'POST', url: '/api/export' });
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toBe('application/zip');
expect(res.headers['content-disposition']).toMatch(/^attachment; filename="waggle-export-\d{4}-\d{2}-\d{2}\.zip"$/);
});
it('ZIP contains expected directories (memories, sessions, workspaces)', async () => {
const res = await injectWithAuth(server, { method: 'POST', url: '/api/export' });
const buffer = Buffer.from(res.rawPayload);
const fileNames = extractZipFileNames(buffer);
// Check that key directories/files are present
const hasMemories = fileNames.some(n => n.startsWith('memories/'));
const hasSessions = fileNames.some(n => n.startsWith('sessions/'));
const hasWorkspaces = fileNames.some(n => n.startsWith('workspaces/'));
const hasSettings = fileNames.some(n => n === 'settings.json');
const hasVaultMeta = fileNames.some(n => n === 'vault-metadata.json');
expect(hasMemories).toBe(true);
expect(hasSessions).toBe(true);
expect(hasWorkspaces).toBe(true);
expect(hasSettings).toBe(true);
expect(hasVaultMeta).toBe(true);
});
it('settings in export have masked API keys', async () => {
const res = await injectWithAuth(server, { method: 'POST', url: '/api/export' });
const buffer = Buffer.from(res.rawPayload);
const fileNames = extractZipFileNames(buffer);
// settings.json must exist
expect(fileNames).toContain('settings.json');
// For compressed entries, we verify by checking the raw buffer
// does NOT contain the original key as plaintext
const rawContent = buffer.toString('utf-8');
expect(rawContent).not.toContain('sk-ant-secret-key-12345678');
});
it('vault metadata excludes secret values', async () => {
const res = await injectWithAuth(server, { method: 'POST', url: '/api/export' });
const buffer = Buffer.from(res.rawPayload);
const fileNames = extractZipFileNames(buffer);
expect(fileNames).toContain('vault-metadata.json');
// Ensure the full ZIP content does NOT contain the decrypted secret value
const rawContent = buffer.toString('utf-8');
expect(rawContent).not.toContain('sk-ant-secret-key-12345678');
});
it('export works with empty data (new installation)', async () => {
// Create a fresh server with empty data
const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-export-empty-'));
const emptyPersonalPath = path.join(emptyDir, 'personal.mind');
const emptyMind = new MindDB(emptyPersonalPath);
emptyMind.close();
const emptyServer = await buildLocalServer({ dataDir: emptyDir });
try {
const res = await injectWithAuth(emptyServer, { method: 'POST', url: '/api/export' });
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toBe('application/zip');
const buffer = Buffer.from(res.rawPayload);
const fileNames = extractZipFileNames(buffer);
// Even with empty data, core files should exist
expect(fileNames).toContain('memories/personal-frames.json');
expect(fileNames).toContain('settings.json');
expect(fileNames).toContain('vault-metadata.json');
} finally {
await emptyServer.close();
fs.rmSync(emptyDir, { recursive: true, force: true });
}
});
it('export ZIP has reasonable size (not empty)', async () => {
const res = await injectWithAuth(server, { method: 'POST', url: '/api/export' });
const buffer = Buffer.from(res.rawPayload);
// ZIP should be at least a few hundred bytes (headers + content)
expect(buffer.length).toBeGreaterThan(100);
});
});