moving
This commit is contained in:
@@ -18,7 +18,11 @@ describe('Workspace & Session API', () => {
|
||||
// Write a minimal config.json so WaggleConfig doesn't error
|
||||
fs.writeFileSync(
|
||||
path.join(dataDir, 'config.json'),
|
||||
JSON.stringify({ defaultModel: 'test/model', providers: {} }),
|
||||
JSON.stringify({
|
||||
defaultModel: 'test/model',
|
||||
providers: {},
|
||||
teamServer: { url: 'https://team.example.com' },
|
||||
}),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
@@ -46,6 +50,255 @@ describe('Workspace & Session API', () => {
|
||||
workspaceId = body.id;
|
||||
});
|
||||
|
||||
it('persists linked local storage and routes uploads to it without persisting secrets', async () => {
|
||||
const linkedRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-linked-storage-'));
|
||||
const content = 'linked workspace survives metadata reload';
|
||||
const secretSentinel = 'must-never-reach-workspace-json';
|
||||
const ownerSentinel = path.join(linkedRoot, 'owner-sentinel.txt');
|
||||
fs.writeFileSync(ownerSentinel, 'user-owned data', 'utf-8');
|
||||
let linkedWorkspaceId: string | undefined;
|
||||
|
||||
try {
|
||||
const createRes = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: '/api/workspaces',
|
||||
payload: {
|
||||
name: 'Linked Storage Persistence',
|
||||
group: 'Test',
|
||||
storageType: 'local',
|
||||
storagePath: linkedRoot,
|
||||
storageConfig: { secretKey: secretSentinel },
|
||||
},
|
||||
});
|
||||
expect(createRes.statusCode).toBe(201);
|
||||
const created = JSON.parse(createRes.body);
|
||||
linkedWorkspaceId = created.id;
|
||||
expect(created.storageType).toBe('local');
|
||||
expect(created.storagePath).toBe(fs.realpathSync.native(linkedRoot));
|
||||
expect(created.storageConfig).toBeUndefined();
|
||||
|
||||
const configPath = path.join(
|
||||
dataDir,
|
||||
'workspaces',
|
||||
linkedWorkspaceId,
|
||||
'workspace.json',
|
||||
);
|
||||
const rawConfig = fs.readFileSync(configPath, 'utf-8');
|
||||
const onDisk = JSON.parse(rawConfig);
|
||||
expect(onDisk.storageType).toBe('local');
|
||||
expect(onDisk.storagePath).toBe(fs.realpathSync.native(linkedRoot));
|
||||
expect(rawConfig).not.toContain(secretSentinel);
|
||||
expect(onDisk.storageConfig).toBeUndefined();
|
||||
|
||||
const { WorkspaceManager } = await import('@waggle/core');
|
||||
expect(new WorkspaceManager(dataDir).get(linkedWorkspaceId)).toMatchObject({
|
||||
storageType: 'local',
|
||||
storagePath: fs.realpathSync.native(linkedRoot),
|
||||
});
|
||||
|
||||
const getRes = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: `/api/workspaces/${linkedWorkspaceId}`,
|
||||
});
|
||||
expect(getRes.statusCode).toBe(200);
|
||||
const reloaded = JSON.parse(getRes.body);
|
||||
expect(reloaded.storageType).toBe('local');
|
||||
expect(reloaded.storagePath).toBe(fs.realpathSync.native(linkedRoot));
|
||||
expect(reloaded.storageConfig).toBeUndefined();
|
||||
|
||||
for (const directory of ['attachments', 'exports', 'notes']) {
|
||||
expect(fs.statSync(path.join(linkedRoot, directory)).isDirectory()).toBe(true);
|
||||
}
|
||||
|
||||
const uploadRes = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: `/api/workspaces/${linkedWorkspaceId}/files/upload`,
|
||||
payload: {
|
||||
path: 'notes',
|
||||
name: 'persistence-probe.txt',
|
||||
data: Buffer.from(content, 'utf-8').toString('base64'),
|
||||
},
|
||||
});
|
||||
expect(uploadRes.statusCode).toBe(201);
|
||||
expect(
|
||||
fs.readFileSync(path.join(linkedRoot, 'notes', 'persistence-probe.txt'), 'utf-8'),
|
||||
).toBe(content);
|
||||
expect(
|
||||
fs.existsSync(
|
||||
path.join(
|
||||
dataDir,
|
||||
'workspaces',
|
||||
linkedWorkspaceId,
|
||||
'files',
|
||||
'notes',
|
||||
'persistence-probe.txt',
|
||||
),
|
||||
),
|
||||
).toBe(false);
|
||||
} finally {
|
||||
if (linkedWorkspaceId) {
|
||||
const deleteRes = await injectWithAuth(server, {
|
||||
method: 'DELETE',
|
||||
url: `/api/workspaces/${linkedWorkspaceId}`,
|
||||
});
|
||||
expect(deleteRes.statusCode).toBe(204);
|
||||
expect(fs.existsSync(linkedRoot)).toBe(true);
|
||||
expect(fs.readFileSync(ownerSentinel, 'utf-8')).toBe('user-owned data');
|
||||
}
|
||||
fs.rmSync(linkedRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid local storage bindings before writing metadata', async () => {
|
||||
const regularFile = path.join(dataDir, 'not-a-storage-directory.txt');
|
||||
fs.writeFileSync(regularFile, 'not a directory', 'utf-8');
|
||||
const collisionRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-linked-collision-'));
|
||||
fs.writeFileSync(path.join(collisionRoot, 'notes'), 'blocks the standard directory', 'utf-8');
|
||||
const invalidPayloads = [
|
||||
{
|
||||
name: 'Local Storage Missing Path',
|
||||
group: 'Test',
|
||||
storageType: 'local',
|
||||
},
|
||||
{
|
||||
name: 'Local Storage Regular File',
|
||||
group: 'Test',
|
||||
storageType: 'local',
|
||||
storagePath: regularFile,
|
||||
},
|
||||
{
|
||||
name: 'Local Storage Directory Collision',
|
||||
group: 'Test',
|
||||
storageType: 'local',
|
||||
storagePath: collisionRoot,
|
||||
},
|
||||
];
|
||||
const unexpectedlyCreated: string[] = [];
|
||||
|
||||
try {
|
||||
for (const payload of invalidPayloads) {
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: '/api/workspaces',
|
||||
payload,
|
||||
});
|
||||
const body = JSON.parse(res.body);
|
||||
if (res.statusCode === 201 && typeof body.id === 'string') {
|
||||
unexpectedlyCreated.push(body.id);
|
||||
}
|
||||
expect(res.statusCode).toBe(400);
|
||||
}
|
||||
|
||||
const listRes = await injectWithAuth(server, {
|
||||
method: 'GET',
|
||||
url: '/api/workspaces',
|
||||
});
|
||||
const names = JSON.parse(listRes.body).map((workspace: { name: string }) => workspace.name);
|
||||
for (const payload of invalidPayloads) {
|
||||
expect(names).not.toContain(payload.name);
|
||||
}
|
||||
expect(fs.existsSync(path.join(collisionRoot, 'attachments'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(collisionRoot, 'exports'))).toBe(false);
|
||||
expect(fs.statSync(path.join(collisionRoot, 'notes')).isFile()).toBe(true);
|
||||
} finally {
|
||||
for (const id of unexpectedlyCreated) {
|
||||
await injectWithAuth(server, {
|
||||
method: 'DELETE',
|
||||
url: `/api/workspaces/${id}`,
|
||||
});
|
||||
}
|
||||
fs.rmSync(collisionRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts non-local storage paths without persisting them as host bindings', async () => {
|
||||
const payloads = [
|
||||
{
|
||||
name: 'Storage Path Without Type',
|
||||
group: 'Test',
|
||||
storagePath: dataDir,
|
||||
},
|
||||
{
|
||||
name: 'Virtual Storage With Retained Path',
|
||||
group: 'Test',
|
||||
storageType: 'virtual',
|
||||
storagePath: dataDir,
|
||||
},
|
||||
{
|
||||
name: 'Team Storage With Bucket Prefix',
|
||||
group: 'Test',
|
||||
storageType: 'team',
|
||||
storagePath: 'team-prefix',
|
||||
},
|
||||
];
|
||||
const createdIds: string[] = [];
|
||||
|
||||
try {
|
||||
for (const payload of payloads) {
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: '/api/workspaces',
|
||||
payload,
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
|
||||
const created = JSON.parse(res.body) as {
|
||||
id: string;
|
||||
storageType?: string;
|
||||
storagePath?: string;
|
||||
};
|
||||
createdIds.push(created.id);
|
||||
expect(created.storageType).toBeUndefined();
|
||||
expect(created.storagePath).toBeUndefined();
|
||||
|
||||
const onDisk = JSON.parse(fs.readFileSync(
|
||||
path.join(dataDir, 'workspaces', created.id, 'workspace.json'),
|
||||
'utf-8',
|
||||
)) as Record<string, unknown>;
|
||||
expect(onDisk).not.toHaveProperty('storageType');
|
||||
expect(onDisk).not.toHaveProperty('storagePath');
|
||||
}
|
||||
} finally {
|
||||
for (const id of createdIds) {
|
||||
await injectWithAuth(server, {
|
||||
method: 'DELETE',
|
||||
url: `/api/workspaces/${id}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('rolls back prepared local directories when metadata creation fails', async () => {
|
||||
const linkedRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-linked-rollback-'));
|
||||
const ownerSentinel = path.join(linkedRoot, 'owner-sentinel.txt');
|
||||
fs.writeFileSync(ownerSentinel, 'keep me', 'utf-8');
|
||||
const updateSpy = vi.spyOn(server.workspaceManager, 'update')
|
||||
.mockImplementationOnce(() => { throw new Error('simulated metadata failure'); });
|
||||
|
||||
try {
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: '/api/workspaces',
|
||||
payload: {
|
||||
name: 'Metadata Failure Rollback',
|
||||
group: 'Test',
|
||||
storageType: 'local',
|
||||
storagePath: linkedRoot,
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(500);
|
||||
for (const directory of ['attachments', 'exports', 'notes']) {
|
||||
expect(fs.existsSync(path.join(linkedRoot, directory))).toBe(false);
|
||||
}
|
||||
expect(fs.readFileSync(ownerSentinel, 'utf-8')).toBe('keep me');
|
||||
expect(server.workspaceManager.list().map(workspace => workspace.name))
|
||||
.not.toContain('Metadata Failure Rollback');
|
||||
} finally {
|
||||
updateSpy.mockRestore();
|
||||
fs.rmSync(linkedRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// --- I2: Team Workspace Creation ---
|
||||
|
||||
it('creates a team workspace with team fields', async () => {
|
||||
@@ -480,51 +733,73 @@ describe('Workspace & Session API', () => {
|
||||
|
||||
// --- MODEL-GATE: probe-model (live-probe the resolved default model) ---
|
||||
|
||||
const exactProbeModel = 'openrouter/openai/test-model';
|
||||
function configureExactProbeModel(): () => void {
|
||||
const priorProvider = { ...server.agentState.llmProvider };
|
||||
server.agentState.llmProvider = {
|
||||
provider: 'anthropic-proxy',
|
||||
health: 'degraded',
|
||||
detail: 'Built-in provider proxy (verification pending)',
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
server.vault.set('openrouter', 'openrouter-probe-model-test-key');
|
||||
return () => {
|
||||
server.agentState.llmProvider = priorProvider;
|
||||
server.vault.delete('openrouter');
|
||||
};
|
||||
}
|
||||
|
||||
it('probe-model reports verified when the model endpoint answers 200', async () => {
|
||||
const restoreModel = configureExactProbeModel();
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}', { status: 200 })));
|
||||
try {
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: '/api/settings/probe-model',
|
||||
payload: { model: 'test/model' },
|
||||
payload: { model: exactProbeModel },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body).toMatchObject({ model: 'test/model', configured: true, verified: true });
|
||||
expect(body).toMatchObject({ model: exactProbeModel, configured: true, verified: true });
|
||||
} finally {
|
||||
restoreModel();
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it('probe-model reports rejected on a 401 from the model endpoint', async () => {
|
||||
const restoreModel = configureExactProbeModel();
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('unauthorized', { status: 401 })));
|
||||
try {
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: '/api/settings/probe-model',
|
||||
payload: { model: 'test/model' },
|
||||
payload: { model: exactProbeModel },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body).toMatchObject({ configured: true, verified: false, rejected: true });
|
||||
} finally {
|
||||
restoreModel();
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it('probe-model reports unverified (transient) when the model endpoint times out', async () => {
|
||||
const restoreModel = configureExactProbeModel();
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('aborted')));
|
||||
try {
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: '/api/settings/probe-model',
|
||||
payload: { model: 'test/model' },
|
||||
payload: { model: exactProbeModel },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body).toMatchObject({ configured: true, verified: false });
|
||||
expect(body.rejected).toBeUndefined();
|
||||
} finally {
|
||||
restoreModel();
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
@@ -939,8 +1214,14 @@ describe('Workspace & Session API', () => {
|
||||
const createRes = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: '/api/workspaces',
|
||||
payload: { name: 'Team WS', group: 'Team', teamId: 'team-test-123' },
|
||||
payload: {
|
||||
name: 'Team WS',
|
||||
group: 'Team',
|
||||
teamId: 'team-test-123',
|
||||
teamServerUrl: 'https://team.example.com',
|
||||
},
|
||||
});
|
||||
expect(createRes.statusCode).toBe(201);
|
||||
const teamWsId = JSON.parse(createRes.body).id;
|
||||
|
||||
// Get context
|
||||
|
||||
Reference in New Issue
Block a user