Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
"prepack": "npm run build",
"typecheck": "tsc --noEmit",
"test": "jest",
"test:core": "jest --runInBand --forceExit src/agent/communication/__tests__/agentMessageBus.test.ts src/agent/core/executors/__tests__/strategyExecutor.test.ts src/agent/core/executors/__tests__/hypothesisExecutor.test.ts src/agent/context/__tests__/enhancedSessionContext.test.ts src/tests/adbTools.test.ts src/services/__tests__/sessionLogger.test.ts src/services/__tests__/traceAnalysisSkillConfig.test.ts src/agent/agents/domain/__tests__/registry.test.ts src/agentv3/__tests__/sqlIncludeInjector.test.ts src/middleware/__tests__/auth.test.ts src/services/__tests__/rbac.test.ts src/routes/__tests__/agentRoutesRbac.test.ts src/routes/__tests__/ownerGuardRoutes.test.ts src/routes/__tests__/requestContextRouteCoverage.test.ts src/middleware/__tests__/legacyApiCompatibility.test.ts",
"test:core": "jest --runInBand --forceExit src/agent/communication/__tests__/agentMessageBus.test.ts src/agent/core/executors/__tests__/strategyExecutor.test.ts src/agent/core/executors/__tests__/hypothesisExecutor.test.ts src/agent/context/__tests__/enhancedSessionContext.test.ts src/tests/adbTools.test.ts src/services/__tests__/sessionLogger.test.ts src/services/__tests__/traceAnalysisSkillConfig.test.ts src/agent/agents/domain/__tests__/registry.test.ts src/agentv3/__tests__/sqlIncludeInjector.test.ts src/middleware/__tests__/auth.test.ts src/services/__tests__/rbac.test.ts src/routes/__tests__/agentRoutesRbac.test.ts src/routes/__tests__/ownerGuardRoutes.test.ts src/routes/__tests__/requestContextRouteCoverage.test.ts src/middleware/__tests__/legacyApiCompatibility.test.ts src/services/__tests__/enterpriseDb.test.ts src/services/__tests__/enterpriseRepository.test.ts",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:unit": "jest --testPathPatterns=src/tests",
Expand Down
51 changes: 51 additions & 0 deletions backend/src/services/__tests__/enterpriseDb.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2024-2026 Gracker (Chris)
// This file is part of SmartPerfetto. See LICENSE for details.

import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import {
openEnterpriseDb,
resolveEnterpriseDbPath,
ENTERPRISE_DB_PATH_ENV,
} from '../enterpriseDb';

describe('enterprise SQLite WAL database', () => {
let tmpDir: string | undefined;

afterEach(async () => {
if (tmpDir) {
await fs.rm(tmpDir, { recursive: true, force: true });
tmpDir = undefined;
}
});

test('resolves the configured database path', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'smartperfetto-enterprise-db-'));
const configuredPath = path.join(tmpDir, 'enterprise.sqlite');

expect(resolveEnterpriseDbPath({
[ENTERPRISE_DB_PATH_ENV]: configuredPath,
} as NodeJS.ProcessEnv)).toBe(configuredPath);
});

test('opens SQLite with WAL, foreign keys, busy timeout, and schema migrations', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'smartperfetto-enterprise-db-'));
const dbPath = path.join(tmpDir, 'enterprise.sqlite');
const db = openEnterpriseDb(dbPath);

try {
expect(db.pragma('journal_mode', { simple: true })).toBe('wal');
expect(db.pragma('foreign_keys', { simple: true })).toBe(1);
expect(db.pragma('busy_timeout', { simple: true })).toBe(5000);

const rows = db.prepare<unknown[], { version: number }>(
'SELECT version FROM enterprise_schema_migrations ORDER BY version',
).all();
expect(rows).toEqual([{ version: 1 }, { version: 2 }, { version: 3 }]);
} finally {
db.close();
}
});
});
171 changes: 171 additions & 0 deletions backend/src/services/__tests__/enterpriseRepository.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2024-2026 Gracker (Chris)
// This file is part of SmartPerfetto. See LICENSE for details.

import Database from 'better-sqlite3';
import { type RequestContext } from '../../middleware/auth';
import { applyEnterpriseMinimalSchema } from '../enterpriseSchema';
import {
buildWorkspaceScopedWhere,
createEnterpriseWorkspaceRepository,
repositoryScopeFromRequestContext,
} from '../enterpriseRepository';

interface TraceAssetRow extends Record<string, unknown> {
id: string;
tenant_id: string;
workspace_id: string;
status: string;
}

function seedWorkspace(db: Database.Database, tenantId: string, workspaceId: string): void {
const now = Date.now();
db.prepare(`
INSERT OR IGNORE INTO organizations (id, name, status, plan, created_at, updated_at)
VALUES (?, ?, 'active', 'enterprise', ?, ?)
`).run(tenantId, tenantId, now, now);
db.prepare(`
INSERT INTO workspaces (id, tenant_id, name, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
`).run(workspaceId, tenantId, workspaceId, now, now);
}

function seedTrace(
db: Database.Database,
input: { id: string; tenantId: string; workspaceId: string; status?: string },
): void {
db.prepare(`
INSERT INTO trace_assets
(id, tenant_id, workspace_id, local_path, status, created_at)
VALUES
(?, ?, ?, ?, ?, ?)
`).run(
input.id,
input.tenantId,
input.workspaceId,
`/tmp/${input.id}.pftrace`,
input.status ?? 'ready',
Date.now(),
);
}

describe('enterprise repository scope abstraction', () => {
let db: Database.Database | undefined;

beforeEach(() => {
db = new Database(':memory:');
applyEnterpriseMinimalSchema(db);
seedWorkspace(db, 'tenant-a', 'workspace-a');
seedWorkspace(db, 'tenant-a', 'workspace-b');
seedWorkspace(db, 'tenant-b', 'workspace-c');
seedTrace(db, { id: 'trace-a', tenantId: 'tenant-a', workspaceId: 'workspace-a' });
seedTrace(db, { id: 'trace-b', tenantId: 'tenant-a', workspaceId: 'workspace-b' });
seedTrace(db, { id: 'trace-c', tenantId: 'tenant-b', workspaceId: 'workspace-c' });
});

afterEach(() => {
db?.close();
db = undefined;
});

test('derives repository scope from RequestContext', () => {
const context: RequestContext = {
tenantId: 'tenant-a',
workspaceId: 'workspace-a',
userId: 'user-a',
authType: 'sso',
roles: ['analyst'],
scopes: ['trace:read'],
requestId: 'req-a',
};

expect(repositoryScopeFromRequestContext(context)).toEqual({
tenantId: 'tenant-a',
workspaceId: 'workspace-a',
userId: 'user-a',
});
});

test('always prepends tenant and workspace filters to generated WHERE clauses', () => {
const where = buildWorkspaceScopedWhere(
{ tenantId: 'tenant-a', workspaceId: 'workspace-a' },
{ status: 'ready' },
);

expect(where.sql).toBe(
'tenant_id = @scopeTenantId AND workspace_id = @scopeWorkspaceId AND status = @criteria_status',
);
expect(where.params).toEqual({
scopeTenantId: 'tenant-a',
scopeWorkspaceId: 'workspace-a',
criteria_status: 'ready',
});
});

test('does not allow callers to override tenant or workspace criteria', () => {
expect(() => buildWorkspaceScopedWhere(
{ tenantId: 'tenant-a', workspaceId: 'workspace-a' },
{ tenant_id: 'tenant-b' },
)).toThrow('tenant_id must come from EnterpriseRepositoryScope');

expect(() => buildWorkspaceScopedWhere(
{ tenantId: 'tenant-a', workspaceId: 'workspace-a' },
{ workspace_id: 'workspace-b' },
)).toThrow('workspace_id must come from EnterpriseRepositoryScope');
});

test('lists only rows inside the scope tenant and workspace', () => {
const repo = createEnterpriseWorkspaceRepository<TraceAssetRow>(db!, 'trace_assets');

const rows = repo.list(
{ tenantId: 'tenant-a', workspaceId: 'workspace-a' },
{ status: 'ready' },
{ orderBy: 'id' },
);

expect(rows.map(row => row.id)).toEqual(['trace-a']);
});

test('get, update, and delete stay scoped by tenant and workspace', () => {
const repo = createEnterpriseWorkspaceRepository<TraceAssetRow>(db!, 'trace_assets');
const scopeA = { tenantId: 'tenant-a', workspaceId: 'workspace-a' };
const scopeB = { tenantId: 'tenant-b', workspaceId: 'workspace-c' };

expect(repo.getById(scopeA, 'trace-c')).toBeNull();
expect(repo.updateById(scopeA, 'trace-c', { status: 'deleted' })).toBe(0);
expect(repo.deleteById(scopeA, 'trace-c')).toBe(0);
expect(repo.getById(scopeB, 'trace-c')?.status).toBe('ready');

expect(repo.updateById(scopeA, 'trace-a', { status: 'archived' })).toBe(1);
expect(repo.getById(scopeA, 'trace-a')?.status).toBe('archived');
expect(repo.deleteById(scopeA, 'trace-a')).toBe(1);
expect(repo.getById(scopeA, 'trace-a')).toBeNull();
});

test('rejects non-workspace tables and unsafe dynamic columns', () => {
expect(() => createEnterpriseWorkspaceRepository(db!, 'organizations' as never)).toThrow(
'Table is not workspace-scoped',
);
expect(() => buildWorkspaceScopedWhere(
{ tenantId: 'tenant-a', workspaceId: 'workspace-a' },
{ 'status; DROP TABLE trace_assets': 'ready' },
)).toThrow('Invalid criteria column');

const repo = createEnterpriseWorkspaceRepository<TraceAssetRow>(db!, 'trace_assets');
expect(() => repo.list(
{ tenantId: 'tenant-a', workspaceId: 'workspace-a' },
{},
{ orderBy: 'id; DROP TABLE trace_assets' },
)).toThrow('Invalid orderBy column');
});

test('does not allow scoped updates to mutate identity columns', () => {
const repo = createEnterpriseWorkspaceRepository<TraceAssetRow>(db!, 'trace_assets');

expect(() => repo.updateById(
{ tenantId: 'tenant-a', workspaceId: 'workspace-a' },
'trace-a',
{ workspace_id: 'workspace-b' },
)).toThrow('workspace_id cannot be updated through a scoped repository');
});
});
Loading