This commit is contained in:
530
packages/server/tests/local/files.test.ts
Normal file
530
packages/server/tests/local/files.test.ts
Normal file
@@ -0,0 +1,530 @@
|
||||
/**
|
||||
* File Management API Tests
|
||||
*
|
||||
* Tests the /api/workspaces/:workspaceId/files/* endpoints
|
||||
* for virtual storage mode (filesystem-backed).
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { MindDB, SessionStore, FrameStore } from '@waggle/core';
|
||||
import { buildLocalServer } from '../../src/local/index.js';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { FileEntry } from '../../src/local/storage/types.js';
|
||||
import { injectWithAuth } from '../test-utils.js';
|
||||
|
||||
describe('File Management API', () => {
|
||||
let server: FastifyInstance;
|
||||
let tmpDir: string;
|
||||
let workspaceId: string;
|
||||
let targetWorkspaceId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-files-'));
|
||||
|
||||
// Create personal.mind (required by buildLocalServer)
|
||||
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('files-test');
|
||||
frames.createIFrame(s1.gop_id, 'File management test', 'normal');
|
||||
mind.close();
|
||||
|
||||
server = await buildLocalServer({ dataDir: tmpDir });
|
||||
|
||||
// Create a test workspace
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: '/api/workspaces',
|
||||
payload: { name: 'File Test Workspace', group: 'Test' },
|
||||
});
|
||||
workspaceId = res.json().id;
|
||||
|
||||
const targetRes = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: '/api/workspaces',
|
||||
payload: { name: 'Cross-workspace Target', group: 'Test' },
|
||||
});
|
||||
targetWorkspaceId = targetRes.json().id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.close();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const prefix = () => `/api/workspaces/${workspaceId}/files`;
|
||||
|
||||
// ── List ─────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /list', () => {
|
||||
it('lists root directory with standard dirs', async () => {
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: `${prefix()}/list?path=/`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const entries = res.json();
|
||||
expect(Array.isArray(entries)).toBe(true);
|
||||
|
||||
const names = entries.map((e: FileEntry) => e.name);
|
||||
expect(names).toContain('attachments');
|
||||
expect(names).toContain('exports');
|
||||
expect(names).toContain('notes');
|
||||
});
|
||||
|
||||
it('returns empty array for non-existent subdirectory', async () => {
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: `${prefix()}/list?path=/nonexistent`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects path traversal', async () => {
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: `${prefix()}/list?path=/../../../etc`,
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toContain('Invalid path');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Upload ───────────────────────────────────────────────────
|
||||
|
||||
describe('POST /upload', () => {
|
||||
it('uploads a file via JSON base64', async () => {
|
||||
const content = 'Hello, Waggle!';
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: `${prefix()}/upload`,
|
||||
payload: {
|
||||
path: '/attachments',
|
||||
name: 'hello.txt',
|
||||
data: Buffer.from(content).toString('base64'),
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
const entry = res.json();
|
||||
expect(entry.name).toBe('hello.txt');
|
||||
expect(entry.path).toBe('/attachments/hello.txt');
|
||||
expect(entry.type).toBe('file');
|
||||
expect(entry.size).toBe(content.length);
|
||||
expect(entry.mimeType).toBe('text/plain');
|
||||
});
|
||||
|
||||
it('uploaded file appears in list', async () => {
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: `${prefix()}/list?path=/attachments`,
|
||||
});
|
||||
const entries = res.json();
|
||||
const names = entries.map((e: FileEntry) => e.name);
|
||||
expect(names).toContain('hello.txt');
|
||||
});
|
||||
|
||||
it('accepts empty file upload (0 bytes)', async () => {
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: `${prefix()}/upload`,
|
||||
payload: { path: '/', name: 'empty.bin', data: '' },
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(res.json().size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Download ─────────────────────────────────────────────────
|
||||
|
||||
describe('GET /download', () => {
|
||||
it('downloads an uploaded file', async () => {
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: `${prefix()}/download?path=/attachments/hello.txt`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe('Hello, Waggle!');
|
||||
expect(res.headers['content-type']).toContain('text/plain');
|
||||
});
|
||||
|
||||
it('returns 404 for non-existent file', async () => {
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: `${prefix()}/download?path=/nonexistent.txt`,
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('returns 400 when path is missing', async () => {
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: `${prefix()}/download`,
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Mkdir ────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /mkdir', () => {
|
||||
it('creates a new directory', async () => {
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: `${prefix()}/mkdir`,
|
||||
payload: { path: '/attachments/screenshots' },
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
const entry = res.json();
|
||||
expect(entry.name).toBe('screenshots');
|
||||
expect(entry.path).toBe('/attachments/screenshots');
|
||||
expect(entry.type).toBe('directory');
|
||||
});
|
||||
|
||||
it('created directory appears in list', async () => {
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: `${prefix()}/list?path=/attachments`,
|
||||
});
|
||||
const names = res.json().map((e: FileEntry) => e.name);
|
||||
expect(names).toContain('screenshots');
|
||||
});
|
||||
|
||||
it('returns 400 when path is missing', async () => {
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: `${prefix()}/mkdir`,
|
||||
payload: {},
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Move / Rename ────────────────────────────────────────────
|
||||
|
||||
describe('POST /move', () => {
|
||||
it('moves a file to a different directory', async () => {
|
||||
// First upload a file
|
||||
await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: `${prefix()}/upload`,
|
||||
payload: {
|
||||
path: '/',
|
||||
name: 'moveme.txt',
|
||||
data: Buffer.from('move this').toString('base64'),
|
||||
},
|
||||
});
|
||||
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: `${prefix()}/move`,
|
||||
payload: { from: '/moveme.txt', to: '/exports/moved.txt' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const entry = res.json();
|
||||
expect(entry.name).toBe('moved.txt');
|
||||
expect(entry.path).toBe('/exports/moved.txt');
|
||||
|
||||
// Original should be gone
|
||||
const origList = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: `${prefix()}/list?path=/`,
|
||||
});
|
||||
const rootNames = origList.json().map((e: FileEntry) => e.name);
|
||||
expect(rootNames).not.toContain('moveme.txt');
|
||||
});
|
||||
|
||||
it('renames a file within the same directory', async () => {
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: `${prefix()}/move`,
|
||||
payload: { from: '/exports/moved.txt', to: '/exports/renamed.txt' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().name).toBe('renamed.txt');
|
||||
});
|
||||
|
||||
it('returns 400 for non-existent source', async () => {
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: `${prefix()}/move`,
|
||||
payload: { from: '/ghost.txt', to: '/exports/ghost.txt' },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Copy ─────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /copy', () => {
|
||||
it('copies a file', async () => {
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: `${prefix()}/copy`,
|
||||
payload: { from: '/exports/renamed.txt', to: '/notes/copy.txt' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const entry = res.json();
|
||||
expect(entry.name).toBe('copy.txt');
|
||||
expect(entry.path).toBe('/notes/copy.txt');
|
||||
|
||||
// Original should still exist
|
||||
const origRes = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: `${prefix()}/download?path=/exports/renamed.txt`,
|
||||
});
|
||||
expect(origRes.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('copies a file from another workspace without reading from the target store', async () => {
|
||||
const source = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: `${prefix()}/upload`,
|
||||
payload: {
|
||||
path: '/exports',
|
||||
name: 'cross-workspace.txt',
|
||||
data: Buffer.from('kept in the source workspace').toString('base64'),
|
||||
},
|
||||
});
|
||||
expect(source.statusCode).toBe(201);
|
||||
|
||||
const copied = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: `/api/workspaces/${targetWorkspaceId}/files/copy`,
|
||||
payload: {
|
||||
sourceWorkspaceId: workspaceId,
|
||||
from: '/exports/cross-workspace.txt',
|
||||
to: '/notes/cross-workspace.txt',
|
||||
},
|
||||
});
|
||||
expect(copied.statusCode).toBe(200);
|
||||
expect(copied.json()).toMatchObject({ name: 'cross-workspace.txt', path: '/notes/cross-workspace.txt', type: 'file' });
|
||||
|
||||
const targetDownload = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: `/api/workspaces/${targetWorkspaceId}/files/download?path=/notes/cross-workspace.txt`,
|
||||
});
|
||||
expect(targetDownload.statusCode).toBe(200);
|
||||
expect(targetDownload.body).toBe('kept in the source workspace');
|
||||
|
||||
const sourceDownload = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: `${prefix()}/download?path=/exports/cross-workspace.txt`,
|
||||
});
|
||||
expect(sourceDownload.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('rejects cross-workspace directory copies explicitly', async () => {
|
||||
const response = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: `/api/workspaces/${targetWorkspaceId}/files/copy`,
|
||||
payload: {
|
||||
sourceWorkspaceId: workspaceId,
|
||||
from: '/attachments',
|
||||
to: '/attachments',
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error).toContain('files only');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Delete ───────────────────────────────────────────────────
|
||||
|
||||
describe('POST /delete', () => {
|
||||
it('deletes a file', async () => {
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: `${prefix()}/delete`,
|
||||
payload: { path: '/notes/copy.txt' },
|
||||
});
|
||||
expect(res.statusCode).toBe(204);
|
||||
|
||||
// Confirm it's gone
|
||||
const dl = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: `${prefix()}/download?path=/notes/copy.txt`,
|
||||
});
|
||||
expect(dl.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('deletes a directory recursively', async () => {
|
||||
// Upload a file inside the screenshots dir
|
||||
await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: `${prefix()}/upload`,
|
||||
payload: {
|
||||
path: '/attachments/screenshots',
|
||||
name: 'screen1.png',
|
||||
data: Buffer.from('fakepng').toString('base64'),
|
||||
},
|
||||
});
|
||||
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: `${prefix()}/delete`,
|
||||
payload: { path: '/attachments/screenshots' },
|
||||
});
|
||||
expect(res.statusCode).toBe(204);
|
||||
|
||||
// Confirm directory is gone
|
||||
const list = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: `${prefix()}/list?path=/attachments`,
|
||||
});
|
||||
const names = list.json().map((e: FileEntry) => e.name);
|
||||
expect(names).not.toContain('screenshots');
|
||||
});
|
||||
|
||||
it('returns 400 when path is missing', async () => {
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: `${prefix()}/delete`,
|
||||
payload: {},
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Security ─────────────────────────────────────────────────
|
||||
|
||||
describe('Path traversal prevention', () => {
|
||||
it('rejects .. in upload path', async () => {
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: `${prefix()}/upload`,
|
||||
payload: {
|
||||
path: '/../../../tmp',
|
||||
name: 'evil.txt',
|
||||
data: Buffer.from('pwned').toString('base64'),
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects .. in download path', async () => {
|
||||
// Fastify URL-normalizes the path, so we encode the dots
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: `${prefix()}/download?path=/../../../etc/passwd`,
|
||||
});
|
||||
// Fastify strips .. during URL parsing → becomes /etc/passwd → 404 (not found)
|
||||
// Either 400 (safePath catches it) or 404 (file doesn't exist) is acceptable
|
||||
expect([400, 404]).toContain(res.statusCode);
|
||||
});
|
||||
|
||||
it('rejects .. in delete path', async () => {
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: `${prefix()}/delete`,
|
||||
payload: { path: '/../../../tmp' },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects .. in move source', async () => {
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: `${prefix()}/move`,
|
||||
payload: { from: '/../../../etc/passwd', to: '/stolen.txt' },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects .. in mkdir', async () => {
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: `${prefix()}/mkdir`,
|
||||
payload: { path: '/../../../tmp/evil' },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Storage Provider Unit Tests ────────────────────────────────
|
||||
|
||||
describe('FsStorageProvider', () => {
|
||||
let tmpRoot: string;
|
||||
|
||||
beforeAll(() => {
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-fsprovider-'));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('ensureStructure creates standard directories', async () => {
|
||||
const { FsStorageProvider } = await import('../../src/local/storage/fs-provider.js');
|
||||
const provider = new FsStorageProvider(tmpRoot);
|
||||
provider.ensureStructure();
|
||||
|
||||
expect(fs.existsSync(path.join(tmpRoot, 'attachments'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpRoot, 'exports'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpRoot, 'notes'))).toBe(true);
|
||||
});
|
||||
|
||||
it('write + read roundtrip', async () => {
|
||||
const { FsStorageProvider } = await import('../../src/local/storage/fs-provider.js');
|
||||
const provider = new FsStorageProvider(tmpRoot);
|
||||
|
||||
const entry = await provider.write('/test.txt', Buffer.from('hello'));
|
||||
expect(entry.name).toBe('test.txt');
|
||||
expect(entry.size).toBe(5);
|
||||
|
||||
const data = await provider.read('/test.txt');
|
||||
expect(data.toString()).toBe('hello');
|
||||
});
|
||||
|
||||
it('exists returns correct values', async () => {
|
||||
const { FsStorageProvider } = await import('../../src/local/storage/fs-provider.js');
|
||||
const provider = new FsStorageProvider(tmpRoot);
|
||||
|
||||
expect(await provider.exists('/test.txt')).toBe(true);
|
||||
expect(await provider.exists('/nope.txt')).toBe(false);
|
||||
});
|
||||
|
||||
it('list returns sorted entries (dirs first)', async () => {
|
||||
const { FsStorageProvider } = await import('../../src/local/storage/fs-provider.js');
|
||||
const provider = new FsStorageProvider(tmpRoot);
|
||||
|
||||
const entries = await provider.list('/');
|
||||
expect(entries.length).toBeGreaterThan(0);
|
||||
// First entries should be directories
|
||||
const firstDir = entries.findIndex(e => e.type === 'directory');
|
||||
const firstFile = entries.findIndex(e => e.type === 'file');
|
||||
if (firstDir >= 0 && firstFile >= 0) {
|
||||
expect(firstDir).toBeLessThan(firstFile);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Path Security', () => {
|
||||
it('safePath rejects traversal', async () => {
|
||||
const { safePath } = await import('../../src/local/storage/security.js');
|
||||
expect(() => safePath('/root', '../etc/passwd')).toThrow('Invalid path');
|
||||
expect(() => safePath('/root', '../../etc')).toThrow('Invalid path');
|
||||
expect(() => safePath('/root', './../../etc')).toThrow('Invalid path');
|
||||
});
|
||||
|
||||
it('safePath allows valid paths', async () => {
|
||||
const { safePath } = await import('../../src/local/storage/security.js');
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-safe-'));
|
||||
fs.mkdirSync(path.join(tmpDir, 'subdir'), { recursive: true });
|
||||
|
||||
const result = safePath(tmpDir, 'subdir');
|
||||
expect(result).toBe(path.resolve(tmpDir, 'subdir'));
|
||||
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('toRelativePath converts correctly', async () => {
|
||||
const { toRelativePath } = await import('../../src/local/storage/security.js');
|
||||
const result = toRelativePath('/root/data', '/root/data/attachments/file.pdf');
|
||||
expect(result).toBe('/attachments/file.pdf');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user