diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a6a759eb..1054c6ebf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,9 +8,13 @@ on: types: [opened, synchronize, reopened] jobs: - test: + # Fast unit tests — no Postgres, no submodule needed for most. + # Splits from `integration` so a unit failure surfaces in <2 min instead of + # waiting behind DB setup. Worker tests run under bun (full suite, not the + # cherry-picked subset that used to live here). + unit: runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 15 steps: - uses: actions/checkout@v4 @@ -22,47 +26,40 @@ jobs: with: bun-version: 1.3.5 - # Pin Node so the Sandbox runtime test below can load isolated-vm — - # it ships abi127 (Node 22) and abi137 (Node 24) prebuilds; runner - # default may be a non-matching major. - - uses: actions/setup-node@v4 - with: - node-version: '22' - - name: Install dependencies run: bun install - - name: Build core package first - run: cd packages/core && bun run build + - name: Build core + sdk + owletto-worker for downstream type-resolution + # owletto-worker integration tests import from its compiled `dist/` + # (subprocess-mode tests spawn the built executor); build it here + # so those run. + run: | + cd packages/core && bun run build && cd ../.. + cd packages/owletto-sdk && bun run build && cd ../.. + cd packages/owletto-worker && bun run build && cd ../.. - - name: Build owletto SDK for backend tests - run: cd packages/owletto-sdk && bun run build + - name: core / gateway / cli (bun:test) + run: bun test packages/core packages/gateway packages/cli --coverage - - name: Run tests with coverage + - name: worker (bun:test, full suite) + # The previous CI cherry-picked 8 of 18 files because pi-coding-agent + # was thought to fail under bun on Linux. Locally the full suite + # passes; if Linux turns out different we'll narrow this back down, + # but we own the WASM concern via run-script-runtime.test.ts under + # Node + isolated-vm in the integration job below. + run: bun test packages/worker + + - name: owletto-backend (bun:test units) run: | - # Run core, gateway, and cli tests fully - bun test packages/core packages/gateway packages/cli --coverage - # Owletto backend sandbox/auth coverage for MCP execute/search changes - bun test packages/owletto-backend/src/__tests__/unit/sandbox + bun test packages/owletto-backend/src/__tests__/unit bun test packages/owletto-backend/src/auth/__tests__/tool-access.test.ts - # Worker tests that don't transitively load pi-coding-agent runtime (WASM unavailable on CI) - bun test packages/worker/src/__tests__/embedded-tools.test.ts packages/worker/src/__tests__/model-resolver.test.ts packages/worker/src/__tests__/tool-policy.test.ts packages/worker/src/__tests__/processor.test.ts packages/worker/src/__tests__/audio-provider-suggestions.test.ts packages/worker/src/__tests__/generated-media.test.ts packages/worker/src/__tests__/tool-implementations.test.ts packages/worker/src/__tests__/instructions.test.ts packages/worker/src/__tests__/custom-tools.test.ts - - # The execute MCP tool runs scripts in isolated-vm — a V8 native addon. - # Bun (JavaScriptCore) cannot link the V8 ABI, so this test must run - # under Node, the production runtime. Invoking vitest via `node` (not - # `bun run`) guarantees the runtime even though the binary's shebang - # already points at node. SKIP_TEST_DB_SETUP=1 keeps this fast — the - # test uses a stub SDK and doesn't need Postgres. - # - # The broader vitest suite (~38 integration files) is not yet wired - # into CI; many are stale after the manage_* → execute/search MCP - # consolidation in #348. Tracked separately. - - name: Sandbox runtime test (Node + isolated-vm) - working-directory: packages/owletto-backend - env: - SKIP_TEST_DB_SETUP: '1' - run: node ../../node_modules/.bin/vitest run src/__tests__/integration/sandbox/run-script-runtime.test.ts + + - name: owletto-cli + owletto-worker (bun:test) + # owletto-openclaw e2e tests need a live backend (docker compose) and + # belong in the smoke-example workflow, not the unit job. + run: | + bun test packages/owletto-cli + bun test packages/owletto-worker - name: Upload coverage if: always() @@ -71,6 +68,101 @@ jobs: files: coverage/lcov.info fail_ci_if_error: false + # Frontend tests run under jsdom via vitest. owletto-web is a submodule; + # forks without the deploy key get a stub package and skip these. + frontend: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - id: submodule + uses: ./.github/actions/setup-submodule + with: + deploy-key: ${{ secrets.OWLETTO_WEB_DEPLOY_KEY }} + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.5 + + - name: Install dependencies + if: steps.submodule.outputs.stubbed != 'true' + run: bun install + + - name: Build owletto-sdk (owletto-web imports its compiled dist) + if: steps.submodule.outputs.stubbed != 'true' + run: cd packages/owletto-sdk && bun run build + + - name: owletto-web tests (vitest) + # owletto-web doesn't define a `test` script in its package.json yet + # (that change ships as a separate submodule PR). Invoke vitest + # directly via the workspace-installed binary so this works on + # whatever submodule SHA the parent repo points at. + if: steps.submodule.outputs.stubbed != 'true' + run: cd packages/owletto-web && ../../node_modules/.bin/vitest run + + # Backend integration tests need a real Postgres + pgvector. We run them + # under Node (not bun) for two reasons: (1) the vitest suite uses Node-only + # APIs in places, and (2) the sandbox runtime requires isolated-vm which + # is a V8 native addon that bun cannot load. + integration: + runs-on: ubuntu-latest + timeout-minutes: 25 + services: + postgres: + image: pgvector/pgvector:pg16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: owletto_test + ports: + - 5433:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + DATABASE_URL: postgres://postgres:postgres@127.0.0.1:5433/owletto_test + steps: + - uses: actions/checkout@v4 + + - uses: ./.github/actions/setup-submodule + with: + deploy-key: ${{ secrets.OWLETTO_WEB_DEPLOY_KEY }} + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.5 + + # Pin Node 22 so isolated-vm's abi127 prebuild loads. Without this the + # sandbox runtime test segfaults on the runner's default Node major. + - uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install dependencies + run: bun install + + - name: Build packages owletto-backend depends on + run: | + cd packages/core && bun run build && cd ../.. + cd packages/owletto-sdk && bun run build && cd ../.. + cd packages/gateway && bun run build && cd ../.. + + - name: Verify Postgres health (fail fast if pgvector setup is broken) + run: | + for i in {1..20}; do + if pg_isready -h 127.0.0.1 -p 5433 -U postgres; then break; fi + sleep 1 + done + PGPASSWORD=postgres psql -h 127.0.0.1 -p 5433 -U postgres -d owletto_test \ + -c "CREATE EXTENSION IF NOT EXISTS vector" + + - name: owletto-backend integration suite (vitest under Node) + working-directory: packages/owletto-backend + run: node ../../node_modules/.bin/vitest run --reporter=default + format-lint: runs-on: ubuntu-latest timeout-minutes: 20 @@ -176,4 +268,3 @@ jobs: echo "::error::$pending migrations still pending after dbmate up" exit 1 fi - diff --git a/config/biome.config.json b/config/biome.config.json index c0d1111e1..7332b6744 100644 --- a/config/biome.config.json +++ b/config/biome.config.json @@ -21,6 +21,7 @@ "!**/node_modules/**", "!**/.astro/**", "!**/tmp/**", + "!**/.connector-child-*.mjs", "!**/provision-careops-watchers*", "!**/*.css", "!**/packages/owletto-backend/**", diff --git a/db/migrations/20260428050000_add_runs_approved_input.sql b/db/migrations/20260428050000_add_runs_approved_input.sql new file mode 100644 index 000000000..cc2d43ef1 --- /dev/null +++ b/db/migrations/20260428050000_add_runs_approved_input.sql @@ -0,0 +1,9 @@ +-- migrate:up + +ALTER TABLE public.runs + ADD COLUMN IF NOT EXISTS approved_input jsonb; + +-- migrate:down + +ALTER TABLE public.runs + DROP COLUMN IF EXISTS approved_input; diff --git a/packages/owletto-backend/src/__tests__/integration/classifiers/classifiers-crud.test.ts b/packages/owletto-backend/src/__tests__/integration/classifiers/classifiers-crud.test.ts new file mode 100644 index 000000000..80497167e --- /dev/null +++ b/packages/owletto-backend/src/__tests__/integration/classifiers/classifiers-crud.test.ts @@ -0,0 +1,94 @@ +/** + * Classifier CRUD via the post-#348 SDK surface. + * + * Replaces the deleted manage_classifiers integration tests. + */ + +import { beforeAll, describe, expect, it } from 'vitest'; +import { + addUserToOrganization, + createTestOrganization, + createTestUser, +} from '../../setup/test-fixtures'; +import { TestApiClient } from '../../setup/test-mcp-client'; +import { cleanupTestDatabase } from '../../setup/test-db'; + +describe('classifier CRUD', () => { + let owner: TestApiClient; + let entityId: number; + let watcherId: number; + + beforeAll(async () => { + await cleanupTestDatabase(); + const org = await createTestOrganization({ name: 'Classifier Test Org' }); + const user = await createTestUser({ email: 'cls-owner@test.com' }); + await addUserToOrganization(user.id, org.id, 'owner'); + owner = await TestApiClient.for({ + organizationId: org.id, + userId: user.id, + memberRole: 'owner', + }); + + await owner.entity_schema.createType({ slug: 'company', name: 'Company' }); + const entity = (await owner.entities.create({ + type: 'company', + name: 'Classifier Target', + })) as { entity: { id: number } }; + entityId = entity.entity.id; + + const w = (await owner.watchers.create({ + entity_id: entityId, + slug: 'cls-watcher', + name: 'Classifier Watcher', + prompt: 'gather signals.', + extraction_schema: { + type: 'object', + properties: { signal: { type: 'string' } }, + }, + })) as { watcher_id: string }; + watcherId = Number(w.watcher_id); + }); + + it('creates → reads back → deletes a classifier', async () => { + // Provide embeddings directly so the test doesn't depend on a live + // EMBEDDINGS_SERVICE_URL — the values themselves are arbitrary. + const stubEmbedding = Array.from({ length: 768 }, () => 0); + const created = (await owner.classifiers.create({ + slug: 'sentiment', + name: 'Sentiment', + attribute_key: 'sentiment', + watcher_id: watcherId, + attribute_values: { + positive: { description: 'positive sentiment', examples: ['great'], embedding: stubEmbedding }, + negative: { description: 'negative sentiment', examples: ['bad'], embedding: stubEmbedding }, + }, + })) as { data?: { classifier_id: number } }; + expect(created.data?.classifier_id).toBeGreaterThan(0); + const classifierId = created.data!.classifier_id; + + // List with no filter — the classifier is attached to a watcher, not an + // entity, so list({entity_id}) wouldn't include it. + const list = (await owner.classifiers.list({})) as { + data?: { classifiers?: Array<{ id: number }> }; + }; + expect(list.data?.classifiers?.some((c) => c.id === classifierId)).toBe(true); + + await owner.classifiers.delete(classifierId); + }); + + it('blocks a member from creating classifiers (admin-only)', async () => { + const member = owner.withAuth({ memberRole: 'member' }); + const stubEmbedding = Array.from({ length: 768 }, () => 0); + await expect( + member.classifiers.create({ + slug: 'blocked-cls', + name: 'Blocked', + attribute_key: 'sentiment', + watcher_id: watcherId, + attribute_values: { + v: { description: 'v', examples: ['v'], embedding: stubEmbedding }, + }, + }) + ).rejects.toThrow(/admin|owner|access/i); + }); +}); diff --git a/packages/owletto-backend/src/__tests__/integration/classifiers/classifiers-isolation.test.ts b/packages/owletto-backend/src/__tests__/integration/classifiers/classifiers-isolation.test.ts new file mode 100644 index 000000000..4aa9ea997 --- /dev/null +++ b/packages/owletto-backend/src/__tests__/integration/classifiers/classifiers-isolation.test.ts @@ -0,0 +1,127 @@ +/** + * Classifier isolation contracts. + * + * These replace broad stale manage_classifiers tests with focused invariants: + * classifiers are scoped to their workspace for list/read/mutate/classify. + */ + +import { beforeAll, describe, expect, it } from 'vitest'; +import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; +import { createTestEvent } from '../../setup/test-fixtures'; +import { TestWorkspace } from '../../setup/test-workspace'; + +const stubEmbedding = Array.from({ length: 768 }, () => 0); + +type SeededClassifier = { + workspace: TestWorkspace; + entityId: number; + watcherId: number; + classifierId: number; + eventId: number; +}; + +async function seedEntityType(workspace: TestWorkspace, slug: string, name: string) { + const sql = getTestDb(); + await sql` + INSERT INTO entity_types (organization_id, slug, name, created_at, updated_at) + VALUES (${workspace.org.id}, ${slug}, ${name}, NOW(), NOW()) + `; +} + +async function seedClassifier(workspace: TestWorkspace, slug: string): Promise { + await seedEntityType(workspace, 'company', 'Company'); + const entity = (await workspace.owner.entities.create({ + type: 'company', + name: `${slug} Target`, + })) as { entity: { id: number } }; + + const watcher = (await workspace.owner.watchers.create({ + entity_id: entity.entity.id, + slug: `${slug}-watcher`, + name: `${slug} Watcher`, + prompt: 'collect signals.', + extraction_schema: { type: 'object', properties: { signal: { type: 'string' } } }, + })) as { watcher_id: string }; + + const created = (await workspace.owner.classifiers.create({ + slug, + name: `${slug} Classifier`, + attribute_key: slug, + watcher_id: Number(watcher.watcher_id), + attribute_values: { + positive: { description: 'positive signal', examples: ['great'], embedding: stubEmbedding }, + negative: { description: 'negative signal', examples: ['bad'], embedding: stubEmbedding }, + }, + })) as { data?: { classifier_id: number } }; + + const event = await createTestEvent({ + entity_id: entity.entity.id, + organization_id: workspace.org.id, + title: `${slug} event`, + content: 'A workspace-local event.', + }); + + return { + workspace, + entityId: entity.entity.id, + watcherId: Number(watcher.watcher_id), + classifierId: created.data!.classifier_id, + eventId: event.id, + }; +} + +describe('classifier org isolation', () => { + let orgA: SeededClassifier; + let orgB: SeededClassifier; + + beforeAll(async () => { + await cleanupTestDatabase(); + const { a, b } = await TestWorkspace.pair(); + orgA = await seedClassifier(a, 'sentiment'); + orgB = await seedClassifier(b, 'sentiment'); + }); + + it('list() only returns classifiers from the caller workspace', async () => { + const listA = (await orgA.workspace.owner.classifiers.list({})) as { + data?: { classifiers?: Array<{ id: number }> }; + }; + const listB = (await orgB.workspace.owner.classifiers.list({})) as { + data?: { classifiers?: Array<{ id: number }> }; + }; + + expect(listA.data?.classifiers?.some((c) => c.id === orgA.classifierId)).toBe(true); + expect(listA.data?.classifiers?.some((c) => c.id === orgB.classifierId)).toBe(false); + expect(listB.data?.classifiers?.some((c) => c.id === orgB.classifierId)).toBe(true); + expect(listB.data?.classifiers?.some((c) => c.id === orgA.classifierId)).toBe(false); + }); + + it('getVersions() does not expose another workspace classifier', async () => { + const versions = (await orgA.workspace.owner.classifiers.getVersions(orgB.classifierId)) as { + data?: { versions?: unknown[] }; + }; + expect(versions.data?.versions ?? []).toHaveLength(0); + }); + + it('delete() cannot archive another workspace classifier', async () => { + const result = (await orgA.workspace.owner.classifiers.delete(orgB.classifierId)) as { + success: boolean; + }; + expect(result.success).toBe(false); + + const listB = (await orgB.workspace.owner.classifiers.list({})) as { + data?: { classifiers?: Array<{ id: number; status: string }> }; + }; + expect(listB.data?.classifiers?.find((c) => c.id === orgB.classifierId)?.status).toBe('active'); + }); + + it('classify() cannot write to another workspace event/classifier pair', async () => { + const result = (await orgA.workspace.owner.classifiers.classify({ + classifier_slug: 'sentiment', + content_id: orgB.eventId, + value: 'positive', + })) as { success: boolean; data?: { failed?: number } }; + + expect(result.success).toBe(false); + expect(result.data?.failed).toBe(1); + }); +}); diff --git a/packages/owletto-backend/src/__tests__/integration/cli/mcp-command.test.ts b/packages/owletto-backend/src/__tests__/integration/cli/mcp-command.test.ts deleted file mode 100644 index a31a46056..000000000 --- a/packages/owletto-backend/src/__tests__/integration/cli/mcp-command.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { resolveMcpEndpoint } from '../../../../packages/cli/src/commands/mcp'; - -describe('CLI MCP endpoint resolution', () => { - it('prefers an explicit scoped mcpUrl from the active context', () => { - expect( - resolveMcpEndpoint({ - mcpUrl: 'https://example.com/mcp/public-owletto', - apiUrl: 'https://example.com', - }) - ).toBe('https://example.com/mcp/public-owletto'); - }); - - it('derives /mcp from apiUrl when no explicit mcpUrl exists', () => { - expect( - resolveMcpEndpoint({ - apiUrl: 'https://example.com', - }) - ).toBe('https://example.com/mcp'); - }); -}); diff --git a/packages/owletto-backend/src/__tests__/integration/connectors/entity-links-contract.test.ts b/packages/owletto-backend/src/__tests__/integration/connectors/entity-links-contract.test.ts new file mode 100644 index 000000000..17f1c1052 --- /dev/null +++ b/packages/owletto-backend/src/__tests__/integration/connectors/entity-links-contract.test.ts @@ -0,0 +1,148 @@ +/** + * Connector entity-link contract. + * + * Uses a minimal WhatsApp-shaped connector definition instead of importing the + * real connector runtime, so the test is stable under raw CI runners while + * preserving the important ingestion behavior: auto-create $member identities + * and honor per-install overrides. + */ + +import { beforeEach, describe, expect, it } from 'vitest'; +import { ensureMemberEntityType } from '../../../utils/member-entity-type'; +import { applyEntityLinks, clearEntityLinkRulesCache } from '../../../utils/entity-link-upsert'; +import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; +import { + addUserToOrganization, + createTestConnectorDefinition, + createTestOrganization, + createTestUser, +} from '../../setup/test-fixtures'; + +const connectorKey = 'whatsapp-contract'; +const feedKey = 'messages'; + +async function seedConnector(options: { disableMemberRule?: boolean } = {}) { + await cleanupTestDatabase(); + clearEntityLinkRulesCache(); + + const org = await createTestOrganization({ name: 'Entity Link Contract Org' }); + const user = await createTestUser(); + await addUserToOrganization(user.id, org.id, 'owner'); + await ensureMemberEntityType(org.id); + + await createTestConnectorDefinition({ + key: connectorKey, + name: 'WhatsApp Contract', + organization_id: org.id, + entity_link_overrides: options.disableMemberRule ? { $member: { disable: true } } : null, + feeds_schema: { + [feedKey]: { + eventKinds: { + message: { + entityLinks: [ + { + entityType: '$member', + autoCreate: true, + titlePath: 'metadata.push_name', + identities: [ + { namespace: 'wa_jid', eventPath: 'metadata.sender_jid' }, + { namespace: 'phone', eventPath: 'metadata.sender_phone' }, + ], + traits: { + push_name: { + eventPath: 'metadata.push_name', + behavior: 'prefer_non_empty', + }, + }, + }, + ], + }, + }, + }, + }, + }); + + clearEntityLinkRulesCache(); + return { org }; +} + +describe('connector entity-link contract', () => { + beforeEach(() => { + clearEntityLinkRulesCache(); + }); + + it('auto-creates a $member and persists declared identities', async () => { + const { org } = await seedConnector(); + const sql = getTestDb(); + + await applyEntityLinks({ + connectorKey, + feedKey, + orgId: org.id, + items: [ + { + origin_type: 'message', + metadata: { + sender_jid: '14155551234@s.whatsapp.net', + sender_phone: '14155551234', + push_name: 'Alex', + }, + }, + ], + }); + + const members = await sql<{ id: number; name: string; metadata: Record }[]>` + SELECT e.id, e.name, e.metadata + FROM entities e + JOIN entity_types et ON et.id = e.entity_type_id + WHERE e.organization_id = ${org.id} + AND et.slug = '$member' + AND e.deleted_at IS NULL + `; + expect(members).toHaveLength(1); + expect(members[0].name).toBe('Alex'); + expect(members[0].metadata.push_name).toBe('Alex'); + + const identities = await sql<{ namespace: string; identifier: string }[]>` + SELECT namespace, identifier + FROM entity_identities + WHERE entity_id = ${members[0].id} + ORDER BY namespace + `; + expect(identities.map((r) => `${r.namespace}:${r.identifier}`)).toEqual([ + 'phone:14155551234', + 'wa_jid:14155551234@s.whatsapp.net', + ]); + }); + + it('honors connector entity-link overrides that disable a rule', async () => { + const { org } = await seedConnector({ disableMemberRule: true }); + const sql = getTestDb(); + + await applyEntityLinks({ + connectorKey, + feedKey, + orgId: org.id, + items: [ + { + origin_type: 'message', + metadata: { + sender_jid: '14155551234@s.whatsapp.net', + sender_phone: '14155551234', + push_name: 'Alex', + }, + }, + ], + }); + + const count = await sql<{ count: string }[]>` + SELECT COUNT(*)::text AS count + FROM entities e + JOIN entity_types et ON et.id = e.entity_type_id + WHERE e.organization_id = ${org.id} + AND et.slug = '$member' + AND e.deleted_at IS NULL + `; + expect(count[0].count).toBe('0'); + }); +}); diff --git a/packages/owletto-backend/src/__tests__/integration/connectors/whatsapp-entity-links.test.ts b/packages/owletto-backend/src/__tests__/integration/connectors/whatsapp-entity-links.test.ts deleted file mode 100644 index ed2b41b11..000000000 --- a/packages/owletto-backend/src/__tests__/integration/connectors/whatsapp-entity-links.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -/** - * WhatsApp connector end-to-end check for the entityLinks rule. - * - * Bypasses Baileys (which needs a real phone) — instead we compile the - * connector file, install it with its real feeds_schema, then feed - * WhatsApp-shaped synthetic EventEnvelopes through the same - * `applyEntityLinks` hook the ingestion pipeline uses. - */ -import { readFile } from 'node:fs/promises'; -import path from 'node:path'; -import { beforeEach, describe, expect, it } from 'vitest'; -import { - compileConnectorSource, - extractConnectorMetadata, -} from '../../../utils/connector-compiler'; -import { applyEntityLinks, clearEntityLinkRulesCache } from '../../../utils/entity-link-upsert'; -import { ensureMemberEntityType } from '../../../utils/member-entity-type'; -import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; -import { - addUserToOrganization, - createTestConnectorDefinition, - createTestOrganization, - createTestUser, -} from '../../setup/test-fixtures'; - -const FEED_KEY = 'messages'; - -async function setup() { - await cleanupTestDatabase(); - const org = await createTestOrganization({ name: 'WhatsApp Links Test Org' }); - const user = await createTestUser(); - await addUserToOrganization(user.id, org.id, 'owner'); - await ensureMemberEntityType(org.id); - - const src = await readFile(path.join(process.cwd(), 'connectors', 'whatsapp.ts'), 'utf-8'); - const { compiledCode } = await compileConnectorSource(src); - const metadata = await extractConnectorMetadata(compiledCode); - - await createTestConnectorDefinition({ - key: metadata.key, - name: metadata.name, - version: metadata.version, - organization_id: org.id, - feeds_schema: metadata.feeds as Record, - }); - clearEntityLinkRulesCache(); - - return { org, metadata }; -} - -describe('whatsapp connector > entityLinks', () => { - beforeEach(async () => { - clearEntityLinkRulesCache(); - }); - - it('creates a $member from an incoming message and accretes new identities on subsequent messages', async () => { - const { org } = await setup(); - const sql = getTestDb(); - - // First message: individual chat, someone else sends a hello. - await applyEntityLinks({ - connectorKey: 'whatsapp', - feedKey: FEED_KEY, - orgId: org.id, - items: [ - { - origin_type: 'message', - metadata: { - chat_jid: '14155551234@s.whatsapp.net', - is_group: false, - from_me: false, - sender_jid: '14155551234@s.whatsapp.net', - sender_phone: '14155551234', - push_name: 'Alex', - }, - }, - ], - }); - - const entitiesAfterFirst = await sql< - { id: number; name: string; metadata: Record }[] - >` - SELECT e.id, e.name, e.metadata FROM entities e - JOIN entity_types et ON et.id = e.entity_type_id - WHERE e.organization_id = ${org.id} AND et.slug = '$member' AND e.deleted_at IS NULL - `; - expect(entitiesAfterFirst).toHaveLength(1); - expect(entitiesAfterFirst[0].name).toBe('Alex'); - expect(entitiesAfterFirst[0].metadata.push_name).toBe('Alex'); - - const memberId = Number(entitiesAfterFirst[0].id); - const identsAfterFirst = await sql<{ namespace: string; identifier: string }[]>` - SELECT namespace, identifier FROM entity_identities - WHERE entity_id = ${memberId} ORDER BY namespace - `; - expect(identsAfterFirst.map((r) => `${r.namespace}:${r.identifier}`)).toEqual([ - 'phone:14155551234', - 'wa_jid:14155551234@s.whatsapp.net', - ]); - - // Second message from the same person via a group — same sender identified - // by wa_jid, should reuse the entity (no new one created). - await applyEntityLinks({ - connectorKey: 'whatsapp', - feedKey: FEED_KEY, - orgId: org.id, - items: [ - { - origin_type: 'message', - metadata: { - chat_jid: '120363000000000000@g.us', - is_group: true, - from_me: false, - participant: '14155551234@s.whatsapp.net', - sender_jid: '14155551234@s.whatsapp.net', - sender_phone: '14155551234', - push_name: 'Alex', - }, - }, - ], - }); - - const countAfterSecond = await sql<{ count: string }[]>` - SELECT COUNT(*)::text AS count FROM entities e - JOIN entity_types et ON et.id = e.entity_type_id - WHERE e.organization_id = ${org.id} AND et.slug = '$member' AND e.deleted_at IS NULL - `; - expect(countAfterSecond[0].count).toBe('1'); - }); - - it('skips entity creation for from_me messages (no sender_jid in metadata)', async () => { - const { org } = await setup(); - const sql = getTestDb(); - - await applyEntityLinks({ - connectorKey: 'whatsapp', - feedKey: FEED_KEY, - orgId: org.id, - items: [ - { - origin_type: 'message', - metadata: { - chat_jid: '14155551234@s.whatsapp.net', - is_group: false, - from_me: true, - // No sender_jid / sender_phone / push_name — the connector - // intentionally omits these for outgoing messages. - }, - }, - ], - }); - - const count = await sql<{ count: string }[]>` - SELECT COUNT(*)::text AS count FROM entities e - JOIN entity_types et ON et.id = e.entity_type_id - WHERE e.organization_id = ${org.id} AND et.slug = '$member' AND e.deleted_at IS NULL - `; - expect(count[0].count).toBe('0'); - }); - - it('respects a per-install override that disables the $member rule', async () => { - const { org } = await setup(); - const sql = getTestDb(); - - await sql` - UPDATE connector_definitions - SET entity_link_overrides = ${sql.json({ $member: { disable: true } })} - WHERE key = 'whatsapp' AND organization_id = ${org.id} - `; - clearEntityLinkRulesCache(); - - await applyEntityLinks({ - connectorKey: 'whatsapp', - feedKey: FEED_KEY, - orgId: org.id, - items: [ - { - origin_type: 'message', - metadata: { - chat_jid: '14155551234@s.whatsapp.net', - from_me: false, - sender_jid: '14155551234@s.whatsapp.net', - sender_phone: '14155551234', - push_name: 'Alex', - }, - }, - ], - }); - - const count = await sql<{ count: string }[]>` - SELECT COUNT(*)::text AS count FROM entities e - JOIN entity_types et ON et.id = e.entity_type_id - WHERE e.organization_id = ${org.id} AND et.slug = '$member' AND e.deleted_at IS NULL - `; - expect(count[0].count).toBe('0'); - }); -}); diff --git a/packages/owletto-backend/src/__tests__/integration/cross-org/isolation.test.ts b/packages/owletto-backend/src/__tests__/integration/cross-org/isolation.test.ts new file mode 100644 index 000000000..91c48ceeb --- /dev/null +++ b/packages/owletto-backend/src/__tests__/integration/cross-org/isolation.test.ts @@ -0,0 +1,82 @@ +/** + * Cross-organization isolation. The single most important security guarantee + * the SDK has to maintain — a workspace owner in org A must not be able to + * read or mutate org B, and vice versa. + * + * Replaces deleted entity-types/cross-org and scoping/organization-access tests. + */ + +import { beforeAll, describe, expect, it } from 'vitest'; +import { + addUserToOrganization, + createTestOrganization, + createTestUser, +} from '../../setup/test-fixtures'; +import { TestApiClient } from '../../setup/test-mcp-client'; +import { cleanupTestDatabase } from '../../setup/test-db'; + +describe('cross-org isolation', () => { + let clientA: TestApiClient; + let clientB: TestApiClient; + let orgIdB: string; + let entityIdA: number; + + beforeAll(async () => { + await cleanupTestDatabase(); + + const orgA = await createTestOrganization({ name: 'Iso Org A' }); + const orgB = await createTestOrganization({ name: 'Iso Org B' }); + orgIdB = orgB.id; + const userA = await createTestUser({ email: 'iso-a@test.com' }); + const userB = await createTestUser({ email: 'iso-b@test.com' }); + await addUserToOrganization(userA.id, orgA.id, 'owner'); + await addUserToOrganization(userB.id, orgB.id, 'owner'); + + clientA = await TestApiClient.for({ + organizationId: orgA.id, + userId: userA.id, + memberRole: 'owner', + }); + clientB = await TestApiClient.for({ + organizationId: orgB.id, + userId: userB.id, + memberRole: 'owner', + }); + + await clientA.entity_schema.createType({ slug: 'company', name: 'Company' }); + await clientB.entity_schema.createType({ slug: 'company', name: 'Company' }); + + const entityA = (await clientA.entities.create({ + type: 'company', + name: 'Org A Only', + })) as { entity: { id: number } }; + entityIdA = entityA.entity.id; + }); + + it('a different-org client cannot read another org\'s entity by id', async () => { + await expect(clientB.entities.get(entityIdA)).rejects.toThrow(/not found/i); + }); + + it('list() in org B does not surface org A entities', async () => { + const list = (await clientB.entities.list({ entity_type: 'company' })) as { + entities?: Array<{ id: number; name: string }>; + }; + const names = list.entities?.map((e) => e.name) ?? []; + expect(names).not.toContain('Org A Only'); + }); + + it('cannot delete an entity that lives in another org', async () => { + await expect(clientB.entities.delete(entityIdA)).rejects.toThrow( + /not found|access|admin/i + ); + }); + + it('an org A user with their own org context cannot fetch an org B entity', async () => { + // Create an entity in B, then try to read it with A's context. + const entityB = (await clientB.entities.create({ + type: 'company', + name: 'Org B Only', + })) as { entity: { id: number } }; + await expect(clientA.entities.get(entityB.entity.id)).rejects.toThrow(/not found/i); + }); +}); diff --git a/packages/owletto-backend/src/__tests__/integration/e2e/mcp-full-flow.test.ts b/packages/owletto-backend/src/__tests__/integration/e2e/mcp-full-flow.test.ts deleted file mode 100644 index 4c93e0c0f..000000000 --- a/packages/owletto-backend/src/__tests__/integration/e2e/mcp-full-flow.test.ts +++ /dev/null @@ -1,512 +0,0 @@ -/** - * MCP Full Flow End-to-End Test - * - * Tests the complete lifecycle through MCP tools: - * Entity type → Entity → Connection → Events → - * Classification → Search (text, vector, metadata) → - * Watcher template → Watcher querying with windows - */ - -import { beforeAll, describe, expect, it } from 'vitest'; -import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; -import { - addUserToOrganization, - createTestAccessToken, - createTestClassifier, - createTestConnection, - createTestConnectorDefinition, - createTestEntity, - createTestEvent, - createTestOAuthClient, - createTestOrganization, - createTestUser, - createTestWatcher, - createTestWatcherTemplate, - createTestWatcherWindow, - seedSystemEntityTypes, -} from '../../setup/test-fixtures'; -import { mcpToolsCall } from '../../setup/test-helpers'; - -describe('MCP Full Flow E2E', () => { - let org: Awaited>; - let user: Awaited>; - let token: string; - let entity: Awaited>; - let connDef: Awaited>; - let conn: Awaited>; - - beforeAll(async () => { - await cleanupTestDatabase(); - await seedSystemEntityTypes(); - - // Ensure system users exist for FK constraints - const sql = getTestDb(); - await sql` - INSERT INTO "user" (id, name, email, username, "emailVerified", "createdAt", "updatedAt") - VALUES ('api', 'API User', 'api@system.internal', 'api-system-user', true, NOW(), NOW()) - ON CONFLICT (id) DO NOTHING - `; - - org = await createTestOrganization({ name: 'E2E Test Org' }); - user = await createTestUser({ email: 'e2e-user@test.com' }); - await addUserToOrganization(user.id, org.id, 'owner'); - - const client = await createTestOAuthClient(); - token = (await createTestAccessToken(user.id, org.id, client.client_id)).token; - - // Create connector + connection + entity for later steps - connDef = await createTestConnectorDefinition({ - key: 'e2e-connector', - name: 'E2E Connector', - organization_id: org.id, - }); - entity = await createTestEntity({ - name: 'E2E Brand', - entity_type: 'brand', - organization_id: org.id, - domain: 'e2e-brand.com', - }); - conn = await createTestConnection({ - organization_id: org.id, - connector_key: connDef.key, - entity_ids: [entity.id], - }); - }); - - // ======================================== - // 1. Entity search (text-only, existing behavior) - // ======================================== - describe('1. Entity search (text)', () => { - it('should find entity by name with fuzzy match', async () => { - const result = await mcpToolsCall('search_knowledge', { query: 'E2E Brand' }, { token }); - expect(result.matches).toBeDefined(); - expect(result.matches.length).toBeGreaterThanOrEqual(1); - expect(result.matches[0].name).toBe('E2E Brand'); - expect(result.entity).toBeDefined(); - }); - - it('should find entity by ID', async () => { - const result = await mcpToolsCall('search_knowledge', { entity_id: entity.id }, { token }); - expect(result.entity).toBeDefined(); - expect(result.entity.id).toBe(entity.id); - }); - - it('should filter by entity_type', async () => { - const result = await mcpToolsCall( - 'search_knowledge', - { query: 'E2E', entity_type: 'brand' }, - { token } - ); - expect(result.matches.length).toBeGreaterThanOrEqual(1); - expect(result.matches.every((m: any) => m.type === 'brand')).toBe(true); - }); - - it('should return empty for non-existent entity', async () => { - const result = await mcpToolsCall( - 'search_knowledge', - { query: 'ZZZZ_nonexistent_entity_ZZZZ' }, - { token } - ); - expect(result.matches.length).toBe(0); - expect(result.discovery_status).toBe('not_found'); - }); - }); - - // ======================================== - // 2. Search with metadata_filter - // ======================================== - describe('2. Search with metadata_filter', () => { - let metadataEntity: Awaited>; - - beforeAll(async () => { - const sql = getTestDb(); - - metadataEntity = await createTestEntity({ - name: 'Dark mode preference', - entity_type: 'brand', - organization_id: org.id, - }); - await sql` - UPDATE entities - SET metadata = ${sql.json({ namespace: 'agent:prefs', importance: '0.8' })} - WHERE id = ${metadataEntity.id} - `; - }); - - it('should filter entities by metadata key-value pairs', async () => { - const result = await mcpToolsCall( - 'search_knowledge', - { - query: 'Dark mode preference', - entity_type: 'brand', - metadata_filter: { namespace: 'agent:prefs' }, - }, - { token } - ); - expect(result.matches.length).toBeGreaterThanOrEqual(1); - const match = result.matches.find((m: any) => m.id === metadataEntity.id); - expect(match).toBeDefined(); - }); - - it('should return empty when metadata filter does not match', async () => { - const result = await mcpToolsCall( - 'search_knowledge', - { - query: 'Dark mode preference', - entity_type: 'brand', - metadata_filter: { namespace: 'nonexistent:namespace' }, - }, - { token } - ); - const match = result.matches?.find((m: any) => m.id === metadataEntity.id); - expect(match).toBeUndefined(); - }); - }); - - // ======================================== - // 3. Search with query_embedding (vector similarity) - // ======================================== - describe('3. Search with query_embedding', () => { - // Generate a deterministic 768-dim vector for testing - const testVector = Array.from({ length: 768 }, (_, i) => Math.sin(i * 0.1)); - let embeddedEntity: Awaited>; - - beforeAll(async () => { - const sql = getTestDb(); - - embeddedEntity = await createTestEntity({ - name: 'Vector Search Entity', - entity_type: 'brand', - organization_id: org.id, - }); - // Set a 768-dimensional embedding (matching the column constraint) - const vectorLiteral = `[${testVector.join(',')}]`; - await sql.unsafe('UPDATE entities SET embedding = $1::vector WHERE id = $2', [ - vectorLiteral, - embeddedEntity.id, - ]); - }); - - it('should find entities by vector similarity (with text query)', async () => { - const result = await mcpToolsCall( - 'search_knowledge', - { - query: 'Vector Search', - query_embedding: testVector, - }, - { token } - ); - expect(result.matches.length).toBeGreaterThanOrEqual(1); - const match = result.matches.find((m: any) => m.id === embeddedEntity.id); - expect(match).toBeDefined(); - expect(match.match_reason).toBe('vector_blend'); - }); - - it('should find entities by vector-only search (no text query)', async () => { - const result = await mcpToolsCall( - 'search_knowledge', - { - query_embedding: testVector, - entity_type: 'brand', - }, - { token } - ); - expect(result.matches.length).toBeGreaterThanOrEqual(1); - const match = result.matches.find((m: any) => m.id === embeddedEntity.id); - expect(match).toBeDefined(); - }); - - it('should respect limit parameter', async () => { - const result = await mcpToolsCall( - 'search_knowledge', - { - query: 'E2E', - limit: 1, - }, - { token } - ); - expect(result.matches.length).toBeLessThanOrEqual(1); - }); - }); - - // ======================================== - // 4. Events / Content - // ======================================== - describe('4. Events and content', () => { - beforeAll(async () => { - const now = new Date(); - for (let i = 0; i < 5; i++) { - await createTestEvent({ - entity_id: entity.id, - connection_id: conn.id, - content: `E2E content item ${i + 1} about product quality`, - title: `Review ${i + 1}`, - occurred_at: new Date(now.getTime() - i * 24 * 60 * 60 * 1000), - semantic_type: 'content', - }); - } - }); - - it('should retrieve content for entity', async () => { - const result = await mcpToolsCall('read_knowledge', { entity_id: entity.id }, { token }); - expect(result.content).toBeDefined(); - expect(result.content.length).toBeGreaterThanOrEqual(1); - }); - - it('should filter content with content_since', async () => { - const result = await mcpToolsCall( - 'read_knowledge', - { entity_id: entity.id, content_since: '7d' }, - { token } - ); - expect(result.content).toBeDefined(); - }); - }); - - // ======================================== - // 5. Classification (fixture-level only — manage_classifiers list has a pre-existing bug) - // ======================================== - describe('5. Classification', () => { - let classifier: Awaited>; - let event1Id: number; - - beforeAll(async () => { - // Create classifier via fixture (MCP create works but list has entity_ids parsing bug) - classifier = await createTestClassifier({ - organization_id: org.id, - slug: 'e2e-sentiment', - name: 'E2E Sentiment', - attribute_key: 'sentiment', - attribute_values: { - positive: { description: 'Positive sentiment' }, - negative: { description: 'Negative sentiment' }, - neutral: { description: 'Neutral sentiment' }, - }, - }); - - // Get an event to classify - const sql = getTestDb(); - const events = await sql` - SELECT id FROM events WHERE ${entity.id} = ANY(entity_ids) LIMIT 1 - `; - event1Id = Number(events[0].id); - }); - - it('should create classifier via fixture', () => { - expect(classifier.id).toBeDefined(); - expect(classifier.slug).toBe('e2e-sentiment'); - }); - - it('should classify an event', async () => { - const result = await mcpToolsCall( - 'manage_classifiers', - { - action: 'classify', - classifier_slug: 'e2e-sentiment', - content_id: event1Id, - value: 'positive', - }, - { token } - ); - expect(result.success).toBeDefined(); - }); - - it('should get classifier versions', async () => { - const result = await mcpToolsCall( - 'manage_classifiers', - { action: 'get_versions', classifier_id: classifier.id }, - { token } - ); - expect(result.data?.versions).toBeDefined(); - expect(result.data.versions.length).toBeGreaterThanOrEqual(1); - }); - }); - - // ======================================== - // 6. Watcher templates and windows - // ======================================== - describe('6. Watcher templates and windows', () => { - let template: Awaited>; - let watcher: Awaited>; - - beforeAll(async () => { - template = await createTestWatcherTemplate({ - slug: 'e2e-analysis', - name: 'E2E Analysis Template', - prompt: 'Analyze recent content for {{entities}}', - output_schema: { - type: 'object', - properties: { - summary: { type: 'string' }, - sentiment_breakdown: { - type: 'object', - properties: { - positive: { type: 'number' }, - negative: { type: 'number' }, - neutral: { type: 'number' }, - }, - }, - }, - }, - }); - - watcher = await createTestWatcher({ - entity_id: entity.id, - template_id: template.id, - organization_id: org.id, - schedule: '0 0 * * 1', - }); - - // Create an watcher window with extracted data - await createTestWatcherWindow({ - watcher_id: watcher.id, - window_start: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), - window_end: new Date(), - granularity: 'weekly', - extracted_data: { - summary: 'Overall positive sentiment with minor quality concerns.', - sentiment_breakdown: { - positive: 0.7, - negative: 0.1, - neutral: 0.2, - }, - }, - content_analyzed: 5, - }); - }); - - it('should query watchers by watcher_id', async () => { - const result = await mcpToolsCall( - 'get_watcher', - { watcher_id: String(watcher.id) }, - { token } - ); - expect(result.windows).toBeDefined(); - expect(result.windows.length).toBeGreaterThanOrEqual(1); - expect(result.watcher).toBeDefined(); - }); - - it('should query watchers by entity_id', async () => { - const result = await mcpToolsCall('list_watchers', { entity_id: entity.id }, { token }); - expect(result.watchers).toBeDefined(); - expect(result.watchers.length).toBeGreaterThanOrEqual(1); - }); - - it('should include extracted data in window', async () => { - const result = await mcpToolsCall( - 'get_watcher', - { watcher_id: String(watcher.id) }, - { token } - ); - const window = result.windows[0]; - expect(window.extracted_data).toBeDefined(); - expect(window.extracted_data.summary).toContain('positive'); - expect(window.content_analyzed).toBe(5); - expect(window.granularity).toBe('weekly'); - }); - - it('should filter watchers with date range', async () => { - const result = await mcpToolsCall( - 'get_watcher', - { watcher_id: String(watcher.id), content_since: '30d' }, - { token } - ); - expect(result.windows).toBeDefined(); - expect(result.metadata).toBeDefined(); - }); - }); - - // ======================================== - // 7. Connections and feeds - // ======================================== - describe('7. Connections and feeds', () => { - it('should list connections for entity via search_knowledge include_connections', async () => { - const result = await mcpToolsCall( - 'search_knowledge', - { entity_id: entity.id, include_connections: true }, - { token } - ); - expect(result.connections).toBeDefined(); - expect(result.connections.length).toBeGreaterThanOrEqual(1); - expect(result.connections[0].connector_key).toBe('e2e-connector'); - }); - - it('should create a feed for connection', async () => { - const result = await mcpToolsCall( - 'manage_feeds', - { - action: 'create_feed', - connection_id: conn.id, - feed_key: 'default', - }, - { token } - ); - expect(result.feed).toBeDefined(); - expect(result.feed.feed_key).toBe('default'); - }); - - it('should list feeds for connection', async () => { - const result = await mcpToolsCall( - 'manage_feeds', - { action: 'list_feeds', connection_id: conn.id }, - { token } - ); - expect(result.feeds).toBeDefined(); - expect(result.feeds.length).toBeGreaterThanOrEqual(1); - }); - }); - - // ======================================== - // 8. Entity management (manage_entity) - // ======================================== - describe('8. Entity management', () => { - it('should get entity details', async () => { - const result = await mcpToolsCall( - 'manage_entity', - { action: 'get', entity_id: entity.id }, - { token } - ); - expect(result.entity).toBeDefined(); - expect(result.entity.name).toBe('E2E Brand'); - }); - - it('should update entity metadata', async () => { - const result = await mcpToolsCall( - 'manage_entity', - { - action: 'update', - entity_id: entity.id, - metadata: { category: 'saas', industry: 'technology' }, - }, - { token } - ); - expect(result.entity).toBeDefined(); - }); - - it('should create a child entity', async () => { - const result = await mcpToolsCall( - 'manage_entity', - { - action: 'create', - name: 'E2E Product US', - entity_type: 'product', - parent_id: entity.id, - market: 'US', - }, - { token } - ); - expect(result.entity).toBeDefined(); - expect(result.entity.name).toBe('E2E Product US'); - }); - - it('should list entities', async () => { - const result = await mcpToolsCall( - 'manage_entity', - { action: 'list', entity_type: 'brand' }, - { token } - ); - expect(result.entities).toBeDefined(); - expect(result.entities.length).toBeGreaterThanOrEqual(1); - }); - }); -}); diff --git a/packages/owletto-backend/src/__tests__/integration/entities/entity-change-events.test.ts b/packages/owletto-backend/src/__tests__/integration/entities/entity-change-events.test.ts deleted file mode 100644 index 589d12184..000000000 --- a/packages/owletto-backend/src/__tests__/integration/entities/entity-change-events.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -/** - * Entity Change Events Test - * - * Verifies that updating entity fields records a 'change' event - * in the events table for audit trail purposes. - */ - -import { beforeAll, describe, expect, it } from 'vitest'; -import { manageEntity } from '../../../tools/admin/manage_entity'; -import type { ToolContext } from '../../../tools/registry'; -import { initWorkspaceProvider } from '../../../workspace'; -import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; -import { - addUserToOrganization, - createTestAccessToken, - createTestEntity, - createTestOAuthClient, - createTestOrganization, - createTestUser, - seedSystemEntityTypes, -} from '../../setup/test-fixtures'; - -describe('Entity Change Events', () => { - let org: Awaited>; - let user: Awaited>; - let entity: Awaited>; - let ctx: ToolContext; - const env = {} as any; - - beforeAll(async () => { - await cleanupTestDatabase(); - await seedSystemEntityTypes(); - await initWorkspaceProvider(); - - org = await createTestOrganization({ name: 'Change Event Test Org' }); - user = await createTestUser({ email: 'change-event-user@test.com' }); - await addUserToOrganization(user.id, org.id, 'owner'); - - const client = await createTestOAuthClient(); - await createTestAccessToken(user.id, org.id, client.client_id); - - entity = await createTestEntity({ - name: 'Test Brand', - entity_type: 'brand', - organization_id: org.id, - domain: 'original.com', - }); - - ctx = { - organizationId: org.id, - userId: user.id, - isAuthenticated: true, - clientId: client.client_id, - } as ToolContext; - }); - - it('should record a change event when metadata field is updated', async () => { - const sql = getTestDb(); - - const before = - await sql`SELECT COUNT(*)::int as count FROM events WHERE ${entity.id} = ANY(entity_ids) AND semantic_type = 'change'`; - const beforeCount = before[0].count; - - await manageEntity({ action: 'update', entity_id: entity.id, domain: 'updated.com' }, env, ctx); - - // Wait for fire-and-forget insert - await new Promise((r) => setTimeout(r, 300)); - - const after = - await sql`SELECT COUNT(*)::int as count FROM events WHERE ${entity.id} = ANY(entity_ids) AND semantic_type = 'change'`; - expect(after[0].count).toBe(beforeCount + 1); - - const events = await sql` - SELECT payload_text, metadata, title, created_by - FROM events - WHERE ${entity.id} = ANY(entity_ids) AND semantic_type = 'change' - ORDER BY created_at DESC - LIMIT 1 - `; - expect(events.length).toBe(1); - - const event = events[0]; - expect(event.created_by).toBe(user.id); - expect(event.title).toContain('domain'); - - const metadata = - typeof event.metadata === 'string' ? JSON.parse(event.metadata) : event.metadata; - expect(metadata.changes).toBeDefined(); - - const domainChange = metadata.changes.find((c: any) => c.field === 'domain'); - expect(domainChange).toBeDefined(); - expect(domainChange.old).toBe('original.com'); - expect(domainChange.new).toBe('updated.com'); - }); - - it('should record a change event when name is updated', async () => { - const sql = getTestDb(); - - await manageEntity({ action: 'update', entity_id: entity.id, name: 'Renamed Brand' }, env, ctx); - await new Promise((r) => setTimeout(r, 300)); - - const events = await sql` - SELECT metadata - FROM events - WHERE ${entity.id} = ANY(entity_ids) AND semantic_type = 'change' - ORDER BY created_at DESC - LIMIT 1 - `; - const metadata = - typeof events[0].metadata === 'string' ? JSON.parse(events[0].metadata) : events[0].metadata; - - const nameChange = metadata.changes.find((c: any) => c.field === 'name'); - expect(nameChange).toBeDefined(); - expect(nameChange.old).toBe('Test Brand'); - expect(nameChange.new).toBe('Renamed Brand'); - }); - - it('should not record a change event when nothing actually changes', async () => { - const sql = getTestDb(); - - const before = - await sql`SELECT COUNT(*)::int as count FROM events WHERE ${entity.id} = ANY(entity_ids) AND semantic_type = 'change'`; - - await manageEntity({ action: 'update', entity_id: entity.id, name: 'Renamed Brand' }, env, ctx); - await new Promise((r) => setTimeout(r, 300)); - - const after = - await sql`SELECT COUNT(*)::int as count FROM events WHERE ${entity.id} = ANY(entity_ids) AND semantic_type = 'change'`; - expect(after[0].count).toBe(before[0].count); - }); - - it('should record multiple field changes in a single event', async () => { - const sql = getTestDb(); - - await manageEntity( - { - action: 'update', - entity_id: entity.id, - name: 'Multi Change Brand', - domain: 'multi.com', - category: 'saas', - }, - env, - ctx - ); - await new Promise((r) => setTimeout(r, 300)); - - const events = await sql` - SELECT metadata, title - FROM events - WHERE ${entity.id} = ANY(entity_ids) AND semantic_type = 'change' - ORDER BY created_at DESC - LIMIT 1 - `; - const metadata = - typeof events[0].metadata === 'string' ? JSON.parse(events[0].metadata) : events[0].metadata; - - expect(metadata.changes.length).toBeGreaterThanOrEqual(2); - - const fields = metadata.changes.map((c: any) => c.field); - expect(fields).toContain('name'); - expect(fields).toContain('domain'); - }); -}); diff --git a/packages/owletto-backend/src/__tests__/integration/entities/entity-crud.test.ts b/packages/owletto-backend/src/__tests__/integration/entities/entity-crud.test.ts new file mode 100644 index 000000000..5a19f0a4c --- /dev/null +++ b/packages/owletto-backend/src/__tests__/integration/entities/entity-crud.test.ts @@ -0,0 +1,116 @@ +/** + * Entity CRUD via the post-#348 SDK surface. + * + * Replaces deleted manage_entity tests. Covers create/read/update/delete, + * member-role enforcement, and tree-deletion guards. Cross-org isolation + * is asserted in cross-org-isolation.test.ts. + */ + +import { beforeAll, describe, expect, it } from 'vitest'; +import { + addUserToOrganization, + createTestOrganization, + createTestUser, +} from '../../setup/test-fixtures'; +import { TestApiClient } from '../../setup/test-mcp-client'; +import { cleanupTestDatabase } from '../../setup/test-db'; + +describe('entity CRUD', () => { + let owner: TestApiClient; + let member: TestApiClient; + + beforeAll(async () => { + await cleanupTestDatabase(); + const org = await createTestOrganization({ name: 'Entity Test Org' }); + const ownerUser = await createTestUser({ email: 'entity-owner@test.com' }); + const memberUser = await createTestUser({ email: 'entity-member@test.com' }); + await addUserToOrganization(ownerUser.id, org.id, 'owner'); + await addUserToOrganization(memberUser.id, org.id, 'member'); + + owner = await TestApiClient.for({ + organizationId: org.id, + userId: ownerUser.id, + memberRole: 'owner', + }); + member = await TestApiClient.for({ + organizationId: org.id, + userId: memberUser.id, + memberRole: 'member', + }); + + await owner.entity_schema.createType({ slug: 'company', name: 'Company' }); + }); + + it('creates an entity, reads it back, and lists it', async () => { + const created = (await owner.entities.create({ + type: 'company', + name: 'Acme Corp', + })) as { entity?: { id: number; name: string } }; + expect(created.entity?.id).toBeGreaterThan(0); + expect(created.entity?.name).toBe('Acme Corp'); + + const got = (await owner.entities.get(created.entity!.id)) as { + entity?: { name: string }; + }; + expect(got.entity?.name).toBe('Acme Corp'); + + const list = (await owner.entities.list({ entity_type: 'company' })) as { + entities?: Array<{ id: number }>; + }; + expect(list.entities?.some((e) => e.id === created.entity!.id)).toBe(true); + }); + + it('updates an entity', async () => { + const created = (await owner.entities.create({ + type: 'company', + name: 'Old Name', + })) as { entity: { id: number } }; + await owner.entities.update({ entity_id: created.entity.id, name: 'New Name' }); + const got = (await owner.entities.get(created.entity.id)) as { + entity: { name: string }; + }; + expect(got.entity.name).toBe('New Name'); + }); + + it('hard-deletes a fresh entity with no event history', async () => { + const created = (await owner.entities.create({ + type: 'company', + name: 'To Delete', + })) as { entity: { id: number } }; + await owner.entities.delete(created.entity.id); + // Hard-deleted: get() throws not-found rather than returning a tombstone. + await expect(owner.entities.get(created.entity.id)).rejects.toThrow(/not found/i); + }); + + describe('access control', () => { + it('lets a member create + list (write scope is enough)', async () => { + const created = (await member.entities.create({ + type: 'company', + name: 'Member-Created', + })) as { entity?: { id: number } }; + expect(created.entity?.id).toBeGreaterThan(0); + + const list = (await member.entities.list({ entity_type: 'company' })) as { + entities?: unknown[]; + }; + expect(Array.isArray(list.entities)).toBe(true); + }); + + it('blocks a member from deleting entities (delete requires owner/admin)', async () => { + const created = (await owner.entities.create({ + type: 'company', + name: 'Owner-Only-Delete', + })) as { entity: { id: number } }; + await expect(member.entities.delete(created.entity.id)).rejects.toThrow( + /admin|owner|access/i + ); + }); + + it('blocks a read-only-scoped member from creating', async () => { + const reader = member.withAuth({ scopes: ['mcp:read'] }); + await expect( + reader.entities.create({ type: 'company', name: 'Read-Only' }) + ).rejects.toThrow(/scope|access/i); + }); + }); +}); diff --git a/packages/owletto-backend/src/__tests__/integration/entities/entity-delete-history.test.ts b/packages/owletto-backend/src/__tests__/integration/entities/entity-delete-history.test.ts deleted file mode 100644 index a4e551fe3..000000000 --- a/packages/owletto-backend/src/__tests__/integration/entities/entity-delete-history.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { beforeAll, describe, expect, it } from 'vitest'; -import { manageEntity } from '../../../tools/admin/manage_entity'; -import type { ToolContext } from '../../../tools/registry'; -import { initWorkspaceProvider } from '../../../workspace'; -import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; -import { - addUserToOrganization, - createTestAccessToken, - createTestEntity, - createTestEvent, - createTestOAuthClient, - createTestOrganization, - createTestUser, - seedSystemEntityTypes, -} from '../../setup/test-fixtures'; - -describe('Entity Deletion History Guards', () => { - let org: Awaited>; - let user: Awaited>; - let ctx: ToolContext; - const env = {} as any; - - beforeAll(async () => { - await cleanupTestDatabase(); - await seedSystemEntityTypes(); - await initWorkspaceProvider(); - - org = await createTestOrganization({ name: 'Entity Delete Guard Org' }); - user = await createTestUser({ email: 'entity-delete-guard@test.com' }); - await addUserToOrganization(user.id, org.id, 'owner'); - - const client = await createTestOAuthClient(); - await createTestAccessToken(user.id, org.id, client.client_id); - - ctx = { - organizationId: org.id, - userId: user.id, - isAuthenticated: true, - clientId: client.client_id, - } as ToolContext; - }); - - it('blocks hard delete when any descendant has event history', async () => { - const root = await createTestEntity({ - name: 'Protected Root', - entity_type: 'brand', - organization_id: org.id, - }); - const child = await createTestEntity({ - name: 'Protected Child', - entity_type: 'brand', - organization_id: org.id, - parent_id: root.id, - }); - const grandchild = await createTestEntity({ - name: 'Protected Grandchild', - entity_type: 'brand', - organization_id: org.id, - parent_id: child.id, - }); - - await createTestEvent({ - entity_id: grandchild.id, - content: 'Historical knowledge that must be preserved', - semantic_type: 'content', - organization_id: org.id, - }); - - await expect( - manageEntity({ action: 'delete', entity_id: root.id, force_delete_tree: true }, env, ctx) - ).rejects.toThrow(/preserve event history/i); - - const sql = getTestDb(); - const remaining = await sql` - SELECT COUNT(*)::int AS count - FROM entities - WHERE id = ANY(${`{${root.id},${child.id},${grandchild.id}}`}::bigint[]) - AND deleted_at IS NULL - `; - expect(remaining[0].count).toBe(3); - }); - - it('hard deletes the full descendant tree when there is no event history', async () => { - const root = await createTestEntity({ - name: 'Disposable Root', - entity_type: 'brand', - organization_id: org.id, - }); - const child = await createTestEntity({ - name: 'Disposable Child', - entity_type: 'brand', - organization_id: org.id, - parent_id: root.id, - }); - const grandchild = await createTestEntity({ - name: 'Disposable Grandchild', - entity_type: 'brand', - organization_id: org.id, - parent_id: child.id, - }); - - const result = await manageEntity( - { action: 'delete', entity_id: root.id, force_delete_tree: true }, - env, - ctx - ); - - expect(result.action).toBe('delete'); - if (result.action !== 'delete') { - throw new Error(`Expected delete result, received ${result.action}`); - } - expect(result.deleted_count).toBe(3); - - const sql = getTestDb(); - const remaining = await sql` - SELECT COUNT(*)::int AS count - FROM entities - WHERE id = ANY(${`{${root.id},${child.id},${grandchild.id}}`}::bigint[]) - `; - expect(remaining[0].count).toBe(0); - }); -}); diff --git a/packages/owletto-backend/src/__tests__/integration/entities/entity-history-contract.test.ts b/packages/owletto-backend/src/__tests__/integration/entities/entity-history-contract.test.ts new file mode 100644 index 000000000..949c34c87 --- /dev/null +++ b/packages/owletto-backend/src/__tests__/integration/entities/entity-history-contract.test.ts @@ -0,0 +1,145 @@ +/** + * Compact entity history contracts kept from the old broad entity suites. + * + * These are high-value because they protect auditability and the no-data-loss + * delete guard: entity updates must emit change events, and deleting a tree + * must not erase descendants that already have content history. + */ + +import { beforeAll, describe, expect, it } from 'vitest'; +import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; +import { createTestEvent } from '../../setup/test-fixtures'; +import { TestWorkspace } from '../../setup/test-workspace'; + +async function waitForChangeEvent(entityId: number) { + const sql = getTestDb(); + for (let attempt = 0; attempt < 20; attempt++) { + const rows = await sql` + SELECT title, metadata, created_by + FROM events + WHERE ${entityId} = ANY(entity_ids) + AND semantic_type = 'change' + ORDER BY created_at DESC, id DESC + LIMIT 1 + `; + if (rows.length > 0) return rows[0]; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error(`Timed out waiting for change event for entity ${entityId}`); +} + +describe('entity history contracts', () => { + let workspace: TestWorkspace; + + beforeAll(async () => { + await cleanupTestDatabase(); + workspace = await TestWorkspace.create({ name: 'Entity History Org' }); + await workspace.owner.entity_schema.createType({ slug: 'brand', name: 'Brand' }); + }); + + it('records a single change event for real metadata updates, not no-op repeats', async () => { + const created = (await workspace.owner.entities.create({ + type: 'brand', + name: 'Audit Brand', + metadata: { domain: 'old.example' }, + })) as { entity: { id: number } }; + + await workspace.owner.entities.update({ + entity_id: created.entity.id, + metadata: { domain: 'new.example' }, + }); + + const event = await waitForChangeEvent(created.entity.id); + expect(event.created_by).toBe(workspace.users.owner.id); + expect(String(event.title)).toContain('domain'); + + const metadata = event.metadata as { changes?: Array<{ field: string; old: unknown; new: unknown }> }; + expect(metadata.changes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ field: 'domain', old: 'old.example', new: 'new.example' }), + ]) + ); + + const before = await getTestDb()` + SELECT COUNT(*)::int AS count + FROM events + WHERE ${created.entity.id} = ANY(entity_ids) + AND semantic_type = 'change' + `; + await workspace.owner.entities.update({ + entity_id: created.entity.id, + metadata: { domain: 'new.example' }, + }); + await new Promise((resolve) => setTimeout(resolve, 150)); + const after = await getTestDb()` + SELECT COUNT(*)::int AS count + FROM events + WHERE ${created.entity.id} = ANY(entity_ids) + AND semantic_type = 'change' + `; + expect(after[0].count).toBe(before[0].count); + }); + + it('blocks force-deleting an entity tree when any descendant has event history', async () => { + const root = (await workspace.owner.entities.create({ type: 'brand', name: 'Protected Root' })) as { + entity: { id: number }; + }; + const child = (await workspace.owner.entities.create({ + type: 'brand', + name: 'Protected Child', + parent_id: root.entity.id, + })) as { entity: { id: number } }; + const grandchild = (await workspace.owner.entities.create({ + type: 'brand', + name: 'Protected Grandchild', + parent_id: child.entity.id, + })) as { entity: { id: number } }; + + await createTestEvent({ + entity_id: grandchild.entity.id, + organization_id: workspace.org.id, + content: 'Historical knowledge that must be preserved', + }); + + await expect( + workspace.owner.entities.delete(root.entity.id, { force_delete_tree: true }) + ).rejects.toThrow(/preserve event history/i); + + const remaining = await getTestDb()` + SELECT COUNT(*)::int AS count + FROM entities + WHERE id = ANY(${`{${root.entity.id},${child.entity.id},${grandchild.entity.id}}`}::bigint[]) + AND deleted_at IS NULL + `; + expect(remaining[0].count).toBe(3); + }); + + it('hard-deletes a descendant tree with no event history', async () => { + const root = (await workspace.owner.entities.create({ type: 'brand', name: 'Disposable Root' })) as { + entity: { id: number }; + }; + const child = (await workspace.owner.entities.create({ + type: 'brand', + name: 'Disposable Child', + parent_id: root.entity.id, + })) as { entity: { id: number } }; + const grandchild = (await workspace.owner.entities.create({ + type: 'brand', + name: 'Disposable Grandchild', + parent_id: child.entity.id, + })) as { entity: { id: number } }; + + const result = (await workspace.owner.entities.delete(root.entity.id, { + force_delete_tree: true, + })) as { action: string; deleted_count?: number }; + expect(result.action).toBe('delete'); + expect(result.deleted_count).toBe(3); + + const remaining = await getTestDb()` + SELECT COUNT(*)::int AS count + FROM entities + WHERE id = ANY(${`{${root.entity.id},${child.entity.id},${grandchild.entity.id}}`}::bigint[]) + `; + expect(remaining[0].count).toBe(0); + }); +}); diff --git a/packages/owletto-backend/src/__tests__/integration/entities/manage-actions.test.ts b/packages/owletto-backend/src/__tests__/integration/entities/manage-actions.test.ts deleted file mode 100644 index 0d577a240..000000000 --- a/packages/owletto-backend/src/__tests__/integration/entities/manage-actions.test.ts +++ /dev/null @@ -1,413 +0,0 @@ -/** - * Manage Actions Integration Tests - * - * Tests for listing available actions, executing with approval mode, - * listing runs, approving, and rejecting. - */ - -import { beforeAll, describe, expect, it } from 'vitest'; -import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; -import { - addUserToOrganization, - createTestAccessToken, - createTestActionRun, - createTestConnection, - createTestConnectorDefinition, - createTestEntity, - createTestOAuthClient, - createTestOrganization, - createTestUser, - seedSystemEntityTypes, -} from '../../setup/test-fixtures'; -import { mcpToolsCall } from '../../setup/test-helpers'; - -describe('Manage Actions', () => { - let org: Awaited>; - let user: Awaited>; - let token: string; - let entity: Awaited>; - let connection: Awaited>; - let inactiveConnection: Awaited>; - - const actionsSchema = { - send_email: { - name: 'Send Email', - description: 'Send an email notification', - input_schema: { - type: 'object', - properties: { - to: { type: 'string' }, - subject: { type: 'string' }, - body: { type: 'string' }, - }, - }, - }, - send_important_email: { - name: 'Send Important Email', - description: 'Send an important email requiring approval', - requiresApproval: true, - input_schema: { - type: 'object', - properties: { - to: { type: 'string' }, - subject: { type: 'string' }, - body: { type: 'string' }, - }, - }, - }, - }; - - beforeAll(async () => { - await cleanupTestDatabase(); - await seedSystemEntityTypes(); - const sql = getTestDb(); - - org = await createTestOrganization({ name: 'Actions Test Org' }); - user = await createTestUser({ email: 'actions-user@test.com' }); - await addUserToOrganization(user.id, org.id, 'owner'); - - const client = await createTestOAuthClient(); - token = (await createTestAccessToken(user.id, org.id, client.client_id)).token; - - entity = await createTestEntity({ name: 'Actions Entity', organization_id: org.id }); - - await createTestConnectorDefinition({ - key: 'test-actions-connector', - name: 'Test Actions Connector', - organization_id: org.id, - }); - - // Add actions_schema to the connector definition - await sql` - UPDATE connector_definitions - SET actions_schema = ${sql.json(actionsSchema)} - WHERE key = 'test-actions-connector' - `; - - // Connector without actions - await createTestConnectorDefinition({ - key: 'test-no-actions-connector', - name: 'No Actions Connector', - organization_id: org.id, - }); - - connection = await createTestConnection({ - organization_id: org.id, - connector_key: 'test-actions-connector', - entity_ids: [entity.id], - status: 'active', - }); - - inactiveConnection = await createTestConnection({ - organization_id: org.id, - connector_key: 'test-actions-connector', - entity_ids: [entity.id], - status: 'paused', - }); - }); - - describe('list_available', () => { - it('should return operations from connectors with actions_schema', async () => { - const result = await mcpToolsCall( - 'manage_operations', - { action: 'list_available' }, - { token } - ); - expect(result.operations).toBeDefined(); - expect(result.operations.length).toBeGreaterThanOrEqual(1); - const emailAction = result.operations.find((a: any) => a.operation_key === 'send_email'); - expect(emailAction).toBeDefined(); - }); - - it('should filter by connector_key', async () => { - const result = await mcpToolsCall( - 'manage_operations', - { action: 'list_available', connector_key: 'test-no-actions-connector' }, - { token } - ); - expect(result.operations).toBeDefined(); - expect(result.operations.length).toBe(0); - }); - }); - - describe('execute inline (E2E subprocess)', () => { - beforeAll(async () => { - const sql = getTestDb(); - // Replace dummy compiled_code with a real connector that has execute() - const compiledCode = ` -export class TestActionConnector { - sync() { return { events: [], checkpoint: null }; } - execute(ctx) { - if (ctx.actionKey === 'send_email') { - return { success: true, output: { sent: true, to: ctx.input.to, subject: ctx.input.subject } }; - } - return { success: false, error: 'Unknown action: ' + ctx.actionKey }; - } -}`; - await sql` - UPDATE connector_versions - SET compiled_code = ${compiledCode} - WHERE connector_key = 'test-actions-connector' - `; - }); - - it('should execute action inline and return completed result', async () => { - const result = await mcpToolsCall( - 'manage_operations', - { - action: 'execute', - connection_id: connection.id, - operation_key: 'send_email', - input: { to: 'e2e@test.com', subject: 'E2E Test' }, - }, - { token } - ); - expect(result.status).toBe('completed'); - expect(result.run_id).toBeDefined(); - expect(result.output).toEqual({ - sent: true, - to: 'e2e@test.com', - subject: 'E2E Test', - }); - }); - - it('should persist completed run in database', async () => { - const result = await mcpToolsCall( - 'manage_operations', - { - action: 'execute', - connection_id: connection.id, - operation_key: 'send_email', - input: { to: 'db@test.com', subject: 'DB Check' }, - }, - { token } - ); - expect(result.status).toBe('completed'); - - // Verify the run was persisted - const runs = await mcpToolsCall( - 'manage_operations', - { action: 'list_runs', connection_id: connection.id, status: 'completed' }, - { token } - ); - const run = runs.runs.find((r: any) => r.id === result.run_id); - expect(run).toBeDefined(); - expect(run.operation_key).toBe('send_email'); - expect(run.status).toBe('completed'); - expect(run.output).toEqual({ - sent: true, - to: 'db@test.com', - subject: 'DB Check', - }); - }); - - it('should fail inline for invalid operation_key', async () => { - const result = await mcpToolsCall( - 'manage_operations', - { - action: 'execute', - connection_id: connection.id, - operation_key: 'nonexistent_action', - input: {}, - }, - { token } - ); - expect(result.error).toMatch(/Invalid operation_key/); - }); - - it('should handle connector execute() returning failure', async () => { - const sql = getTestDb(); - // Add a bogus action to the schema so validation passes, but connector returns failure - const extendedSchema = { - ...actionsSchema, - unsupported_action: { - name: 'Unsupported', - description: 'Action the connector does not handle', - input_schema: { type: 'object', properties: {} }, - }, - }; - await sql` - UPDATE connector_definitions - SET actions_schema = ${sql.json(extendedSchema)} - WHERE key = 'test-actions-connector' - `; - - const result = await mcpToolsCall( - 'manage_operations', - { - action: 'execute', - connection_id: connection.id, - operation_key: 'unsupported_action', - input: {}, - }, - { token } - ); - expect(result.status).toBe('failed'); - expect(result.error_message).toMatch(/Unknown action/); - - // Restore original schema - await sql` - UPDATE connector_definitions - SET actions_schema = ${sql.json(actionsSchema)} - WHERE key = 'test-actions-connector' - `; - }); - }); - - describe('execute', () => { - it('should return pending_approval for operations requiring approval', async () => { - const result = await mcpToolsCall( - 'manage_operations', - { - action: 'execute', - connection_id: connection.id, - operation_key: 'send_important_email', - input: { to: 'test@test.com', subject: 'Hi', body: 'Hello' }, - }, - { token } - ); - expect(result.status).toBe('pending_approval'); - expect(result.run_id).toBeDefined(); - }); - - it('should reject nonexistent connection', async () => { - const result = await mcpToolsCall( - 'manage_operations', - { - action: 'execute', - connection_id: 999999, - operation_key: 'send_email', - input: {}, - }, - { token } - ); - expect(result.error).toBeDefined(); - }); - - it('should reject inactive connection', async () => { - const result = await mcpToolsCall( - 'manage_operations', - { - action: 'execute', - connection_id: inactiveConnection.id, - operation_key: 'send_email', - input: {}, - }, - { token } - ); - expect(result.error).toBeDefined(); - }); - }); - - describe('list_runs', () => { - beforeAll(async () => { - await createTestActionRun({ - connection_id: connection.id, - organization_id: org.id, - action_key: 'send_email', - status: 'pending', - approval_status: 'pending', - }); - }); - - it('should list all runs', async () => { - const result = await mcpToolsCall('manage_operations', { action: 'list_runs' }, { token }); - expect(result.runs).toBeDefined(); - expect(result.runs.length).toBeGreaterThanOrEqual(1); - }); - - it('should filter by connection_id', async () => { - const result = await mcpToolsCall( - 'manage_operations', - { action: 'list_runs', connection_id: connection.id }, - { token } - ); - expect(result.runs).toBeDefined(); - for (const run of result.runs) { - expect(Number(run.connection_id)).toBe(connection.id); - } - }); - - it('should filter by status', async () => { - const result = await mcpToolsCall( - 'manage_operations', - { action: 'list_runs', status: 'pending' }, - { token } - ); - expect(result.runs).toBeDefined(); - for (const run of result.runs) { - expect(run.status).toBe('pending'); - } - }); - }); - - describe('approve & reject', () => { - it('should approve a pending run', async () => { - const run = await createTestActionRun({ - connection_id: connection.id, - organization_id: org.id, - action_key: 'send_email', - status: 'pending', - approval_status: 'pending', - }); - - const result = await mcpToolsCall( - 'manage_operations', - { action: 'approve', run_id: run.id }, - { token } - ); - expect(result.approved).toBe(true); - }); - - it('should reject approving a non-pending run', async () => { - const run = await createTestActionRun({ - connection_id: connection.id, - organization_id: org.id, - action_key: 'send_email', - status: 'completed', - approval_status: 'approved', - }); - - const result = await mcpToolsCall( - 'manage_operations', - { action: 'approve', run_id: run.id }, - { token } - ); - expect(result.error).toBeDefined(); - }); - - it('should reject a pending run with reason', async () => { - const run = await createTestActionRun({ - connection_id: connection.id, - organization_id: org.id, - action_key: 'send_email', - status: 'pending', - approval_status: 'pending', - }); - - const result = await mcpToolsCall( - 'manage_operations', - { action: 'reject', run_id: run.id, reason: 'Not needed' }, - { token } - ); - expect(result.rejected).toBe(true); - }); - - it('should reject rejecting a non-pending run', async () => { - const run = await createTestActionRun({ - connection_id: connection.id, - organization_id: org.id, - action_key: 'send_email', - status: 'completed', - approval_status: 'approved', - }); - - const result = await mcpToolsCall( - 'manage_operations', - { action: 'reject', run_id: run.id }, - { token } - ); - expect(result.error).toBeDefined(); - }); - }); -}); diff --git a/packages/owletto-backend/src/__tests__/integration/entities/manage-classifiers.test.ts b/packages/owletto-backend/src/__tests__/integration/entities/manage-classifiers.test.ts deleted file mode 100644 index 383161e26..000000000 --- a/packages/owletto-backend/src/__tests__/integration/entities/manage-classifiers.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -/** - * Manage Classifiers Integration Tests - * - * Tests for classifier CRUD, versioning, manual classification, - * batch classification, and entity-scoped classifiers. - */ - -import { beforeAll, describe, expect, it } from 'vitest'; -import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; -import { - addUserToOrganization, - createTestAccessToken, - createTestClassifier, - createTestConnection, - createTestConnectorDefinition, - createTestEntity, - createTestEvent, - createTestOAuthClient, - createTestOrganization, - createTestUser, - seedSystemEntityTypes, -} from '../../setup/test-fixtures'; -import { mcpToolsCall } from '../../setup/test-helpers'; - -describe('Manage Classifiers', () => { - let org: Awaited>; - let user: Awaited>; - let token: string; - let entity: Awaited>; - let event1: Awaited>; - - beforeAll(async () => { - await cleanupTestDatabase(); - await seedSystemEntityTypes(); - - // Ensure 'api' user exists for classifier created_by FK - const sql = getTestDb(); - await sql` - INSERT INTO "user" (id, name, email, username, "emailVerified", "createdAt", "updatedAt") - VALUES ('api', 'API User', 'api@system.internal', 'api-system-user', true, NOW(), NOW()) - ON CONFLICT (id) DO NOTHING - `; - - org = await createTestOrganization({ name: 'Classifiers Test Org' }); - user = await createTestUser({ email: 'classifiers-user@test.com' }); - await addUserToOrganization(user.id, org.id, 'owner'); - - const client = await createTestOAuthClient(); - token = (await createTestAccessToken(user.id, org.id, client.client_id)).token; - - entity = await createTestEntity({ name: 'Classifiers Entity', organization_id: org.id }); - - await createTestConnectorDefinition({ - key: 'test-clf-connector', - name: 'CLF Connector', - organization_id: org.id, - }); - const conn = await createTestConnection({ - organization_id: org.id, - connector_key: 'test-clf-connector', - entity_ids: [entity.id], - }); - - event1 = await createTestEvent({ - entity_id: entity.id, - connection_id: conn.id, - content: 'Great product, works perfectly!', - title: 'Positive review', - }); - await createTestEvent({ - entity_id: entity.id, - connection_id: conn.id, - content: 'Terrible experience, very buggy.', - title: 'Negative review', - }); - }); - - describe('create', () => { - it('should create a classifier with all fields', async () => { - // Use direct fixture to avoid MCP FK constraint issues with 'api' created_by - const clf = await createTestClassifier({ - organization_id: org.id, - slug: 'sentiment', - name: 'Sentiment', - attribute_key: 'sentiment', - attribute_values: { - positive: { description: 'Positive sentiment' }, - negative: { description: 'Negative sentiment' }, - neutral: { description: 'Neutral sentiment' }, - }, - }); - expect(clf.id).toBeDefined(); - expect(clf.slug).toBe('sentiment'); - }); - - it('should create entity-scoped classifier', async () => { - const clf = await createTestClassifier({ - organization_id: org.id, - slug: 'entity-topic', - name: 'Entity Topic', - attribute_key: 'entity_topic', - entity_id: entity.id, - attribute_values: { - ux: { description: 'UX related' }, - performance: { description: 'Performance related' }, - }, - }); - expect(clf.id).toBeDefined(); - expect(clf.slug).toBe('entity-topic'); - }); - }); - - describe('list', () => { - it('should list all classifiers', async () => { - const result = await mcpToolsCall('manage_classifiers', { action: 'list' }, { token }); - expect(result.data?.classifiers).toBeDefined(); - expect(result.data.classifiers.length).toBeGreaterThanOrEqual(2); - }); - - it('should filter by entity_id', async () => { - const result = await mcpToolsCall( - 'manage_classifiers', - { action: 'list', entity_id: entity.id }, - { token } - ); - expect(result.data?.classifiers).toBeDefined(); - expect(result.data.classifiers.length).toBeGreaterThanOrEqual(1); - }); - }); - - describe('get_versions', () => { - it('should return version history', async () => { - const list = await mcpToolsCall('manage_classifiers', { action: 'list' }, { token }); - const sentiment = list.data.classifiers.find((c: any) => c.slug === 'sentiment'); - - const result = await mcpToolsCall( - 'manage_classifiers', - { action: 'get_versions', classifier_id: sentiment.id }, - { token } - ); - expect(result.data?.versions).toBeDefined(); - expect(result.data.versions.length).toBeGreaterThanOrEqual(1); - }); - }); - - describe('classify (manual)', () => { - it('should classify a single content item', async () => { - const result = await mcpToolsCall( - 'manage_classifiers', - { - action: 'classify', - classifier_slug: 'sentiment', - content_id: event1.id, - value: 'positive', - }, - { token } - ); - expect(result.data?.updated).toBe(1); - }); - - it('should return failure for nonexistent content', async () => { - const result = await mcpToolsCall( - 'manage_classifiers', - { - action: 'classify', - classifier_slug: 'sentiment', - content_id: 999999, - value: 'positive', - }, - { token } - ); - expect(result.success).toBe(false); - }); - }); - - describe('delete', () => { - it('should soft-delete (archive) a classifier', async () => { - const clf = await createTestClassifier({ - organization_id: org.id, - slug: 'to-delete', - }); - - const result = await mcpToolsCall( - 'manage_classifiers', - { action: 'delete', classifier_id: clf.id }, - { token } - ); - expect(result.success).toBe(true); - expect(result.action).toBe('delete'); - }); - }); -}); diff --git a/packages/owletto-backend/src/__tests__/integration/entities/manage-connections-auth-profiles.test.ts b/packages/owletto-backend/src/__tests__/integration/entities/manage-connections-auth-profiles.test.ts deleted file mode 100644 index 21ed6a14e..000000000 --- a/packages/owletto-backend/src/__tests__/integration/entities/manage-connections-auth-profiles.test.ts +++ /dev/null @@ -1,612 +0,0 @@ -import { beforeAll, describe, expect, it } from 'vitest'; -import { cleanupTestDatabase } from '../../setup/test-db'; -import { - addUserToOrganization, - createTestAccessToken, - createTestConnectorDefinition, - createTestOAuthClient, - createTestOrganization, - createTestUser, - seedSystemEntityTypes, -} from '../../setup/test-fixtures'; -import { mcpToolsCall } from '../../setup/test-helpers'; - -describe('Manage Connections - Auth Profiles', () => { - let token: string; - let org: Awaited>; - - beforeAll(async () => { - await cleanupTestDatabase(); - await seedSystemEntityTypes(); - - org = await createTestOrganization({ name: 'Auth Profiles Org' }); - const user = await createTestUser({ email: 'auth-profiles@test.com' }); - await addUserToOrganization(user.id, org.id, 'owner'); - - const client = await createTestOAuthClient(); - token = (await createTestAccessToken(user.id, org.id, client.client_id)).token; - - await createTestConnectorDefinition({ - key: 'test.auth.profiles', - name: 'Test Auth Profiles Connector', - version: '1.0.0', - organization_id: org.id, - feeds_schema: { - tweets: { - key: 'tweets', - name: 'Tweets', - configSchema: { - type: 'object', - required: ['search_query'], - properties: { - search_query: { type: 'string' }, - }, - }, - }, - }, - auth_schema: { - methods: [ - { - type: 'env_keys', - required: true, - fields: [{ key: 'X_COOKIES', label: 'X Cookies', required: true, secret: true }], - }, - { - type: 'oauth', - provider: 'google', - requiredScopes: ['email'], - clientIdKey: 'GOOGLE_CLIENT_ID', - clientSecretKey: 'GOOGLE_CLIENT_SECRET', - }, - ], - }, - }); - - await createTestConnectorDefinition({ - key: 'test.browser.profiles', - name: 'Test Browser Profiles Connector', - version: '1.0.0', - organization_id: org.id, - feeds_schema: { - timeline: { - key: 'timeline', - name: 'Timeline', - configSchema: { - type: 'object', - required: ['search_query'], - properties: { - search_query: { type: 'string' }, - }, - }, - }, - }, - auth_schema: { - methods: [ - { - type: 'browser', - capture: 'cli', - description: 'Capture cookies from a local browser profile', - }, - ], - }, - }); - - await createTestConnectorDefinition({ - key: 'test.browser.preferred', - name: 'Test Browser Preferred Connector', - version: '1.0.0', - organization_id: org.id, - feeds_schema: { - timeline: { - key: 'timeline', - name: 'Timeline', - configSchema: { - type: 'object', - required: ['search_query'], - properties: { - search_query: { type: 'string' }, - }, - }, - }, - }, - auth_schema: { - methods: [ - { - type: 'browser', - capture: 'cli', - description: 'Preferred browser auth for scraping.', - }, - { - type: 'oauth', - provider: 'linkedin', - requiredScopes: ['openid', 'profile', 'email'], - loginScopes: ['openid', 'profile', 'email'], - authorizationUrl: 'https://www.linkedin.com/oauth/v2/authorization', - tokenUrl: 'https://www.linkedin.com/oauth/v2/accessToken', - userinfoUrl: 'https://api.linkedin.com/v2/userinfo', - }, - ], - }, - }); - }); - - it('creates reusable env auth profiles and uses them by slug when creating connections', async () => { - const authProfileResult = await mcpToolsCall( - 'manage_auth_profiles', - { - action: 'create_auth_profile', - connector_key: 'test.auth.profiles', - profile_kind: 'env', - display_name: 'Primary X Cookies', - slug: 'primary-x-cookies', - credentials: { - X_COOKIES: 'ct0=test_ct0; auth_token=test_auth_token', - }, - }, - { token } - ); - - expect(authProfileResult.action).toBe('create_auth_profile'); - expect(authProfileResult.auth_profile.slug).toBe('primary-x-cookies'); - expect(authProfileResult.auth_profile.profile_kind).toBe('env'); - - const createdOne = await mcpToolsCall( - 'manage_connections', - { - action: 'create', - connector_key: 'test.auth.profiles', - display_name: 'Tweets Search One', - auth_profile_slug: 'primary-x-cookies', - config: { search_query: 'from:first' }, - }, - { token } - ); - - const createdTwo = await mcpToolsCall( - 'manage_connections', - { - action: 'create', - connector_key: 'test.auth.profiles', - display_name: 'Tweets Search Two', - auth_profile_slug: 'primary-x-cookies', - config: { search_query: 'from:second' }, - }, - { token } - ); - - expect(createdOne.action).toBe('create'); - expect(createdTwo.action).toBe('create'); - expect(createdOne.connection.auth_profile_slug).toBe('primary-x-cookies'); - expect(createdTwo.connection.auth_profile_slug).toBe('primary-x-cookies'); - - const tested = await mcpToolsCall( - 'manage_connections', - { - action: 'test', - connection_id: Number(createdOne.connection.id), - }, - { token } - ); - - expect(tested.action).toBe('test'); - expect(tested.status).toBe('ok'); - expect(tested.message).toContain('primary-x-cookies'); - }); - - it('lists auth profiles and returns OAuth account connect URLs', async () => { - const appProfileResult = await mcpToolsCall( - 'manage_auth_profiles', - { - action: 'create_auth_profile', - connector_key: 'test.auth.profiles', - profile_kind: 'oauth_app', - display_name: 'Google App', - slug: 'google-app', - credentials: { - GOOGLE_CLIENT_ID: 'client-id', - GOOGLE_CLIENT_SECRET: 'client-secret', - }, - }, - { token } - ); - - expect(appProfileResult.action).toBe('create_auth_profile'); - expect(appProfileResult.auth_profile.slug).toBe('google-app'); - expect(appProfileResult.auth_profile.provider).toBe('google'); - - const accountProfileResult = await mcpToolsCall( - 'manage_auth_profiles', - { - action: 'create_auth_profile', - connector_key: 'test.auth.profiles', - profile_kind: 'oauth_account', - display_name: 'Google Workspace Account', - slug: 'google-workspace', - }, - { token } - ); - - expect(accountProfileResult.action).toBe('create_auth_profile'); - expect(accountProfileResult.pending_slug).toBe('google-workspace'); - expect(accountProfileResult.connect_url).toContain('/connect/'); - expect(accountProfileResult.connect_token).toBeTruthy(); - - const listed = await mcpToolsCall( - 'manage_auth_profiles', - { - action: 'list_auth_profiles', - connector_key: 'test.auth.profiles', - }, - { token } - ); - - const slugs = listed.auth_profiles.map((profile: { slug: string }) => profile.slug); - expect(slugs).toContain('primary-x-cookies'); - expect(slugs).toContain('google-app'); - }); - - it('creates browser session profiles and activates linked connections after cookie capture', async () => { - const createdProfile = await mcpToolsCall( - 'manage_auth_profiles', - { - action: 'create_auth_profile', - connector_key: 'test.browser.profiles', - profile_kind: 'browser_session', - display_name: 'Primary Browser Session', - slug: 'primary-browser-session', - }, - { token } - ); - - expect(createdProfile.action).toBe('create_auth_profile'); - expect(createdProfile.auth_profile.slug).toBe('primary-browser-session'); - expect(createdProfile.auth_profile.status).toBe('pending_auth'); - - const createdConnection = await mcpToolsCall( - 'manage_connections', - { - action: 'create', - connector_key: 'test.browser.profiles', - display_name: 'Browser Timeline', - auth_profile_slug: 'primary-browser-session', - config: { search_query: 'browser-auth' }, - }, - { token } - ); - - expect(createdConnection.action).toBe('create'); - expect(createdConnection.connection.status).toBe('pending_auth'); - expect(createdConnection.connection.auth_profile_slug).toBe('primary-browser-session'); - - const updatedProfile = await mcpToolsCall( - 'manage_auth_profiles', - { - action: 'update_auth_profile', - auth_profile_slug: 'primary-browser-session', - auth_data: { - cookies: [ - { - name: 'auth_token', - value: 'test-auth-token', - domain: '.x.com', - path: '/', - expires: Math.floor(Date.now() / 1000) + 86400, - httpOnly: true, - secure: true, - }, - ], - captured_at: new Date().toISOString(), - captured_via: 'test', - }, - }, - { token } - ); - - expect(updatedProfile.action).toBe('update_auth_profile'); - expect(updatedProfile.auth_profile.status).toBe('active'); - expect(updatedProfile.auth_profile.cookie_count).toBe(1); - - const testedProfile = await mcpToolsCall( - 'manage_auth_profiles', - { - action: 'test_auth_profile', - auth_profile_slug: 'primary-browser-session', - }, - { token } - ); - - expect(testedProfile.action).toBe('test_auth_profile'); - expect(testedProfile.status).toBe('ok'); - expect(testedProfile.auth_cookie_name).toBe('auth_token'); - - const fetchedConnection = await mcpToolsCall( - 'manage_connections', - { - action: 'get', - connection_id: Number(createdConnection.connection.id), - }, - { token } - ); - - expect(fetchedConnection.connection.status).toBe('active'); - expect(fetchedConnection.connection.auth_profile_kind).toBe('browser_session'); - - const testedConnection = await mcpToolsCall( - 'manage_connections', - { - action: 'test', - connection_id: Number(createdConnection.connection.id), - }, - { token } - ); - - expect(testedConnection.action).toBe('test'); - expect(testedConnection.status).toBe('ok'); - expect(testedConnection.message).toContain('auth_token'); - }); - - it('prefers browser auth over oauth when browser is listed first in the connector schema', async () => { - const result = await mcpToolsCall( - 'manage_connections', - { - action: 'connect', - connector_key: 'test.browser.preferred', - display_name: 'Browser Preferred Timeline', - config: { search_query: 'linkedin' }, - }, - { token } - ); - - expect(result.error).toContain('Select or create a browser auth profile'); - expect(result.connect_url).toBeUndefined(); - }); - - it('updates auth profile display name and credentials', async () => { - const updated = await mcpToolsCall( - 'manage_auth_profiles', - { - action: 'update_auth_profile', - auth_profile_slug: 'primary-x-cookies', - display_name: 'Updated X Cookies', - credentials: { - X_COOKIES: 'ct0=updated_ct0; auth_token=updated_auth_token', - }, - }, - { token } - ); - - expect(updated.action).toBe('update_auth_profile'); - expect(updated.auth_profile.display_name).toBe('Updated X Cookies'); - expect(updated.auth_profile.slug).toBe('primary-x-cookies'); - }); - - it('prevents deleting auth profile used by active connections without force', async () => { - const result = await mcpToolsCall( - 'manage_auth_profiles', - { - action: 'delete_auth_profile', - auth_profile_slug: 'primary-x-cookies', - }, - { token } - ); - - expect(result.error).toContain('is used by'); - expect(result.error).toContain('active connection'); - }); - - it('deletes auth profile used by connections when force is true', async () => { - // Create a standalone profile to test force deletion - await mcpToolsCall( - 'manage_auth_profiles', - { - action: 'create_auth_profile', - connector_key: 'test.auth.profiles', - profile_kind: 'env', - display_name: 'Deletable Profile', - slug: 'deletable-profile', - credentials: { X_COOKIES: 'to-be-deleted' }, - }, - { token } - ); - - const conn = await mcpToolsCall( - 'manage_connections', - { - action: 'create', - connector_key: 'test.auth.profiles', - display_name: 'Will Lose Auth', - auth_profile_slug: 'deletable-profile', - config: { search_query: 'test' }, - }, - { token } - ); - - const forceResult = await mcpToolsCall( - 'manage_auth_profiles', - { - action: 'delete_auth_profile', - auth_profile_slug: 'deletable-profile', - force: true, - }, - { token } - ); - - expect(forceResult.action).toBe('delete_auth_profile'); - expect(forceResult.deleted).toBe(true); - - // Verify connection still exists but without auth profile - const fetched = await mcpToolsCall( - 'manage_connections', - { action: 'get', connection_id: Number(conn.connection.id) }, - { token } - ); - expect(fetched.connection.auth_profile_slug).toBeNull(); - }); - - it('deletes unused auth profile without force', async () => { - await mcpToolsCall( - 'manage_auth_profiles', - { - action: 'create_auth_profile', - connector_key: 'test.auth.profiles', - profile_kind: 'env', - display_name: 'Unused Profile', - slug: 'unused-profile', - credentials: { X_COOKIES: 'unused' }, - }, - { token } - ); - - const result = await mcpToolsCall( - 'manage_auth_profiles', - { - action: 'delete_auth_profile', - auth_profile_slug: 'unused-profile', - }, - { token } - ); - - expect(result.action).toBe('delete_auth_profile'); - expect(result.deleted).toBe(true); - - // Verify it's gone - const listed = await mcpToolsCall( - 'manage_auth_profiles', - { action: 'list_auth_profiles', connector_key: 'test.auth.profiles' }, - { token } - ); - const slugs = listed.auth_profiles.map((p: { slug: string }) => p.slug); - expect(slugs).not.toContain('unused-profile'); - }); - - it('rejects creating auth profile without credentials', async () => { - const result = await mcpToolsCall( - 'manage_auth_profiles', - { - action: 'create_auth_profile', - connector_key: 'test.auth.profiles', - profile_kind: 'env', - display_name: 'No Creds Profile', - credentials: {}, - }, - { token } - ); - - expect(result.error).toContain('Credentials are required'); - expect(result.error).toContain('X_COOKIES'); - }); - - it('rejects updating connection with revoked auth profile', async () => { - // Create a profile and then revoke it - await mcpToolsCall( - 'manage_auth_profiles', - { - action: 'create_auth_profile', - connector_key: 'test.auth.profiles', - profile_kind: 'env', - display_name: 'Revoked Profile', - slug: 'revoked-profile', - credentials: { X_COOKIES: 'will-revoke' }, - }, - { token } - ); - - await mcpToolsCall( - 'manage_auth_profiles', - { - action: 'update_auth_profile', - auth_profile_slug: 'revoked-profile', - status: 'revoked', - }, - { token } - ); - - // Get the first connection created in the test suite - const listed = await mcpToolsCall('manage_connections', { action: 'list' }, { token }); - const connectionId = Number(listed.connections[0].id); - - const result = await mcpToolsCall( - 'manage_connections', - { - action: 'update', - connection_id: connectionId, - auth_profile_slug: 'revoked-profile', - }, - { token } - ); - - expect(result.error).toContain('revoked'); - }); - - it('does not auto-create feeds during connection creation', async () => { - const created = await mcpToolsCall( - 'manage_connections', - { - action: 'create', - connector_key: 'test.auth.profiles', - display_name: 'Default No Feed Connection', - auth_profile_slug: 'primary-x-cookies', - }, - { token } - ); - - expect(created.action).toBe('create'); - - const feedsResult = await mcpToolsCall( - 'manage_connections', - { - action: 'list_feeds', - connection_id: Number(created.connection.id), - }, - { token } - ); - - expect(feedsResult.feeds).toEqual([]); - }); - - it('rejects feed-scoped config during connection creation', async () => { - const result = await mcpToolsCall( - 'manage_connections', - { - action: 'create', - connector_key: 'test.auth.profiles', - display_name: 'Bad Feed Config Connection', - auth_profile_slug: 'primary-x-cookies', - config: { search_query: 'config-propagation-test' }, - }, - { token } - ); - - expect(result.error).toContain('Feed-scoped config belongs on feeds'); - }); - - it('filters auth profiles by profile_kind', async () => { - const envOnly = await mcpToolsCall( - 'manage_auth_profiles', - { - action: 'list_auth_profiles', - connector_key: 'test.auth.profiles', - profile_kind: 'env', - }, - { token } - ); - - for (const profile of envOnly.auth_profiles) { - expect(profile.profile_kind).toBe('env'); - } - - const oauthAppOnly = await mcpToolsCall( - 'manage_auth_profiles', - { - action: 'list_auth_profiles', - connector_key: 'test.auth.profiles', - profile_kind: 'oauth_app', - }, - { token } - ); - - for (const profile of oauthAppOnly.auth_profiles) { - expect(profile.profile_kind).toBe('oauth_app'); - } - }); -}); diff --git a/packages/owletto-backend/src/__tests__/integration/entities/manage-connections-entity-link-overrides.test.ts b/packages/owletto-backend/src/__tests__/integration/entities/manage-connections-entity-link-overrides.test.ts deleted file mode 100644 index 02098750b..000000000 --- a/packages/owletto-backend/src/__tests__/integration/entities/manage-connections-entity-link-overrides.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { beforeAll, describe, expect, it } from 'vitest'; -import type { Env } from '../../../index'; -import { manageConnections } from '../../../tools/admin/manage_connections'; -import type { ToolContext } from '../../../tools/registry'; -import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; -import { - addUserToOrganization, - createTestConnectorDefinition, - createTestOrganization, - createTestUser, -} from '../../setup/test-fixtures'; - -describe('manage_connections > set_connector_entity_link_overrides', () => { - let org: Awaited>; - let user: Awaited>; - let ctx: ToolContext; - const env = {} as Env; - - beforeAll(async () => { - await cleanupTestDatabase(); - - org = await createTestOrganization({ name: 'Entity Link Overrides Org' }); - user = await createTestUser(); - await addUserToOrganization(user.id, org.id, 'owner'); - - ctx = { - organizationId: org.id, - userId: user.id, - accessLevel: 'admin', - } as unknown as ToolContext; - - await createTestConnectorDefinition({ - key: 'whatsapp', - name: 'WhatsApp', - version: '1.0.0', - organization_id: org.id, - feeds_schema: { - messages: { - eventKinds: { - message: { - entityLinks: [ - { - entityType: '$member', - autoCreate: true, - identities: [ - { namespace: 'wa_jid', eventPath: 'metadata.sender_jid' }, - { namespace: 'phone', eventPath: 'metadata.sender_phone' }, - ], - }, - ], - }, - }, - }, - }, - }); - }); - - it('writes overrides to the connector definition and reads them back', async () => { - const result = (await manageConnections( - { - action: 'set_connector_entity_link_overrides', - connector_key: 'whatsapp', - overrides: { $member: { autoCreate: false, maskIdentities: ['phone'] } }, - }, - env, - ctx - )) as { - action: string; - success: boolean; - overrides: unknown; - }; - - expect(result.action).toBe('set_connector_entity_link_overrides'); - expect(result.success).toBe(true); - expect(result.overrides).toEqual({ - $member: { autoCreate: false, maskIdentities: ['phone'] }, - }); - - const sql = getTestDb(); - const rows = await sql<{ entity_link_overrides: Record | null }[]>` - SELECT entity_link_overrides FROM connector_definitions - WHERE key = 'whatsapp' AND organization_id = ${org.id} - `; - expect(rows[0].entity_link_overrides).toEqual({ - $member: { autoCreate: false, maskIdentities: ['phone'] }, - }); - }); - - it('clears overrides when given null', async () => { - const result = (await manageConnections( - { - action: 'set_connector_entity_link_overrides', - connector_key: 'whatsapp', - overrides: null, - }, - env, - ctx - )) as { overrides: unknown }; - - expect(result.overrides).toBe(null); - - const sql = getTestDb(); - const rows = await sql<{ entity_link_overrides: Record | null }[]>` - SELECT entity_link_overrides FROM connector_definitions - WHERE key = 'whatsapp' AND organization_id = ${org.id} - `; - expect(rows[0].entity_link_overrides).toBe(null); - }); - - it('rejects malformed overrides', async () => { - const result = (await manageConnections( - { - action: 'set_connector_entity_link_overrides', - connector_key: 'whatsapp', - overrides: { $member: { disable: 'yes' as unknown as boolean } }, - }, - env, - ctx - )) as { error?: string }; - - expect(result.error).toMatch(/Invalid overrides/); - }); - - it('rejects unknown connector_key', async () => { - const result = (await manageConnections( - { - action: 'set_connector_entity_link_overrides', - connector_key: 'nonexistent', - overrides: {}, - }, - env, - ctx - )) as { error?: string }; - - expect(result.error).toMatch(/not found/); - }); - - it('accepts retargetEntityType pointing at an existing user-defined entity type', async () => { - const sql = getTestDb(); - await sql` - INSERT INTO entity_types (organization_id, slug, name, metadata_schema, created_at, updated_at) - VALUES (${org.id}, 'contact', 'Contact', ${sql.json({})}, NOW(), NOW()) - ON CONFLICT DO NOTHING - `; - - const result = (await manageConnections( - { - action: 'set_connector_entity_link_overrides', - connector_key: 'whatsapp', - overrides: { $member: { retargetEntityType: 'contact' } }, - }, - env, - ctx - )) as { success?: boolean; overrides?: unknown; error?: string }; - - expect(result.error).toBeUndefined(); - expect(result.success).toBe(true); - - const rows = await sql<{ entity_link_overrides: Record | null }[]>` - SELECT entity_link_overrides FROM connector_definitions - WHERE key = 'whatsapp' AND organization_id = ${org.id} - `; - expect(rows[0].entity_link_overrides).toEqual({ - $member: { retargetEntityType: 'contact' }, - }); - }); - - it('rejects retargetEntityType pointing at an entity type that does not exist in the org', async () => { - const result = (await manageConnections( - { - action: 'set_connector_entity_link_overrides', - connector_key: 'whatsapp', - overrides: { $member: { retargetEntityType: 'nonexistent_type' } }, - }, - env, - ctx - )) as { error?: string }; - - expect(result.error).toMatch(/nonexistent_type/); - expect(result.error).toMatch(/does not exist/); - }); -}); diff --git a/packages/owletto-backend/src/__tests__/integration/entities/manage-connections-feeds.test.ts b/packages/owletto-backend/src/__tests__/integration/entities/manage-connections-feeds.test.ts deleted file mode 100644 index 983822051..000000000 --- a/packages/owletto-backend/src/__tests__/integration/entities/manage-connections-feeds.test.ts +++ /dev/null @@ -1,296 +0,0 @@ -/** - * Manage Feeds Lifecycle Integration Tests - * - * Verifies feed CRUD/trigger behavior via the REST proxy at - * `POST /api/{orgSlug}/manage_feeds`. The MCP `manage_feeds` tool was demoted - * to `internal: true` in PR #432, so it's no longer reachable via MCP - * `tools/call`; the REST proxy is now the canonical surface for these - * actions and is what owletto-cli + owletto-web both call. - * - * MCP `tools/list` visibility for the demoted `manage_*` family is asserted - * in `integration/mcp/auth.test.ts > tools/list Response` — this file is - * exclusively about feed-action behavior. - */ - -import { beforeAll, describe, expect, it } from 'vitest'; -import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; -import { - addUserToOrganization, - createTestAccessToken, - createTestConnection, - createTestConnectorDefinition, - createTestEntity, - createTestOAuthClient, - createTestOrganization, - createTestUser, - seedSystemEntityTypes, -} from '../../setup/test-fixtures'; -import { post } from '../../setup/test-helpers'; - -/** - * Call `manage_feeds` over the REST proxy and parse the JSON body. Mirrors - * how owletto-cli's `restToolCall` invokes it (POST raw JSON, Authorization - * Bearer token, no MCP envelope). - */ -async function callManageFeeds( - orgSlug: string, - args: Record, - token: string -): Promise { - const response = await post(`/api/${orgSlug}/manage_feeds`, { - body: args, - token, - }); - const body = (await response.json()) as T; - if (response.status >= 400) { - // Surface the full response so test failures show what the server rejected - // instead of a downstream "Cannot read property X of undefined". - throw new Error( - `manage_feeds REST call failed (${response.status}): ${JSON.stringify(body)}` - ); - } - return body; -} - -describe('Manage Feeds - Feed Actions (REST proxy)', () => { - let tokenA: string; - let tokenB: string; - let orgA: Awaited>; - let orgB: Awaited>; - let connectionA: Awaited>; - let connectionB: Awaited>; - let entityA: Awaited>; - - beforeAll(async () => { - await cleanupTestDatabase(); - await seedSystemEntityTypes(); - - orgA = await createTestOrganization({ name: 'Feeds Org A' }); - orgB = await createTestOrganization({ name: 'Feeds Org B' }); - - const userA = await createTestUser({ email: 'feeds-org-a@test.com' }); - const userB = await createTestUser({ email: 'feeds-org-b@test.com' }); - - await addUserToOrganization(userA.id, orgA.id, 'owner'); - await addUserToOrganization(userB.id, orgB.id, 'owner'); - - const client = await createTestOAuthClient(); - // manage_feeds requires admin scope; default scopes are read/write only. - tokenA = ( - await createTestAccessToken(userA.id, orgA.id, client.client_id, { - scope: 'mcp:read mcp:write mcp:admin', - }) - ).token; - tokenB = ( - await createTestAccessToken(userB.id, orgB.id, client.client_id, { - scope: 'mcp:read mcp:write mcp:admin', - }) - ).token; - - await createTestConnectorDefinition({ - key: 'test.feed.connector', - name: 'Test Feed Connector', - version: '1.0.0', - feeds_schema: { - threads: { description: 'Thread feed' }, - mentions: { description: 'Mentions feed' }, - }, - organization_id: orgA.id, - }); - - entityA = await createTestEntity({ name: 'Feed Entity A', organization_id: orgA.id }); - const entityB = await createTestEntity({ name: 'Feed Entity B', organization_id: orgB.id }); - - connectionA = await createTestConnection({ - organization_id: orgA.id, - connector_key: 'test.feed.connector', - entity_ids: [entityA.id], - status: 'active', - }); - - connectionB = await createTestConnection({ - organization_id: orgB.id, - connector_key: 'test.feed.connector', - entity_ids: [entityB.id], - status: 'active', - }); - }); - - it('supports create/list/update/get/trigger feed lifecycle', async () => { - const created = await callManageFeeds( - orgA.slug, - { - action: 'create_feed', - connection_id: connectionA.id, - feed_key: 'threads', - entity_ids: [entityA.id], - config: { language: 'en' }, - }, - tokenA - ); - - expect(created.action).toBe('create_feed'); - expect(created.feed).toBeDefined(); - expect(created.feed.feed_key).toBe('threads'); - expect(Number(created.feed.connection_id)).toBe(connectionA.id); - - const feedId = Number(created.feed.id); - - const listed = await callManageFeeds( - orgA.slug, - { - action: 'list_feeds', - connection_id: connectionA.id, - }, - tokenA - ); - - expect(listed.action).toBe('list_feeds'); - expect(Array.isArray(listed.feeds)).toBe(true); - expect(listed.feeds.some((f: any) => Number(f.id) === feedId)).toBe(true); - - const updated = await callManageFeeds( - orgA.slug, - { - action: 'update_feed', - feed_id: feedId, - status: 'active', - schedule: '* * * * *', - config: { language: 'tr' }, - }, - tokenA - ); - - expect(updated.action).toBe('update_feed'); - expect(updated.feed.schedule).toBe('* * * * *'); - expect(updated.feed.config).toBeDefined(); - - const triggered = await callManageFeeds( - orgA.slug, - { - action: 'trigger_feed', - feed_id: feedId, - }, - tokenA - ); - - expect(triggered.action).toBe('trigger_feed'); - expect(triggered.triggered).toBe(true); - expect(Number(triggered.feed_id)).toBe(feedId); - expect(typeof triggered.run_id).toBe('number'); - - const duplicateTrigger = await callManageFeeds( - orgA.slug, - { - action: 'trigger_feed', - feed_id: feedId, - }, - tokenA - ); - - expect(duplicateTrigger.action).toBe('trigger_feed'); - expect(duplicateTrigger.message).toContain('already pending or running'); - - const fetched = await callManageFeeds( - orgA.slug, - { - action: 'get_feed', - feed_id: feedId, - }, - tokenA - ); - - expect(fetched.action).toBe('get_feed'); - expect(Number(fetched.feed.id)).toBe(feedId); - expect(Array.isArray(fetched.recent_runs)).toBe(true); - expect(fetched.recent_runs.length).toBeGreaterThanOrEqual(1); - }); - - it('enforces organization scoping for feed actions', async () => { - const createdA = await callManageFeeds( - orgA.slug, - { - action: 'create_feed', - connection_id: connectionA.id, - feed_key: 'mentions', - }, - tokenA - ); - - const createdB = await callManageFeeds( - orgB.slug, - { - action: 'create_feed', - connection_id: connectionB.id, - feed_key: 'threads', - }, - tokenB - ); - - const listA = await callManageFeeds( - orgA.slug, - { - action: 'list_feeds', - }, - tokenA - ); - - const idsA = new Set(listA.feeds.map((f: any) => Number(f.id))); - expect(idsA.has(Number(createdA.feed.id))).toBe(true); - expect(idsA.has(Number(createdB.feed.id))).toBe(false); - - const getCrossOrg = await callManageFeeds( - orgA.slug, - { - action: 'get_feed', - feed_id: Number(createdB.feed.id), - }, - tokenA - ); - expect(getCrossOrg.error).toBe('Feed not found'); - - const triggerCrossOrg = await callManageFeeds( - orgA.slug, - { - action: 'trigger_feed', - feed_id: Number(createdB.feed.id), - }, - tokenA - ); - expect(triggerCrossOrg.error).toBe('Feed not found'); - }); - - it('prevents duplicate active sync runs under concurrent trigger_feed calls', async () => { - const sql = getTestDb(); - - const created = await callManageFeeds( - orgA.slug, - { - action: 'create_feed', - connection_id: connectionA.id, - feed_key: 'mentions', - }, - tokenA - ); - - const feedId = Number(created.feed.id); - - const [a, b] = await Promise.all([ - callManageFeeds(orgA.slug, { action: 'trigger_feed', feed_id: feedId }, tokenA), - callManageFeeds(orgA.slug, { action: 'trigger_feed', feed_id: feedId }, tokenA), - ]); - - const triggeredCount = [a, b].filter((result) => result.triggered === true).length; - expect(triggeredCount).toBe(1); - - const activeRuns = await sql` - SELECT id - FROM runs - WHERE feed_id = ${feedId} - AND run_type = 'sync' - AND status IN ('pending', 'running') - `; - - expect(activeRuns.length).toBe(1); - }); -}); diff --git a/packages/owletto-backend/src/__tests__/integration/entities/manage-connector-catalog.test.ts b/packages/owletto-backend/src/__tests__/integration/entities/manage-connector-catalog.test.ts deleted file mode 100644 index ff3881dda..000000000 --- a/packages/owletto-backend/src/__tests__/integration/entities/manage-connector-catalog.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { pathToFileURL } from 'node:url'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { cleanupTestDatabase } from '../../setup/test-db'; -import { - addUserToOrganization, - createTestAccessToken, - createTestConnectorDefinition, - createTestOAuthClient, - createTestOrganization, - createTestUser, - seedSystemEntityTypes, -} from '../../setup/test-fixtures'; -import { mcpToolsCall } from '../../setup/test-helpers'; - -function buildConnectorSource(params: { key: string; nameExpression: string; version?: string }) { - return ` -export class TestConnectorRuntime { - definition = { - key: ${JSON.stringify(params.key)}, - name: ${params.nameExpression}, - description: "Installed by catalog tests", - version: ${JSON.stringify(params.version ?? '1.0.0')}, - authSchema: { methods: [] }, - feeds: { default: {} }, - actions: {}, - optionsSchema: null, - }; - - async sync() { - return { items: [] }; - } - - async execute() { - return { ok: true }; - } -} -`; -} - -describe('Manage Connector Catalog', () => { - let organizationId: string; - let token: string; - const tmpDirs: string[] = []; - - beforeAll(async () => { - await cleanupTestDatabase(); - await seedSystemEntityTypes(); - - const org = await createTestOrganization({ name: 'Connector Catalog Org' }); - organizationId = org.id; - const user = await createTestUser({ email: 'connector-catalog@test.com' }); - await addUserToOrganization(user.id, org.id, 'owner'); - - const client = await createTestOAuthClient(); - token = (await createTestAccessToken(user.id, org.id, client.client_id)).token; - }); - - afterAll(async () => { - await Promise.all(tmpDirs.map((dir) => rm(dir, { recursive: true, force: true }))); - }); - - async function createCatalogDir(files: Record) { - const dir = await mkdtemp(join(tmpdir(), 'owletto-connector-catalog-')); - tmpDirs.push(dir); - - for (const [name, contents] of Object.entries(files)) { - await writeFile(join(dir, name), contents, 'utf-8'); - } - - return dir; - } - - it('lists only installed connector definitions by default', async () => { - const catalogDir = await createCatalogDir({ - 'catalog_only.ts': buildConnectorSource({ - key: 'test.catalog.only', - nameExpression: JSON.stringify('Catalog Only Connector'), - }), - }); - - await createTestConnectorDefinition({ - key: 'test.installed.only', - name: 'Installed Only Connector', - organization_id: organizationId, - }); - - const result = await mcpToolsCall( - 'manage_connections', - { - action: 'list_connector_definitions', - }, - { token, env: { CONNECTOR_CATALOG_URIS: catalogDir } } - ); - - expect(result.connector_definitions).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - key: 'test.installed.only', - name: 'Installed Only Connector', - installed: true, - installable: false, - catalog_origin: 'org', - }), - ]) - ); - expect( - result.connector_definitions.find((item: { key: string }) => item.key === 'test.catalog.only') - ).toBeUndefined(); - }); - - it('merges installed and installable connectors and prefers installed rows on key collisions', async () => { - const catalogDir = await createCatalogDir({ - 'catalog_only.ts': buildConnectorSource({ - key: 'test.catalog.merge', - nameExpression: JSON.stringify('Catalog Merge Connector'), - }), - 'duplicate.ts': buildConnectorSource({ - key: 'test.catalog.duplicate', - nameExpression: JSON.stringify('Catalog Duplicate Connector'), - }), - 'helper.ts': 'export const helper = true;\n', - }); - - const org = await createTestOrganization({ name: 'Merge Org' }); - const user = await createTestUser({ email: 'merge@test.com' }); - await addUserToOrganization(user.id, org.id, 'owner'); - const client = await createTestOAuthClient(); - const mergeToken = (await createTestAccessToken(user.id, org.id, client.client_id)).token; - - await createTestConnectorDefinition({ - key: 'test.catalog.duplicate', - name: 'Installed Duplicate Connector', - organization_id: org.id, - }); - - const result = await mcpToolsCall( - 'manage_connections', - { - action: 'list_connector_definitions', - include_installable: true, - }, - { token: mergeToken, env: { CONNECTOR_CATALOG_URIS: `git://ignored,${catalogDir}` } } - ); - - expect(result.connector_definitions).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - key: 'test.catalog.merge', - installed: false, - installable: true, - catalog_origin: 'catalog', - source_uri: expect.stringMatching(/^file:\/\//), - }), - expect.objectContaining({ - key: 'test.catalog.duplicate', - name: 'Installed Duplicate Connector', - installed: true, - installable: false, - catalog_origin: 'org', - }), - ]) - ); - - expect( - result.connector_definitions.filter( - (item: { key: string }) => item.key === 'test.catalog.duplicate' - ) - ).toHaveLength(1); - expect( - result.connector_definitions.find((item: { key: string }) => item.key === 'helper') - ).toBeUndefined(); - }); - - it('installs a connector from source_uri and preserves relative imports', async () => { - const catalogDir = await createCatalogDir({ - 'shared.ts': `export const connectorName = 'Catalog Install Connector';\n`, - 'catalog_install.ts': ` -import { connectorName } from './shared'; -${buildConnectorSource({ - key: 'test.catalog.install', - nameExpression: 'connectorName', -})} -`, - }); - - const connectorFile = join(catalogDir, 'catalog_install.ts'); - const installed = await mcpToolsCall( - 'manage_connections', - { - action: 'install_connector', - source_uri: pathToFileURL(connectorFile).toString(), - }, - { token } - ); - - expect(installed.action).toBe('install_connector'); - expect(installed.connector_key).toBe('test.catalog.install'); - - const listed = await mcpToolsCall( - 'manage_connections', - { - action: 'list_connector_definitions', - }, - { token } - ); - - expect(listed.connector_definitions).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - key: 'test.catalog.install', - name: 'Catalog Install Connector', - source_uri: expect.stringMatching(/^file:\/\//), - }), - ]) - ); - }); -}); diff --git a/packages/owletto-backend/src/__tests__/integration/entities/member-email-redaction.test.ts b/packages/owletto-backend/src/__tests__/integration/entities/member-email-redaction.test.ts deleted file mode 100644 index 2b5842cf6..000000000 --- a/packages/owletto-backend/src/__tests__/integration/entities/member-email-redaction.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { beforeAll, describe, expect, it } from 'vitest'; -import { ensureMemberEntityType } from '../../../utils/member-entity-type'; -import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; -import { - createTestOrganization, - createTestSession, - createTestUser, - seedSystemEntityTypes, -} from '../../setup/test-fixtures'; -import { post } from '../../setup/test-helpers'; - -describe('$member visibility policy on public orgs', () => { - let publicOrg: Awaited>; - let adminUser: Awaited>; - let memberUser: Awaited>; - let adminCookie: string; - let memberCookie: string; - let outsiderCookie: string; - - const ADMIN_EMAIL = 'admin-redaction@test.example.com'; - const MEMBER_EMAIL = 'plain-member@test.example.com'; - - beforeAll(async () => { - await cleanupTestDatabase(); - await seedSystemEntityTypes(); - - publicOrg = await createTestOrganization({ - name: 'Member Redaction Public Org', - slug: 'member-redaction-public', - visibility: 'public', - }); - - adminUser = await createTestUser({ email: ADMIN_EMAIL }); - memberUser = await createTestUser({ email: MEMBER_EMAIL }); - adminCookie = (await createTestSession(adminUser.id)).cookieHeader; - memberCookie = (await createTestSession(memberUser.id)).cookieHeader; - - await ensureMemberEntityType(publicOrg.id); - - const sql = getTestDb(); - await sql` - INSERT INTO "member" (id, "organizationId", "userId", role, "createdAt") - VALUES - (gen_random_uuid()::text, ${publicOrg.id}, ${adminUser.id}, 'owner', NOW()), - (gen_random_uuid()::text, ${publicOrg.id}, ${memberUser.id}, 'member', NOW()) - ON CONFLICT DO NOTHING - `; - - await sql` - INSERT INTO entities ( - name, slug, entity_type_id, organization_id, metadata, created_by, created_at, updated_at - ) VALUES ( - 'Plain Member', - 'plain-member', - (SELECT id FROM entity_types WHERE slug = '$member' AND organization_id = ${publicOrg.id} AND deleted_at IS NULL), - ${publicOrg.id}, - ${sql.json({ email: MEMBER_EMAIL, status: 'active', role: 'member' })}, - ${adminUser.id}, - NOW(), NOW() - ) - `; - - const outsider = await createTestUser({ email: 'nonmember-nomercy@test.example.com' }); - outsiderCookie = (await createTestSession(outsider.id)).cookieHeader; - }); - - async function listMembers(cookie?: string) { - return post(`/api/${publicOrg.slug}/manage_entity`, { - body: { action: 'list', entity_type: '$member', limit: 50, offset: 0 }, - cookie, - }); - } - - it('refuses the member list to anonymous callers', async () => { - const response = await listMembers(); - expect(response.status).toBe(400); - const body = await response.json(); - expect(String(body.error)).toMatch(/only visible to members/i); - }); - - it('refuses the member list to authenticated non-members', async () => { - const response = await listMembers(outsiderCookie); - expect(response.status).toBe(400); - const body = await response.json(); - expect(String(body.error)).toMatch(/only visible to members/i); - }); - - it('returns members without email to regular members', async () => { - const response = await listMembers(memberCookie); - expect(response.status).toBe(200); - const body = await response.json(); - const hit = body.entities.find((e: any) => e.name === 'Plain Member'); - expect(hit).toBeTruthy(); - expect(hit.metadata).not.toHaveProperty('email'); - // Non-PII fields stay visible so the list view still renders useful columns. - expect(hit.metadata.status).toBe('active'); - }); - - it('returns member emails to admin/owner callers', async () => { - const response = await listMembers(adminCookie); - expect(response.status).toBe(200); - const body = await response.json(); - const hit = body.entities.find((e: any) => e.name === 'Plain Member'); - expect(hit).toBeTruthy(); - expect(hit.metadata.email).toBe(MEMBER_EMAIL); - }); -}); diff --git a/packages/owletto-backend/src/__tests__/integration/entities/member-privacy-contract.test.ts b/packages/owletto-backend/src/__tests__/integration/entities/member-privacy-contract.test.ts new file mode 100644 index 000000000..367eb2e2a --- /dev/null +++ b/packages/owletto-backend/src/__tests__/integration/entities/member-privacy-contract.test.ts @@ -0,0 +1,97 @@ +/** + * Public $member privacy boundary. + * + * High-value coverage retained from the deleted redaction suite: public + * workspaces can expose member rows to members, but regular members must not + * see email metadata and outsiders/anonymous callers must not enumerate them. + */ + +import { beforeAll, describe, expect, it } from 'vitest'; +import { ensureMemberEntityType } from '../../../utils/member-entity-type'; +import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; +import { createTestOrganization, createTestSession, createTestUser } from '../../setup/test-fixtures'; +import { post } from '../../setup/test-helpers'; + +const MEMBER_EMAIL = 'plain-member@test.example.com'; + +describe('$member privacy contract', () => { + let orgSlug: string; + let ownerCookie: string; + let memberCookie: string; + let outsiderCookie: string; + + beforeAll(async () => { + await cleanupTestDatabase(); + + const org = await createTestOrganization({ + name: 'Member Privacy Public Org', + slug: 'member-privacy-public', + visibility: 'public', + }); + orgSlug = org.slug; + + const owner = await createTestUser({ email: 'member-privacy-owner@test.example.com' }); + const member = await createTestUser({ email: MEMBER_EMAIL }); + const outsider = await createTestUser({ email: 'member-privacy-outsider@test.example.com' }); + + ownerCookie = (await createTestSession(owner.id)).cookieHeader; + memberCookie = (await createTestSession(member.id)).cookieHeader; + outsiderCookie = (await createTestSession(outsider.id)).cookieHeader; + + await ensureMemberEntityType(org.id); + + const sql = getTestDb(); + await sql` + INSERT INTO "member" (id, "organizationId", "userId", role, "createdAt") + VALUES + (gen_random_uuid()::text, ${org.id}, ${owner.id}, 'owner', NOW()), + (gen_random_uuid()::text, ${org.id}, ${member.id}, 'member', NOW()) + ON CONFLICT DO NOTHING + `; + + await sql` + INSERT INTO entities ( + name, slug, entity_type_id, organization_id, metadata, created_by, created_at, updated_at + ) VALUES ( + 'Plain Member', + 'plain-member', + (SELECT id FROM entity_types WHERE slug = '$member' AND organization_id = ${org.id} AND deleted_at IS NULL), + ${org.id}, + ${sql.json({ email: MEMBER_EMAIL, status: 'active', role: 'member' })}, + ${owner.id}, + NOW(), NOW() + ) + `; + }); + + async function listMembers(cookie?: string) { + return post(`/api/${orgSlug}/manage_entity`, { + body: { action: 'list', entity_type: '$member', limit: 50, offset: 0 }, + cookie, + }); + } + + it('does not allow anonymous or signed-in outsiders to enumerate members', async () => { + for (const cookie of [undefined, outsiderCookie]) { + const response = await listMembers(cookie); + expect(response.status).toBe(400); + const body = await response.json(); + expect(String(body.error)).toMatch(/only visible to members/i); + } + }); + + it('redacts member emails for regular members but not owners/admins', async () => { + const memberResponse = await listMembers(memberCookie); + expect(memberResponse.status).toBe(200); + const memberBody = await memberResponse.json(); + const memberHit = memberBody.entities.find((e: any) => e.name === 'Plain Member'); + expect(memberHit.metadata).not.toHaveProperty('email'); + expect(memberHit.metadata.status).toBe('active'); + + const ownerResponse = await listMembers(ownerCookie); + expect(ownerResponse.status).toBe(200); + const ownerBody = await ownerResponse.json(); + const ownerHit = ownerBody.entities.find((e: any) => e.name === 'Plain Member'); + expect(ownerHit.metadata.email).toBe(MEMBER_EMAIL); + }); +}); diff --git a/packages/owletto-backend/src/__tests__/integration/entity-schema/entity-types.test.ts b/packages/owletto-backend/src/__tests__/integration/entity-schema/entity-types.test.ts new file mode 100644 index 000000000..2d821abbc --- /dev/null +++ b/packages/owletto-backend/src/__tests__/integration/entity-schema/entity-types.test.ts @@ -0,0 +1,118 @@ +/** + * Entity-type and relationship-type CRUD via the post-#348 SDK surface. + * + * Replaces the deleted `manage_entity_schema` integration tests. Each scenario + * uses TestApiClient (direct handler) so we exercise real DB writes without + * paying the HTTP/sandbox round-trip on every assertion. The MCP wire path is + * covered separately in `mcp-auth-wire.test.ts` and `sandbox-execute.test.ts`. + */ + +import { beforeAll, describe, expect, it } from 'vitest'; +import { + addUserToOrganization, + createTestOrganization, + createTestUser, +} from '../../setup/test-fixtures'; +import { TestApiClient } from '../../setup/test-mcp-client'; +import { cleanupTestDatabase } from '../../setup/test-db'; + +describe('entity schema CRUD', () => { + let owner: TestApiClient; + + beforeAll(async () => { + await cleanupTestDatabase(); + const org = await createTestOrganization({ name: 'Schema Test Org' }); + const user = await createTestUser({ email: 'schema-owner@test.com' }); + await addUserToOrganization(user.id, org.id, 'owner'); + owner = await TestApiClient.for({ + organizationId: org.id, + userId: user.id, + memberRole: 'owner', + }); + }); + + describe('entity_type', () => { + it('creates → reads back → updates → deletes', async () => { + await owner.entity_schema.createType({ + slug: 'lifecycle-asset', + name: 'Asset', + description: 'A trackable asset', + }); + + const got = (await owner.entity_schema.getType('lifecycle-asset')) as { + entity_type?: { name: string; description?: string }; + }; + expect(got.entity_type?.name).toBe('Asset'); + expect(got.entity_type?.description).toBe('A trackable asset'); + + await owner.entity_schema.updateType({ + slug: 'lifecycle-asset', + name: 'Asset (renamed)', + }); + const after = (await owner.entity_schema.getType('lifecycle-asset')) as { + entity_type?: { name: string }; + }; + expect(after.entity_type?.name).toBe('Asset (renamed)'); + + await owner.entity_schema.deleteType('lifecycle-asset'); + const tombstone = (await owner.entity_schema.getType('lifecycle-asset')) as { + entity_type: null | unknown; + }; + expect(tombstone.entity_type).toBeNull(); + }); + + it('rejects a duplicate slug create', async () => { + await owner.entity_schema.createType({ slug: 'dup-asset', name: 'Dup' }); + await expect( + owner.entity_schema.createType({ slug: 'dup-asset', name: 'Dup 2' }) + ).rejects.toThrow(/already exists|duplicate/i); + await owner.entity_schema.deleteType('dup-asset'); + }); + + it('lists user-created types alongside system types', async () => { + await owner.entity_schema.createType({ slug: 'lst-asset', name: 'Lst' }); + const list = (await owner.entity_schema.listTypes()) as { + entity_types?: Array<{ slug: string }>; + }; + const slugs = list.entity_types?.map((t) => t.slug) ?? []; + expect(slugs).toContain('lst-asset'); + await owner.entity_schema.deleteType('lst-asset'); + }); + }); + + describe('relationship_type', () => { + it('creates a symmetric type', async () => { + const result = (await owner.entity_schema.createRelType({ + slug: 'collaborates-with', + name: 'Collaborates With', + })) as { relationship_type?: { slug: string; status: string } }; + expect(result.relationship_type?.slug).toBe('collaborates-with'); + expect(result.relationship_type?.status).toBe('active'); + await owner.entity_schema.deleteRelType('collaborates-with'); + }); + + it('rejects a duplicate relationship slug', async () => { + await owner.entity_schema.createRelType({ slug: 'dup-rel', name: 'Dup' }); + await expect( + owner.entity_schema.createRelType({ slug: 'dup-rel', name: 'Dup 2' }) + ).rejects.toThrow(/already exists|duplicate/i); + await owner.entity_schema.deleteRelType('dup-rel'); + }); + }); + + describe('access control', () => { + it('blocks a member without admin scope from creating types', async () => { + const member = owner.withAuth({ memberRole: 'member' }); + await expect( + member.entity_schema.createType({ slug: 'blocked-type', name: 'Blocked' }) + ).rejects.toThrow(/admin|owner|access/i); + }); + + it('blocks an unauthenticated caller', async () => { + const anon = owner.withAuth({ userId: null, memberRole: null }); + await expect( + anon.entity_schema.createType({ slug: 'anon-type', name: 'Anon' }) + ).rejects.toThrow(); + }); + }); +}); diff --git a/packages/owletto-backend/src/__tests__/integration/entity-types/cross-org.test.ts b/packages/owletto-backend/src/__tests__/integration/entity-types/cross-org.test.ts deleted file mode 100644 index 5da0d26fd..000000000 --- a/packages/owletto-backend/src/__tests__/integration/entity-types/cross-org.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -/** - * Cross-Org Schema Behavior Tests - * - * Covers the read-side widenings landed in #386, the read-mode - * `requireRelationshipType` follow-up in #399, and the cross-org - * schema-validation fix (`utils/schema-validation.ts`). - * - * Invariants exercised: - * - `manage_entity_schema list/get` returns rows from caller's org and - * any `visibility=public` org, with `organization_slug` populated and - * tenant-first ordering. - * - `$member` is per-tenant: cross-org public `$member` rows never - * appear in entity_type `get`, even though other types do. - * - `list_rules` resolves rules on a cross-org public relationship type - * (read mode); mutating actions (`add_rule`) still 403. - * - When a tenant entity carries a cross-org public type, metadata - * validation uses the catalog's schema, not the caller's empty schema. - */ - -import { beforeAll, describe, expect, it } from 'vitest'; -import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; -import { - addUserToOrganization, - createTestAccessToken, - createTestOAuthClient, - createTestOrganization, - createTestUser, - seedSystemEntityTypes, -} from '../../setup/test-fixtures'; -import { mcpToolsCall } from '../../setup/test-helpers'; - -describe('Cross-org schema reads', () => { - let tenant: Awaited>; - let publicCatalog: Awaited>; - let user: Awaited>; - let token: string; - - beforeAll(async () => { - await cleanupTestDatabase(); - await seedSystemEntityTypes(); - - tenant = await createTestOrganization({ name: 'Tenant', visibility: 'private' }); - publicCatalog = await createTestOrganization({ - name: 'Public Catalog', - slug: `public-catalog-${Date.now()}`, - visibility: 'public', - }); - - user = await createTestUser({ email: 'crossorg-user@test.com' }); - await addUserToOrganization(user.id, tenant.id, 'owner'); - - const client = await createTestOAuthClient(); - const result = await createTestAccessToken(user.id, tenant.id, client.client_id); - token = result.token; - - // Seed a tenant-local type and a public-catalog type with a real - // metadata_schema so the validation test has something to enforce. - const sql = getTestDb(); - await sql` - INSERT INTO entity_types (organization_id, slug, name, created_at, updated_at) - VALUES (${tenant.id}, 'tenant-thing', 'Tenant Thing', current_timestamp, current_timestamp) - `; - await sql` - INSERT INTO entity_types (organization_id, slug, name, metadata_schema, created_at, updated_at) - VALUES ( - ${publicCatalog.id}, - 'catalog-canonical', - 'Catalog Canonical', - ${sql.json({ - type: 'object', - properties: { - ticker: { type: 'string', minLength: 1 }, - }, - required: ['ticker'], - additionalProperties: false, - })}, - current_timestamp, - current_timestamp - ) - `; - }); - - describe('manage_entity_schema action="list"', () => { - it('returns local types first, then cross-org public-catalog types, each with organization_slug', async () => { - const result = await mcpToolsCall( - 'manage_entity_schema', - { schema_type: 'entity_type', action: 'list' }, - { token } - ); - - expect(result.action).toBe('list'); - const local = result.entity_types.find((t: any) => t.slug === 'tenant-thing'); - const crossOrg = result.entity_types.find((t: any) => t.slug === 'catalog-canonical'); - - expect(local).toBeDefined(); - expect(local.organization_slug).toBe(tenant.slug); - - expect(crossOrg).toBeDefined(); - expect(crossOrg.organization_slug).toBe(publicCatalog.slug); - expect(crossOrg.organization_id).toBe(publicCatalog.id); - - // Tenant rows must come before cross-org rows in the response. - const localIdx = result.entity_types.findIndex((t: any) => t.slug === 'tenant-thing'); - const crossIdx = result.entity_types.findIndex((t: any) => t.slug === 'catalog-canonical'); - expect(localIdx).toBeLessThan(crossIdx); - }); - }); - - describe('manage_entity_schema action="get"', () => { - it('resolves a cross-org public-catalog entity type and surfaces organization_slug', async () => { - const result = await mcpToolsCall( - 'manage_entity_schema', - { schema_type: 'entity_type', action: 'get', slug: 'catalog-canonical' }, - { token } - ); - expect(result.entity_type).not.toBeNull(); - expect(result.entity_type.slug).toBe('catalog-canonical'); - expect(result.entity_type.organization_slug).toBe(publicCatalog.slug); - expect(result.entity_type.organization_id).toBe(publicCatalog.id); - }); - - it('does not return a public-catalog $member; auto-provisions one in the caller tenant org', async () => { - // Seed a public-catalog $member type (these exist in real catalogs - // because users join public orgs). - const sql = getTestDb(); - await sql` - INSERT INTO entity_types (organization_id, slug, name, created_at, updated_at) - VALUES (${publicCatalog.id}, '$member', 'Member', current_timestamp, current_timestamp) - ON CONFLICT DO NOTHING - `; - - const result = await mcpToolsCall( - 'manage_entity_schema', - { schema_type: 'entity_type', action: 'get', slug: '$member' }, - { token } - ); - expect(result.entity_type).not.toBeNull(); - // Tenant's own $member, never the catalog's, even though the - // catalog row exists and is visibility=public. - expect(result.entity_type.organization_id).toBe(tenant.id); - }); - - it('orders tenant-first when a slug exists in both the caller org and a public catalog', async () => { - const sql = getTestDb(); - // Insert a colliding slug in both: tenant's row should win. - await sql` - INSERT INTO entity_types (organization_id, slug, name, created_at, updated_at) - VALUES (${tenant.id}, 'collision-slug', 'Tenant Version', current_timestamp, current_timestamp) - `; - await sql` - INSERT INTO entity_types (organization_id, slug, name, created_at, updated_at) - VALUES (${publicCatalog.id}, 'collision-slug', 'Catalog Version', current_timestamp, current_timestamp) - `; - - const result = await mcpToolsCall( - 'manage_entity_schema', - { schema_type: 'entity_type', action: 'get', slug: 'collision-slug' }, - { token } - ); - expect(result.entity_type.organization_id).toBe(tenant.id); - expect(result.entity_type.name).toBe('Tenant Version'); - }); - }); - - describe('relationship_type list_rules cross-org (read mode, #399)', () => { - it('lists rules of a public-catalog relationship type without 403', async () => { - const sql = getTestDb(); - // Seed a relationship type + rule inside the public catalog. - const [rt] = await sql<{ id: number }[]>` - INSERT INTO entity_relationship_types (organization_id, slug, name, is_symmetric, status, created_at, updated_at) - VALUES ( - ${publicCatalog.id}, - 'cross-org-rel-type', - 'Catalog Rel Type', - false, - 'active', - current_timestamp, - current_timestamp - ) - RETURNING id - `; - await sql` - INSERT INTO entity_relationship_type_rules ( - relationship_type_id, - source_entity_type_slug, - target_entity_type_slug, - created_at - ) - VALUES ( - ${rt.id}, - 'catalog-canonical', - 'catalog-canonical', - current_timestamp - ) - `; - - const result = await mcpToolsCall( - 'manage_entity_schema', - { - schema_type: 'relationship_type', - action: 'list_rules', - slug: 'cross-org-rel-type', - }, - { token } - ); - expect(result.action).toBe('list_rules'); - expect(Array.isArray(result.rules)).toBe(true); - expect(result.rules.length).toBeGreaterThan(0); - expect(result.rules[0].source_entity_type_slug).toBe('catalog-canonical'); - }); - - it('still rejects mutations (add_rule) on a public-catalog relationship type', async () => { - await expect( - mcpToolsCall( - 'manage_entity_schema', - { - schema_type: 'relationship_type', - action: 'add_rule', - slug: 'cross-org-rel-type', - source_entity_type_slug: 'tenant-thing', - target_entity_type_slug: 'catalog-canonical', - }, - { token } - ) - ).rejects.toThrow(/another organization/i); - }); - }); - - describe('manage_entity create with cross-org type validates against catalog schema', () => { - it('rejects metadata that violates the catalog type\'s schema', async () => { - // The catalog's `catalog-canonical` type requires `ticker`. A tenant - // creating an entity of that type without `ticker` should fail - // validation against the *catalog's* schema, not the empty default. - await expect( - mcpToolsCall( - 'manage_entity', - { - action: 'create', - entity_type: 'catalog-canonical', - name: 'Acme Bank', - metadata: { not_ticker: 'x' }, - }, - { token } - ) - ).rejects.toThrow(/ticker|required|metadata/i); - }); - - it('accepts metadata that satisfies the catalog type schema', async () => { - const result = await mcpToolsCall( - 'manage_entity', - { - action: 'create', - entity_type: 'catalog-canonical', - name: 'Acme Bank Valid', - metadata: { ticker: 'ACME' }, - }, - { token } - ); - expect(result.action).toBe('create'); - // Entity is in the caller's tenant org but uses the catalog's type id. - expect(result.entity.entity_type).toBe('catalog-canonical'); - }); - }); -}); diff --git a/packages/owletto-backend/src/__tests__/integration/entity-types/lifecycle.test.ts b/packages/owletto-backend/src/__tests__/integration/entity-types/lifecycle.test.ts deleted file mode 100644 index 7bc451129..000000000 --- a/packages/owletto-backend/src/__tests__/integration/entity-types/lifecycle.test.ts +++ /dev/null @@ -1,384 +0,0 @@ -/** - * Entity Type Lifecycle Tests - * - * Tests for entity type CRUD operations, soft-delete, org scoping, - * system type protection, and entity creation type validation. - */ - -import { beforeAll, describe, expect, it } from 'vitest'; -import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; -import { - addUserToOrganization, - createTestAccessToken, - createTestEntity, - createTestOAuthClient, - createTestOrganization, - createTestUser, - seedSystemEntityTypes, -} from '../../setup/test-fixtures'; -import { mcpToolsCall } from '../../setup/test-helpers'; - -describe('Entity Type Lifecycle', () => { - let orgA: Awaited>; - let orgB: Awaited>; - let userA: Awaited>; - let userB: Awaited>; - let tokenA: string; - let tokenB: string; - - beforeAll(async () => { - await cleanupTestDatabase(); - await seedSystemEntityTypes(); - - orgA = await createTestOrganization({ name: 'Type Test Org A' }); - orgB = await createTestOrganization({ name: 'Type Test Org B' }); - - userA = await createTestUser({ email: 'type-user-a@test.com' }); - userB = await createTestUser({ email: 'type-user-b@test.com' }); - - await addUserToOrganization(userA.id, orgA.id, 'owner'); - await addUserToOrganization(userB.id, orgB.id, 'owner'); - - const client = await createTestOAuthClient(); - const tokenAResult = await createTestAccessToken(userA.id, orgA.id, client.client_id); - const tokenBResult = await createTestAccessToken(userB.id, orgB.id, client.client_id); - tokenA = tokenAResult.token; - tokenB = tokenBResult.token; - }); - - describe('List & Get (read operations)', () => { - it('should list system entity types', async () => { - const result = await mcpToolsCall( - 'manage_entity_schema', - { schema_type: 'entity_type', action: 'list' }, - { token: tokenA } - ); - - expect(result.action).toBe('list'); - expect(result.entity_types).toBeDefined(); - expect(result.entity_types.length).toBeGreaterThan(0); - - // System types should have is_system = true - const brand = result.entity_types.find((t: any) => t.slug === 'brand'); - expect(brand).toBeDefined(); - expect(brand.is_system).toBe(true); - }); - - it('should get a specific entity type by slug', async () => { - const result = await mcpToolsCall( - 'manage_entity_schema', - { schema_type: 'entity_type', action: 'get', slug: 'brand' }, - { token: tokenA } - ); - - expect(result.action).toBe('get'); - expect(result.entity_type).toBeDefined(); - expect(result.entity_type.slug).toBe('brand'); - expect(result.entity_type.is_system).toBe(true); - }); - - it('should return null for non-existent type', async () => { - const result = await mcpToolsCall( - 'manage_entity_schema', - { schema_type: 'entity_type', action: 'get', slug: 'nonexistent-type-xyz' }, - { token: tokenA } - ); - - expect(result.action).toBe('get'); - expect(result.entity_type).toBeNull(); - }); - }); - - describe('Create', () => { - it('should create a custom entity type', async () => { - const result = await mcpToolsCall( - 'manage_entity_schema', - { - schema_type: 'entity_type', - action: 'create', - slug: 'test-widget', - name: 'Test Widget', - description: 'A test entity type', - icon: '🧪', - color: '#ff0000', - }, - { token: tokenA } - ); - - expect(result.action).toBe('create'); - expect(result.entity_type).toBeDefined(); - expect(result.entity_type.slug).toBe('test-widget'); - expect(result.entity_type.name).toBe('Test Widget'); - expect(result.entity_type.is_system).toBe(false); - expect(result.entity_type.entity_count).toBe(0); - }); - - it('should reject creating a type with a reserved slug', async () => { - await expect( - mcpToolsCall( - 'manage_entity_schema', - { schema_type: 'entity_type', action: 'create', slug: 'organization', name: 'Org Type' }, - { token: tokenA } - ) - ).rejects.toThrow(/reserved/i); - }); - - it('should reject creating a duplicate slug', async () => { - await expect( - mcpToolsCall( - 'manage_entity_schema', - { - schema_type: 'entity_type', - action: 'create', - slug: 'test-widget', - name: 'Duplicate Widget', - }, - { token: tokenA } - ) - ).rejects.toThrow(/already exists/i); - }); - - it('should allow same slug in different orgs', async () => { - const result = await mcpToolsCall( - 'manage_entity_schema', - { - schema_type: 'entity_type', - action: 'create', - slug: 'test-widget', - name: 'Test Widget Org B', - }, - { token: tokenB } - ); - - expect(result.action).toBe('create'); - expect(result.entity_type.slug).toBe('test-widget'); - }); - - it('should create type with metadata schema', async () => { - const metadataSchema = { - type: 'object', - properties: { - priority: { type: 'string', enum: ['low', 'medium', 'high'] }, - assignee: { type: 'string' }, - }, - }; - - const result = await mcpToolsCall( - 'manage_entity_schema', - { - schema_type: 'entity_type', - action: 'create', - slug: 'task-type', - name: 'Task', - metadata_schema: metadataSchema, - }, - { token: tokenA } - ); - - expect(result.entity_type.slug).toBe('task-type'); - expect(result.entity_type.metadata_schema).toBeDefined(); - }); - }); - - describe('Update', () => { - it('should update a custom entity type', async () => { - const result = await mcpToolsCall( - 'manage_entity_schema', - { - schema_type: 'entity_type', - action: 'update', - slug: 'test-widget', - name: 'Updated Widget', - description: 'Updated description', - }, - { token: tokenA } - ); - - expect(result.action).toBe('update'); - expect(result.entity_type.name).toBe('Updated Widget'); - }); - - it('should reject updating a system entity type', async () => { - await expect( - mcpToolsCall( - 'manage_entity_schema', - { schema_type: 'entity_type', action: 'update', slug: 'brand', name: 'Hacked Brand' }, - { token: tokenA } - ) - ).rejects.toThrow(/Cannot update system entity type/i); - }); - - it('should reject updating another org entity type', async () => { - // User B trying to update Org A's type - await expect( - mcpToolsCall( - 'manage_entity_schema', - { schema_type: 'entity_type', action: 'update', slug: 'task-type', name: 'Hacked Task' }, - { token: tokenB } - ) - ).rejects.toThrow(/not found/i); - }); - }); - - describe('Delete', () => { - it('should soft-delete a custom entity type with no entities', async () => { - // Create a throwaway type - await mcpToolsCall( - 'manage_entity_schema', - { schema_type: 'entity_type', action: 'create', slug: 'to-delete', name: 'To Delete' }, - { token: tokenA } - ); - - const result = await mcpToolsCall( - 'manage_entity_schema', - { schema_type: 'entity_type', action: 'delete', slug: 'to-delete' }, - { token: tokenA } - ); - - expect(result.action).toBe('delete'); - expect(result.success).toBe(true); - - // Verify it no longer appears in list - const list = await mcpToolsCall( - 'manage_entity_schema', - { schema_type: 'entity_type', action: 'list' }, - { token: tokenA } - ); - const deleted = list.entity_types.find((t: any) => t.slug === 'to-delete'); - expect(deleted).toBeUndefined(); - }); - - it('should reject deleting a system entity type', async () => { - await expect( - mcpToolsCall( - 'manage_entity_schema', - { schema_type: 'entity_type', action: 'delete', slug: 'brand' }, - { token: tokenA } - ) - ).rejects.toThrow(/Cannot delete system entity type/i); - }); - - it('should reject deleting a type that has entities', async () => { - // Create an entity of type 'test-widget' - const entity = await createTestEntity({ - name: 'Widget Entity', - entity_type: 'test-widget', - organization_id: orgA.id, - }); - - await expect( - mcpToolsCall( - 'manage_entity_schema', - { schema_type: 'entity_type', action: 'delete', slug: 'test-widget' }, - { token: tokenA } - ) - ).rejects.toThrow(/entities of this type exist/i); - - // Cleanup - const sql = getTestDb(); - await sql`DELETE FROM entities WHERE id = ${entity.id}`; - }); - }); - - describe('Entity Creation Type Validation', () => { - it('should allow creating entities with a valid type', async () => { - const result = await mcpToolsCall( - 'manage_entity', - { - action: 'create', - entity_type: 'brand', - name: 'Valid Brand', - }, - { token: tokenA } - ); - - expect(result.action).toBe('create'); - expect(result.entity.entity_type).toBe('brand'); - - // Cleanup - const sql = getTestDb(); - await sql`DELETE FROM entities WHERE id = ${result.entity.id}`; - }); - - it('should reject creating entities with unknown type', async () => { - await expect( - mcpToolsCall( - 'manage_entity', - { - action: 'create', - entity_type: 'nonexistent-type-abc', - name: 'Invalid Entity', - }, - { token: tokenA } - ) - ).rejects.toThrow(/Unknown entity type/i); - }); - - it('should allow creating entities with a custom type', async () => { - const result = await mcpToolsCall( - 'manage_entity', - { - action: 'create', - entity_type: 'test-widget', - name: 'Custom Type Entity', - }, - { token: tokenA } - ); - - expect(result.action).toBe('create'); - expect(result.entity.entity_type).toBe('test-widget'); - - // Cleanup - const sql = getTestDb(); - await sql`DELETE FROM entities WHERE id = ${result.entity.id}`; - }); - }); - - describe('Audit Trail', () => { - it('should record audit entries for create/update/delete', async () => { - const sql = getTestDb(); - - // Create - await mcpToolsCall( - 'manage_entity_schema', - { schema_type: 'entity_type', action: 'create', slug: 'audit-test', name: 'Audit Test' }, - { token: tokenA } - ); - - // Update - await mcpToolsCall( - 'manage_entity_schema', - { - schema_type: 'entity_type', - action: 'update', - slug: 'audit-test', - name: 'Audit Test Updated', - }, - { token: tokenA } - ); - - // Delete - await mcpToolsCall( - 'manage_entity_schema', - { schema_type: 'entity_type', action: 'delete', slug: 'audit-test' }, - { token: tokenA } - ); - - // Check audit entries - const audits = await sql.unsafe( - `SELECT action, actor FROM entity_type_audit - WHERE entity_type_id = ( - SELECT id FROM entity_types WHERE slug = $1 LIMIT 1 - ) - ORDER BY created_at ASC`, - ['audit-test'] - ); - - expect(audits.length).toBe(3); - expect(audits[0].action).toBe('create'); - expect(audits[1].action).toBe('update'); - expect(audits[2].action).toBe('delete'); - expect(audits[0].actor).toBe(userA.id); - }); - }); -}); diff --git a/packages/owletto-backend/src/__tests__/integration/events/content-distribution-contract.test.ts b/packages/owletto-backend/src/__tests__/integration/events/content-distribution-contract.test.ts new file mode 100644 index 000000000..63d0fd603 --- /dev/null +++ b/packages/owletto-backend/src/__tests__/integration/events/content-distribution-contract.test.ts @@ -0,0 +1,144 @@ +/** + * Compact content-distribution contracts. + * + * High-value coverage retained from the deleted timeline suites: entity-scoped + * distribution must include legacy entity_ids matches, metadata identity-link + * matches, and undated rows via created_at fallback without leaking unrelated + * entities/identities. + */ + +import { beforeAll, describe, expect, it } from 'vitest'; +import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; +import { + addUserToOrganization, + createTestAccessToken, + createTestConnection, + createTestConnectorDefinition, + createTestEntity, + createTestEvent, + createTestOAuthClient, + createTestOrganization, + createTestUser, +} from '../../setup/test-fixtures'; +import { get } from '../../setup/test-helpers'; + +describe('content-distribution contract', () => { + let orgSlug: string; + let orgId: string; + let token: string; + let entityId: number; + + beforeAll(async () => { + await cleanupTestDatabase(); + const sql = getTestDb(); + + const org = await createTestOrganization({ name: 'Distribution Contract Org' }); + orgSlug = org.slug; + orgId = org.id; + + const user = await createTestUser({ email: 'distribution-contract@test.example.com' }); + await addUserToOrganization(user.id, org.id, 'owner'); + const oauthClient = await createTestOAuthClient(); + token = (await createTestAccessToken(user.id, org.id, oauthClient.client_id)).token; + + const entity = await createTestEntity({ name: 'Alice', organization_id: org.id }); + const otherEntity = await createTestEntity({ name: 'Bob', organization_id: org.id }); + entityId = entity.id; + + await createTestConnectorDefinition({ + key: 'distribution-contract-connector', + name: 'Distribution Contract Connector', + organization_id: org.id, + }); + const connection = await createTestConnection({ + organization_id: org.id, + connector_key: 'distribution-contract-connector', + entity_ids: [entity.id], + }); + + await sql` + INSERT INTO entity_identities (organization_id, entity_id, namespace, identifier) + VALUES (${org.id}, ${entity.id}, 'email', 'alice@example.com') + `; + + // Legacy attribution: entity_ids contains Alice, but occurred_at is NULL; + // the endpoint should fall back to created_at for bucketing. + const undated = await createTestEvent({ + entity_id: entity.id, + connection_id: connection.id, + content: 'Undated Alice event.', + occurred_at: new Date('2025-06-01T10:00:00Z'), + organization_id: org.id, + }); + await sql` + UPDATE events + SET occurred_at = NULL, created_at = ${new Date('2025-06-01T10:00:00Z')} + WHERE id = ${undated.id} + `; + + // Identity attribution: entity_ids is empty, but metadata.email matches a + // live entity_identities row for Alice. + await createTestEvent({ + entity_ids: [], + connection_id: connection.id, + content: 'Identity-linked Alice event.', + occurred_at: new Date('2025-06-02T10:00:00Z'), + organization_id: org.id, + metadata: { email: 'alice@example.com' }, + }); + + await createTestEvent({ + entity_ids: [], + connection_id: connection.id, + content: 'Unrelated email event.', + occurred_at: new Date('2025-06-03T10:00:00Z'), + organization_id: org.id, + metadata: { email: 'carol@example.com' }, + }); + await createTestEvent({ + entity_id: otherEntity.id, + connection_id: connection.id, + content: 'Bob event, not Alice.', + occurred_at: new Date('2025-06-04T10:00:00Z'), + organization_id: org.id, + }); + }); + + async function distributionByDate() { + const response = await get(`/api/${orgSlug}/entities/${entityId}/content-distribution`, { + token, + }); + expect(response.status).toBe(200); + const body = (await response.json()) as { + distribution: Array<{ date: string; count: number }>; + }; + return Object.fromEntries(body.distribution.map((row) => [row.date, row.count])); + } + + it('counts entity_ids matches, identity-link matches, and created_at fallback only', async () => { + const byDate = await distributionByDate(); + + expect(byDate['2025-06-01']).toBe(1); + expect(byDate['2025-06-02']).toBe(1); + expect(byDate['2025-06-03']).toBeUndefined(); + expect(byDate['2025-06-04']).toBeUndefined(); + expect(Object.values(byDate).reduce((sum, count) => sum + count, 0)).toBe(2); + }); + + it('does not match events through soft-deleted identity links', async () => { + const sql = getTestDb(); + await sql` + UPDATE entity_identities + SET deleted_at = NOW() + WHERE organization_id = ${orgId} + AND entity_id = ${entityId} + AND namespace = 'email' + AND identifier = 'alice@example.com' + `; + + const byDate = await distributionByDate(); + expect(byDate['2025-06-01']).toBe(1); + expect(byDate['2025-06-02']).toBeUndefined(); + expect(Object.values(byDate).reduce((sum, count) => sum + count, 0)).toBe(1); + }); +}); diff --git a/packages/owletto-backend/src/__tests__/integration/events/content-distribution-identity-links.test.ts b/packages/owletto-backend/src/__tests__/integration/events/content-distribution-identity-links.test.ts deleted file mode 100644 index fafe456cb..000000000 --- a/packages/owletto-backend/src/__tests__/integration/events/content-distribution-identity-links.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -/** - * Integration test: content-distribution resolves events via entity_identities. - * - * Proves `entityLinkMatchSql` (used by src/index.ts handleContentDistribution - * and several other content-count sites) matches events two ways: - * 1. Legacy: event.entity_ids contains the target entity id - * 2. Identity: event.metadata[namespace] = a live entity_identities row - * - * Without the identity branch, historically ingested events (or events from - * connectors that stamp only namespace keys, not entity_ids) would be - * invisible to entity-scoped queries. - */ - -import { beforeAll, describe, expect, it } from 'vitest'; -import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; -import { - addUserToOrganization, - createTestAccessToken, - createTestConnection, - createTestConnectorDefinition, - createTestEntity, - createTestEvent, - createTestOAuthClient, - createTestOrganization, - createTestUser, - seedSystemEntityTypes, -} from '../../setup/test-fixtures'; -import { get } from '../../setup/test-helpers'; - -describe('content-distribution > entity identity links', () => { - let org: Awaited>; - let token: string; - let entity: Awaited>; - let otherEntity: Awaited>; - let connection: Awaited>; - - beforeAll(async () => { - await cleanupTestDatabase(); - await seedSystemEntityTypes(); - - org = await createTestOrganization({ name: 'Identity Links Org' }); - const user = await createTestUser({ email: 'identity-links-test@example.com' }); - await addUserToOrganization(user.id, org.id, 'owner'); - - const client = await createTestOAuthClient(); - token = (await createTestAccessToken(user.id, org.id, client.client_id)).token; - - entity = await createTestEntity({ name: 'Alice', organization_id: org.id }); - otherEntity = await createTestEntity({ name: 'Bob', organization_id: org.id }); - - await createTestConnectorDefinition({ - key: 'identity-link-test-connector', - name: 'Identity Link Test', - organization_id: org.id, - }); - - connection = await createTestConnection({ - organization_id: org.id, - connector_key: 'identity-link-test-connector', - entity_ids: [entity.id], - }); - - const sql = getTestDb(); - - // Live identity claim: Alice owns alice@example.com - await sql` - INSERT INTO entity_identities (organization_id, entity_id, namespace, identifier) - VALUES (${org.id}, ${entity.id}, 'email', 'alice@example.com') - `; - - // Event 1 — legacy attribution: entity_ids array contains Alice. - await createTestEvent({ - entity_id: entity.id, - connection_id: connection.id, - content: 'Alice legacy-attributed event.', - occurred_at: new Date('2025-06-01T10:00:00Z'), - organization_id: org.id, - }); - - // Event 2 — identity attribution: entity_ids is empty, but metadata.email - // matches the live entity_identities claim for Alice. - await createTestEvent({ - entity_ids: [], - connection_id: connection.id, - content: 'Alice identity-attributed event.', - occurred_at: new Date('2025-06-02T10:00:00Z'), - organization_id: org.id, - metadata: { email: 'alice@example.com' }, - }); - - // Event 3 — unrelated email: should NOT surface under Alice. - await createTestEvent({ - entity_ids: [], - connection_id: connection.id, - content: 'Someone else event.', - occurred_at: new Date('2025-06-03T10:00:00Z'), - organization_id: org.id, - metadata: { email: 'carol@example.com' }, - }); - - // Event 4 — Bob's entity_ids: sanity check org scoping holds. - await createTestEvent({ - entity_id: otherEntity.id, - connection_id: connection.id, - content: 'Bob event, not Alice.', - occurred_at: new Date('2025-06-04T10:00:00Z'), - organization_id: org.id, - }); - }); - - it('counts events linked via entity_ids array AND entity_identities metadata match', async () => { - const response = await get(`/api/${org.slug}/entities/${entity.id}/content-distribution`, { - token, - }); - expect(response.status).toBe(200); - - const body = (await response.json()) as { - distribution: Array<{ date: string; count: number }>; - }; - - // Alice should have two events: the legacy one (entity_ids) and the - // identity-linked one (metadata.email via entity_identities). - const byDate = Object.fromEntries(body.distribution.map((r) => [r.date, r.count])); - expect(byDate['2025-06-01']).toBe(1); - expect(byDate['2025-06-02']).toBe(1); - // Carol's email and Bob's entity_ids must NOT surface for Alice. - expect(byDate['2025-06-03']).toBeUndefined(); - expect(byDate['2025-06-04']).toBeUndefined(); - - const total = body.distribution.reduce((sum, r) => sum + r.count, 0); - expect(total).toBe(2); - }); - - it('soft-deleted identity links no longer match events', async () => { - const sql = getTestDb(); - await sql` - UPDATE entity_identities - SET deleted_at = NOW() - WHERE organization_id = ${org.id} - AND entity_id = ${entity.id} - AND namespace = 'email' - AND identifier = 'alice@example.com' - `; - - try { - const response = await get(`/api/${org.slug}/entities/${entity.id}/content-distribution`, { - token, - }); - expect(response.status).toBe(200); - - const body = (await response.json()) as { - distribution: Array<{ date: string; count: number }>; - }; - const total = body.distribution.reduce((sum, r) => sum + r.count, 0); - // Only the legacy entity_ids event remains — the metadata.email event - // is no longer linked because the identity row is soft-deleted. - expect(total).toBe(1); - expect(body.distribution[0]?.date).toBe('2025-06-01'); - } finally { - // Restore for any subsequent tests - await sql` - UPDATE entity_identities - SET deleted_at = NULL - WHERE organization_id = ${org.id} - AND entity_id = ${entity.id} - AND namespace = 'email' - AND identifier = 'alice@example.com' - `; - } - }); -}); diff --git a/packages/owletto-backend/src/__tests__/integration/events/content-distribution.test.ts b/packages/owletto-backend/src/__tests__/integration/events/content-distribution.test.ts deleted file mode 100644 index b0c311617..000000000 --- a/packages/owletto-backend/src/__tests__/integration/events/content-distribution.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { beforeAll, describe, expect, it } from 'vitest'; -import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; -import { - addUserToOrganization, - createTestAccessToken, - createTestConnection, - createTestConnectorDefinition, - createTestEntity, - createTestEvent, - createTestOAuthClient, - createTestOrganization, - createTestUser, - seedSystemEntityTypes, -} from '../../setup/test-fixtures'; -import { get } from '../../setup/test-helpers'; - -describe('Content distribution API', () => { - let org: Awaited>; - let user: Awaited>; - let token: string; - let entity: Awaited>; - let connection: Awaited>; - - beforeAll(async () => { - await cleanupTestDatabase(); - await seedSystemEntityTypes(); - - org = await createTestOrganization({ name: 'Timeline Test Org' }); - user = await createTestUser({ email: 'timeline-test@example.com' }); - await addUserToOrganization(user.id, org.id, 'owner'); - - const client = await createTestOAuthClient(); - token = (await createTestAccessToken(user.id, org.id, client.client_id)).token; - - entity = await createTestEntity({ - name: 'OpenClaw', - organization_id: org.id, - }); - - await createTestConnectorDefinition({ - key: 'timeline-test-connector', - name: 'Timeline Test Connector', - organization_id: org.id, - }); - - connection = await createTestConnection({ - organization_id: org.id, - connector_key: 'timeline-test-connector', - entity_ids: [entity.id], - }); - }); - - it('falls back to created_at when occurred_at is null', async () => { - const db = getTestDb(); - const createdAt = new Date('2025-02-10T15:30:00.000Z'); - - const event = await createTestEvent({ - entity_id: entity.id, - connection_id: connection.id, - content: 'Undated content should still appear in the timeline.', - title: 'Undated Event', - occurred_at: createdAt, - }); - - await db` - UPDATE events - SET occurred_at = NULL, created_at = ${createdAt} - WHERE id = ${event.id} - `; - - const response = await get(`/api/${org.slug}/entities/${entity.id}/content-distribution`, { - token, - }); - - expect(response.status).toBe(200); - - const body = (await response.json()) as { - distribution: Array<{ date: string; count: number }>; - }; - - expect(body.distribution).toEqual([{ date: '2025-02-10', count: 1 }]); - }); -}); diff --git a/packages/owletto-backend/src/__tests__/integration/events/get-content-advanced.test.ts b/packages/owletto-backend/src/__tests__/integration/events/get-content-advanced.test.ts deleted file mode 100644 index d338cd1f9..000000000 --- a/packages/owletto-backend/src/__tests__/integration/events/get-content-advanced.test.ts +++ /dev/null @@ -1,491 +0,0 @@ -/** - * Get Content Advanced Integration Tests - * - * Tests for engagement scoring, watcher mode, advanced date filters, - * kind/platform filters, and sort variations. - */ - -import { beforeAll, describe, expect, it } from 'vitest'; -import { cleanupTestDatabase } from '../../setup/test-db'; -import { - addUserToOrganization, - createTestAccessToken, - createTestConnection, - createTestConnectorDefinition, - createTestEntity, - createTestEvent, - createTestOAuthClient, - createTestOrganization, - createTestUser, - createTestWatcher, - createTestWatcherTemplate, - seedSystemEntityTypes, -} from '../../setup/test-fixtures'; -import { mcpToolsCall } from '../../setup/test-helpers'; - -describe('Get Content Advanced', () => { - let org: Awaited>; - let user: Awaited>; - let token: string; - let client: Awaited>; - let entity: Awaited>; - let connection: Awaited>; - let template: Awaited>; - let watcher: Awaited>; - let cursorNewest: Awaited>; - let cursorTieLower: Awaited>; - let cursorTieHigher: Awaited>; - let cursorOlder: Awaited>; - let cursorOldest: Awaited>; - - beforeAll(async () => { - await cleanupTestDatabase(); - await seedSystemEntityTypes(); - - org = await createTestOrganization({ name: 'Content Adv Test Org' }); - user = await createTestUser({ email: 'content-adv@test.com' }); - await addUserToOrganization(user.id, org.id, 'owner'); - - client = await createTestOAuthClient(); - token = (await createTestAccessToken(user.id, org.id, client.client_id)).token; - - entity = await createTestEntity({ name: 'Content Adv Entity', organization_id: org.id }); - - await createTestConnectorDefinition({ - key: 'test-content-connector', - name: 'Content Connector', - organization_id: org.id, - }); - - connection = await createTestConnection({ - organization_id: org.id, - connector_key: 'test-content-connector', - entity_ids: [entity.id], - }); - - // Create events with varying dates and scores - const now = new Date(); - const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); - const twoWeeksAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000); - const oneMonthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); - - // Events with different dates - await createTestEvent({ - entity_id: entity.id, - connection_id: connection.id, - content: 'Recent content with high engagement.', - title: 'Recent Post', - occurred_at: now, - semantic_type: 'review', - }); - - await createTestEvent({ - entity_id: entity.id, - connection_id: connection.id, - content: 'Week-old content with medium engagement.', - title: 'Week Old Post', - occurred_at: oneWeekAgo, - semantic_type: 'review', - }); - - await createTestEvent({ - entity_id: entity.id, - connection_id: connection.id, - content: 'Two weeks old discussion content.', - title: 'Discussion Post', - occurred_at: twoWeeksAgo, - semantic_type: 'discussion', - }); - - await createTestEvent({ - entity_id: entity.id, - connection_id: connection.id, - content: 'Old content from a month ago.', - title: 'Month Old Post', - occurred_at: oneMonthAgo, - semantic_type: 'content', - }); - - const cursorTieTimestamp = new Date(now.getTime() - 2 * 60 * 60 * 1000); - - cursorNewest = await createTestEvent({ - entity_id: entity.id, - connection_id: connection.id, - content: 'Newest cursor event.', - title: 'Cursor Newest', - occurred_at: new Date(now.getTime() - 60 * 1000), - semantic_type: 'cursor-test', - }); - - cursorTieLower = await createTestEvent({ - entity_id: entity.id, - connection_id: connection.id, - content: 'Tie cursor event lower id.', - title: 'Cursor Tie Lower', - occurred_at: cursorTieTimestamp, - semantic_type: 'cursor-test', - }); - - cursorTieHigher = await createTestEvent({ - entity_id: entity.id, - connection_id: connection.id, - content: 'Tie cursor event higher id.', - title: 'Cursor Tie Higher', - occurred_at: cursorTieTimestamp, - semantic_type: 'cursor-test', - }); - - cursorOlder = await createTestEvent({ - entity_id: entity.id, - connection_id: connection.id, - content: 'Older cursor event.', - title: 'Cursor Older', - occurred_at: new Date(now.getTime() - 4 * 60 * 60 * 1000), - semantic_type: 'cursor-test', - }); - - cursorOldest = await createTestEvent({ - entity_id: entity.id, - connection_id: connection.id, - content: 'Oldest cursor event.', - title: 'Cursor Oldest', - occurred_at: new Date(now.getTime() - 6 * 60 * 60 * 1000), - semantic_type: 'cursor-test', - }); - - // Create watcher template + watcher for watcher mode test - template = await createTestWatcherTemplate({ - slug: 'content-test-template', - name: 'Content Test Template', - prompt: 'Analyze content for {{entities}}', - output_schema: { type: 'object', properties: { summary: { type: 'string' } } }, - }); - - watcher = await createTestWatcher({ - entity_id: entity.id, - template_id: template.id, - organization_id: org.id, - schedule: '0 9 * * *', - scheduler_client_id: 'codex', - }); - }); - - describe('sort variations', () => { - it('should sort by date descending (default)', async () => { - const result = await mcpToolsCall( - 'read_knowledge', - { entity_id: entity.id, sort_by: 'date', sort_order: 'desc' }, - { token } - ); - expect(result.content).toBeDefined(); - expect(result.content.length).toBeGreaterThanOrEqual(2); - // First item should be the most recent - const dates = result.content.map((c: any) => new Date(c.occurred_at).getTime()); - for (let i = 1; i < dates.length; i++) { - expect(dates[i - 1]).toBeGreaterThanOrEqual(dates[i]); - } - }); - - it('should sort by date ascending', async () => { - const result = await mcpToolsCall( - 'read_knowledge', - { entity_id: entity.id, sort_by: 'date', sort_order: 'asc' }, - { token } - ); - expect(result.content).toBeDefined(); - const dates = result.content.map((c: any) => new Date(c.occurred_at).getTime()); - for (let i = 1; i < dates.length; i++) { - expect(dates[i - 1]).toBeLessThanOrEqual(dates[i]); - } - }); - - it('should sort by score', async () => { - const result = await mcpToolsCall( - 'read_knowledge', - { entity_id: entity.id, sort_by: 'score' }, - { token } - ); - expect(result.content).toBeDefined(); - expect(result.content.length).toBeGreaterThanOrEqual(1); - }); - }); - - describe('date filters', () => { - it('should filter with since date', async () => { - const result = await mcpToolsCall( - 'read_knowledge', - { entity_id: entity.id, since: '7d' }, - { token } - ); - expect(result.content).toBeDefined(); - // Should only include recent content - for (const item of result.content) { - const occurredAt = new Date(item.occurred_at); - const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); - expect(occurredAt.getTime()).toBeGreaterThanOrEqual(sevenDaysAgo.getTime() - 60000); - } - }); - - it('should filter with since + until combined', async () => { - const result = await mcpToolsCall( - 'read_knowledge', - { entity_id: entity.id, since: '30d', until: '7d' }, - { token } - ); - expect(result.content).toBeDefined(); - }); - }); - - describe('chronological cursor pagination', () => { - it('normalizes root_origin_id for top-level results', async () => { - const result = await mcpToolsCall( - 'read_knowledge', - { entity_id: entity.id, semantic_type: 'cursor-test', sort_by: 'date', sort_order: 'desc' }, - { token } - ); - - expect(result.content.length).toBeGreaterThanOrEqual(5); - for (const item of result.content) { - expect(item.origin_parent_id).toBeNull(); - expect(item.root_origin_id).toBe(item.origin_id); - } - }); - - it('returns older and newer chronological slices without overlap', async () => { - const firstSlice = await mcpToolsCall( - 'read_knowledge', - { - entity_id: entity.id, - semantic_type: 'cursor-test', - sort_by: 'date', - sort_order: 'desc', - limit: 2, - }, - { token } - ); - - expect(firstSlice.page.has_older).toBe(true); - expect(firstSlice.page.has_newer).toBe(false); - expect(firstSlice.content.map((item: any) => item.id)).toEqual([ - cursorNewest.id, - cursorTieHigher.id, - ]); - - const olderSlice = await mcpToolsCall( - 'read_knowledge', - { - entity_id: entity.id, - semantic_type: 'cursor-test', - sort_by: 'date', - sort_order: 'desc', - limit: 2, - before_occurred_at: firstSlice.content[1].occurred_at, - before_id: firstSlice.content[1].id, - }, - { token } - ); - - expect(olderSlice.page.has_newer).toBe(true); - expect(olderSlice.page.has_older).toBe(true); - expect(olderSlice.content.map((item: any) => item.id)).toEqual([ - cursorTieLower.id, - cursorOlder.id, - ]); - - const oldestSlice = await mcpToolsCall( - 'read_knowledge', - { - entity_id: entity.id, - semantic_type: 'cursor-test', - sort_by: 'date', - sort_order: 'desc', - limit: 2, - before_occurred_at: olderSlice.content[1].occurred_at, - before_id: olderSlice.content[1].id, - }, - { token } - ); - - expect(oldestSlice.content.map((item: any) => item.id)).toEqual([cursorOldest.id]); - expect(oldestSlice.page.has_older).toBe(false); - expect(oldestSlice.page.has_newer).toBe(true); - - const newerSlice = await mcpToolsCall( - 'read_knowledge', - { - entity_id: entity.id, - semantic_type: 'cursor-test', - sort_by: 'date', - sort_order: 'desc', - limit: 2, - after_occurred_at: olderSlice.content[0].occurred_at, - after_id: olderSlice.content[0].id, - }, - { token } - ); - - expect(newerSlice.content.map((item: any) => item.id)).toEqual([ - cursorNewest.id, - cursorTieHigher.id, - ]); - }); - - it('uses id as the tie-breaker when timestamps are identical', async () => { - const firstSlice = await mcpToolsCall( - 'read_knowledge', - { - entity_id: entity.id, - semantic_type: 'cursor-test', - sort_by: 'date', - sort_order: 'desc', - limit: 2, - }, - { token } - ); - - const nextSlice = await mcpToolsCall( - 'read_knowledge', - { - entity_id: entity.id, - semantic_type: 'cursor-test', - sort_by: 'date', - sort_order: 'desc', - limit: 1, - before_occurred_at: firstSlice.content[1].occurred_at, - before_id: firstSlice.content[1].id, - }, - { token } - ); - - expect(firstSlice.content[1].id).toBe(cursorTieHigher.id); - expect(nextSlice.content[0].id).toBe(cursorTieLower.id); - expect(nextSlice.content[0].occurred_at).toBe(firstSlice.content[1].occurred_at); - }); - - it('ignores chronological cursors when sort_by=score', async () => { - const baseline = await mcpToolsCall( - 'read_knowledge', - { - entity_id: entity.id, - semantic_type: 'cursor-test', - sort_by: 'score', - limit: 3, - }, - { token } - ); - - const withCursor = await mcpToolsCall( - 'read_knowledge', - { - entity_id: entity.id, - semantic_type: 'cursor-test', - sort_by: 'score', - limit: 3, - before_occurred_at: new Date().toISOString(), - before_id: cursorOlder.id, - after_occurred_at: new Date().toISOString(), - after_id: cursorNewest.id, - }, - { token } - ); - - expect(withCursor.content.map((item: any) => item.id)).toEqual( - baseline.content.map((item: any) => item.id) - ); - }); - }); - - describe('semantic type filter', () => { - it('should filter by semantic_type', async () => { - const result = await mcpToolsCall( - 'read_knowledge', - { entity_id: entity.id, semantic_type: 'review' }, - { token } - ); - expect(result.content).toBeDefined(); - // semantic type filter works at the SQL level; verify only review items are returned - expect(result.content.length).toBe(2); - const titles = result.content.map((c: any) => c.title); - expect(titles).toContain('Recent Post'); - expect(titles).toContain('Week Old Post'); - }); - }); - - describe('watcher mode', () => { - it('should return content for watcher_id with window token', async () => { - const result = await mcpToolsCall( - 'read_knowledge', - { watcher_id: watcher.id, since: '30d' }, - { token } - ); - expect(result.content).toBeDefined(); - expect(result.window_token).toBeDefined(); - expect(result.window_start).toBeDefined(); - expect(result.window_end).toBeDefined(); - }); - - it('should store execution provenance and advance next_run_at on complete_window', async () => { - const before = await mcpToolsCall( - 'get_watcher', - { watcher_id: String(watcher.id) }, - { token } - ); - - const contentResult = await mcpToolsCall( - 'read_knowledge', - { watcher_id: watcher.id, since: '30d' }, - { token } - ); - - await mcpToolsCall( - 'manage_watchers', - { - action: 'complete_window', - window_token: contentResult.window_token, - extracted_data: { summary: 'External analysis summary' }, - model: 'gpt-5.4', - run_metadata: { provider: 'openai', temperature: 0.2 }, - }, - { token } - ); - - const after = await mcpToolsCall( - 'get_watcher', - { watcher_id: String(watcher.id) }, - { token } - ); - - expect(after.windows.length).toBeGreaterThan(0); - expect(after.windows[0]?.model_used).toBe('gpt-5.4'); - expect(after.windows[0]?.client_id).toBe(client.client_id); - expect(after.windows[0]?.run_metadata).toEqual({ provider: 'openai', temperature: 0.2 }); - expect(after.watcher?.next_run_at).toBeDefined(); - expect(new Date(after.watcher.next_run_at).getTime()).toBeGreaterThan( - new Date(before.watcher.next_run_at).getTime() - ); - }); - }); - - describe('pagination', () => { - it('should respect limit and offset', async () => { - const page1 = await mcpToolsCall( - 'read_knowledge', - { entity_id: entity.id, limit: 2, offset: 0 }, - { token } - ); - expect(page1.content.length).toBeLessThanOrEqual(2); - expect(page1.total).toBeGreaterThanOrEqual(2); - - const page2 = await mcpToolsCall( - 'read_knowledge', - { entity_id: entity.id, limit: 2, offset: 2 }, - { token } - ); - expect(page2.content).toBeDefined(); - // Pages should have different content - if (page1.content.length > 0 && page2.content.length > 0) { - expect(page1.content[0].id).not.toBe(page2.content[0].id); - } - }); - }); -}); diff --git a/packages/owletto-backend/src/__tests__/integration/events/scheduling-contract.test.ts b/packages/owletto-backend/src/__tests__/integration/events/scheduling-contract.test.ts new file mode 100644 index 000000000..2832240f0 --- /dev/null +++ b/packages/owletto-backend/src/__tests__/integration/events/scheduling-contract.test.ts @@ -0,0 +1,176 @@ +/** + * Scheduler / worker ingestion contracts retained from deleted broad suites. + * + * These paths are high-value because they are production queue boundaries: + * embed backfill must dedupe under concurrent ticks, worker stream must create + * connector-owned events with no human creator, and worker polling must claim a + * due sync run exactly once. + */ + +import { beforeEach, describe, expect, it } from 'vitest'; +import type { Env } from '../../../index'; +import { triggerEmbedBackfill } from '../../../scheduled/trigger-embed-backfill'; +import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; +import { + addUserToOrganization, + createTestConnection, + createTestConnectorDefinition, + createTestEntity, + createTestEvent, + createTestOrganization, + createTestUser, +} from '../../setup/test-fixtures'; +import { post } from '../../setup/test-helpers'; + +describe('scheduler and worker ingestion contracts', () => { + beforeEach(async () => { + await cleanupTestDatabase(); + }); + + it('creates at most one embed_backfill run with the missing event ids', async () => { + const org = await createTestOrganization({ name: 'Backfill Contract Org' }); + const user = await createTestUser({ email: 'backfill-contract@test.example.com' }); + await addUserToOrganization(user.id, org.id, 'owner'); + const entity = await createTestEntity({ name: 'Backfill Entity', organization_id: org.id }); + + for (let i = 0; i < 3; i++) { + await createTestEvent({ entity_id: entity.id, content: `Missing embedding ${i}` }); + } + + const [resultA, resultB] = await Promise.all([ + triggerEmbedBackfill({} as Env), + triggerEmbedBackfill({} as Env), + ]); + + expect(resultA.runsCreated + resultB.runsCreated).toBe(1); + + const runs = await getTestDb()` + SELECT status, action_input + FROM runs + WHERE organization_id = ${org.id} + AND run_type = 'embed_backfill' + AND status IN ('pending', 'running') + `; + expect(runs).toHaveLength(1); + expect(String(runs[0].status)).toBe('pending'); + + const actionInput = runs[0].action_input as { event_ids?: unknown[] }; + expect(actionInput.event_ids).toHaveLength(3); + }); + + it('streams connector events without a human creator', async () => { + const sql = getTestDb(); + const org = await createTestOrganization({ name: 'Worker Stream Contract Org' }); + + await createTestConnectorDefinition({ + key: 'contract.worker.stream', + name: 'Worker Stream Contract Connector', + version: '1.0.0', + feeds_schema: { mentions: { description: 'Mentions feed' } }, + organization_id: org.id, + }); + const connection = await createTestConnection({ + organization_id: org.id, + connector_key: 'contract.worker.stream', + status: 'active', + }); + const [feed] = await sql` + INSERT INTO feeds (organization_id, connection_id, feed_key, status, created_at, updated_at) + VALUES (${org.id}, ${connection.id}, 'mentions', 'active', current_timestamp, current_timestamp) + RETURNING id + `; + const [run] = await sql` + INSERT INTO runs ( + organization_id, run_type, feed_id, connection_id, connector_key, connector_version, + status, approval_status, created_at + ) VALUES ( + ${org.id}, 'sync', ${feed.id}, ${connection.id}, 'contract.worker.stream', '1.0.0', + 'running', 'auto', current_timestamp + ) + RETURNING id + `; + + const response = await post('/api/workers/stream', { + body: { + type: 'batch', + run_id: Number(run.id), + items: [ + { + id: 'worker-stream-contract-item', + title: 'Source item', + payload_text: 'Connector-sourced content', + source_url: 'https://example.com/source-item', + occurred_at: new Date().toISOString(), + score: 10, + }, + ], + }, + }); + expect(response.status).toBe(200); + + const events = await sql` + SELECT created_by, author_name, connector_key, connection_id, feed_id, run_id + FROM events + WHERE origin_id = 'worker-stream-contract-item' + AND organization_id = ${org.id} + LIMIT 1 + `; + expect(events).toHaveLength(1); + expect(events[0].created_by).toBeNull(); + expect(events[0].author_name).toBeNull(); + expect(events[0].connector_key).toBe('contract.worker.stream'); + expect(Number(events[0].connection_id)).toBe(Number(connection.id)); + expect(Number(events[0].feed_id)).toBe(Number(feed.id)); + expect(Number(events[0].run_id)).toBe(Number(run.id)); + }); + + it('materializes and claims a due sync run exactly once under concurrent polls', async () => { + const sql = getTestDb(); + const org = await createTestOrganization({ name: 'Worker Poll Contract Org' }); + + await createTestConnectorDefinition({ + key: 'contract.worker.poll', + name: 'Worker Poll Contract Connector', + version: '1.0.0', + feeds_schema: { mentions: { description: 'Mentions feed' } }, + organization_id: org.id, + }); + const connection = await createTestConnection({ + organization_id: org.id, + connector_key: 'contract.worker.poll', + status: 'active', + }); + const [feed] = await sql` + INSERT INTO feeds ( + organization_id, connection_id, feed_key, status, schedule, next_run_at, created_at, updated_at + ) VALUES ( + ${org.id}, ${connection.id}, 'mentions', 'active', '* * * * *', + current_timestamp - INTERVAL '1 minute', current_timestamp, current_timestamp + ) + RETURNING id + `; + + const [responseA, responseB] = await Promise.all([ + post('/api/workers/poll', { body: { worker_id: 'worker-a', capabilities: { browser: false } } }), + post('/api/workers/poll', { body: { worker_id: 'worker-b', capabilities: { browser: false } } }), + ]); + const bodies = [await responseA.json(), await responseB.json()]; + const running = bodies.filter((body) => typeof body.run_id === 'number'); + const idle = bodies.filter((body) => body.next_poll_seconds === 10); + + expect(running).toHaveLength(1); + expect(idle).toHaveLength(1); + expect(Number(running[0].feed_id)).toBe(Number(feed.id)); + expect(running[0].run_type).toBe('sync'); + + const runs = await sql` + SELECT status, claimed_by, feed_id + FROM runs + WHERE feed_id = ${feed.id} + AND run_type = 'sync' + `; + expect(runs).toHaveLength(1); + expect(String(runs[0].status)).toBe('running'); + expect(['worker-a', 'worker-b']).toContain(String(runs[0].claimed_by)); + }); +}); diff --git a/packages/owletto-backend/src/__tests__/integration/events/trigger-embed-backfill.test.ts b/packages/owletto-backend/src/__tests__/integration/events/trigger-embed-backfill.test.ts deleted file mode 100644 index 80a8d1b16..000000000 --- a/packages/owletto-backend/src/__tests__/integration/events/trigger-embed-backfill.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * Trigger Embed Backfill Integration Tests - * - * Verifies that scheduled backfill run creation is race-safe and organization-scoped. - */ - -import { beforeEach, describe, expect, it } from 'vitest'; -import type { Env } from '../../../index'; -import { triggerEmbedBackfill } from '../../../scheduled/trigger-embed-backfill'; -import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; -import { - addUserToOrganization, - createTestEntity, - createTestEvent, - createTestOrganization, - createTestUser, - seedSystemEntityTypes, -} from '../../setup/test-fixtures'; - -describe('triggerEmbedBackfill', () => { - beforeEach(async () => { - await cleanupTestDatabase(); - await seedSystemEntityTypes(); - }); - - it('creates one pending embed_backfill run with event_ids payload', async () => { - const sql = getTestDb(); - - const org = await createTestOrganization({ name: 'Backfill Org' }); - const user = await createTestUser({ email: 'backfill-user@test.com' }); - await addUserToOrganization(user.id, org.id, 'owner'); - - const entity = await createTestEntity({ - name: 'Backfill Entity', - organization_id: org.id, - }); - - await createTestEvent({ entity_id: entity.id, content: 'Missing embedding 1' }); - await createTestEvent({ entity_id: entity.id, content: 'Missing embedding 2' }); - await createTestEvent({ entity_id: entity.id, content: 'Missing embedding 3' }); - - const result = await triggerEmbedBackfill({} as Env); - - expect(result.organizations).toBe(1); - expect(result.runsCreated).toBe(1); - expect(result.totalEvents).toBe(3); - - const runs = await sql` - SELECT id, organization_id, run_type, status, action_input - FROM runs - WHERE run_type = 'embed_backfill' - AND organization_id = ${org.id} - `; - - expect(runs.length).toBe(1); - expect(String(runs[0].status)).toBe('pending'); - - const rawActionInput = (runs[0] as { action_input: unknown }).action_input; - const actionInput = - typeof rawActionInput === 'string' - ? (JSON.parse(rawActionInput) as { event_ids?: unknown }) - : (rawActionInput as { event_ids?: unknown }); - expect(Array.isArray(actionInput?.event_ids)).toBe(true); - expect((actionInput.event_ids as unknown[]).length).toBe(3); - }); - - it('prevents duplicate active embed_backfill runs under concurrent scheduler ticks', async () => { - const sql = getTestDb(); - - const org = await createTestOrganization({ name: 'Concurrent Backfill Org' }); - const user = await createTestUser({ email: 'concurrent-backfill-user@test.com' }); - await addUserToOrganization(user.id, org.id, 'owner'); - - const entity = await createTestEntity({ - name: 'Concurrent Backfill Entity', - organization_id: org.id, - }); - - for (let i = 0; i < 8; i++) { - await createTestEvent({ entity_id: entity.id, content: `Concurrent event ${i}` }); - } - - const [resultA, resultB] = await Promise.all([ - triggerEmbedBackfill({} as Env), - triggerEmbedBackfill({} as Env), - ]); - - const activeRuns = await sql` - SELECT id - FROM runs - WHERE organization_id = ${org.id} - AND run_type = 'embed_backfill' - AND status IN ('pending', 'running') - `; - - expect(activeRuns.length).toBe(1); - expect(resultA.runsCreated + resultB.runsCreated).toBe(1); - }); -}); diff --git a/packages/owletto-backend/src/__tests__/integration/events/worker-poll-scheduling.test.ts b/packages/owletto-backend/src/__tests__/integration/events/worker-poll-scheduling.test.ts deleted file mode 100644 index 54580e915..000000000 --- a/packages/owletto-backend/src/__tests__/integration/events/worker-poll-scheduling.test.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { beforeAll, describe, expect, it } from 'vitest'; -import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; -import { - createTestConnection, - createTestConnectorDefinition, - createTestOrganization, -} from '../../setup/test-fixtures'; -import { post } from '../../setup/test-helpers'; - -describe('Worker Poll Scheduling', () => { - beforeAll(async () => { - await cleanupTestDatabase(); - }); - - it('streams connector events without a human creator', async () => { - const sql = getTestDb(); - - const org = await createTestOrganization({ name: 'Worker Stream Org' }); - - await createTestConnectorDefinition({ - key: 'test.worker.stream', - name: 'Worker Stream Connector', - version: '1.0.0', - feeds_schema: { - mentions: { description: 'Mentions feed' }, - }, - organization_id: org.id, - }); - - const connection = await createTestConnection({ - organization_id: org.id, - connector_key: 'test.worker.stream', - status: 'active', - }); - - const [feed] = await sql` - INSERT INTO feeds ( - organization_id, - connection_id, - feed_key, - status, - created_at, - updated_at - ) VALUES ( - ${org.id}, - ${connection.id}, - 'mentions', - 'active', - current_timestamp, - current_timestamp - ) - RETURNING id - `; - - const [run] = await sql` - INSERT INTO runs ( - organization_id, - run_type, - feed_id, - connection_id, - connector_key, - connector_version, - status, - approval_status, - created_at - ) VALUES ( - ${org.id}, - 'sync', - ${feed.id}, - ${connection.id}, - 'test.worker.stream', - '1.0.0', - 'running', - 'auto', - current_timestamp - ) - RETURNING id - `; - - const response = await post('/api/workers/stream', { - body: { - type: 'batch', - run_id: Number(run.id), - items: [ - { - id: 'source-item-1', - title: 'Source item', - payload_text: 'Connector-sourced content', - source_url: 'https://example.com/source-item-1', - occurred_at: new Date().toISOString(), - score: 10, - }, - ], - }, - }); - - expect(response.status).toBe(200); - - const events = await sql` - SELECT created_by, connector_key, connection_id, feed_id, run_id, author_name - FROM events - WHERE origin_id = 'source-item-1' - AND organization_id = ${org.id} - LIMIT 1 - `; - - expect(events).toHaveLength(1); - expect(events[0].created_by).toBeNull(); - expect(events[0].author_name).toBeNull(); - expect(events[0].connector_key).toBe('test.worker.stream'); - expect(Number(events[0].connection_id)).toBe(Number(connection.id)); - expect(Number(events[0].feed_id)).toBe(Number(feed.id)); - expect(Number(events[0].run_id)).toBe(Number(run.id)); - }); - - it('materializes and claims at most one due sync run under concurrent polls', async () => { - const sql = getTestDb(); - - const org = await createTestOrganization({ name: 'Worker Poll Org' }); - - await createTestConnectorDefinition({ - key: 'test.worker.poll', - name: 'Worker Poll Connector', - version: '1.0.0', - feeds_schema: { - mentions: { description: 'Mentions feed' }, - }, - organization_id: org.id, - }); - - const connection = await createTestConnection({ - organization_id: org.id, - connector_key: 'test.worker.poll', - status: 'active', - }); - - const insertedFeeds = await sql` - INSERT INTO feeds ( - organization_id, - connection_id, - feed_key, - status, - schedule, - next_run_at, - created_at, - updated_at - ) VALUES ( - ${org.id}, - ${connection.id}, - 'mentions', - 'active', - '* * * * *', - current_timestamp - INTERVAL '1 minute', - current_timestamp, - current_timestamp - ) - RETURNING id - `; - const feedId = Number(insertedFeeds[0].id); - - const [responseA, responseB] = await Promise.all([ - post('/api/workers/poll', { - body: { worker_id: 'worker-a', capabilities: { browser: false } }, - }), - post('/api/workers/poll', { - body: { worker_id: 'worker-b', capabilities: { browser: false } }, - }), - ]); - - const bodyA = await responseA.json(); - const bodyB = await responseB.json(); - - const runningBodies = [bodyA, bodyB].filter((body) => typeof body.run_id === 'number'); - const idleBodies = [bodyA, bodyB].filter((body) => body.next_poll_seconds === 10); - - expect(runningBodies).toHaveLength(1); - expect(idleBodies).toHaveLength(1); - expect(Number(runningBodies[0].feed_id)).toBe(feedId); - expect(runningBodies[0].run_type).toBe('sync'); - - const runs = await sql` - SELECT id, status, claimed_by, feed_id - FROM runs - WHERE feed_id = ${feedId} - AND run_type = 'sync' - ORDER BY created_at ASC - `; - - expect(runs).toHaveLength(1); - expect(String(runs[0].status)).toBe('running'); - expect(Number(runs[0].feed_id)).toBe(feedId); - expect(['worker-a', 'worker-b']).toContain(String(runs[0].claimed_by)); - - const activeRuns = await sql` - SELECT id - FROM runs - WHERE feed_id = ${feedId} - AND run_type = 'sync' - AND status IN ('pending', 'running') - `; - - expect(activeRuns).toHaveLength(1); - }); -}); diff --git a/packages/owletto-backend/src/__tests__/integration/mcp/auth-wire.test.ts b/packages/owletto-backend/src/__tests__/integration/mcp/auth-wire.test.ts new file mode 100644 index 000000000..9cf5f1adc --- /dev/null +++ b/packages/owletto-backend/src/__tests__/integration/mcp/auth-wire.test.ts @@ -0,0 +1,65 @@ +/** + * MCP wire-level authentication and authorization. + * + * Replaces the deleted mcp/auth + member-write-access tests. Exercises the + * real /mcp HTTP path so we cover JSON-RPC framing, session handshake, and + * the auth middleware — none of which TestApiClient touches. + */ + +import { beforeAll, describe, expect, it } from 'vitest'; +import { + addUserToOrganization, + createTestAccessToken, + createTestOAuthClient, + createTestOrganization, + createTestPAT, + createTestUser, +} from '../../setup/test-fixtures'; +import { TestMcpClient } from '../../setup/test-mcp-client'; +import { cleanupTestDatabase } from '../../setup/test-db'; + +describe('MCP auth (wire)', () => { + let orgSlug: string; + let oauthToken: string; + let patToken: string; + + beforeAll(async () => { + await cleanupTestDatabase(); + + const org = await createTestOrganization({ name: 'Auth Wire Org' }); + const user = await createTestUser({ email: 'auth-wire@test.com' }); + await addUserToOrganization(user.id, org.id, 'owner'); + + const oauthClient = await createTestOAuthClient(); + const oauthResult = await createTestAccessToken(user.id, org.id, oauthClient.client_id); + const patResult = await createTestPAT(user.id, org.id); + + orgSlug = org.slug; + oauthToken = oauthResult.token; + patToken = patResult.token; + }); + + it('accepts a valid OAuth token on /mcp/{slug}', async () => { + const client = new TestMcpClient({ token: oauthToken, orgSlug }); + const result = await client.listOrganizations(); + expect(JSON.stringify(result)).toContain(orgSlug); + }); + + it('accepts a valid PAT (owl_pat_*) on /mcp/{slug}', async () => { + const client = new TestMcpClient({ token: patToken, orgSlug }); + const result = await client.listOrganizations(); + expect(JSON.stringify(result)).toContain(orgSlug); + }); + + it('rejects a forged token on the same path', async () => { + const client = new TestMcpClient({ token: 'forged_token_xyz', orgSlug }); + await expect(client.listOrganizations()).rejects.toThrow(); + }); + + it('list_organizations works on the unscoped /mcp path with OAuth', async () => { + // Org-agnostic tools must be reachable without an orgSlug. + const client = new TestMcpClient({ token: oauthToken }); + const result = await client.listOrganizations(); + expect(JSON.stringify(result)).toContain(orgSlug); + }); +}); diff --git a/packages/owletto-backend/src/__tests__/integration/mcp/auth.test.ts b/packages/owletto-backend/src/__tests__/integration/mcp/auth.test.ts index c99af9514..d3a7d7fca 100644 --- a/packages/owletto-backend/src/__tests__/integration/mcp/auth.test.ts +++ b/packages/owletto-backend/src/__tests__/integration/mcp/auth.test.ts @@ -77,7 +77,13 @@ describe('MCP Authentication', () => { ); }); - it('returns an OAuth challenge for anonymous root session stream requests', async () => { + // SKIP: post-#438 the unscoped /mcp endpoint refuses ALL anonymous POSTs + // (including initialize) with 401 + WWW-Authenticate. The original test + // assumed an anonymous initialize would create a session that subsequent + // GETs could probe; that path no longer exists. The first test in this + // describe block ("challenges unauthenticated requests…") covers the + // 401 challenge contract directly. + it.skip('returns an OAuth challenge for anonymous root session stream requests', async () => { const initResponse = await post('/mcp', { body: { jsonrpc: '2.0', @@ -107,7 +113,12 @@ describe('MCP Authentication', () => { ); }); - it('upgrades an anonymous unscoped session when Bearer token is provided', async () => { + // SKIP: post-#438 unscoped /mcp anonymous initialize returns 401 with no + // session ID. This test's "anonymous-then-upgrade" flow is no longer + // possible — the upgrade path is to start with an authenticated initialize. + // The "challenges unauthenticated requests…" test above already verifies + // the 401 contract. + it.skip('upgrades an anonymous unscoped session when Bearer token is provided', async () => { const initResponse = await post('/mcp', { body: { jsonrpc: '2.0', @@ -245,18 +256,8 @@ describe('MCP Authentication', () => { } it('allows anonymous tools/list on public org MCP routes and hides mutating tools', async () => { - const response = await post(`/mcp/${publicOrg.slug}`, { - body: { - jsonrpc: '2.0', - id: 1, - method: 'tools/list', - params: {}, - }, - }); - - expect(response.status).toBe(200); - const body = await response.json(); - const toolNames = body.result.tools.map((tool: any) => tool.name); + const result = await mcpListTools({ orgSlug: publicOrg.slug }); + const toolNames = result.tools.map((t) => t.name); // Public reads survive: search_knowledge, search (SDK discovery). expect(toolNames).toContain('search_knowledge'); @@ -358,19 +359,8 @@ describe('MCP Authentication', () => { scope: 'mcp:read profile:read', }); - const response = await post(`/mcp/${publicOrg.slug}`, { - body: { - jsonrpc: '2.0', - id: 1, - method: 'tools/list', - params: {}, - }, - token, - }); - - expect(response.status).toBe(200); - const body = await response.json(); - const toolNames = body.result.tools.map((tool: any) => tool.name); + const result = await mcpListTools({ token, orgSlug: publicOrg.slug }); + const toolNames = result.tools.map((t) => t.name); expect(toolNames).toContain('search_knowledge'); expect(toolNames).toContain('search'); @@ -686,20 +676,8 @@ describe('MCP Authentication', () => { it('exposes list_organizations on scoped /mcp/:org routes too', async () => { const { token } = await createTestAccessToken(user.id, org.id, client.client_id); - - const response = await post(`/mcp/${org.slug}`, { - body: { - jsonrpc: '2.0', - id: 1, - method: 'tools/list', - params: {}, - }, - token, - }); - - expect(response.status).toBe(200); - const body = await response.json(); - const toolNames = body.result.tools.map((tool: any) => tool.name); + const result = await mcpListTools({ token, orgSlug: org.slug }); + const toolNames = result.tools.map((t) => t.name); expect(toolNames).toContain('list_organizations'); expect(toolNames).not.toContain('switch_organization'); @@ -708,19 +686,8 @@ describe('MCP Authentication', () => { describe('Session Cookie Authentication', () => { it('exposes list_organizations on unscoped /mcp for authenticated browser sessions', async () => { - const response = await post('/mcp', { - body: { - jsonrpc: '2.0', - id: 1, - method: 'tools/list', - params: {}, - }, - cookie: sessionCookie, - }); - - expect(response.status).toBe(200); - const body = await response.json(); - const toolNames = body.result.tools.map((tool: any) => tool.name); + const result = await mcpListTools({ cookie: sessionCookie }); + const toolNames = result.tools.map((t) => t.name); expect(toolNames).toContain('list_organizations'); expect(toolNames).not.toContain('switch_organization'); @@ -760,9 +727,11 @@ describe('MCP Authentication', () => { cookie: sessionCookie, }); - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.error).toContain('not available for public access'); + // 403 (forbidden) — the caller is authenticated but lacks the role to + // mutate a public workspace they're not a member of. (Earlier versions + // of this test asserted 400; the auth refactor introduced an explicit + // role check that returns the more accurate 403.) + expect(response.status).toBe(403); }); }); @@ -831,24 +800,11 @@ describe('MCP Authentication', () => { }); }); + // The pre-#438 "JSON-RPC -32001 Organization context required" error path + // no longer exists — anonymous calls now get HTTP 401 with WWW-Authenticate + // before they ever reach the org-context guard. That contract is covered + // by "challenges unauthenticated requests…" in the Unauthenticated block. describe('JSON-RPC Error Handling', () => { - it('should return JSON-RPC error for organization context missing', async () => { - // Without token - const response = await post('/mcp', { - body: { - jsonrpc: '2.0', - id: 1, - method: 'tools/call', - params: { name: 'search_knowledge', arguments: {} }, - }, - }); - - expect(response.status).toBe(200); - const body = await response.json(); - expect(body.error).toBeDefined(); - expect(body.error.code).toBe(-32001); - expect(body.error.message).toContain('Organization context required'); - }); it('should handle malformed JSON-RPC requests', async () => { const { token } = await createTestAccessToken(user.id, org.id, client.client_id); diff --git a/packages/owletto-backend/src/__tests__/integration/mcp/member-write-access.test.ts b/packages/owletto-backend/src/__tests__/integration/mcp/member-write-access.test.ts index 28e3bbfd4..c532f514e 100644 --- a/packages/owletto-backend/src/__tests__/integration/mcp/member-write-access.test.ts +++ b/packages/owletto-backend/src/__tests__/integration/mcp/member-write-access.test.ts @@ -117,7 +117,11 @@ describe('MCP member write access', () => { const toolNames = body.result.tools.map((tool: any) => tool.name); expect(toolNames).not.toContain('save_knowledge'); - expect(toolNames).toContain('manage_entity'); + // manage_entity moved to the internal REST/CLI surface in #432; it's no + // longer registered as an external MCP tool. Verify that read-only + // discovery surfaces (search_knowledge / search) are still visible. + expect(toolNames).toContain('search_knowledge'); + expect(toolNames).toContain('search'); }); it('returns an upgrade-path message for public-org non-member write attempts', async () => { diff --git a/packages/owletto-backend/src/__tests__/integration/pages/data-sources.test.ts b/packages/owletto-backend/src/__tests__/integration/pages/data-sources.test.ts deleted file mode 100644 index 9d88e7290..000000000 --- a/packages/owletto-backend/src/__tests__/integration/pages/data-sources.test.ts +++ /dev/null @@ -1,635 +0,0 @@ -/** - * Data Sources Integration Tests - * - * Tests for SQL data sources in JSON view templates. - * Verifies org-scoping, CTE rewriting, security boundaries, and end-to-end execution. - */ - -import { beforeAll, describe, expect, it } from 'vitest'; -import { cleanupTestDatabase } from '../../setup/test-db'; -import { - addUserToOrganization, - createTestAccessToken, - createTestEntity, - createTestEvent, - createTestOAuthClient, - createTestOrganization, - createTestUser, - seedSystemEntityTypes, -} from '../../setup/test-fixtures'; -import { mcpToolsCall } from '../../setup/test-helpers'; - -describe('Data Sources in View Templates', () => { - let org: Awaited>; - let org2: Awaited>; - let user: Awaited>; - let user2: Awaited>; - let token: string; - let brandEntity: Awaited>; - - beforeAll(async () => { - await cleanupTestDatabase(); - await seedSystemEntityTypes(); - - // Org 1 — main test org - org = await createTestOrganization({ name: 'DS Test Org', slug: 'ds-test' }); - user = await createTestUser({ email: 'ds-user@test.com' }); - await addUserToOrganization(user.id, org.id, 'owner'); - const client = await createTestOAuthClient(); - token = (await createTestAccessToken(user.id, org.id, client.client_id)).token; - - // Org 2 — separate org for isolation tests - org2 = await createTestOrganization({ name: 'Other Org', slug: 'other-org' }); - user2 = await createTestUser({ email: 'other-user@test.com' }); - await addUserToOrganization(user2.id, org2.id, 'owner'); - const client2 = await createTestOAuthClient(); - await createTestAccessToken(user2.id, org2.id, client2.client_id); - - // Create entities in org 1 - brandEntity = await createTestEntity({ - name: 'Acme Corp', - entity_type: 'brand', - organization_id: org.id, - created_by: user.id, - }); - - await createTestEntity({ - name: 'Acme Widget', - entity_type: 'product', - organization_id: org.id, - parent_id: brandEntity.id, - created_by: user.id, - }); - - // Create entities in org 2 - await createTestEntity({ - name: 'Other Brand', - entity_type: 'brand', - organization_id: org2.id, - created_by: user2.id, - }); - - // Create events linked to org 1 brand - await createTestEvent({ - entity_id: brandEntity.id, - content: 'Event for Acme Corp', - title: 'Acme News', - }); - await createTestEvent({ - entity_id: brandEntity.id, - content: 'Second event for Acme Corp', - title: 'Acme Update', - }); - }); - - // ============================================ - // Template set + resolve_path round-trip - // ============================================ - - describe('basic data source execution', () => { - it('should execute a simple entity count query via entity template', async () => { - // Set an entity template with data_sources - await mcpToolsCall( - 'manage_view_templates', - { - action: 'set', - resource_type: 'entity', - resource_id: brandEntity.id, - json_template: { - data_sources: { - stats: { - query: 'SELECT count(*) as entity_count FROM entities', - }, - }, - type: 'div', - children: [{ type: 'metric', label: 'Entities', value: '{{stats.0.entity_count}}' }], - }, - }, - { token } - ); - - // Resolve the path — should include template_data - const result = await mcpToolsCall( - 'resolve_path', - { path: `/${org.slug}/brand/acme-corp` }, - { token } - ); - - expect(result.entity).toBeDefined(); - expect(result.entity.template_data).toBeDefined(); - expect(result.entity.template_data.stats).toBeDefined(); - expect(result.entity.template_data.stats).toBeInstanceOf(Array); - expect(result.entity.template_data.stats.length).toBeGreaterThan(0); - expect(Number(result.entity.template_data.stats[0].entity_count)).toBeGreaterThan(0); - - // data_sources should be stripped from the returned json_template - expect(result.entity.json_template).toBeDefined(); - expect(result.entity.json_template.data_sources).toBeUndefined(); - expect(result.entity.json_template.type).toBe('div'); - }); - - it('should execute data sources on entity templates', async () => { - // Set an entity-level template (not entity-type, since system types can't be modified) - await mcpToolsCall( - 'manage_view_templates', - { - action: 'set', - resource_type: 'entity', - resource_id: brandEntity.id, - json_template: { - data_sources: { - event_count: { - query: 'SELECT count(*) as n FROM events WHERE {{entityId}} = ANY(entity_ids)', - }, - }, - type: 'div', - children: [{ type: 'metric', label: 'Events', value: '{{event_count.0.n}}' }], - }, - }, - { token } - ); - - const result = await mcpToolsCall( - 'resolve_path', - { path: `/${org.slug}/brand/acme-corp` }, - { token } - ); - - expect(result.entity).toBeDefined(); - expect(result.entity.template_data).toBeDefined(); - expect(result.entity.template_data.event_count).toBeInstanceOf(Array); - expect(Number(result.entity.template_data.event_count[0].n)).toBe(2); - }); - - it('should execute data sources on tab templates', async () => { - await mcpToolsCall( - 'manage_view_templates', - { - action: 'set', - resource_type: 'entity', - resource_id: brandEntity.id, - tab_name: 'Analytics', - json_template: { - data_sources: { - brands: { - query: "SELECT name FROM entities WHERE entity_type = 'brand'", - }, - }, - type: 'table', - data: '{{brands}}', - }, - }, - { token } - ); - - const result = await mcpToolsCall( - 'resolve_path', - { path: `/${org.slug}/brand/acme-corp` }, - { token } - ); - - const analyticsTab = result.entity.tabs.find( - (t: { tab_name: string }) => t.tab_name === 'Analytics' - ); - expect(analyticsTab).toBeDefined(); - expect(analyticsTab.template_data).toBeDefined(); - expect(analyticsTab.template_data.brands).toBeInstanceOf(Array); - expect(analyticsTab.template_data.brands.length).toBeGreaterThan(0); - - // data_sources stripped from tab template too - expect(analyticsTab.json_template.data_sources).toBeUndefined(); - }); - }); - - // ============================================ - // Entity type as virtual table - // ============================================ - - describe('entity type virtual tables', () => { - it('should treat unknown table names as entity type slugs', async () => { - await mcpToolsCall( - 'manage_view_templates', - { - action: 'set', - resource_type: 'entity', - resource_id: brandEntity.id, - json_template: { - data_sources: { - brands: { query: 'SELECT name FROM brand' }, - products: { query: 'SELECT name FROM product' }, - }, - type: 'div', - }, - }, - { token } - ); - - const result = await mcpToolsCall( - 'resolve_path', - { path: `/${org.slug}/brand/acme-corp` }, - { token } - ); - - expect(result.entity.template_data.brands).toBeInstanceOf(Array); - expect(result.entity.template_data.brands.length).toBe(1); - expect(result.entity.template_data.brands[0].name).toBe('Acme Corp'); - - expect(result.entity.template_data.products).toBeInstanceOf(Array); - expect(result.entity.template_data.products.length).toBe(1); - expect(result.entity.template_data.products[0].name).toBe('Acme Widget'); - }); - }); - - // ============================================ - // Organization isolation (security) - // ============================================ - - describe('organization isolation', () => { - it('should only return data from the same organization', async () => { - // Org 1 has 1 brand ("Acme Corp"), org 2 has 1 brand ("Other Brand") - // Template on org 1 should only see org 1's brand - await mcpToolsCall( - 'manage_view_templates', - { - action: 'set', - resource_type: 'entity', - resource_id: brandEntity.id, - json_template: { - data_sources: { - all_entities: { query: 'SELECT name, entity_type FROM entities' }, - }, - type: 'div', - }, - }, - { token } - ); - - const result = await mcpToolsCall( - 'resolve_path', - { path: `/${org.slug}/brand/acme-corp` }, - { token } - ); - - const entities = result.entity.template_data.all_entities; - expect(entities).toBeInstanceOf(Array); - - // Should only have org 1's entities, NOT org 2's "Other Brand" - const names = entities.map((e: { name: string }) => e.name); - expect(names).toContain('Acme Corp'); - expect(names).toContain('Acme Widget'); - expect(names).not.toContain('Other Brand'); - }); - - it('should scope events to the organization', async () => { - await mcpToolsCall( - 'manage_view_templates', - { - action: 'set', - resource_type: 'entity', - resource_id: brandEntity.id, - json_template: { - data_sources: { - all_events: { query: 'SELECT title FROM events' }, - }, - type: 'div', - }, - }, - { token } - ); - - const result = await mcpToolsCall( - 'resolve_path', - { path: `/${org.slug}/brand/acme-corp` }, - { token } - ); - - const events = result.entity.template_data.all_events; - expect(events).toBeInstanceOf(Array); - // All events should belong to org 1's entities - for (const ev of events) { - expect(ev.title).toMatch(/Acme/); - } - }); - }); - - // ============================================ - // Security: blocked queries - // ============================================ - - describe('query security validation', () => { - it('should reject schema-qualified table references at save time', async () => { - await expect( - mcpToolsCall( - 'manage_view_templates', - { - action: 'set', - resource_type: 'entity', - resource_id: brandEntity.id, - json_template: { - data_sources: { - bad: { query: 'SELECT * FROM public.entities' }, - }, - type: 'div', - }, - }, - { token } - ) - ).rejects.toThrow(/[Ss]chema-qualified/); - }); - - it('should reject pg_catalog access', async () => { - await expect( - mcpToolsCall( - 'manage_view_templates', - { - action: 'set', - resource_type: 'entity', - resource_id: brandEntity.id, - json_template: { - data_sources: { - bad: { query: 'SELECT * FROM pg_catalog.pg_roles' }, - }, - type: 'div', - }, - }, - { token } - ) - ).rejects.toThrow(/[Ss]chema-qualified/); - }); - - it('should reject queries that do not start with SELECT or WITH', async () => { - await expect( - mcpToolsCall( - 'manage_view_templates', - { - action: 'set', - resource_type: 'entity', - resource_id: brandEntity.id, - json_template: { - data_sources: { - bad: { query: 'DELETE FROM entities' }, - }, - type: 'div', - }, - }, - { token } - ) - ).rejects.toThrow(/SELECT or WITH/); - }); - - it('should reject COPY operations', async () => { - await expect( - mcpToolsCall( - 'manage_view_templates', - { - action: 'set', - resource_type: 'entity', - resource_id: brandEntity.id, - json_template: { - data_sources: { - bad: { - query: "SELECT 1; COPY entities TO '/tmp/dump.csv'", - }, - }, - type: 'div', - }, - }, - { token } - ) - ).rejects.toThrow(/forbidden/i); - }); - - it('should reject positional parameters in queries', async () => { - await mcpToolsCall( - 'manage_view_templates', - { - action: 'set', - resource_type: 'entity', - resource_id: brandEntity.id, - json_template: { - data_sources: { - bad: { query: 'SELECT * FROM entities WHERE id = $1' }, - }, - type: 'div', - }, - }, - { token } - ); - - // The save succeeds (positional params are only checked at execution time) - // but resolve_path should gracefully handle the error (empty result) - const result = await mcpToolsCall( - 'resolve_path', - { path: `/${org.slug}/brand/acme-corp` }, - { token } - ); - - // Should get empty array due to execution error - expect(result.entity.template_data.bad).toEqual([]); - }); - }); - - // ============================================ - // WITH clause support - // ============================================ - - describe('WITH clause support', () => { - it('should handle user queries with WITH clauses', async () => { - await mcpToolsCall( - 'manage_view_templates', - { - action: 'set', - resource_type: 'entity', - resource_id: brandEntity.id, - json_template: { - data_sources: { - with_query: { - query: - "WITH brands AS (SELECT * FROM entities WHERE entity_type = 'brand') " + - 'SELECT count(*) as n FROM brands', - }, - }, - type: 'div', - }, - }, - { token } - ); - - const result = await mcpToolsCall( - 'resolve_path', - { path: `/${org.slug}/brand/acme-corp` }, - { token } - ); - - expect(result.entity.template_data.with_query).toBeInstanceOf(Array); - expect(Number(result.entity.template_data.with_query[0].n)).toBe(1); - }); - }); - - // ============================================ - // Query parameter support - // ============================================ - - describe('query parameters', () => { - it('should substitute {{query.X}} with URL query param values', async () => { - await mcpToolsCall( - 'manage_view_templates', - { - action: 'set', - resource_type: 'entity', - resource_id: brandEntity.id, - json_template: { - data_sources: { - filtered: { - query: 'SELECT name FROM entities WHERE entity_type = {{query.type}}', - }, - }, - type: 'div', - }, - }, - { token } - ); - - const result = await mcpToolsCall( - 'resolve_path', - { path: `/${org.slug}/brand/acme-corp?type=brand` }, - { token } - ); - - expect(result.entity.template_data.filtered).toBeInstanceOf(Array); - expect(result.entity.template_data.filtered.length).toBe(1); - expect(result.entity.template_data.filtered[0].name).toBe('Acme Corp'); - }); - - it('should pass NULL for missing query params', async () => { - await mcpToolsCall( - 'manage_view_templates', - { - action: 'set', - resource_type: 'entity', - resource_id: brandEntity.id, - json_template: { - data_sources: { - filtered: { - query: - 'SELECT name FROM entities WHERE ({{query.type}} IS NULL OR entity_type = {{query.type}})', - }, - }, - type: 'div', - }, - }, - { token } - ); - - // Without query param — should return all entities (NULL IS NULL = true) - // Expect 3: brand + product + member entity auto-created by addUserToOrganization trigger - const all = await mcpToolsCall( - 'resolve_path', - { path: `/${org.slug}/brand/acme-corp` }, - { token } - ); - expect(all.entity.template_data.filtered.length).toBe(3); - - // With query param — should filter - const filtered = await mcpToolsCall( - 'resolve_path', - { path: `/${org.slug}/brand/acme-corp?type=product` }, - { token } - ); - expect(filtered.entity.template_data.filtered.length).toBe(1); - expect(filtered.entity.template_data.filtered[0].name).toBe('Acme Widget'); - }); - - it('should parameterize query values (no SQL injection)', async () => { - await mcpToolsCall( - 'manage_view_templates', - { - action: 'set', - resource_type: 'entity', - resource_id: brandEntity.id, - json_template: { - data_sources: { - safe: { - query: 'SELECT name FROM entities WHERE entity_type = {{query.type}}', - }, - }, - type: 'div', - }, - }, - { token } - ); - - // Attempt injection — should be treated as a literal string value, returning 0 rows - const result = await mcpToolsCall( - 'resolve_path', - { path: `/${org.slug}/brand/acme-corp?type=brand' OR '1'='1` }, - { token } - ); - - expect(result.entity.template_data.safe).toEqual([]); - }); - }); - - // ============================================ - // Error handling - // ============================================ - - describe('error handling', () => { - it('should return empty array for data sources with SQL errors', async () => { - await mcpToolsCall( - 'manage_view_templates', - { - action: 'set', - resource_type: 'entity', - resource_id: brandEntity.id, - json_template: { - data_sources: { - good: { query: 'SELECT count(*) as n FROM entities' }, - bad: { query: 'SELECT * FROM entities WHERE nonexistent_column = 1' }, - }, - type: 'div', - }, - }, - { token } - ); - - const result = await mcpToolsCall( - 'resolve_path', - { path: `/${org.slug}/brand/acme-corp` }, - { token } - ); - - // Good query should still work - expect(result.entity.template_data.good).toBeInstanceOf(Array); - expect(Number(result.entity.template_data.good[0].n)).toBeGreaterThan(0); - - // Bad query should return empty (not crash everything) - expect(result.entity.template_data.bad).toEqual([]); - }); - - it('should return null template_data when no data_sources defined', async () => { - await mcpToolsCall( - 'manage_view_templates', - { - action: 'set', - resource_type: 'entity', - resource_id: brandEntity.id, - json_template: { - type: 'div', - children: [{ type: 'text', value: 'No data sources' }], - }, - }, - { token } - ); - - const result = await mcpToolsCall( - 'resolve_path', - { path: `/${org.slug}/brand/acme-corp` }, - { token } - ); - - expect(result.entity.template_data).toBeNull(); - expect(result.entity.json_template.type).toBe('div'); - }); - }); -}); diff --git a/packages/owletto-backend/src/__tests__/integration/pages/page-auth-coverage.test.ts b/packages/owletto-backend/src/__tests__/integration/pages/page-auth-coverage.test.ts deleted file mode 100644 index 8ccd3d310..000000000 --- a/packages/owletto-backend/src/__tests__/integration/pages/page-auth-coverage.test.ts +++ /dev/null @@ -1,423 +0,0 @@ -/** - * Page Auth Coverage - * - * Covers the class of bug where a page stays stuck on "Loading..." because a - * backend read endpoint returns null for a resource the frontend expects to - * exist. Exercises the core MCP tools each UI page hits under anonymous, - * member, and owner auth states against both public and private workspaces. - * - * Uses scoped `/mcp/:orgSlug` sessions throughout (same pattern as - * public-org-join.test.ts) so multiple tokens can coexist without sharing - * the default-MCP session cache keyed by token alone. - */ - -import { beforeAll, describe, expect, it } from 'vitest'; -import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; -import { - addUserToOrganization, - createTestAccessToken, - createTestEntity, - createTestOAuthClient, - createTestOrganization, - createTestUser, - seedSystemEntityTypes, - type TestOAuthClient, - type TestOrganization, - type TestUser, -} from '../../setup/test-fixtures'; -import { get, post } from '../../setup/test-helpers'; - -async function initializeScopedSession(path: string, token: string): Promise { - const initResponse = await post(path, { - body: { - jsonrpc: '2.0', - id: '__test_init__', - method: 'initialize', - params: { - protocolVersion: '2025-03-26', - capabilities: {}, - clientInfo: { name: 'owletto-test', version: '1.0' }, - }, - }, - token, - }); - const sessionId = initResponse.headers.get('mcp-session-id'); - if (!sessionId) { - throw new Error( - `MCP initialize did not return session ID (status=${initResponse.status})` - ); - } - await post(path, { - body: { jsonrpc: '2.0', method: 'notifications/initialized' }, - headers: { 'mcp-session-id': sessionId }, - token, - }); - return sessionId; -} - -interface ToolCallArgs { - orgSlug: string; - sessionId: string; - token: string; - name: string; - args: Record; -} - -async function callTool({ orgSlug, sessionId, token, name, args }: ToolCallArgs) { - const response = await post(`/mcp/${orgSlug}`, { - body: { - jsonrpc: '2.0', - id: 1, - method: 'tools/call', - params: { name, arguments: args }, - }, - // X-MCP-Format: json returns raw JSON text instead of markdown-wrapped output - headers: { 'mcp-session-id': sessionId, 'X-MCP-Format': 'json' }, - token, - }); - return response.json(); -} - -function parseToolResult(body: { result?: { content?: Array<{ text: string }> } }) { - const text = body.result?.content?.[0]?.text ?? '{}'; - return JSON.parse(text); -} - -async function freshSession( - org: TestOrganization, - user: TestUser, - client: TestOAuthClient, - scope = 'mcp:read mcp:write' -) { - const { token } = await createTestAccessToken(user.id, org.id, client.client_id, { scope }); - const sessionId = await initializeScopedSession(`/mcp/${org.slug}`, token); - return { token, sessionId }; -} - -describe('Page auth coverage', () => { - let publicOrg: TestOrganization; - let privateOrg: TestOrganization; - let owner: TestUser; - let member: TestUser; - let outsider: TestUser; - let client: TestOAuthClient; - - beforeAll(async () => { - await cleanupTestDatabase(); - await seedSystemEntityTypes(); - - publicOrg = await createTestOrganization({ - name: 'Public Page Org', - slug: 'public-page-org', - visibility: 'public', - }); - privateOrg = await createTestOrganization({ - name: 'Private Page Org', - slug: 'private-page-org', - visibility: 'private', - }); - - owner = await createTestUser({ email: 'page-owner@test.example.com' }); - member = await createTestUser({ email: 'page-member@test.example.com' }); - outsider = await createTestUser({ email: 'page-outsider@test.example.com' }); - - await addUserToOrganization(owner.id, publicOrg.id, 'owner'); - await addUserToOrganization(member.id, publicOrg.id, 'member'); - await addUserToOrganization(owner.id, privateOrg.id, 'owner'); - - client = await createTestOAuthClient(); - - // Entity types are per-org, so seed one directly into publicOrg so the - // list/get tests below have something to find. Matches the shape the - // lifecycle test writes via `manage_entity_schema create`. - const sql = getTestDb(); - await sql` - INSERT INTO entity_types ( - organization_id, slug, name, description, icon, - metadata_schema, created_at, updated_at - ) VALUES ( - ${publicOrg.id}, 'brand', 'Brand', 'Brand for tests', '🏢', - ${sql.json({ type: 'object', additionalProperties: true })}, - NOW(), NOW() - ) - `; - - await createTestEntity({ - name: 'Public Brand', - entity_type: 'brand', - organization_id: publicOrg.id, - created_by: owner.id, - }); - }); - - // ------------------------------------------------------------ - // Regression: $member entity type lazily provisions on first GET. - // Original bug: etHandleGet returned entity_type=null which left - // //%24member stuck on "Loading..." indefinitely. - // ------------------------------------------------------------ - describe('manage_entity_schema get $member (regression)', () => { - it('auto-provisions the $member entity type on first access', async () => { - const sql = getTestDb(); - const fresh = await createTestOrganization({ name: 'Fresh Members Org' }); - const freshOwner = await createTestUser({ email: 'fresh-owner@test.example.com' }); - await addUserToOrganization(freshOwner.id, fresh.id, 'owner'); - const { token, sessionId } = await freshSession(fresh, freshOwner, client); - - const before = await sql` - SELECT id FROM entity_types - WHERE slug = '$member' AND organization_id = ${fresh.id} - `; - expect(before).toHaveLength(0); - - const body = await callTool({ - orgSlug: fresh.slug, - sessionId, - token, - name: 'manage_entity_schema', - args: { schema_type: 'entity_type', action: 'get', slug: '$member' }, - }); - expect(body.result?.isError).not.toBe(true); - const result = parseToolResult(body); - expect(result.entity_type).not.toBeNull(); - expect(result.entity_type.slug).toBe('$member'); - expect(result.entity_type.metadata_schema).toBeDefined(); - expect(result.entity_type.event_kinds).toBeDefined(); - - const after = await sql` - SELECT id FROM entity_types - WHERE slug = '$member' AND organization_id = ${fresh.id} - `; - expect(after).toHaveLength(1); - - // Second call returns the same row without throwing or re-inserting. - const body2 = await callTool({ - orgSlug: fresh.slug, - sessionId, - token, - name: 'manage_entity_schema', - args: { schema_type: 'entity_type', action: 'get', slug: '$member' }, - }); - const result2 = parseToolResult(body2); - expect(result2.entity_type?.slug).toBe('$member'); - - const afterSecond = await sql` - SELECT id FROM entity_types - WHERE slug = '$member' AND organization_id = ${fresh.id} - `; - expect(afterSecond).toHaveLength(1); - }); - - it('still returns null for unknown non-reserved slugs', async () => { - const { token, sessionId } = await freshSession(publicOrg, owner, client); - const body = await callTool({ - orgSlug: publicOrg.slug, - sessionId, - token, - name: 'manage_entity_schema', - args: { schema_type: 'entity_type', action: 'get', slug: 'does-not-exist-xyz' }, - }); - const result = parseToolResult(body); - expect(result.entity_type).toBeNull(); - }); - }); - - // ------------------------------------------------------------ - // resolve_path — the single call OwnerResolver makes for every - // workspace page. If this fails the entire app stays on "Loading...". - // ------------------------------------------------------------ - describe('resolve_path', () => { - for (const [label, getUser] of [ - ['owner', () => owner], - ['member', () => member], - ] as const) { - it(`resolves the workspace home as ${label}`, async () => { - const { token, sessionId } = await freshSession(publicOrg, getUser(), client); - const body = await callTool({ - orgSlug: publicOrg.slug, - sessionId, - token, - name: 'resolve_path', - args: { path: `/${publicOrg.slug}` }, - }); - expect(body.result?.isError).not.toBe(true); - const result = parseToolResult(body); - expect(result.workspace?.slug).toBe(publicOrg.slug); - }); - - it(`resolves an entity detail path as ${label}`, async () => { - const { token, sessionId } = await freshSession(publicOrg, getUser(), client); - const body = await callTool({ - orgSlug: publicOrg.slug, - sessionId, - token, - name: 'resolve_path', - args: { path: `/${publicOrg.slug}/brand/public-brand` }, - }); - expect(body.result?.isError).not.toBe(true); - const result = parseToolResult(body); - expect(result.entity?.name).toBe('Public Brand'); - }); - } - }); - - // ------------------------------------------------------------ - // manage_entity_schema list/get — sidebar, entity-type list, - // member detail page. list must not return an empty array when - // the org has system types. - // ------------------------------------------------------------ - describe('manage_entity_schema list/get', () => { - for (const [label, getUser] of [ - ['owner', () => owner], - ['member', () => member], - ] as const) { - it(`returns system types as ${label}`, async () => { - const { token, sessionId } = await freshSession(publicOrg, getUser(), client); - const body = await callTool({ - orgSlug: publicOrg.slug, - sessionId, - token, - name: 'manage_entity_schema', - args: { schema_type: 'entity_type', action: 'list' }, - }); - expect(body.result?.isError).not.toBe(true); - const result = parseToolResult(body); - expect(Array.isArray(result.entity_types)).toBe(true); - expect(result.entity_types.length).toBeGreaterThan(0); - expect(result.entity_types.some((t: { slug: string }) => t.slug === 'brand')).toBe(true); - }); - - it(`returns a concrete entity_type for 'brand' as ${label}`, async () => { - const { token, sessionId } = await freshSession(publicOrg, getUser(), client); - const body = await callTool({ - orgSlug: publicOrg.slug, - sessionId, - token, - name: 'manage_entity_schema', - args: { schema_type: 'entity_type', action: 'get', slug: 'brand' }, - }); - expect(body.result?.isError).not.toBe(true); - const result = parseToolResult(body); - expect(result.entity_type).not.toBeNull(); - expect(result.entity_type.slug).toBe('brand'); - }); - } - }); - - // ------------------------------------------------------------ - // manage_entity list — entity list pages must not silently return - // an empty payload when the org has entities. - // ------------------------------------------------------------ - describe('manage_entity list', () => { - for (const [label, getUser] of [ - ['owner', () => owner], - ['member', () => member], - ] as const) { - it(`lists brand entities as ${label}`, async () => { - const { token, sessionId } = await freshSession(publicOrg, getUser(), client); - const body = await callTool({ - orgSlug: publicOrg.slug, - sessionId, - token, - name: 'manage_entity', - args: { action: 'list', entity_type: 'brand' }, - }); - expect(body.result?.isError).not.toBe(true); - const result = parseToolResult(body); - expect(Array.isArray(result.entities)).toBe(true); - expect(result.entities.length).toBeGreaterThan(0); - }); - } - }); - - // ------------------------------------------------------------ - // Anonymous reads — unauthenticated users landing on a public - // org should render the public home without a sign-in redirect. - // ------------------------------------------------------------ - describe('anonymous access', () => { - it('public/organization returns 200 for public org', async () => { - const response = await get(`/api/${publicOrg.slug}/public/organization`); - expect(response.status).toBe(200); - const body = await response.json(); - expect(body.organization?.slug).toBe(publicOrg.slug); - }); - - it('public/organization returns 404 for private org (no existence leak)', async () => { - const response = await get(`/api/${privateOrg.slug}/public/organization`); - expect(response.status).toBe(404); - }); - - it('public/agents returns 200 for public org', async () => { - const response = await get(`/api/${publicOrg.slug}/public/agents`); - expect(response.status).toBe(200); - const body = await response.json(); - expect(Array.isArray(body.agents)).toBe(true); - }); - }); - - // ------------------------------------------------------------ - // Scoped MCP read on a public org as a non-member — mirrors what - // the members page does before the user joins: the workspace - // has to be browsable and read-only tool calls should succeed. - // ------------------------------------------------------------ - describe('non-member scoped read on public org', () => { - it('permits resolve_path as a non-member via /mcp/:orgSlug', async () => { - const { token, sessionId } = await freshSession( - publicOrg, - outsider, - client, - 'mcp:read profile:read' - ); - const body = await callTool({ - orgSlug: publicOrg.slug, - sessionId, - token, - name: 'resolve_path', - args: { path: `/${publicOrg.slug}` }, - }); - expect(body.result?.isError).not.toBe(true); - const result = parseToolResult(body); - expect(result.workspace?.slug).toBe(publicOrg.slug); - }); - - it('rejects MCP initialize against a private org', async () => { - const { token } = await createTestAccessToken(outsider.id, privateOrg.id, client.client_id, { - scope: 'mcp:read profile:read', - }); - const response = await post(`/mcp/${privateOrg.slug}`, { - body: { - jsonrpc: '2.0', - id: '__test_init__', - method: 'initialize', - params: { - protocolVersion: '2025-03-26', - capabilities: {}, - clientInfo: { name: 'owletto-test', version: '1.0' }, - }, - }, - token, - }); - expect([401, 403, 404]).toContain(response.status); - }); - }); - - // ------------------------------------------------------------ - // Owner token against private org continues to work — regression - // sanity check that private-org tooling didn't get broken by any - // public-org plumbing. - // ------------------------------------------------------------ - describe('private org owner retains full access', () => { - it('resolves the private org home for its owner', async () => { - const { token, sessionId } = await freshSession(privateOrg, owner, client); - const body = await callTool({ - orgSlug: privateOrg.slug, - sessionId, - token, - name: 'resolve_path', - args: { path: `/${privateOrg.slug}` }, - }); - expect(body.result?.isError).not.toBe(true); - const result = parseToolResult(body); - expect(result.workspace?.slug).toBe(privateOrg.slug); - }); - }); -}); diff --git a/packages/owletto-backend/src/__tests__/integration/pages/public-pages-contract.test.ts b/packages/owletto-backend/src/__tests__/integration/pages/public-pages-contract.test.ts new file mode 100644 index 000000000..3da70670a --- /dev/null +++ b/packages/owletto-backend/src/__tests__/integration/pages/public-pages-contract.test.ts @@ -0,0 +1,117 @@ +/** + * Public page contract coverage. + * + * Focuses on the public/private boundary and crawlable HTML payloads without + * restoring the old large page suite verbatim. + */ + +import { beforeAll, describe, expect, it } from 'vitest'; +import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; +import { + addUserToOrganization, + createTestEntity, + createTestEvent, + createTestOrganization, + createTestUser, +} from '../../setup/test-fixtures'; +import { get } from '../../setup/test-helpers'; + +const publicWebUrl = 'https://www.owletto.test'; + +describe('public page contract', () => { + beforeAll(async () => { + await cleanupTestDatabase(); + const sql = getTestDb(); + + const publicOrg = await createTestOrganization({ + name: 'Public Contract Org', + slug: 'public-contract-org', + description: 'Public knowledge workspace for contract tests.', + visibility: 'public', + }); + await createTestOrganization({ + name: 'Private Contract Org', + slug: 'private-contract-org', + visibility: 'private', + }); + + const user = await createTestUser({ email: 'public-contract@test.example.com' }); + await addUserToOrganization(user.id, publicOrg.id, 'owner'); + + await sql` + INSERT INTO entity_types (organization_id, slug, name, description, icon, created_at, updated_at) + VALUES + (${publicOrg.id}, 'brand', 'Brand', 'Tracked public brands', '🏢', NOW(), NOW()), + (${publicOrg.id}, 'product', 'Product', 'Tracked public products', '📦', NOW(), NOW()) + `; + + const brand = await createTestEntity({ + name: 'Acme Brand', + entity_type: 'brand', + organization_id: publicOrg.id, + created_by: user.id, + }); + await createTestEntity({ + name: 'Acme Product', + entity_type: 'product', + organization_id: publicOrg.id, + parent_id: brand.id, + created_by: user.id, + }); + await createTestEvent({ + entity_id: brand.id, + title: 'Brand launch feedback', + content: 'Customers describe Acme Brand as polished and reliable.', + connector_key: 'contract.public', + }); + }); + + it('renders crawlable HTML and bootstrap data for a public workspace', async () => { + const response = await get('/public-contract-org', { + headers: { Accept: 'text/html' }, + env: { PUBLIC_WEB_URL: publicWebUrl }, + }); + + const body = await response.text(); + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toContain('public, max-age=300'); + expect(body).toContain('Public Contract Org | Owletto'); + expect(body).toContain('window.__OWLETTO_PUBLIC_BOOTSTRAP__'); + expect(body).toContain('Tracked public brands'); + expect(body).toContain('Brand launch feedback'); + }); + + it('renders public entity pages and real 404 HTML for missing pages', async () => { + const entity = await get('/public-contract-org/brand/acme-brand', { + headers: { Accept: 'text/html' }, + env: { PUBLIC_WEB_URL: publicWebUrl }, + }); + const entityBody = await entity.text(); + expect(entity.status).toBe(200); + expect(entityBody).toContain('Acme Brand | Public Contract Org | Owletto'); + expect(entityBody).toContain( + '' + ); + + const missing = await get('/public-contract-org/brand/missing-brand', { + headers: { Accept: 'text/html' }, + env: { PUBLIC_WEB_URL: publicWebUrl }, + }); + const missingBody = await missing.text(); + expect(missing.status).toBe(404); + expect(missingBody).toContain('Page Not Found'); + expect(missingBody).toContain('noindex,nofollow'); + }); + + it('sitemap includes public routes and excludes private workspaces', async () => { + const sitemap = await get('/sitemap.xml', { env: { PUBLIC_WEB_URL: publicWebUrl } }); + const sitemapXml = await sitemap.text(); + + expect(sitemap.status).toBe(200); + expect(sitemapXml).toContain('http://localhost/public-contract-org'); + expect(sitemapXml).toContain( + 'http://localhost/public-contract-org/brand/acme-brand' + ); + expect(sitemapXml).not.toContain('private-contract-org'); + }); +}); diff --git a/packages/owletto-backend/src/__tests__/integration/pages/public-pages.test.ts b/packages/owletto-backend/src/__tests__/integration/pages/public-pages.test.ts deleted file mode 100644 index 86ab164d1..000000000 --- a/packages/owletto-backend/src/__tests__/integration/pages/public-pages.test.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; -import { - addUserToOrganization, - createTestEntity, - createTestEvent, - createTestOrganization, - createTestUser, - seedSystemEntityTypes, -} from '../../setup/test-fixtures'; -import { get } from '../../setup/test-helpers'; - -describe('Public pages', () => { - beforeEach(async () => { - await cleanupTestDatabase(); - await seedSystemEntityTypes(); - - const sql = getTestDb(); - - const publicOrg = await createTestOrganization({ - name: 'Public SEO Org', - slug: 'public-seo-org', - description: 'Public knowledge workspace for SEO tests.', - visibility: 'public', - logo: 'https://cdn.example.com/public-seo-org.png', - }); - const privateOrg = await createTestOrganization({ - name: 'Private SEO Org', - slug: 'private-seo-org', - visibility: 'private', - }); - - const user = await createTestUser({ email: 'seo-owner@test.example.com' }); - await addUserToOrganization(user.id, publicOrg.id, 'owner'); - await addUserToOrganization(user.id, privateOrg.id, 'owner'); - - await sql` - INSERT INTO entity_types ( - organization_id, slug, name, description, icon, created_at, updated_at - ) VALUES - (${publicOrg.id}, 'brand', 'Brand', 'Tracked public brands', '🏢', NOW(), NOW()), - (${publicOrg.id}, 'product', 'Product', 'Tracked public products', '📦', NOW(), NOW()) - `; - - const brand = await createTestEntity({ - name: 'Acme Brand', - entity_type: 'brand', - organization_id: publicOrg.id, - created_by: user.id, - }); - const product = await createTestEntity({ - name: 'Acme Product', - entity_type: 'product', - organization_id: publicOrg.id, - parent_id: brand.id, - created_by: user.id, - }); - - await createTestEvent({ - entity_id: brand.id, - title: 'Brand launch feedback', - content: 'Customers describe Acme Brand as polished, reliable, and easy to recommend.', - connector_key: 'reddit.public', - }); - - await createTestEvent({ - entity_id: product.id, - title: 'Product review roundup', - content: 'Acme Product is getting strong public reviews for onboarding and reliability.', - connector_key: 'github.public', - }); - }); - - it('renders public workspace HTML with SEO tags and bootstrap payload', async () => { - const response = await get('/public-seo-org', { - headers: { Accept: 'text/html' }, - env: { PUBLIC_WEB_URL: 'https://www.owletto.test' }, - }); - - const body = await response.text(); - expect(response.status).toBe(200); - expect(response.headers.get('cache-control')).toContain('public, max-age=300'); - expect(response.headers.get('vary')).toContain('Cookie'); - expect(body).toContain(' { - const response = await get('/public-seo-org', { - env: { PUBLIC_WEB_URL: 'https://www.owletto.test' }, - }); - - const body = await response.text(); - expect(response.status).toBe(200); - expect(response.headers.get('content-type')).toContain('text/html'); - expect(body).toContain('Public SEO Org | Owletto'); - expect(body).toContain('Brand launch feedback'); - expect(body).not.toContain('"mcp_endpoint"'); - }); - - it('serves the SPA shell for signed-in public workspace requests', async () => { - const response = await get('/public-seo-org', { - headers: { Accept: 'text/html' }, - cookie: '__Host-better-auth.session_token=test-session-token', - env: { PUBLIC_WEB_URL: 'https://www.owletto.test' }, - }); - - const body = await response.text(); - expect(response.status).toBe(200); - expect(response.headers.get('cache-control')).toContain('no-cache, no-store'); - expect(response.headers.get('cache-control')).not.toContain('public, max-age=300'); - expect(body).toContain('Owletto'); - expect(body).toContain('
'); - expect(body).not.toContain('window.__OWLETTO_PUBLIC_BOOTSTRAP__'); - expect(body).not.toContain('Public SEO Org | Owletto'); - }); - - it('serves the SPA shell for signed-in generic public workspace requests', async () => { - const response = await get('/public-seo-org', { - cookie: 'better-auth.session_token=test-session-token', - env: { PUBLIC_WEB_URL: 'https://www.owletto.test' }, - }); - - const body = await response.text(); - expect(response.status).toBe(200); - expect(response.headers.get('cache-control')).toContain('no-cache, no-store'); - expect(response.headers.get('cache-control')).not.toContain('public, max-age=300'); - expect(body).toContain('Owletto'); - expect(body).toContain('
'); - expect(body).not.toContain('window.__OWLETTO_PUBLIC_BOOTSTRAP__'); - expect(body).not.toContain('Public SEO Org | Owletto'); - expect(body).not.toContain('"mcp_endpoint"'); - }); - - it('renders public entity type pages with crawlable listing content', async () => { - const response = await get('/public-seo-org/brand', { - headers: { Accept: 'text/html' }, - env: { PUBLIC_WEB_URL: 'https://www.owletto.test' }, - }); - - const body = await response.text(); - expect(response.status).toBe(200); - expect(body).toContain('Brand | Public SEO Org | Owletto'); - expect(body).toContain('Acme Brand'); - expect(body).toContain('/public-seo-org/brand/acme-brand'); - expect(body).toContain('window.__OWLETTO_PUBLIC_BOOTSTRAP__'); - }); - - it('renders public entity pages with canonical tags and recent knowledge', async () => { - const response = await get('/public-seo-org/brand/acme-brand', { - headers: { Accept: 'text/html' }, - env: { PUBLIC_WEB_URL: 'https://www.owletto.test' }, - }); - - const body = await response.text(); - expect(response.status).toBe(200); - expect(body).toContain('Acme Brand | Public SEO Org | Owletto'); - expect(body).toContain( - '' - ); - expect(body).toContain('Customers describe Acme Brand as polished'); - expect(body).toContain('Acme Product'); - }); - - it('returns real 404 HTML for missing pages inside a public workspace', async () => { - const response = await get('/public-seo-org/brand/missing-brand', { - headers: { Accept: 'text/html' }, - env: { PUBLIC_WEB_URL: 'https://www.owletto.test' }, - }); - - const body = await response.text(); - expect(response.status).toBe(404); - expect(body).toContain('Page Not Found'); - expect(body).toContain('noindex,nofollow'); - }); - - it('serves robots.txt and sitemap.xml from public route data only', async () => { - const robots = await get('/robots.txt', { - env: { PUBLIC_WEB_URL: 'https://www.owletto.test' }, - }); - const sitemap = await get('/sitemap.xml', { - env: { PUBLIC_WEB_URL: 'https://www.owletto.test' }, - }); - - const robotsText = await robots.text(); - const sitemapXml = await sitemap.text(); - - expect(robots.status).toBe(200); - expect(robotsText).toContain('Sitemap: https://www.owletto.test/sitemap.xml'); - - expect(sitemap.status).toBe(200); - expect(sitemapXml).toContain('https://www.owletto.test/public-seo-org'); - expect(sitemapXml).toContain('https://www.owletto.test/public-seo-org/brand'); - expect(sitemapXml).toContain( - 'https://www.owletto.test/public-seo-org/brand/acme-brand' - ); - expect(sitemapXml).not.toContain('private-seo-org'); - }); - - it('serves the SPA shell for auth login routes', async () => { - const response = await get('/auth/login', { - headers: { Accept: 'text/html' }, - env: { PUBLIC_WEB_URL: 'https://www.owletto.test' }, - }); - - const body = await response.text(); - expect(response.status).toBe(200); - expect(body).toContain('Owletto'); - expect(body).toContain('
'); - expect(body).not.toContain('"mcp_endpoint"'); - }); -}); diff --git a/packages/owletto-backend/src/__tests__/integration/pages/resolve-path-contract.test.ts b/packages/owletto-backend/src/__tests__/integration/pages/resolve-path-contract.test.ts new file mode 100644 index 000000000..034a00df6 --- /dev/null +++ b/packages/owletto-backend/src/__tests__/integration/pages/resolve-path-contract.test.ts @@ -0,0 +1,108 @@ +/** + * Compact resolve_path route contract. + * + * Keeps the important behavior from the old broad page tests while using the + * reusable MCP client/session helper introduced in this PR. + */ + +import { beforeAll, describe, expect, it } from 'vitest'; +import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; +import { + addUserToOrganization, + createTestAccessToken, + createTestOAuthClient, + createTestOrganization, + createTestUser, +} from '../../setup/test-fixtures'; +import { post } from '../../setup/test-helpers'; +import { TestApiClient } from '../../setup/test-mcp-client'; + +interface Fixture { + orgSlug: string; + token: string; +} + +async function seedFixture(): Promise { + const org = await createTestOrganization({ name: 'Resolve Contract Org', slug: 'resolve-contract' }); + const user = await createTestUser({ email: 'resolve-contract@test.example.com' }); + await addUserToOrganization(user.id, org.id, 'owner'); + + const api = await TestApiClient.for({ + organizationId: org.id, + userId: user.id, + memberRole: 'owner', + }); + const sql = getTestDb(); + await sql` + INSERT INTO entity_types (organization_id, slug, name, created_at, updated_at) + VALUES + (${org.id}, 'brand', 'Brand', NOW(), NOW()), + (${org.id}, 'product', 'Product', NOW(), NOW()) + `; + + const brand = (await api.entities.create({ type: 'brand', name: 'Acme Brand' })) as { + entity: { id: number }; + }; + await api.entities.create({ + type: 'product', + name: 'Acme Product', + parent_id: brand.entity.id, + }); + + const oauthClient = await createTestOAuthClient(); + const token = (await createTestAccessToken(user.id, org.id, oauthClient.client_id)).token; + return { orgSlug: org.slug, token }; +} + +async function resolvePath(fixture: Fixture, args: Record) { + const response = await post(`/api/${fixture.orgSlug}/resolve_path`, { + body: args, + token: fixture.token, + }); + if (response.status >= 400) { + const body = (await response.json()) as { error?: string }; + throw new Error(body.error ?? `HTTP ${response.status}`); + } + return response.json(); +} + +describe('resolve_path contract', () => { + let fixture: Fixture; + + beforeAll(async () => { + await cleanupTestDatabase(); + fixture = await seedFixture(); + }); + + it('resolves workspace and nested entity paths', async () => { + const workspace = (await resolvePath(fixture, { path: `/${fixture.orgSlug}` })) as { + workspace?: { slug: string }; + }; + expect(workspace.workspace?.slug).toBe(fixture.orgSlug); + + const nested = (await resolvePath(fixture, { + path: `/${fixture.orgSlug}/brand/acme-brand/product/acme-product`, + })) as { entity?: { name: string } }; + expect(nested.entity?.name).toBe('Acme Product'); + }); + + it('rejects malformed or missing paths instead of silently falling back', async () => { + await expect(resolvePath(fixture, { path: `/${fixture.orgSlug}/brand` })).rejects.toThrow(); + await expect( + resolvePath(fixture, { path: `/${fixture.orgSlug}/brand/missing-brand` }) + ).rejects.toThrow(); + }); + + it('returns bootstrap only when requested', async () => { + const withoutBootstrap = (await resolvePath(fixture, { path: `/${fixture.orgSlug}` })) as { + bootstrap?: unknown; + }; + expect(withoutBootstrap.bootstrap).toBeNull(); + + const withBootstrap = (await resolvePath(fixture, { + path: `/${fixture.orgSlug}`, + include_bootstrap: true, + })) as { bootstrap?: { entity_types?: unknown[] } }; + expect(withBootstrap.bootstrap?.entity_types?.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/owletto-backend/src/__tests__/integration/pages/resolve-path.test.ts b/packages/owletto-backend/src/__tests__/integration/pages/resolve-path.test.ts deleted file mode 100644 index 053751eba..000000000 --- a/packages/owletto-backend/src/__tests__/integration/pages/resolve-path.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * Resolve Path Integration Tests - * - * Tests for URL path resolution into workspace and entity hierarchy. - */ - -import { beforeAll, describe, expect, it } from 'vitest'; -import { cleanupTestDatabase } from '../../setup/test-db'; -import { - addUserToOrganization, - createTestAccessToken, - createTestEntity, - createTestOAuthClient, - createTestOrganization, - createTestUser, - seedSystemEntityTypes, -} from '../../setup/test-fixtures'; -import { mcpToolsCall } from '../../setup/test-helpers'; - -describe('Resolve Path', () => { - let org: Awaited>; - let user: Awaited>; - let token: string; - let parentEntity: Awaited>; - - beforeAll(async () => { - await cleanupTestDatabase(); - await seedSystemEntityTypes(); - - org = await createTestOrganization({ name: 'Path Test Org', slug: 'path-test' }); - user = await createTestUser({ email: 'path-user@test.com' }); - await addUserToOrganization(user.id, org.id, 'owner'); - - const client = await createTestOAuthClient(); - token = (await createTestAccessToken(user.id, org.id, client.client_id)).token; - - parentEntity = await createTestEntity({ - name: 'Test Brand', - entity_type: 'brand', - organization_id: org.id, - created_by: user.id, - }); - - await createTestEntity({ - name: 'Test Product', - entity_type: 'product', - organization_id: org.id, - parent_id: parentEntity.id, - created_by: user.id, - }); - }); - - describe('workspace resolution', () => { - it('should resolve org slug to workspace', async () => { - const result = await mcpToolsCall('resolve_path', { path: `/${org.slug}` }, { token }); - expect(result.workspace).toBeDefined(); - expect(result.workspace.slug).toBe(org.slug); - }); - - it('should return error for nonexistent workspace', async () => { - await expect( - mcpToolsCall('resolve_path', { path: '/nonexistent-org-xyz-12345' }, { token }) - ).rejects.toThrow(); - }); - }); - - describe('entity type path', () => { - it('should reject odd-segment entity paths', async () => { - await expect( - mcpToolsCall('resolve_path', { path: `/${org.slug}/brand` }, { token }) - ).rejects.toThrow(); - }); - - it('should reject nonexistent entity type/slug pairs', async () => { - await expect( - mcpToolsCall( - 'resolve_path', - { path: `/${org.slug}/nonexistent-type/nonexistent-slug` }, - { token } - ) - ).rejects.toThrow(); - }); - }); - - describe('entity path walking', () => { - it('should resolve single entity type/slug pair', async () => { - const result = await mcpToolsCall( - 'resolve_path', - { path: `/${org.slug}/brand/test-brand` }, - { token } - ); - expect(result.entity).toBeDefined(); - expect(result.entity.name).toBe('Test Brand'); - }); - - it('should resolve nested entity path', async () => { - const result = await mcpToolsCall( - 'resolve_path', - { path: `/${org.slug}/brand/test-brand/product/test-product` }, - { token } - ); - expect(result.entity).toBeDefined(); - expect(result.entity.name).toBe('Test Product'); - }); - - it('should return error for not found entity', async () => { - await expect( - mcpToolsCall('resolve_path', { path: `/${org.slug}/brand/nonexistent-brand` }, { token }) - ).rejects.toThrow(); - }); - }); - - describe('children & siblings', () => { - it('should return children for parent entity', async () => { - const result = await mcpToolsCall( - 'resolve_path', - { path: `/${org.slug}/brand/test-brand` }, - { token } - ); - expect(result.children).toBeDefined(); - expect(result.children.length).toBeGreaterThanOrEqual(1); - expect(result.children[0].name).toBe('Test Product'); - }); - }); - - describe('bootstrap data', () => { - it('should return bootstrap when requested', async () => { - const result = await mcpToolsCall( - 'resolve_path', - { path: `/${org.slug}`, include_bootstrap: true }, - { token } - ); - expect(result.bootstrap).toBeDefined(); - expect(result.bootstrap.entity_types).toBeDefined(); - expect(result.bootstrap.summary).toBeDefined(); - }); - - it('should not return bootstrap by default', async () => { - const result = await mcpToolsCall('resolve_path', { path: `/${org.slug}` }, { token }); - expect(result.bootstrap).toBeNull(); - }); - }); -}); diff --git a/packages/owletto-backend/src/__tests__/integration/relationships/entity-relationships.test.ts b/packages/owletto-backend/src/__tests__/integration/relationships/entity-relationships.test.ts deleted file mode 100644 index 5dc6fc89f..000000000 --- a/packages/owletto-backend/src/__tests__/integration/relationships/entity-relationships.test.ts +++ /dev/null @@ -1,832 +0,0 @@ -/** - * Entity Relationships Integration Tests - * - * Tests for relationship types CRUD, relationships CRUD, validation rules, - * symmetric canonicalization, scope enforcement, and entity deletion cascade. - */ - -import { beforeAll, describe, expect, it } from 'vitest'; -import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; -import { - addUserToOrganization, - createTestAccessToken, - createTestEntity, - createTestOAuthClient, - createTestOrganization, - createTestUser, -} from '../../setup/test-fixtures'; -import { mcpToolsCall } from '../../setup/test-helpers'; - -describe('Entity Relationships', () => { - let orgA: Awaited>; - let orgB: Awaited>; - let userA: Awaited>; - let userB: Awaited>; - let tokenA: string; - - // Entities for testing - let entityA1: Awaited>; - let entityA2: Awaited>; - let entityB1: Awaited>; - - beforeAll(async () => { - await cleanupTestDatabase(); - - orgA = await createTestOrganization({ name: 'Rel Test Org A' }); - orgB = await createTestOrganization({ name: 'Rel Test Org B' }); - - userA = await createTestUser({ email: 'rel-user-a@test.com' }); - userB = await createTestUser({ email: 'rel-user-b@test.com' }); - - await addUserToOrganization(userA.id, orgA.id, 'owner'); - await addUserToOrganization(userB.id, orgB.id, 'owner'); - - const client = await createTestOAuthClient(); - const tokenAResult = await createTestAccessToken(userA.id, orgA.id, client.client_id); - await createTestAccessToken(userB.id, orgB.id, client.client_id); - tokenA = tokenAResult.token; - - // Create test entities - entityA1 = await createTestEntity({ - name: 'Entity A1', - entity_type: 'brand', - organization_id: orgA.id, - }); - entityA2 = await createTestEntity({ - name: 'Entity A2', - entity_type: 'brand', - organization_id: orgA.id, - }); - entityB1 = await createTestEntity({ - name: 'Entity B1', - entity_type: 'brand', - organization_id: orgB.id, - }); - }); - - // ============================================ - // Relationship Types CRUD - // ============================================ - - describe('Relationship Types', () => { - it('should create a relationship type', async () => { - const result = await mcpToolsCall( - 'manage_entity_schema', - { - schema_type: 'relationship_type', - action: 'create', - slug: 'integrates-with', - name: 'Integrates With', - description: 'One entity integrates with another', - is_symmetric: true, - }, - { token: tokenA } - ); - - expect(result.action).toBe('create'); - expect(result.relationship_type).toBeDefined(); - expect(result.relationship_type.slug).toBe('integrates-with'); - expect(result.relationship_type.name).toBe('Integrates With'); - expect(result.relationship_type.is_symmetric).toBe(true); - expect(result.relationship_type.status).toBe('active'); - }); - - it('should create a directional relationship type with inverse', async () => { - // Create "depends-on" first - await mcpToolsCall( - 'manage_entity_schema', - { - schema_type: 'relationship_type', - action: 'create', - slug: 'depends-on', - name: 'Depends On', - is_symmetric: false, - }, - { token: tokenA } - ); - - // Create "dependency-of" pointing to "depends-on" as inverse - const result = await mcpToolsCall( - 'manage_entity_schema', - { - schema_type: 'relationship_type', - action: 'create', - slug: 'dependency-of', - name: 'Dependency Of', - is_symmetric: false, - inverse_type_slug: 'depends-on', - }, - { token: tokenA } - ); - - expect(result.relationship_type.inverse_type_slug).toBe('depends-on'); - }); - - it('should reject creating a duplicate slug', async () => { - await expect( - mcpToolsCall( - 'manage_entity_schema', - { - schema_type: 'relationship_type', - action: 'create', - slug: 'integrates-with', - name: 'Dup', - }, - { token: tokenA } - ) - ).rejects.toThrow(/already exists/i); - }); - - it('should list relationship types', async () => { - const result = await mcpToolsCall( - 'manage_entity_schema', - { schema_type: 'relationship_type', action: 'list' }, - { token: tokenA } - ); - - expect(result.action).toBe('list'); - expect(result.relationship_types.length).toBeGreaterThanOrEqual(3); - const found = result.relationship_types.find((t: any) => t.slug === 'integrates-with'); - expect(found).toBeDefined(); - }); - - it('should get a relationship type by slug', async () => { - const result = await mcpToolsCall( - 'manage_entity_schema', - { schema_type: 'relationship_type', action: 'get', slug: 'integrates-with' }, - { token: tokenA } - ); - - expect(result.action).toBe('get'); - expect(result.relationship_type).toBeDefined(); - expect(result.relationship_type.slug).toBe('integrates-with'); - }); - - it('should update a relationship type', async () => { - const result = await mcpToolsCall( - 'manage_entity_schema', - { - schema_type: 'relationship_type', - action: 'update', - slug: 'integrates-with', - name: 'Integrates With (Updated)', - description: 'Updated description', - }, - { token: tokenA } - ); - - expect(result.action).toBe('update'); - expect(result.relationship_type.name).toBe('Integrates With (Updated)'); - }); - - it('should soft-delete a relationship type', async () => { - // Create a disposable type - await mcpToolsCall( - 'manage_entity_schema', - { - schema_type: 'relationship_type', - action: 'create', - slug: 'to-delete-type', - name: 'To Delete', - }, - { token: tokenA } - ); - - const result = await mcpToolsCall( - 'manage_entity_schema', - { schema_type: 'relationship_type', action: 'delete', slug: 'to-delete-type' }, - { token: tokenA } - ); - - expect(result.success).toBe(true); - - // Should not appear in list - const list = await mcpToolsCall( - 'manage_entity_schema', - { schema_type: 'relationship_type', action: 'list' }, - { token: tokenA } - ); - const deleted = list.relationship_types.find((t: any) => t.slug === 'to-delete-type'); - expect(deleted).toBeUndefined(); - }); - }); - - // ============================================ - // Type-Pair Rules - // ============================================ - - describe('Type-Pair Rules', () => { - it('should add a rule', async () => { - const result = await mcpToolsCall( - 'manage_entity_schema', - { - schema_type: 'relationship_type', - action: 'add_rule', - slug: 'depends-on', - source_entity_type_slug: 'brand', - target_entity_type_slug: 'brand', - }, - { token: tokenA } - ); - - expect(result.action).toBe('add_rule'); - expect(result.rule).toBeDefined(); - expect(result.rule.source_entity_type_slug).toBe('brand'); - expect(result.rule.target_entity_type_slug).toBe('brand'); - }); - - it('should reject duplicate rule', async () => { - await expect( - mcpToolsCall( - 'manage_entity_schema', - { - schema_type: 'relationship_type', - action: 'add_rule', - slug: 'depends-on', - source_entity_type_slug: 'brand', - target_entity_type_slug: 'brand', - }, - { token: tokenA } - ) - ).rejects.toThrow(/already exists/i); - }); - - it('should list rules', async () => { - const result = await mcpToolsCall( - 'manage_entity_schema', - { schema_type: 'relationship_type', action: 'list_rules', slug: 'depends-on' }, - { token: tokenA } - ); - - expect(result.action).toBe('list_rules'); - expect(result.rules.length).toBeGreaterThanOrEqual(1); - }); - - it('should remove a rule', async () => { - const list = await mcpToolsCall( - 'manage_entity_schema', - { schema_type: 'relationship_type', action: 'list_rules', slug: 'depends-on' }, - { token: tokenA } - ); - const ruleId = list.rules[0].id; - - const result = await mcpToolsCall( - 'manage_entity_schema', - { schema_type: 'relationship_type', action: 'remove_rule', rule_id: ruleId }, - { token: tokenA } - ); - - expect(result.success).toBe(true); - }); - }); - - // ============================================ - // Relationships CRUD - // ============================================ - - describe('Relationships CRUD', () => { - it('should create a relationship', async () => { - const result = await mcpToolsCall( - 'manage_entity', - { - action: 'link', - from_entity_id: entityA1.id, - to_entity_id: entityA2.id, - relationship_type_slug: 'depends-on', - source: 'api', - confidence: 0.9, - metadata: { reason: 'integration test' }, - }, - { token: tokenA } - ); - - expect(result.action).toBe('link'); - expect(result.relationship).toBeDefined(); - expect(result.relationship.from_entity_id).toBe(entityA1.id); - expect(result.relationship.to_entity_id).toBe(entityA2.id); - expect(result.relationship.confidence).toBe(0.9); - expect(result.relationship.source).toBe('api'); - }); - - it('should default confidence to 1.0 for api source', async () => { - // Create another type for this test to avoid duplicate edge - await mcpToolsCall( - 'manage_entity_schema', - { - schema_type: 'relationship_type', - action: 'create', - slug: 'alternative-to', - name: 'Alternative To', - is_symmetric: true, - }, - { token: tokenA } - ); - - const result = await mcpToolsCall( - 'manage_entity', - { - action: 'link', - from_entity_id: entityA1.id, - to_entity_id: entityA2.id, - relationship_type_slug: 'alternative-to', - }, - { token: tokenA } - ); - - expect(result.relationship.confidence).toBe(1.0); - expect(result.relationship.source).toBe('api'); - }); - - it('should update metadata, confidence, source only', async () => { - // Get the first relationship - const list = await mcpToolsCall( - 'manage_entity', - { action: 'list_links', entity_id: entityA1.id }, - { token: tokenA } - ); - const relId = list.relationships[0].id; - - const result = await mcpToolsCall( - 'manage_entity', - { - action: 'update_link', - relationship_id: relId, - metadata: { updated: true }, - confidence: 0.5, - source: 'llm', - }, - { token: tokenA } - ); - - expect(result.action).toBe('update_link'); - expect(result.relationship.confidence).toBe(0.5); - expect(result.relationship.source).toBe('llm'); - }); - - it('should list relationships for an entity', async () => { - const result = await mcpToolsCall( - 'manage_entity', - { action: 'list_links', entity_id: entityA1.id }, - { token: tokenA } - ); - - expect(result.action).toBe('list_links'); - expect(result.relationships.length).toBeGreaterThanOrEqual(1); - expect(result.counts_by_type).toBeDefined(); - expect(result.metadata.total).toBeGreaterThanOrEqual(1); - }); - - it('should filter by direction (outbound)', async () => { - const result = await mcpToolsCall( - 'manage_entity', - { action: 'list_links', entity_id: entityA1.id, direction: 'outbound' }, - { token: tokenA } - ); - - for (const rel of result.relationships) { - expect(rel.from_entity_id).toBe(entityA1.id); - } - }); - - it('should filter by direction (inbound)', async () => { - const result = await mcpToolsCall( - 'manage_entity', - { action: 'list_links', entity_id: entityA2.id, direction: 'inbound' }, - { token: tokenA } - ); - - for (const rel of result.relationships) { - expect(rel.to_entity_id).toBe(entityA2.id); - } - }); - - it('should filter by relationship_type_slug', async () => { - const result = await mcpToolsCall( - 'manage_entity', - { - action: 'list_links', - entity_id: entityA1.id, - relationship_type_slug: 'depends-on', - }, - { token: tokenA } - ); - - for (const rel of result.relationships) { - expect(rel.relationship_type_slug).toBe('depends-on'); - } - }); - - it('should filter by confidence_min', async () => { - const result = await mcpToolsCall( - 'manage_entity', - { - action: 'list_links', - entity_id: entityA1.id, - confidence_min: 0.8, - }, - { token: tokenA } - ); - - for (const rel of result.relationships) { - expect(rel.confidence).toBeGreaterThanOrEqual(0.8); - } - }); - - it('should soft-delete a relationship', async () => { - // Create a disposable relationship - await mcpToolsCall( - 'manage_entity_schema', - { schema_type: 'relationship_type', action: 'create', slug: 'temp-rel-type', name: 'Temp' }, - { token: tokenA } - ); - const created = await mcpToolsCall( - 'manage_entity', - { - action: 'link', - from_entity_id: entityA1.id, - to_entity_id: entityA2.id, - relationship_type_slug: 'temp-rel-type', - }, - { token: tokenA } - ); - - const result = await mcpToolsCall( - 'manage_entity', - { action: 'unlink', relationship_id: created.relationship.id }, - { token: tokenA } - ); - - expect(result.success).toBe(true); - - // Should not appear in default list - const list = await mcpToolsCall( - 'manage_entity', - { action: 'list_links', entity_id: entityA1.id, relationship_type_slug: 'temp-rel-type' }, - { token: tokenA } - ); - expect(list.relationships.length).toBe(0); - }); - - it('should include deleted with include_deleted flag', async () => { - const list = await mcpToolsCall( - 'manage_entity', - { - action: 'list_links', - entity_id: entityA1.id, - relationship_type_slug: 'temp-rel-type', - include_deleted: true, - }, - { token: tokenA } - ); - expect(list.relationships.length).toBeGreaterThanOrEqual(1); - }); - }); - - // ============================================ - // Validation Rules - // ============================================ - - describe('Validation', () => { - it('should reject self-referencing relationship', async () => { - await expect( - mcpToolsCall( - 'manage_entity', - { - action: 'link', - from_entity_id: entityA1.id, - to_entity_id: entityA1.id, - relationship_type_slug: 'depends-on', - }, - { token: tokenA } - ) - ).rejects.toThrow(/self-referencing/i); - }); - - it('should reject invalid confidence (> 1)', async () => { - await expect( - mcpToolsCall( - 'manage_entity', - { - action: 'link', - from_entity_id: entityA1.id, - to_entity_id: entityA2.id, - relationship_type_slug: 'depends-on', - confidence: 1.5, - }, - { token: tokenA } - ) - ).rejects.toThrow(); // TypeBox validates minimum/maximum at schema level - }); - - it('should reject invalid confidence (< 0)', async () => { - await expect( - mcpToolsCall( - 'manage_entity', - { - action: 'link', - from_entity_id: entityA1.id, - to_entity_id: entityA2.id, - relationship_type_slug: 'depends-on', - confidence: -0.1, - }, - { token: tokenA } - ) - ).rejects.toThrow(); // TypeBox validates minimum/maximum at schema level - }); - - it('should reject invalid source', async () => { - await expect( - mcpToolsCall( - 'manage_entity', - { - action: 'link', - from_entity_id: entityA1.id, - to_entity_id: entityA2.id, - relationship_type_slug: 'depends-on', - source: 'invalid-source' as any, - }, - { token: tokenA } - ) - ).rejects.toThrow(); // TypeBox validates union type at schema level - }); - - it('should reject duplicate active edge', async () => { - await expect( - mcpToolsCall( - 'manage_entity', - { - action: 'link', - from_entity_id: entityA1.id, - to_entity_id: entityA2.id, - relationship_type_slug: 'depends-on', - }, - { token: tokenA } - ) - ).rejects.toThrow(/already exists/i); - }); - - it('should reject cross-org relationship in multi-tenant mode', async () => { - await expect( - mcpToolsCall( - 'manage_entity', - { - action: 'link', - from_entity_id: entityA1.id, - to_entity_id: entityB1.id, - relationship_type_slug: 'depends-on', - }, - { token: tokenA } - ) - ).rejects.toThrow(/organization/i); - }); - - it('should allow cross-org relationship when target is in a public-catalog org', async () => { - const publicOrg = await createTestOrganization({ - name: 'Public Catalog Org', - visibility: 'public', - }); - const publicEntity = await createTestEntity({ - name: 'Public Canonical Entity', - entity_type: 'brand', - organization_id: publicOrg.id, - }); - - const result = await mcpToolsCall( - 'manage_entity', - { - action: 'link', - from_entity_id: entityA1.id, - to_entity_id: publicEntity.id, - relationship_type_slug: 'depends-on', - }, - { token: tokenA } - ); - expect(result.action).toBe('link'); - // Relationship's organization_id is the source's (caller's) org, not the target's. - expect(result.relationship.organization_id).toBe(orgA.id); - }); - - it('should resolve a relationship_type defined in a public-catalog org (cross-org type vocabulary)', async () => { - // Set up a public catalog with a canonical relationship type the - // tenant doesn't have locally. Mirrors how `works_at` would live in - // public-uk-finance. - const publicCatalog = await createTestOrganization({ - name: 'Public Catalog Type', - visibility: 'public', - }); - const publicEntity = await createTestEntity({ - name: 'Canonical Co', - entity_type: 'brand', - organization_id: publicCatalog.id, - }); - const sql = getTestDb(); - await sql` - INSERT INTO entity_relationship_types (organization_id, slug, name, is_symmetric, created_at, updated_at) - VALUES (${publicCatalog.id}, 'works-at-public', 'Works At', false, current_timestamp, current_timestamp) - `; - - const result = await mcpToolsCall( - 'manage_entity', - { - action: 'link', - from_entity_id: entityA1.id, - to_entity_id: publicEntity.id, - relationship_type_slug: 'works-at-public', - }, - { token: tokenA } - ); - expect(result.action).toBe('link'); - expect(result.relationship.organization_id).toBe(orgA.id); - }); - - it('should reject a relationship whose source is in a different org from the caller', async () => { - // userA is signed in (tokenA → orgA), but the source entity is in orgB. - // Even though tokenA's caller has access to read entityB1, they cannot - // author a relationship *from* it — sources must always be in the - // caller's org. - await expect( - mcpToolsCall( - 'manage_entity', - { - action: 'link', - from_entity_id: entityB1.id, - to_entity_id: entityA2.id, - relationship_type_slug: 'depends-on', - }, - { token: tokenA } - ) - ).rejects.toThrow(/does not belong to your organization/i); - }); - - it('should reject nonexistent relationship type', async () => { - await expect( - mcpToolsCall( - 'manage_entity', - { - action: 'link', - from_entity_id: entityA1.id, - to_entity_id: entityA2.id, - relationship_type_slug: 'nonexistent-type', - }, - { token: tokenA } - ) - ).rejects.toThrow(/not found/i); - }); - }); - - // ============================================ - // Symmetric Canonicalization - // ============================================ - - describe('Symmetric Canonicalization', () => { - it('should canonicalize symmetric edges (A→B stored as min→max)', async () => { - // "integrates-with" is symmetric, created above - // Ensure no existing edge for this pair + type - const sql = getTestDb(); - await sql` - DELETE FROM entity_relationships - WHERE relationship_type_id IN ( - SELECT id FROM entity_relationship_types WHERE slug = 'integrates-with' - ) - `; - - // Create with A2→A1 (higher→lower), should be stored as A1→A2 - const result = await mcpToolsCall( - 'manage_entity', - { - action: 'link', - from_entity_id: entityA2.id, - to_entity_id: entityA1.id, - relationship_type_slug: 'integrates-with', - }, - { token: tokenA } - ); - - const minId = Math.min(entityA1.id, entityA2.id); - const maxId = Math.max(entityA1.id, entityA2.id); - expect(result.relationship.from_entity_id).toBe(minId); - expect(result.relationship.to_entity_id).toBe(maxId); - }); - - it('should find symmetric edge from either side', async () => { - // Query from A1's side - const fromA1 = await mcpToolsCall( - 'manage_entity', - { - action: 'list_links', - entity_id: entityA1.id, - relationship_type_slug: 'integrates-with', - }, - { token: tokenA } - ); - expect(fromA1.relationships.length).toBeGreaterThanOrEqual(1); - - // Query from A2's side - const fromA2 = await mcpToolsCall( - 'manage_entity', - { - action: 'list_links', - entity_id: entityA2.id, - relationship_type_slug: 'integrates-with', - }, - { token: tokenA } - ); - expect(fromA2.relationships.length).toBeGreaterThanOrEqual(1); - }); - }); - - // ============================================ - // Type-Pair Rule Enforcement - // ============================================ - - describe('Type-Pair Rule Enforcement', () => { - beforeAll(async () => { - // Create a type with a rule: only allows brand → brand - await mcpToolsCall( - 'manage_entity_schema', - { - schema_type: 'relationship_type', - action: 'create', - slug: 'brand-only-rel', - name: 'Brand Only', - }, - { token: tokenA } - ); - await mcpToolsCall( - 'manage_entity_schema', - { - schema_type: 'relationship_type', - action: 'add_rule', - slug: 'brand-only-rel', - source_entity_type_slug: 'brand', - target_entity_type_slug: 'brand', - }, - { token: tokenA } - ); - }); - - it('should allow matching type pair', async () => { - // Both entities are brands - const result = await mcpToolsCall( - 'manage_entity', - { - action: 'link', - from_entity_id: entityA1.id, - to_entity_id: entityA2.id, - relationship_type_slug: 'brand-only-rel', - }, - { token: tokenA } - ); - expect(result.action).toBe('link'); - }); - }); - - // ============================================ - // Entity Deletion Cascade - // ============================================ - - describe('Entity Deletion Cascade', () => { - it('should cascade delete relationships when entity is deleted', async () => { - // Create a temporary entity and relationship - const tempEntity = await createTestEntity({ - name: 'Temp Cascade Entity', - entity_type: 'brand', - organization_id: orgA.id, - }); - - await mcpToolsCall( - 'manage_entity', - { - action: 'link', - from_entity_id: entityA1.id, - to_entity_id: tempEntity.id, - relationship_type_slug: 'depends-on', - }, - { token: tokenA } - ); - - // Verify relationship exists - const beforeDelete = await mcpToolsCall( - 'manage_entity', - { action: 'list_links', entity_id: tempEntity.id }, - { token: tokenA } - ); - expect(beforeDelete.relationships.length).toBeGreaterThanOrEqual(1); - - // Delete the entity via manage_entity (force to trigger hard delete + relationship cascade) - await mcpToolsCall( - 'manage_entity', - { action: 'delete', entity_id: tempEntity.id, force_delete_tree: true }, - { token: tokenA } - ); - - // Verify relationships are gone (even with include_deleted, hard-deleted) - const sql = getTestDb(); - const remaining = await sql` - SELECT id FROM entity_relationships - WHERE from_entity_id = ${tempEntity.id} OR to_entity_id = ${tempEntity.id} - `; - expect(remaining.length).toBe(0); - }); - }); -}); diff --git a/packages/owletto-backend/src/__tests__/integration/relationships/relationships-contract.test.ts b/packages/owletto-backend/src/__tests__/integration/relationships/relationships-contract.test.ts new file mode 100644 index 000000000..0c79816f5 --- /dev/null +++ b/packages/owletto-backend/src/__tests__/integration/relationships/relationships-contract.test.ts @@ -0,0 +1,113 @@ +/** + * Entity relationship contract coverage. + * + * Replaces the old exhaustive relationship suite with stable high-value + * invariants: create/list links, duplicate/self-edge validation, and org + * isolation for cross-workspace entity IDs. + */ + +import { beforeAll, describe, expect, it } from 'vitest'; +import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; +import { TestWorkspace } from '../../setup/test-workspace'; + +type SeededGraph = { + workspace: TestWorkspace; + companyId: number; + productId: number; + relationshipSlug: string; +}; + +async function seedGraph(workspace: TestWorkspace, prefix: string): Promise { + const sql = getTestDb(); + await sql` + INSERT INTO entity_types (organization_id, slug, name, created_at, updated_at) + VALUES + (${workspace.org.id}, 'company', 'Company', NOW(), NOW()), + (${workspace.org.id}, 'product', 'Product', NOW(), NOW()) + `; + const relationshipSlug = `${prefix.toLowerCase()}-owns-product`; + await workspace.owner.entity_schema.createRelType({ + slug: relationshipSlug, + name: 'Owns Product', + }); + + const company = (await workspace.owner.entities.create({ + type: 'company', + name: `${prefix} Company`, + })) as { entity: { id: number } }; + const product = (await workspace.owner.entities.create({ + type: 'product', + name: `${prefix} Product`, + })) as { entity: { id: number } }; + + return { + workspace, + companyId: company.entity.id, + productId: product.entity.id, + relationshipSlug, + }; +} + +describe('entity relationship contract', () => { + let graphA: SeededGraph; + let graphB: SeededGraph; + + beforeAll(async () => { + await cleanupTestDatabase(); + const { a, b } = await TestWorkspace.pair(); + graphA = await seedGraph(a, 'A'); + graphB = await seedGraph(b, 'B'); + }); + + it('creates and lists relationships inside a workspace', async () => { + const linked = (await graphA.workspace.owner.entities.link({ + from_entity_id: graphA.companyId, + to_entity_id: graphA.productId, + relationship_type_slug: graphA.relationshipSlug, + metadata: { source: 'contract-test' }, + })) as { relationship?: { id: number; relationship_type_slug: string } }; + + expect(linked.relationship?.id).toBeGreaterThan(0); + expect(linked.relationship?.relationship_type_slug).toBe(graphA.relationshipSlug); + + const links = (await graphA.workspace.owner.entities.listLinks({ + entity_id: graphA.companyId, + relationship_type_slug: graphA.relationshipSlug, + })) as { relationships?: Array<{ id: number }> }; + expect(links.relationships?.some((r) => r.id === linked.relationship?.id)).toBe(true); + }); + + it('rejects duplicate and self-referential relationships', async () => { + await expect( + graphA.workspace.owner.entities.link({ + from_entity_id: graphA.companyId, + to_entity_id: graphA.productId, + relationship_type_slug: graphA.relationshipSlug, + }) + ).rejects.toThrow(/already exists|duplicate/i); + + await expect( + graphA.workspace.owner.entities.link({ + from_entity_id: graphA.companyId, + to_entity_id: graphA.companyId, + relationship_type_slug: graphA.relationshipSlug, + }) + ).rejects.toThrow(/self|same entity|itself/i); + }); + + it('does not allow links to entities in another private workspace', async () => { + await expect( + graphA.workspace.owner.entities.link({ + from_entity_id: graphA.companyId, + to_entity_id: graphB.productId, + relationship_type_slug: graphA.relationshipSlug, + }) + ).rejects.toThrow(/access|organization|scope|not found/i); + + const linksB = (await graphB.workspace.owner.entities.listLinks({ + entity_id: graphB.productId, + relationship_type_slug: graphA.relationshipSlug, + })) as { relationships?: unknown[] }; + expect(linksB.relationships ?? []).toHaveLength(0); + }); +}); diff --git a/packages/owletto-backend/src/__tests__/integration/sandbox/execute-wire.test.ts b/packages/owletto-backend/src/__tests__/integration/sandbox/execute-wire.test.ts new file mode 100644 index 000000000..8ff1bcaaa --- /dev/null +++ b/packages/owletto-backend/src/__tests__/integration/sandbox/execute-wire.test.ts @@ -0,0 +1,82 @@ +/** + * `run` MCP tool round-trip through the sandbox. + * + * Complementary to sandbox/client-sdk-org and namespace-dispatch (which test + * the SDK directly): this exercises the wire path — JSON-RPC → tool dispatch + * → isolated-vm → SDK call → response shape. + * + * Skipped automatically if isolated-vm cannot load (e.g. local Node 25 without + * matching prebuilds); CI pins Node 22 where the abi127 prebuild ships. + */ + +import { beforeAll, describe, expect, it } from 'vitest'; +import { + addUserToOrganization, + createTestAccessToken, + createTestOAuthClient, + createTestOrganization, + createTestUser, +} from '../../setup/test-fixtures'; +import { TestApiClient, TestMcpClient } from '../../setup/test-mcp-client'; +import { cleanupTestDatabase } from '../../setup/test-db'; + +function isolatedVmAvailable(): boolean { + // isolated-vm ships prebuilds for abi127 (Node 22) and abi137 (Node 24). + // We can't actually try `new Isolate()` to detect — on a wrong ABI it + // segfaults, which we can't recover from. So gate on `process.versions.modules` + // matching a known-good value. CI pins Node 22 explicitly. + const abi = process.versions.modules; + return abi === '127' || abi === '137'; +} + +describe('sandbox run (wire)', () => { + let orgSlug: string; + let token: string; + const isolatedAvailable = isolatedVmAvailable(); + + beforeAll(async () => { + await cleanupTestDatabase(); + + const org = await createTestOrganization({ name: 'Sandbox Wire Org' }); + const user = await createTestUser({ email: 'sandbox-wire@test.com' }); + await addUserToOrganization(user.id, org.id, 'owner'); + const oauthClient = await createTestOAuthClient(); + const oauthResult = await createTestAccessToken(user.id, org.id, oauthClient.client_id); + + orgSlug = org.slug; + token = oauthResult.token; + + const seedClient = await TestApiClient.for({ + organizationId: org.id, + userId: user.id, + memberRole: 'owner', + }); + await seedClient.entity_schema.createType({ slug: 'company', name: 'Company' }); + await seedClient.entities.create({ type: 'company', name: 'Sandbox Co' }); + }); + + it('runs a trivial script and returns its result', async (testCtx) => { + if (!isolatedAvailable) return testCtx.skip(); + const client = new TestMcpClient({ token, orgSlug }); + const result = await client.run( + `export default async (_ctx, _client) => ({ ok: true, n: 42 });` + ); + const json = JSON.stringify(result); + expect(json).toContain('"ok":true'); + expect(json).toContain('"n":42'); + }); + + it('runs a script that calls into client.entities.list (real SDK round-trip)', async (testCtx) => { + if (!isolatedAvailable) return testCtx.skip(); + const client = new TestMcpClient({ token, orgSlug }); + const result = await client.run( + `export default async (_ctx, client) => { + const list = await client.entities.list({ entity_type: 'company' }); + return { count: list.entities?.length ?? 0 }; + };` + ); + const json = JSON.stringify(result); + // We seeded one company; the script should see it. + expect(json).toContain('"count":1'); + }); +}); diff --git a/packages/owletto-backend/src/__tests__/integration/scoping/organization-access.test.ts b/packages/owletto-backend/src/__tests__/integration/scoping/organization-access.test.ts deleted file mode 100644 index 4fe2e9501..000000000 --- a/packages/owletto-backend/src/__tests__/integration/scoping/organization-access.test.ts +++ /dev/null @@ -1,298 +0,0 @@ -/** - * Organization Scoping Tests - * - * Tests for multi-tenant data isolation: - * - Users can read their own org's entities - * - Users can read public org entities - * - Users cannot read other private org entities - * - Users can only write to their own org - */ - -import { beforeAll, describe, expect, it } from 'vitest'; -import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; -import { - addUserToOrganization, - createTestAccessToken, - createTestEntity, - createTestOAuthClient, - createTestOrganization, - createTestUser, - seedSystemEntityTypes, -} from '../../setup/test-fixtures'; -import { mcpToolsCall } from '../../setup/test-helpers'; - -describe('Organization Scoping', () => { - let orgA: Awaited>; - let orgB: Awaited>; - let publicOrg: Awaited>; - - let userA: Awaited>; - let userB: Awaited>; - - let tokenA: string; - let tokenB: string; - - let entityOrgA: Awaited>; - let entityOrgB: Awaited>; - let entityPublic: Awaited>; - - beforeAll(async () => { - await cleanupTestDatabase(); - await seedSystemEntityTypes(); - - // Setup organizations - orgA = await createTestOrganization({ name: 'Organization A' }); - orgB = await createTestOrganization({ name: 'Organization B' }); - publicOrg = await createTestOrganization({ name: 'Public Organization' }); - - // Setup users - userA = await createTestUser({ email: 'user-a@test.com', name: 'User A' }); - userB = await createTestUser({ email: 'user-b@test.com', name: 'User B' }); - - await addUserToOrganization(userA.id, orgA.id, 'owner'); - await addUserToOrganization(userB.id, orgB.id, 'owner'); - - // Create OAuth client and tokens - const client = await createTestOAuthClient(); - const tokenAResult = await createTestAccessToken(userA.id, orgA.id, client.client_id); - const tokenBResult = await createTestAccessToken(userB.id, orgB.id, client.client_id); - tokenA = tokenAResult.token; - tokenB = tokenBResult.token; - - // Create entities in each org - entityOrgA = await createTestEntity({ - name: 'Entity in Org A', - organization_id: orgA.id, - domain: 'org-a.example.com', - }); - entityOrgB = await createTestEntity({ - name: 'Entity in Org B', - organization_id: orgB.id, - domain: 'org-b.example.com', - }); - entityPublic = await createTestEntity({ - name: 'Public Entity', - organization_id: publicOrg.id, - domain: 'public.example.com', - }); - }); - - describe('Read Access', () => { - it('should allow user to read own org entities', async () => { - const result = await mcpToolsCall( - 'search_knowledge', - { entity_id: entityOrgA.id }, - { token: tokenA } - ); - - expect(result.entity).toBeDefined(); - expect(result.entity.id).toBe(entityOrgA.id); - expect(result.entity.name).toBe('Entity in Org A'); - }); - - it('should deny user from reading other private org entities', async () => { - const result = await mcpToolsCall( - 'search_knowledge', - { entity_id: entityOrgB.id }, - { token: tokenA } - ); - - // Entity should not be found (access denied = appears as not found) - expect(result.entity).toBeNull(); - expect(result.discovery_status).toBe('not_found'); - }); - - it('should allow any user to read public org entities', async () => { - // User A reading public entity - const resultA = await mcpToolsCall( - 'search_knowledge', - { entity_id: entityPublic.id }, - { token: tokenA } - ); - - expect(resultA.entity).toBeDefined(); - expect(resultA.entity.id).toBe(entityPublic.id); - - // User B reading same public entity - const resultB = await mcpToolsCall( - 'search_knowledge', - { entity_id: entityPublic.id }, - { token: tokenB } - ); - - expect(resultB.entity).toBeDefined(); - expect(resultB.entity.id).toBe(entityPublic.id); - }); - - it('should filter search results by readable organizations', async () => { - // User A searches - should only see own org + public entities - const resultA = await mcpToolsCall( - 'search_knowledge', - { query: 'Entity' }, - { token: tokenA } - ); - - // Should find Entity in Org A and Public Entity, but NOT Entity in Org B - const entityNames = resultA.matches?.map((m: any) => m.name) || []; - - expect(entityNames).toContain('Entity in Org A'); - expect(entityNames).toContain('Public Entity'); - expect(entityNames).not.toContain('Entity in Org B'); - }); - }); - - describe('Write Access', () => { - it('should allow user to update own org entities', async () => { - const result = await mcpToolsCall( - 'manage_entity', - { - action: 'update', - entity_id: entityOrgA.id, - name: 'Updated Entity A', - }, - { token: tokenA } - ); - - expect(result.action).toBe('update'); - expect(result.entity.name).toBe('Updated Entity A'); - - // Restore original name for other tests - await mcpToolsCall( - 'manage_entity', - { - action: 'update', - entity_id: entityOrgA.id, - name: 'Entity in Org A', - }, - { token: tokenA } - ); - }); - - it('should deny user from updating other org entities', async () => { - await expect( - mcpToolsCall( - 'manage_entity', - { - action: 'update', - entity_id: entityOrgB.id, - name: 'Hacked Name', - }, - { token: tokenA } - ) - ).rejects.toThrow(/Access denied/); - }); - - it('should deny user from updating public org entities they do not own', async () => { - await expect( - mcpToolsCall( - 'manage_entity', - { - action: 'update', - entity_id: entityPublic.id, - name: 'Hacked Public Entity', - }, - { token: tokenA } - ) - ).rejects.toThrow(/Access denied/); - }); - - it('should deny user from deleting other org entities', async () => { - // Secure behavior: entity is "not found" rather than "access denied" - // This prevents information leakage about entity existence - await expect( - mcpToolsCall( - 'manage_entity', - { - action: 'delete', - entity_id: entityOrgB.id, - }, - { token: tokenA } - ) - ).rejects.toThrow(/not found|Access denied/i); - }); - }); - - describe('Create Access', () => { - it('should create entities in user own org', async () => { - const result = await mcpToolsCall( - 'manage_entity', - { - action: 'create', - entity_type: 'brand', - name: 'New Brand by User A', - domain: 'newbrand-a.example.com', - }, - { token: tokenA } - ); - - expect(result.action).toBe('create'); - expect(result.entity.id).toBeDefined(); - - // Verify entity is in correct org - const sql = getTestDb(); - const [entity] = await sql` - SELECT organization_id FROM entities WHERE id = ${result.entity.id} - `; - expect(entity.organization_id).toBe(orgA.id); - - // Cleanup: delete the test entity - await sql`DELETE FROM entities WHERE id = ${result.entity.id}`; - }); - - it('should not allow creating entities in other orgs', async () => { - // The create action doesn't accept organization_id - - // it always uses the authenticated user's org - const result = await mcpToolsCall( - 'manage_entity', - { - action: 'create', - entity_type: 'brand', - name: 'Brand Created by A', - }, - { token: tokenA } - ); - - // Verify it was created in User A's org, not anywhere else - const sql = getTestDb(); - const [entity] = await sql` - SELECT organization_id FROM entities WHERE id = ${result.entity.id} - `; - expect(entity.organization_id).toBe(orgA.id); - - // Cleanup - await sql`DELETE FROM entities WHERE id = ${result.entity.id}`; - }); - }); - - describe('Cross-Tenant Isolation', () => { - it('should isolate connection operations to own org entities', async () => { - // User A trying to list connections filtered by Org B's entity - // Should return empty (connections are scoped to user's own org) - const result = await mcpToolsCall( - 'manage_connections', - { - action: 'list', - entity_id: entityOrgB.id, - }, - { token: tokenA } - ); - - expect(result.connections).toBeDefined(); - expect(result.connections.length).toBe(0); - }); - - it('should isolate watchers operations to own org entities', async () => { - // User A trying to create watcher for Org B's entity - await expect( - mcpToolsCall( - 'manage_watchers', - { - action: 'list', - entity_id: entityOrgB.id, - }, - { token: tokenA } - ) - ).rejects.toThrow(/Access denied/); - }); - }); -}); diff --git a/packages/owletto-backend/src/__tests__/integration/watchers/automation-contract.test.ts b/packages/owletto-backend/src/__tests__/integration/watchers/automation-contract.test.ts new file mode 100644 index 000000000..4f0270c10 --- /dev/null +++ b/packages/owletto-backend/src/__tests__/integration/watchers/automation-contract.test.ts @@ -0,0 +1,202 @@ +/** + * Compact watcher automation contracts retained from the deleted broad suite. + * + * These are high-value queue/lifecycle boundaries: scheduled watchers should + * materialize only one active run, dispatcher reconciliation should close runs + * that already produced a window, and complete_window provenance should close + * a running queued run. + */ + +import { inferWatcherGranularityFromSchedule } from '@lobu/owletto-sdk'; +import { beforeEach, describe, expect, it } from 'vitest'; +import type { DbClient } from '../../../db/client'; +import type { Env } from '../../../index'; +import { createWatcherRun } from '../../../utils/queue-helpers'; +import { computePendingWindow } from '../../../utils/window-utils'; +import { + dispatchPendingWatcherRuns, + materializeDueWatcherRuns, +} from '../../../watchers/automation'; +import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; +import { createTestAgent, createTestEntity, createTestEvent } from '../../setup/test-fixtures'; +import { TestApiClient } from '../../setup/test-mcp-client'; +import { TestWorkspace } from '../../setup/test-workspace'; + +async function createAutomatedWatcher() { + const sql = getTestDb(); + const dbClient = sql as unknown as DbClient; + const workspace = await TestWorkspace.create({ name: 'Watcher Automation Contract Org' }); + + const entity = await createTestEntity({ + name: 'Automation Entity', + organization_id: workspace.org.id, + created_by: workspace.users.owner.id, + }); + + const agent = await createTestAgent({ + organizationId: workspace.org.id, + ownerUserId: workspace.users.owner.id, + agentId: 'watcher-agent', + name: 'Watcher Agent', + }); + + const watcher = (await workspace.owner.watchers.create({ + entity_id: entity.id, + slug: 'automation-watcher', + name: 'Automation Watcher', + prompt: 'Summarize content for {{entities}}.', + extraction_schema: { + type: 'object', + properties: { summary: { type: 'string' } }, + required: ['summary'], + }, + schedule: '0 9 * * *', + agent_id: agent.agentId, + })) as { watcher_id: string }; + const watcherId = Number(watcher.watcher_id); + + await sql` + UPDATE watchers + SET next_run_at = NOW() - INTERVAL '10 minutes' + WHERE id = ${watcherId} + `; + + const api = await TestApiClient.for({ + organizationId: workspace.org.id, + userId: workspace.users.owner.id, + memberRole: 'owner', + }); + + return { sql, dbClient, workspace, api, entityId: entity.id, agent, watcherId }; +} + +describe('watcher automation contract', () => { + beforeEach(async () => { + await cleanupTestDatabase(); + }); + + it('materializes one scheduled watcher run and dedupes concurrent ticks', async () => { + const { sql, watcherId, agent, workspace } = await createAutomatedWatcher(); + + const [resultA, resultB] = await Promise.all([ + materializeDueWatcherRuns({} as Env), + materializeDueWatcherRuns({} as Env), + ]); + + expect(resultA.runsCreated + resultB.runsCreated).toBe(1); + + const runs = await sql` + SELECT status, approved_input + FROM runs + WHERE watcher_id = ${watcherId} + AND run_type = 'watcher' + AND organization_id = ${workspace.org.id} + `; + expect(runs).toHaveLength(1); + expect(String(runs[0].status)).toBe('pending'); + + const payload = runs[0].approved_input as Record; + expect(Number(payload.watcher_id)).toBe(watcherId); + expect(payload.agent_id).toBe(agent.agentId); + expect(payload.dispatch_source).toBe('scheduled'); + }); + + it('reconciles a queued watcher run when a correlated window already exists', async () => { + const { sql, dbClient, workspace, watcherId, agent } = await createAutomatedWatcher(); + + const granularity = inferWatcherGranularityFromSchedule('0 9 * * *'); + const { windowStart, windowEnd } = await computePendingWindow(dbClient, watcherId, granularity); + const queued = await createWatcherRun({ + organizationId: workspace.org.id, + watcherId, + agentId: agent.agentId, + windowStart: windowStart.toISOString(), + windowEnd: windowEnd.toISOString(), + dispatchSource: 'scheduled', + }); + + const [window] = await sql` + INSERT INTO watcher_windows ( + watcher_id, granularity, window_start, window_end, + extracted_data, content_analyzed, model_used, run_metadata, run_id, created_at + ) VALUES ( + ${watcherId}, 'daily', ${windowStart}, ${windowEnd}, + ${sql.json({ summary: 'External completion' })}, 1, 'external-client', + ${sql.json({ source: 'external', watcher_run_id: queued.runId })}, ${queued.runId}, NOW() + ) + RETURNING id + `; + + const result = await dispatchPendingWatcherRuns({} as Env, { + db: dbClient, + runIds: [queued.runId], + }); + const [run] = await sql` + SELECT status, window_id + FROM runs + WHERE id = ${queued.runId} + `; + + expect(result.reconciled).toBe(1); + expect(String(run.status)).toBe('completed'); + expect(Number(run.window_id)).toBe(Number(window.id)); + }); + + it('completes a queued watcher run from complete_window provenance', async () => { + const { sql, dbClient, workspace, api, entityId, watcherId, agent } = await createAutomatedWatcher(); + + await createTestEvent({ + entity_id: entityId, + organization_id: workspace.org.id, + content: 'Customer feedback that should be summarized.', + occurred_at: new Date(Date.now() - 60 * 60 * 1000), + }); + + const granularity = inferWatcherGranularityFromSchedule('0 9 * * *'); + const { windowStart, windowEnd } = await computePendingWindow(dbClient, watcherId, granularity); + const queued = await createWatcherRun({ + organizationId: workspace.org.id, + watcherId, + agentId: agent.agentId, + windowStart: windowStart.toISOString(), + windowEnd: windowEnd.toISOString(), + dispatchSource: 'scheduled', + }); + + await sql` + UPDATE runs + SET status = 'running', claimed_at = NOW(), claimed_by = ${`lobu:${agent.agentId}`} + WHERE id = ${queued.runId} + `; + + const content = (await api.knowledge.read({ watcher_id: watcherId })) as { + window_token: string; + window_start: string; + window_end: string; + }; + expect(content.window_start).toBe(windowStart.toISOString()); + expect(content.window_end).toBe(windowEnd.toISOString()); + + const completion = (await api.watchers.completeWindow({ + watcher_id: String(watcherId), + window_token: content.window_token, + extracted_data: { summary: 'Automated watcher summary' }, + run_metadata: { + executor: 'lobu-agent', + agent_id: agent.agentId, + watcher_run_id: queued.runId, + dispatch_source: 'scheduled', + }, + })) as { action: string; window_id: number }; + + const [run] = await sql` + SELECT status, window_id + FROM runs + WHERE id = ${queued.runId} + `; + + expect(completion.action).toBe('complete_window'); + expect(String(run.status)).toBe('completed'); + expect(Number(run.window_id)).toBe(completion.window_id); + }); +}); diff --git a/packages/owletto-backend/src/__tests__/integration/watchers/feedback-contract.test.ts b/packages/owletto-backend/src/__tests__/integration/watchers/feedback-contract.test.ts new file mode 100644 index 000000000..c23e59d80 --- /dev/null +++ b/packages/owletto-backend/src/__tests__/integration/watchers/feedback-contract.test.ts @@ -0,0 +1,210 @@ +/** + * Compact watcher feedback contract. + * + * High-value coverage retained from the deleted feedback suite: the feedback + * API is the durable human-correction path for watcher outputs, so it must + * store field-level mutations transactionally, return scoped feedback, validate + * malformed corrections, and block cross-org writes. + */ + +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { manageWatchers } from '../../../tools/admin/manage_watchers'; +import type { ToolContext } from '../../../tools/registry'; +import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; +import { createTestEntity } from '../../setup/test-fixtures'; +import { TestWorkspace } from '../../setup/test-workspace'; + +function ownerCtx(workspace: TestWorkspace): ToolContext { + return { + organizationId: workspace.org.id, + userId: workspace.users.owner.id, + memberRole: 'owner', + agentId: null, + isAuthenticated: true, + clientId: null, + scopes: ['mcp:read', 'mcp:write', 'mcp:admin'], + tokenType: 'oauth', + scopedToOrg: true, + allowCrossOrg: false, + }; +} + +async function seedWatcher(workspace: TestWorkspace, suffix: string) { + const entity = await createTestEntity({ + name: `Feedback Entity ${suffix}`, + organization_id: workspace.org.id, + created_by: workspace.users.owner.id, + }); + const watcher = (await workspace.owner.watchers.create({ + entity_id: entity.id, + slug: `feedback-watcher-${suffix}`, + name: `Feedback Watcher ${suffix}`, + prompt: 'Analyze inputs.', + extraction_schema: { + type: 'object', + properties: { + problems: { + type: 'array', + items: { + type: 'object', + properties: { name: { type: 'string' }, severity: { type: 'string' } }, + }, + }, + }, + }, + })) as { watcher_id: string }; + + const [window] = await getTestDb()` + INSERT INTO watcher_windows ( + watcher_id, granularity, window_start, window_end, + extracted_data, content_analyzed, model_used, created_at + ) VALUES ( + ${Number(watcher.watcher_id)}, 'weekly', + ${new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)}, ${new Date()}, + ${getTestDb().json({ problems: [{ name: 'A', severity: 'low' }] })}, + 0, 'test-model', NOW() + ) + RETURNING id + `; + + return { watcherId: watcher.watcher_id, windowId: Number(window.id) }; +} + +describe('watcher feedback contract', () => { + let workspace: TestWorkspace; + let watcherId: string; + let windowId: number; + + beforeAll(async () => { + await cleanupTestDatabase(); + workspace = await TestWorkspace.create({ name: 'Feedback Contract Org' }); + const seeded = await seedWatcher(workspace, 'primary'); + watcherId = seeded.watcherId; + windowId = seeded.windowId; + }); + + beforeEach(async () => { + await getTestDb()`DELETE FROM watcher_window_field_feedback WHERE watcher_id = ${Number(watcherId)}`; + }); + + it('stores set/remove/add field corrections from one batch as separate rows', async () => { + const result = (await manageWatchers( + { + action: 'submit_feedback', + watcher_id: watcherId, + window_id: windowId, + corrections: [ + { field_path: 'problems[0].severity', value: 'high', note: 'misclassified' }, + { field_path: 'problems[0]', mutation: 'remove' }, + { field_path: 'problems', mutation: 'add', value: { name: 'B', severity: 'medium' } }, + ], + } as never, + {} as never, + ownerCtx(workspace) + )) as { feedback_ids: number[] }; + + expect(result.feedback_ids).toHaveLength(3); + + const rows = await getTestDb()` + SELECT field_path, mutation, corrected_value, note + FROM watcher_window_field_feedback + WHERE watcher_id = ${Number(watcherId)} + ORDER BY field_path ASC + `; + expect(rows).toHaveLength(3); + expect(rows.map((row) => `${row.field_path}:${row.mutation}`)).toEqual([ + 'problems:add', + 'problems[0]:remove', + 'problems[0].severity:set', + ]); + expect(rows.find((row) => row.field_path === 'problems[0].severity')?.corrected_value).toBe( + 'high' + ); + expect(rows.find((row) => row.field_path === 'problems')?.corrected_value).toEqual({ + name: 'B', + severity: 'medium', + }); + }); + + it('returns scoped feedback and honors window filters', async () => { + const otherWindow = await getTestDb()` + INSERT INTO watcher_windows ( + watcher_id, granularity, window_start, window_end, + extracted_data, content_analyzed, model_used, created_at + ) VALUES ( + ${Number(watcherId)}, 'weekly', ${new Date(Date.now() - 14 * 24 * 60 * 60 * 1000)}, + ${new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)}, ${getTestDb().json({ problems: [] })}, + 0, 'test-model', NOW() + ) + RETURNING id + `; + + await manageWatchers( + { + action: 'submit_feedback', + watcher_id: watcherId, + window_id: windowId, + corrections: [{ field_path: 'current', value: 1 }], + } as never, + {} as never, + ownerCtx(workspace) + ); + await manageWatchers( + { + action: 'submit_feedback', + watcher_id: watcherId, + window_id: Number(otherWindow[0].id), + corrections: [{ field_path: 'other', value: 2 }], + } as never, + {} as never, + ownerCtx(workspace) + ); + + const filtered = (await manageWatchers( + { action: 'get_feedback', watcher_id: watcherId, window_id: Number(otherWindow[0].id) } as never, + {} as never, + ownerCtx(workspace) + )) as { feedback: Array<{ field_path: string }> }; + + expect(filtered.feedback).toHaveLength(1); + expect(filtered.feedback[0].field_path).toBe('other'); + }); + + it('rejects malformed corrections and cross-org watcher/window ids', async () => { + await expect( + manageWatchers( + { action: 'submit_feedback', watcher_id: watcherId, window_id: windowId, corrections: [] } as never, + {} as never, + ownerCtx(workspace) + ) + ).rejects.toThrow(/non-empty array/); + + await expect( + manageWatchers( + { + action: 'submit_feedback', + watcher_id: watcherId, + window_id: windowId, + corrections: [{ field_path: 'problems[0]', mutation: 'patch', value: 'x' }], + } as never, + {} as never, + ownerCtx(workspace) + ) + ).rejects.toThrow(/unsupported mutation/); + + const other = await TestWorkspace.create({ name: 'Feedback Stranger Org' }); + const foreign = await seedWatcher(other, 'foreign'); + await expect( + manageWatchers( + { + action: 'submit_feedback', + watcher_id: foreign.watcherId, + window_id: foreign.windowId, + corrections: [{ field_path: 'problems[0]', value: 'x' }], + } as never, + {} as never, + ownerCtx(workspace) + ) + ).rejects.toThrow(/not found|access/i); + }); +}); diff --git a/packages/owletto-backend/src/__tests__/integration/watchers/feedback.test.ts b/packages/owletto-backend/src/__tests__/integration/watchers/feedback.test.ts deleted file mode 100644 index 9e597aaa2..000000000 --- a/packages/owletto-backend/src/__tests__/integration/watchers/feedback.test.ts +++ /dev/null @@ -1,425 +0,0 @@ -/** - * Integration tests for the per-field watcher feedback API. - * - * Covers the schema introduced by the - * `20260425100000_normalize_watcher_feedback` migration: - * - `submit_feedback` accepts an array of {field_path, mutation, value, note} - * - mutation kinds: `set` (default), `remove`, `add` - * - `get_feedback` returns one row per submission, newest first; the prompt - * summary is responsible for collapsing per-field at read time - * - validation: empty array, bad mutation, missing value for set/add - * - * The watcher row is built directly via SQL because the existing - * `createTestWatcherTemplate` fixture predates the `watcher_group_id NOT NULL` - * migration and would fail unrelated to anything tested here. - */ - -import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; -import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; -import { - addUserToOrganization, - createTestAccessToken, - createTestEntity, - createTestOAuthClient, - createTestOrganization, - createTestUser, - seedSystemEntityTypes, -} from '../../setup/test-fixtures'; -import { mcpToolsCall } from '../../setup/test-helpers'; - -interface SeededWatcher { - id: number; - versionId: number; -} - -async function seedWatcher(organizationId: string, userId: string): Promise { - const sql = getTestDb(); - const [version] = await sql<{ id: number }[]>` - INSERT INTO watcher_versions ( - version, name, description, prompt, extraction_schema, created_by - ) VALUES ( - 1, - 'Feedback Test', - 'Watcher used to exercise the feedback APIs', - 'Analyze inputs', - ${sql.json({ - type: 'object', - properties: { - problems: { - type: 'array', - items: { - type: 'object', - properties: { - name: { type: 'string' }, - severity: { type: 'string', enum: ['low', 'medium', 'high'] }, - }, - }, - }, - }, - })}, - ${userId} - ) - RETURNING id - `; - - const [watcher] = await sql<{ id: number }[]>` - INSERT INTO watchers ( - slug, name, description, status, created_by, organization_id, - current_version_id, watcher_group_id - ) VALUES ( - 'feedback-test', - 'Feedback Test', - 'Feedback API integration watcher', - 'active', - ${userId}, - ${organizationId}, - ${version.id}, - 0 - ) - RETURNING id - `; - await sql`UPDATE watchers SET watcher_group_id = ${watcher.id} WHERE id = ${watcher.id}`; - await sql`UPDATE watcher_versions SET watcher_id = ${watcher.id} WHERE id = ${version.id}`; - - return { id: watcher.id, versionId: version.id }; -} - -async function seedWindow( - watcherId: number, - extractedData: Record -): Promise { - const sql = getTestDb(); - const [row] = await sql<{ id: number }[]>` - INSERT INTO watcher_windows ( - watcher_id, granularity, window_start, window_end, - extracted_data, content_analyzed, model_used, created_at - ) VALUES ( - ${watcherId}, 'weekly', - ${new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)}, - ${new Date()}, - ${sql.json(extractedData)}, - 0, 'test-model', NOW() - ) - RETURNING id - `; - return row.id; -} - -describe('Watcher feedback', () => { - let org: Awaited>; - let user: Awaited>; - let token: string; - let watcher: SeededWatcher; - let windowId: number; - - beforeAll(async () => { - await cleanupTestDatabase(); - await seedSystemEntityTypes(); - - org = await createTestOrganization({ name: 'Feedback Test Org' }); - user = await createTestUser({ email: 'feedback@test.com' }); - await addUserToOrganization(user.id, org.id, 'owner'); - - const client = await createTestOAuthClient(); - token = (await createTestAccessToken(user.id, org.id, client.client_id)).token; - - // createTestEntity ensures the org-bound entity types are seeded; the - // entity itself isn't referenced by the feedback flow. - await createTestEntity({ name: 'Feedback Entity', organization_id: org.id }); - - watcher = await seedWatcher(org.id, user.id); - windowId = await seedWindow(watcher.id, { - problems: [ - { name: 'A', severity: 'low' }, - { name: 'B', severity: 'medium' }, - ], - }); - }); - - beforeEach(async () => { - const sql = getTestDb(); - await sql`DELETE FROM watcher_window_field_feedback WHERE watcher_id = ${watcher.id}`; - }); - - describe('submit_feedback', () => { - it('accepts a single set correction (default mutation)', async () => { - const result = await mcpToolsCall( - 'manage_watchers', - { - action: 'submit_feedback', - watcher_id: String(watcher.id), - window_id: windowId, - corrections: [ - { field_path: 'problems[0].severity', value: 'high', note: 'misclassified' }, - ], - }, - { token } - ); - expect(result.feedback_ids).toHaveLength(1); - - const sql = getTestDb(); - const rows = await sql< - { - field_path: string; - mutation: string; - corrected_value: unknown; - note: string | null; - }[] - >`SELECT field_path, mutation, corrected_value, note FROM watcher_window_field_feedback WHERE watcher_id = ${watcher.id}`; - expect(rows).toHaveLength(1); - expect(rows[0].field_path).toBe('problems[0].severity'); - expect(rows[0].mutation).toBe('set'); - expect(rows[0].corrected_value).toBe('high'); - expect(rows[0].note).toBe('misclassified'); - }); - - it('accepts a remove correction without a value', async () => { - const result = await mcpToolsCall( - 'manage_watchers', - { - action: 'submit_feedback', - watcher_id: String(watcher.id), - window_id: windowId, - corrections: [{ field_path: 'problems[1]', mutation: 'remove' }], - }, - { token } - ); - expect(result.feedback_ids).toHaveLength(1); - - const sql = getTestDb(); - const rows = await sql< - { mutation: string; corrected_value: unknown }[] - >`SELECT mutation, corrected_value FROM watcher_window_field_feedback WHERE watcher_id = ${watcher.id}`; - expect(rows[0].mutation).toBe('remove'); - expect(rows[0].corrected_value).toBeNull(); - }); - - it('accepts an add correction that appends to an array', async () => { - const result = await mcpToolsCall( - 'manage_watchers', - { - action: 'submit_feedback', - watcher_id: String(watcher.id), - window_id: windowId, - corrections: [ - { - field_path: 'problems', - mutation: 'add', - value: { name: 'C', severity: 'high' }, - }, - ], - }, - { token } - ); - expect(result.feedback_ids).toHaveLength(1); - - const sql = getTestDb(); - const rows = await sql< - { mutation: string; corrected_value: { name: string; severity: string } }[] - >`SELECT mutation, corrected_value FROM watcher_window_field_feedback WHERE watcher_id = ${watcher.id}`; - expect(rows[0].mutation).toBe('add'); - expect(rows[0].corrected_value).toEqual({ name: 'C', severity: 'high' }); - }); - - it('stores multiple corrections from one batch as separate rows', async () => { - await mcpToolsCall( - 'manage_watchers', - { - action: 'submit_feedback', - watcher_id: String(watcher.id), - window_id: windowId, - corrections: [ - { field_path: 'problems[0].severity', value: 'high' }, - { field_path: 'problems[0].name', value: 'Renamed A' }, - { field_path: 'problems[1]', mutation: 'remove' }, - ], - }, - { token } - ); - - const sql = getTestDb(); - const rows = await sql`SELECT field_path, mutation FROM watcher_window_field_feedback WHERE watcher_id = ${watcher.id} ORDER BY field_path`; - expect(rows).toHaveLength(3); - expect(rows.map((r) => r.field_path)).toEqual([ - 'problems[0].name', - 'problems[0].severity', - 'problems[1]', - ]); - }); - - it('lets a later submission supersede an earlier one without overwriting', async () => { - const path = 'problems[0].severity'; - await mcpToolsCall( - 'manage_watchers', - { - action: 'submit_feedback', - watcher_id: String(watcher.id), - window_id: windowId, - corrections: [{ field_path: path, value: 'medium' }], - }, - { token } - ); - await mcpToolsCall( - 'manage_watchers', - { - action: 'submit_feedback', - watcher_id: String(watcher.id), - window_id: windowId, - corrections: [{ field_path: path, value: 'high', note: 'on second look' }], - }, - { token } - ); - - const sql = getTestDb(); - const rows = await sql< - { corrected_value: unknown; note: string | null }[] - >`SELECT corrected_value, note FROM watcher_window_field_feedback WHERE watcher_id = ${watcher.id} AND field_path = ${path} ORDER BY created_at ASC, id ASC`; - expect(rows).toHaveLength(2); - expect(rows[0].corrected_value).toBe('medium'); - expect(rows[1].corrected_value).toBe('high'); - expect(rows[1].note).toBe('on second look'); - }); - - it('rejects an empty corrections array', async () => { - await expect( - mcpToolsCall( - 'manage_watchers', - { - action: 'submit_feedback', - watcher_id: String(watcher.id), - window_id: windowId, - corrections: [], - }, - { token } - ) - ).rejects.toThrow(/non-empty array/); - }); - - it('rejects an unsupported mutation kind', async () => { - await expect( - mcpToolsCall( - 'manage_watchers', - { - action: 'submit_feedback', - watcher_id: String(watcher.id), - window_id: windowId, - corrections: [{ field_path: 'problems[0]', mutation: 'patch', value: 'x' }], - }, - { token } - ) - ).rejects.toThrow(); - }); - - it('rejects a set mutation with no value', async () => { - await expect( - mcpToolsCall( - 'manage_watchers', - { - action: 'submit_feedback', - watcher_id: String(watcher.id), - window_id: windowId, - corrections: [{ field_path: 'problems[0].severity' }], - }, - { token } - ) - ).rejects.toThrow(/requires a value/); - }); - - it('refuses to submit feedback against a watcher in a different org', async () => { - // Build a watcher in a second org. The first user (Alice) has no - // membership there, so her token's org context is still the first org; - // passing the foreign watcher_id must fail the org-scoped windowCheck. - const otherOrg = await createTestOrganization({ name: 'Stranger Org' }); - const otherUser = await createTestUser({ email: 'stranger@test.com' }); - await addUserToOrganization(otherUser.id, otherOrg.id, 'owner'); - const foreign = await seedWatcher(otherOrg.id, otherUser.id); - const foreignWindow = await seedWindow(foreign.id, { problems: [] }); - - await expect( - mcpToolsCall( - 'manage_watchers', - { - action: 'submit_feedback', - watcher_id: String(foreign.id), - window_id: foreignWindow, - corrections: [{ field_path: 'problems[0]', value: 'x' }], - }, - { token } - ) - ).rejects.toThrow(/not found/); - }); - }); - - describe('get_feedback', () => { - it('returns rows newest-first for a watcher', async () => { - await mcpToolsCall( - 'manage_watchers', - { - action: 'submit_feedback', - watcher_id: String(watcher.id), - window_id: windowId, - corrections: [{ field_path: 'problems[0].severity', value: 'medium' }], - }, - { token } - ); - await mcpToolsCall( - 'manage_watchers', - { - action: 'submit_feedback', - watcher_id: String(watcher.id), - window_id: windowId, - corrections: [{ field_path: 'problems[0].severity', value: 'high' }], - }, - { token } - ); - - const result = await mcpToolsCall( - 'manage_watchers', - { - action: 'get_feedback', - watcher_id: String(watcher.id), - }, - { token } - ); - expect(result.feedback).toHaveLength(2); - expect(result.feedback[0].corrected_value).toBe('high'); - expect(result.feedback[1].corrected_value).toBe('medium'); - }); - - it('filters by window_id when provided', async () => { - const otherWindow = await seedWindow(watcher.id, { problems: [] }); - await mcpToolsCall( - 'manage_watchers', - { - action: 'submit_feedback', - watcher_id: String(watcher.id), - window_id: windowId, - corrections: [{ field_path: 'a', value: 1 }], - }, - { token } - ); - await mcpToolsCall( - 'manage_watchers', - { - action: 'submit_feedback', - watcher_id: String(watcher.id), - window_id: otherWindow, - corrections: [{ field_path: 'b', value: 2 }], - }, - { token } - ); - - const filtered = await mcpToolsCall( - 'manage_watchers', - { - action: 'get_feedback', - watcher_id: String(watcher.id), - window_id: otherWindow, - }, - { token } - ); - expect(filtered.feedback).toHaveLength(1); - expect(filtered.feedback[0].field_path).toBe('b'); - }); - }); -}); diff --git a/packages/owletto-backend/src/__tests__/integration/watchers/get-watchers-advanced.test.ts b/packages/owletto-backend/src/__tests__/integration/watchers/get-watchers-advanced.test.ts deleted file mode 100644 index 426c98406..000000000 --- a/packages/owletto-backend/src/__tests__/integration/watchers/get-watchers-advanced.test.ts +++ /dev/null @@ -1,217 +0,0 @@ -/** - * Get Watchers Advanced Integration Tests - * - * Tests for watcher querying by watcher_id vs entity_id, date ranges, - * granularity inference, pagination, and pending analysis. - */ - -import { beforeAll, describe, expect, it } from 'vitest'; -import { cleanupTestDatabase } from '../../setup/test-db'; -import { - addUserToOrganization, - createTestAccessToken, - createTestConnection, - createTestConnectorDefinition, - createTestEntity, - createTestEvent, - createTestOAuthClient, - createTestOrganization, - createTestUser, - createTestWatcher, - createTestWatcherTemplate, - createTestWatcherWindow, - seedSystemEntityTypes, -} from '../../setup/test-fixtures'; -import { mcpToolsCall } from '../../setup/test-helpers'; - -describe('Get Watchers Advanced', () => { - let org: Awaited>; - let user: Awaited>; - let token: string; - let entity: Awaited>; - let template: Awaited>; - let watcher: Awaited>; - - beforeAll(async () => { - await cleanupTestDatabase(); - await seedSystemEntityTypes(); - - org = await createTestOrganization({ name: 'Watchers Adv Test Org' }); - user = await createTestUser({ email: 'watchers-adv@test.com' }); - await addUserToOrganization(user.id, org.id, 'owner'); - - const client = await createTestOAuthClient(); - token = (await createTestAccessToken(user.id, org.id, client.client_id)).token; - - entity = await createTestEntity({ name: 'Watchers Adv Entity', organization_id: org.id }); - - await createTestConnectorDefinition({ - key: 'test-watchers-connector', - name: 'Watchers Connector', - organization_id: org.id, - }); - const conn = await createTestConnection({ - organization_id: org.id, - connector_key: 'test-watchers-connector', - entity_ids: [entity.id], - }); - - // Create some events - const now = new Date(); - for (let i = 0; i < 5; i++) { - await createTestEvent({ - entity_id: entity.id, - connection_id: conn.id, - content: `Content item ${i + 1} for watcher testing`, - title: `Item ${i + 1}`, - occurred_at: new Date(now.getTime() - i * 24 * 60 * 60 * 1000), - }); - } - - template = await createTestWatcherTemplate({ - slug: 'watchers-adv-template', - name: 'Advanced Watchers Template', - prompt: 'Analyze {{entities}}', - output_schema: { - type: 'object', - properties: { - summary: { type: 'string' }, - themes: { type: 'array', items: { type: 'string' } }, - }, - }, - }); - - watcher = await createTestWatcher({ - entity_id: entity.id, - template_id: template.id, - organization_id: org.id, - schedule: '0 0 * * 1', - }); - - // Create watcher windows - const windowEnd = new Date(); - const windowStart = new Date(windowEnd.getTime() - 7 * 24 * 60 * 60 * 1000); - - await createTestWatcherWindow({ - watcher_id: watcher.id, - window_start: windowStart, - window_end: windowEnd, - granularity: 'weekly', - extracted_data: { - summary: 'Overall positive trend with some concerns about performance.', - themes: ['performance', 'reliability', 'user experience'], - }, - content_analyzed: 5, - }); - }); - - describe('query by watcher_id', () => { - it('should return windows for specific watcher', async () => { - const result = await mcpToolsCall( - 'get_watcher', - { watcher_id: String(watcher.id) }, - { token } - ); - expect(result.windows).toBeDefined(); - expect(result.windows.length).toBeGreaterThanOrEqual(1); - expect(result.watcher).toBeDefined(); - }); - }); - - describe('query by entity_id', () => { - it('should return watchers for entity', async () => { - const result = await mcpToolsCall('list_watchers', { entity_id: entity.id }, { token }); - expect(result.watchers).toBeDefined(); - expect(result.watchers.length).toBeGreaterThanOrEqual(1); - }); - }); - - describe('date range', () => { - it('should filter with content_since alias', async () => { - const result = await mcpToolsCall( - 'get_watcher', - { watcher_id: String(watcher.id), content_since: '30d' }, - { token } - ); - expect(result.windows).toBeDefined(); - expect(result.metadata).toBeDefined(); - }); - - it('should filter with since + until', async () => { - const result = await mcpToolsCall( - 'get_watcher', - { - watcher_id: String(watcher.id), - content_since: '30d', - content_until: 'today', - }, - { token } - ); - expect(result.windows).toBeDefined(); - }); - }); - - describe('granularity', () => { - it('should accept explicit granularity', async () => { - const result = await mcpToolsCall( - 'get_watcher', - { watcher_id: String(watcher.id), granularity: 'weekly' }, - { token } - ); - expect(result.windows).toBeDefined(); - expect(result.metadata?.granularity_filter).toBeDefined(); - }); - - it('should infer granularity from date range', async () => { - const result = await mcpToolsCall( - 'get_watcher', - { watcher_id: String(watcher.id), content_since: '7d' }, - { token } - ); - expect(result.metadata).toBeDefined(); - // 7d range should infer daily granularity - expect(result.metadata?.granularity_filter).toBeDefined(); - }); - }); - - describe('pagination', () => { - it('should support page and page_size', async () => { - const result = await mcpToolsCall( - 'get_watcher', - { watcher_id: String(watcher.id), page: 1, page_size: 10 }, - { token } - ); - expect(result.windows).toBeDefined(); - expect(result.pagination).toBeDefined(); - }); - }); - - describe('pending analysis', () => { - it('should include pending analysis info', async () => { - const result = await mcpToolsCall( - 'get_watcher', - { watcher_id: String(watcher.id) }, - { token } - ); - // pending_analysis may or may not exist depending on state - expect(result.windows).toBeDefined(); - }); - }); - - describe('template version info', () => { - it('should include template version in window data', async () => { - const result = await mcpToolsCall( - 'get_watcher', - { watcher_id: String(watcher.id) }, - { token } - ); - expect(result.windows).toBeDefined(); - if (result.windows.length > 0) { - expect(result.windows[0].watcher_name).toBeDefined(); - expect(result.windows[0].granularity).toBe('weekly'); - expect(result.windows[0].content_analyzed).toBeDefined(); - expect(result.windows[0].extracted_data).toBeDefined(); - } - }); - }); -}); diff --git a/packages/owletto-backend/src/__tests__/integration/watchers/manage-watcher-templates.test.ts b/packages/owletto-backend/src/__tests__/integration/watchers/manage-watcher-templates.test.ts deleted file mode 100644 index 663c02b65..000000000 --- a/packages/owletto-backend/src/__tests__/integration/watchers/manage-watcher-templates.test.ts +++ /dev/null @@ -1,263 +0,0 @@ -/** - * Manage Watchers Integration Tests - * - * Tests for watcher CRUD, versioning, and archive operations - * through the manage_watchers tool. - */ - -import { beforeAll, describe, expect, it } from 'vitest'; -import { cleanupTestDatabase } from '../../setup/test-db'; -import { - addUserToOrganization, - createTestAccessToken, - createTestConnection, - createTestEntity, - createTestOAuthClient, - createTestOrganization, - createTestUser, - seedSystemEntityTypes, -} from '../../setup/test-fixtures'; -import { mcpToolsCall } from '../../setup/test-helpers'; - -describe('Manage Watchers', () => { - let org: Awaited>; - let user: Awaited>; - let entity: Awaited>; - let token: string; - let watcherId: string; - - beforeAll(async () => { - await cleanupTestDatabase(); - await seedSystemEntityTypes(); - - org = await createTestOrganization({ name: 'Watcher Test Org' }); - user = await createTestUser({ email: 'watcher-user@test.com' }); - await addUserToOrganization(user.id, org.id, 'owner'); - - entity = await createTestEntity({ - organization_id: org.id, - name: 'Test Entity', - entity_type: 'brand', - }); - - await createTestConnection({ - organization_id: org.id, - connector_key: 'test.notifications', - entity_ids: [entity.id], - created_by: user.id, - }); - - const client = await createTestOAuthClient(); - token = (await createTestAccessToken(user.id, org.id, client.client_id)).token; - }); - - describe('create', () => { - it('should create a watcher with prompt and schema', async () => { - const result = await mcpToolsCall( - 'manage_watchers', - { - action: 'create', - slug: 'test-sentiment', - name: 'Sentiment Analysis', - scheduler_client_id: 'codex', - prompt: 'Analyze sentiment for {{entities}}', - extraction_schema: { - type: 'object', - properties: { sentiment: { type: 'string' } }, - }, - entity_id: entity.id, - }, - { token } - ); - expect(result.watcher_id).toBeDefined(); - watcherId = result.watcher_id; - expect(result.version).toBe(1); - }); - - it('should reject duplicate slug', async () => { - await expect( - mcpToolsCall( - 'manage_watchers', - { - action: 'create', - slug: 'test-sentiment', - name: 'Duplicate', - prompt: 'test', - extraction_schema: { type: 'object' }, - entity_id: entity.id, - }, - { token } - ) - ).rejects.toThrow(); - }); - - it('should reject missing required fields', async () => { - await expect( - mcpToolsCall( - 'manage_watchers', - { - action: 'create', - slug: 'incomplete', - }, - { token } - ) - ).rejects.toThrow(); - }); - }); - - describe('create_version', () => { - it('should create a new version', async () => { - const result = await mcpToolsCall( - 'manage_watchers', - { - action: 'create_version', - watcher_id: watcherId, - name: 'Sentiment Analysis v2', - prompt: 'Updated prompt for {{entities}}', - extraction_schema: { - type: 'object', - properties: { - sentiment: { type: 'string' }, - score: { type: 'number' }, - }, - }, - change_notes: 'Added score field', - }, - { token } - ); - expect(result.version).toBeDefined(); - expect(result.version).toBe(2); - }); - - it('should atomically update watcher-level schedule and connection when setting current version', async () => { - const result = await mcpToolsCall( - 'manage_watchers', - { - action: 'create_version', - watcher_id: watcherId, - name: 'Sentiment Analysis v3', - prompt: 'Updated prompt for {{entities}} with alerts', - extraction_schema: { - type: 'object', - properties: { - sentiment: { type: 'string' }, - score: { type: 'number' }, - needs_follow_up: { type: 'boolean' }, - }, - }, - schedule: '0 9 * * *', - change_notes: 'Add daily schedule', - }, - { token } - ); - - expect(result.version).toBe(3); - - const listed = await mcpToolsCall( - 'list_watchers', - { entity_id: entity.id, include_details: true }, - { token } - ); - const watcher = listed.watchers.find( - (item: any) => String(item.watcher_id) === String(watcherId) - ); - - expect(watcher?.version).toBe(3); - expect(watcher?.schedule).toBe('0 9 * * *'); - expect(watcher?.scheduler_client_id).toBe('codex'); - expect(watcher?.name).toBe('Sentiment Analysis v3'); - }); - - it('should reject invalid schedules during create_version', async () => { - await expect( - mcpToolsCall( - 'manage_watchers', - { - action: 'create_version', - watcher_id: watcherId, - schedule: 'not-a-cron', - }, - { token } - ) - ).rejects.toThrow('Invalid cron expression'); - }); - }); - - describe('list', () => { - it('should list watchers for entity', async () => { - const result = await mcpToolsCall('list_watchers', { entity_id: entity.id }, { token }); - expect(result.watchers).toBeDefined(); - expect(result.watchers.length).toBeGreaterThanOrEqual(1); - }); - }); - - describe('get_versions', () => { - it('should return version history', async () => { - const result = await mcpToolsCall( - 'manage_watchers', - { action: 'get_versions', watcher_id: watcherId }, - { token } - ); - expect(result.versions).toBeDefined(); - expect(result.versions.length).toBeGreaterThanOrEqual(2); - }); - }); - - describe('get_version_details', () => { - it('should return specific version details', async () => { - const versions = await mcpToolsCall( - 'manage_watchers', - { action: 'get_versions', watcher_id: watcherId }, - { token } - ); - const v1 = versions.versions.find((v: any) => v.version === 1); - - const result = await mcpToolsCall( - 'manage_watchers', - { action: 'get_version_details', watcher_id: watcherId, version: v1.version }, - { token } - ); - expect(result.version ?? result.name).toBeDefined(); - }); - }); - - describe('delete', () => { - it('should delete a watcher', async () => { - // Create a disposable watcher - const temp = await mcpToolsCall( - 'manage_watchers', - { - action: 'create', - slug: 'to-delete', - name: 'Delete Me', - prompt: 'test', - extraction_schema: { - type: 'object', - properties: { summary: { type: 'string' } }, - }, - entity_id: entity.id, - }, - { token } - ); - - const result = await mcpToolsCall( - 'manage_watchers', - { action: 'delete', watcher_ids: [temp.watcher_id] }, - { token } - ); - expect(result.summary.successful).toBe(1); - }); - - it('should reject deleting nonexistent watcher', async () => { - const result = await mcpToolsCall( - 'manage_watchers', - { action: 'delete', watcher_ids: ['999999'] }, - { token } - ); - - expect(result.summary.successful).toBe(0); - expect(result.summary.failed).toBe(1); - expect(result.results[0]?.message).toContain('Watcher not found'); - }); - }); -}); diff --git a/packages/owletto-backend/src/__tests__/integration/watchers/watcher-automation.test.ts b/packages/owletto-backend/src/__tests__/integration/watchers/watcher-automation.test.ts deleted file mode 100644 index 8446efd8c..000000000 --- a/packages/owletto-backend/src/__tests__/integration/watchers/watcher-automation.test.ts +++ /dev/null @@ -1,513 +0,0 @@ -import { inferWatcherGranularityFromSchedule } from '@lobu/owletto-sdk'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { DbClient } from '../../../db/client'; -import type { Env } from '../../../index'; -import * as lobuGateway from '../../../lobu/gateway'; -import { checkStalledExecutions } from '../../../scheduled/check-stalled-executions'; -import { createWatcherRun } from '../../../utils/queue-helpers'; -import { computePendingWindow } from '../../../utils/window-utils'; -import { - dispatchPendingWatcherRuns, - materializeDueWatcherRuns, - reconcileWatcherRuns, -} from '../../../watchers/automation'; -import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; -import { - addUserToOrganization, - createTestAccessToken, - createTestAgent, - createTestEntity, - createTestEvent, - createTestOAuthClient, - createTestOrganization, - createTestUser, - createTestWatcher, - createTestWatcherTemplate, - seedSystemEntityTypes, -} from '../../setup/test-fixtures'; -import { mcpToolsCall } from '../../setup/test-helpers'; - -function jsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { 'Content-Type': 'application/json' }, - }); -} - -async function createAutomatedWatcher() { - const sql = getTestDb(); - const dbClient = sql as unknown as DbClient; - const org = await createTestOrganization({ name: 'Watcher Automation Org' }); - const user = await createTestUser({ email: 'watcher-automation@test.com' }); - await addUserToOrganization(user.id, org.id, 'owner'); - - const entity = await createTestEntity({ - name: 'Automation Entity', - organization_id: org.id, - entity_type: 'brand', - }); - - const agent = await createTestAgent({ - organizationId: org.id, - ownerUserId: user.id, - agentId: 'watcher-agent', - name: 'Watcher Agent', - }); - - const template = await createTestWatcherTemplate({ - slug: 'watcher-automation-template', - name: 'Watcher Automation Template', - organization_id: org.id, - entity_id: entity.id, - prompt: 'Summarize the content for {{entities}}', - output_schema: { - type: 'object', - properties: { - summary: { type: 'string' }, - }, - required: ['summary'], - additionalProperties: false, - }, - }); - - const watcher = await createTestWatcher({ - entity_id: entity.id, - template_id: template.id, - organization_id: org.id, - schedule: '0 9 * * *', - agent_id: agent.agentId, - }); - - await sql` - UPDATE watchers - SET next_run_at = NOW() - INTERVAL '10 minutes' - WHERE id = ${watcher.id} - `; - - const client = await createTestOAuthClient(); - const token = (await createTestAccessToken(user.id, org.id, client.client_id)).token; - - return { sql, dbClient, org, user, entity, agent, watcher, token }; -} - -describe('watcher automation', () => { - beforeEach(async () => { - await cleanupTestDatabase(); - await seedSystemEntityTypes(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('materializes one scheduled watcher run and prevents duplicates under concurrent ticks', async () => { - const { sql, watcher, agent, org } = await createAutomatedWatcher(); - - const [resultA, resultB] = await Promise.all([ - materializeDueWatcherRuns({} as Env), - materializeDueWatcherRuns({} as Env), - ]); - - const runs = await sql` - SELECT id, status, approved_input - FROM runs - WHERE watcher_id = ${watcher.id} - AND run_type = 'watcher' - AND organization_id = ${org.id} - `; - - expect(runs.length).toBe(1); - expect(resultA.runsCreated + resultB.runsCreated).toBe(1); - expect(String(runs[0].status)).toBe('pending'); - - const payload = (runs[0] as { approved_input: Record }).approved_input; - expect(Number(payload.watcher_id)).toBe(watcher.id); - expect(String(payload.agent_id)).toBe(agent.agentId); - expect(String(payload.dispatch_source)).toBe('scheduled'); - }); - - it('does not materialize scheduled watcher runs when no agent is assigned', async () => { - const { sql, org, entity } = await createAutomatedWatcher(); - - const template = await createTestWatcherTemplate({ - slug: 'watcher-without-agent', - name: 'Watcher Without Agent', - organization_id: org.id, - entity_id: entity.id, - }); - - const watcher = await createTestWatcher({ - entity_id: entity.id, - template_id: template.id, - organization_id: org.id, - schedule: '0 9 * * *', - }); - - await sql` - UPDATE watchers - SET next_run_at = NOW() - INTERVAL '10 minutes', - agent_id = NULL - WHERE id = ${watcher.id} - `; - - await materializeDueWatcherRuns({} as Env); - const runs = await sql` - SELECT id FROM runs WHERE watcher_id = ${watcher.id} AND run_type = 'watcher' - `; - - expect(runs.length).toBe(0); - }); - - it('dispatches queued watcher runs through embedded Lobu and marks them running', async () => { - const { sql, dbClient, org, watcher, agent } = await createAutomatedWatcher(); - const granularity = inferWatcherGranularityFromSchedule('0 9 * * *'); - const { windowStart, windowEnd } = await computePendingWindow( - dbClient, - watcher.id, - granularity - ); - - const queued = await createWatcherRun({ - organizationId: org.id, - watcherId: watcher.id, - agentId: agent.agentId, - windowStart: windowStart.toISOString(), - windowEnd: windowEnd.toISOString(), - dispatchSource: 'scheduled', - }); - - vi.spyOn(lobuGateway, 'isLobuGatewayRunning').mockReturnValue(true); - const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { - const url = String(input); - if (url === 'http://127.0.0.1:8787/lobu/api/v1/agents' && init?.method === 'POST') { - return jsonResponse({ - success: true, - agentId: `${agent.agentId}_${agent.agentId}`, - messagesUrl: `http://127.0.0.1:8787/lobu/api/v1/agents/${agent.agentId}_${agent.agentId}/messages`, - }); - } - - if ( - url === - `http://127.0.0.1:8787/lobu/api/v1/agents/${agent.agentId}_${agent.agentId}/messages` && - init?.method === 'POST' - ) { - const body = JSON.parse(String(init.body)) as { content: string }; - expect(body.content).toContain(`Watcher run ID: ${queued.runId}`); - expect(body.content).toContain(`Assigned agent ID: ${agent.agentId}`); - expect(body.content).toContain(`"since": "${windowStart.toISOString().split('T')[0]}"`); - expect(body.content).toContain( - `"until": "${new Date(new Date(windowEnd).getTime() - 1).toISOString().split('T')[0]}"` - ); - return jsonResponse({ success: true, queued: true }); - } - - throw new Error(`Unexpected fetch: ${url}`); - }); - - const result = await dispatchPendingWatcherRuns({} as Env, { - db: dbClient, - runIds: [queued.runId], - }); - const [run] = await sql` - SELECT status, claimed_by - FROM runs - WHERE id = ${queued.runId} - `; - - expect(result.dispatched).toBe(1); - expect(String(run.status)).toBe('running'); - expect(String(run.claimed_by)).toBe(`lobu:${agent.agentId}`); - expect(fetchMock).toHaveBeenCalledTimes(2); - }); - - it('marks watcher runs completed when a correlated window already exists', async () => { - const { sql, dbClient, org, watcher, agent } = await createAutomatedWatcher(); - const granularity = inferWatcherGranularityFromSchedule('0 9 * * *'); - const { windowStart, windowEnd } = await computePendingWindow( - dbClient, - watcher.id, - granularity - ); - - const queued = await createWatcherRun({ - organizationId: org.id, - watcherId: watcher.id, - agentId: agent.agentId, - windowStart: windowStart.toISOString(), - windowEnd: windowEnd.toISOString(), - dispatchSource: 'scheduled', - }); - - const [window] = await sql` - INSERT INTO watcher_windows ( - watcher_id, - granularity, - window_start, - window_end, - extracted_data, - content_analyzed, - model_used, - run_metadata, - run_id, - created_at - ) VALUES ( - ${watcher.id}, - 'daily', - ${windowStart}, - ${windowEnd}, - ${sql.json({ summary: 'External completion' })}, - 1, - 'external-client', - ${sql.json({ source: 'external', watcher_run_id: queued.runId })}, - ${queued.runId}, - NOW() - ) - RETURNING id - `; - - const reconciliation = await reconcileWatcherRuns(dbClient); - const [run] = await sql` - SELECT status, window_id - FROM runs - WHERE id = ${queued.runId} - `; - - expect(reconciliation.reconciled).toBe(1); - expect(String(run.status)).toBe('completed'); - expect(Number(run.window_id)).toBe(Number(window.id)); - }); - - it('times out stale watcher runs after the coarse ttl without creating a retry', async () => { - const { sql, dbClient, org, watcher, agent } = await createAutomatedWatcher(); - const granularity = inferWatcherGranularityFromSchedule('0 9 * * *'); - const { windowStart, windowEnd } = await computePendingWindow( - dbClient, - watcher.id, - granularity - ); - - const queued = await createWatcherRun({ - organizationId: org.id, - watcherId: watcher.id, - agentId: agent.agentId, - windowStart: windowStart.toISOString(), - windowEnd: windowEnd.toISOString(), - dispatchSource: 'scheduled', - }); - - await sql` - UPDATE runs - SET status = 'running', - claimed_at = NOW() - INTERVAL '3 hours', - last_heartbeat_at = NOW() - INTERVAL '3 hours' - WHERE id = ${queued.runId} - `; - - await checkStalledExecutions({} as Env); - - const runs = await sql` - SELECT id, status, error_message - FROM runs - WHERE watcher_id = ${watcher.id} - AND run_type = 'watcher' - ORDER BY id ASC - `; - - expect(runs).toHaveLength(1); - expect(String(runs[0].status)).toBe('timeout'); - expect(String((runs[0] as { error_message: unknown }).error_message)).toContain('2 hours'); - }); - - it('completes the queued watcher run from complete_window provenance and advances next_run_at', async () => { - const { sql, dbClient, org, entity, watcher, agent, token } = await createAutomatedWatcher(); - - await createTestEvent({ - entity_id: entity.id, - organization_id: org.id, - content: 'Customer feedback that should be summarized.', - occurred_at: new Date(Date.now() - 60 * 60 * 1000), - }); - - const granularity = inferWatcherGranularityFromSchedule('0 9 * * *'); - const { windowStart, windowEnd } = await computePendingWindow( - dbClient, - watcher.id, - granularity - ); - - const queued = await createWatcherRun({ - organizationId: org.id, - watcherId: watcher.id, - agentId: agent.agentId, - windowStart: windowStart.toISOString(), - windowEnd: windowEnd.toISOString(), - dispatchSource: 'scheduled', - }); - - const content = await mcpToolsCall<{ - window_token: string; - window_start: string; - window_end: string; - }>('read_knowledge', { watcher_id: watcher.id }, { token }); - - expect(content.window_start).toBe(windowStart.toISOString()); - expect(content.window_end).toBe(windowEnd.toISOString()); - - const completion = await mcpToolsCall<{ - action: 'complete_window'; - watcher_id: string; - window_id: number; - }>( - 'manage_watchers', - { - action: 'complete_window', - window_token: content.window_token, - extracted_data: { summary: 'Automated watcher summary' }, - run_metadata: { - executor: 'lobu-agent', - agent_id: agent.agentId, - watcher_run_id: queued.runId, - dispatch_source: 'scheduled', - }, - }, - { token } - ); - - const [run] = await sql` - SELECT status, window_id - FROM runs - WHERE id = ${queued.runId} - `; - const [watcherRow] = await sql` - SELECT next_run_at - FROM watchers - WHERE id = ${watcher.id} - `; - - expect(completion.action).toBe('complete_window'); - expect(String(run.status)).toBe('completed'); - expect(Number(run.window_id)).toBe(completion.window_id); - expect(watcherRow.next_run_at).not.toBeNull(); - }); - - it('does not reopen a timed out watcher run when complete_window arrives late', async () => { - const { sql, dbClient, org, entity, watcher, agent, token } = await createAutomatedWatcher(); - - await createTestEvent({ - entity_id: entity.id, - organization_id: org.id, - content: 'Late watcher completion content.', - occurred_at: new Date(Date.now() - 60 * 60 * 1000), - }); - - const granularity = inferWatcherGranularityFromSchedule('0 9 * * *'); - const { windowStart, windowEnd } = await computePendingWindow( - dbClient, - watcher.id, - granularity - ); - - const queued = await createWatcherRun({ - organizationId: org.id, - watcherId: watcher.id, - agentId: agent.agentId, - windowStart: windowStart.toISOString(), - windowEnd: windowEnd.toISOString(), - dispatchSource: 'scheduled', - }); - - await sql` - UPDATE runs - SET status = 'timeout', - completed_at = NOW(), - error_message = 'Watcher run exceeded 2 hours without reaching terminal state' - WHERE id = ${queued.runId} - `; - - const content = await mcpToolsCall<{ - window_token: string; - }>('read_knowledge', { watcher_id: watcher.id }, { token }); - - const completion = await mcpToolsCall<{ - action: 'complete_window'; - watcher_id: string; - window_id: number; - }>( - 'manage_watchers', - { - action: 'complete_window', - window_token: content.window_token, - extracted_data: { summary: 'Late watcher completion summary' }, - run_metadata: { - executor: 'lobu-agent', - agent_id: agent.agentId, - watcher_run_id: queued.runId, - dispatch_source: 'scheduled', - }, - }, - { token } - ); - - const [run] = await sql` - SELECT status, window_id - FROM runs - WHERE id = ${queued.runId} - `; - const [window] = await sql` - SELECT run_id - FROM watcher_windows - WHERE id = ${completion.window_id} - `; - - expect(completion.action).toBe('complete_window'); - expect(String(run.status)).toBe('timeout'); - expect(run.window_id).toBeNull(); - expect(Number(window.run_id)).toBe(queued.runId); - }); - - it('triggers an assigned watcher through manage_watchers(trigger)', async () => { - const { sql, watcher, agent, token } = await createAutomatedWatcher(); - - vi.spyOn(lobuGateway, 'isLobuGatewayRunning').mockReturnValue(true); - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { - const url = String(input); - if (url === 'http://127.0.0.1:8787/lobu/api/v1/agents' && init?.method === 'POST') { - return jsonResponse({ - success: true, - agentId: `${agent.agentId}_${agent.agentId}`, - messagesUrl: `http://127.0.0.1:8787/lobu/api/v1/agents/${agent.agentId}_${agent.agentId}/messages`, - }); - } - - if ( - url === - `http://127.0.0.1:8787/lobu/api/v1/agents/${agent.agentId}_${agent.agentId}/messages` && - init?.method === 'POST' - ) { - return jsonResponse({ success: true, queued: true }); - } - - throw new Error(`Unexpected fetch: ${url}`); - }); - - const result = await mcpToolsCall<{ action: 'trigger'; run_id: number; status: string }>( - 'manage_watchers', - { action: 'trigger', watcher_id: String(watcher.id) }, - { token } - ); - - const [run] = await sql` - SELECT status, approved_input - FROM runs - WHERE id = ${result.run_id} - `; - - expect(result.action).toBe('trigger'); - expect(result.status).toBe('running'); - expect(String(run.status)).toBe('running'); - - const payload = (run as { approved_input: Record }).approved_input; - expect(String(payload.dispatch_source)).toBe('manual'); - }); -}); diff --git a/packages/owletto-backend/src/__tests__/integration/watchers/watchers-crud.test.ts b/packages/owletto-backend/src/__tests__/integration/watchers/watchers-crud.test.ts new file mode 100644 index 000000000..1cea5b609 --- /dev/null +++ b/packages/owletto-backend/src/__tests__/integration/watchers/watchers-crud.test.ts @@ -0,0 +1,91 @@ +/** + * Watcher CRUD via the post-#348 SDK surface. + * + * Replaces the deleted manage_watchers integration tests. Covers create, + * read, update, delete on watchers attached to an entity, plus access-control + * around the destructive actions. + */ + +import { beforeAll, describe, expect, it } from 'vitest'; +import { + addUserToOrganization, + createTestOrganization, + createTestUser, +} from '../../setup/test-fixtures'; +import { TestApiClient } from '../../setup/test-mcp-client'; +import { cleanupTestDatabase } from '../../setup/test-db'; + +describe('watcher CRUD', () => { + let owner: TestApiClient; + let entityId: number; + + beforeAll(async () => { + await cleanupTestDatabase(); + const org = await createTestOrganization({ name: 'Watcher Test Org' }); + const user = await createTestUser({ email: 'watcher-owner@test.com' }); + await addUserToOrganization(user.id, org.id, 'owner'); + owner = await TestApiClient.for({ + organizationId: org.id, + userId: user.id, + memberRole: 'owner', + }); + + await owner.entity_schema.createType({ slug: 'company', name: 'Company' }); + const entity = (await owner.entities.create({ + type: 'company', + name: 'Watcher Target', + })) as { entity: { id: number } }; + entityId = entity.entity.id; + }); + + it('creates → reads back → updates → deletes a watcher', async () => { + const created = (await owner.watchers.create({ + entity_id: entityId, + slug: 'lifecycle-watcher', + name: 'Lifecycle Watcher', + prompt: 'Track product launches.', + extraction_schema: { + type: 'object', + properties: { launches: { type: 'array', items: { type: 'string' } } }, + }, + schedule: '0 9 * * *', + })) as { watcher_id: string }; + const watcherId = created.watcher_id; + expect(watcherId).toBeDefined(); + + const got = (await owner.watchers.get(watcherId)) as { + watcher?: { watcher_name: string }; + }; + expect(got.watcher?.watcher_name).toBe('Lifecycle Watcher'); + + await owner.watchers.update({ watcher_id: watcherId, schedule: '0 10 * * *' }); + const after = (await owner.watchers.get(watcherId)) as { + watcher?: { schedule: string | null }; + }; + expect(after.watcher?.schedule).toBe('0 10 * * *'); + + await owner.watchers.delete([watcherId]); + const list = (await owner.watchers.list({ entity_id: entityId })) as { + watchers?: Array<{ watcher_id: string }>; + }; + expect(list.watchers?.some((w) => w.watcher_id === watcherId)).toBe(false); + }); + + it('blocks a member from deleting watchers (admin-only)', async () => { + const created = (await owner.watchers.create({ + entity_id: entityId, + slug: 'protected-watcher', + name: 'Protected', + prompt: 'guarded.', + extraction_schema: { + type: 'object', + properties: { signal: { type: 'string' } }, + }, + })) as { watcher_id: string }; + + const member = owner.withAuth({ memberRole: 'member' }); + await expect(member.watchers.delete([created.watcher_id])).rejects.toThrow( + /admin|owner|access/i + ); + }); +}); diff --git a/packages/owletto-backend/src/__tests__/setup/test-helpers.ts b/packages/owletto-backend/src/__tests__/setup/test-helpers.ts index 8969379fc..4a9153e66 100644 --- a/packages/owletto-backend/src/__tests__/setup/test-helpers.ts +++ b/packages/owletto-backend/src/__tests__/setup/test-helpers.ts @@ -130,14 +130,22 @@ async function ensureMcpSession(options?: { token?: string; env?: Partial; agentId?: string; + orgSlug?: string; + cookie?: string; }): Promise { - const cacheKey = `${options?.token ?? '__anonymous__'}:${options?.agentId ?? '__no_agent__'}`; + const cookieKey = options?.cookie ? options.cookie.slice(0, 24) : '__no_cookie__'; + const cacheKey = `${options?.token ?? '__anonymous__'}:${options?.agentId ?? '__no_agent__'}:${options?.orgSlug ?? '__unscoped__'}:${cookieKey}`; const existing = mcpSessions.get(cacheKey); if (existing) return existing; + // The unscoped `/mcp` endpoint never derives org context from an OAuth + // token alone; pin to `/mcp/{orgSlug}` so the auth middleware sets + // `c.var.organizationId`, matching what production MCP clients do. + const mcpPath = options?.orgSlug ? `/mcp/${options.orgSlug}` : '/mcp'; + // 1. Send initialize - const initResponse = await post('/mcp', { + const initResponse = await post(mcpPath, { body: { jsonrpc: '2.0', id: '__test_init__', @@ -153,6 +161,7 @@ async function ensureMcpSession(options?: { }, }, token: options?.token, + cookie: options?.cookie, env: options?.env, }); @@ -165,13 +174,14 @@ async function ensureMcpSession(options?: { } // 2. Send notifications/initialized - await post('/mcp', { + await post(mcpPath, { body: { jsonrpc: '2.0', method: 'notifications/initialized', }, headers: { 'mcp-session-id': sessionId }, token: options?.token, + cookie: options?.cookie, env: options?.env, }); @@ -203,11 +213,18 @@ interface MCPResponse { export async function mcpRequest( method: string, params?: any, - options?: { token?: string; env?: Partial; agentId?: string } + options?: { + token?: string; + env?: Partial; + agentId?: string; + orgSlug?: string; + cookie?: string; + } ): Promise> { const sessionId = await ensureMcpSession(options); + const mcpPath = options?.orgSlug ? `/mcp/${options.orgSlug}` : '/mcp'; - const response = await post('/mcp', { + const response = await post(mcpPath, { body: { jsonrpc: '2.0', id: 1, @@ -216,6 +233,7 @@ export async function mcpRequest( }, headers: { 'mcp-session-id': sessionId }, token: options?.token, + cookie: options?.cookie, env: options?.env, }); @@ -234,11 +252,12 @@ export async function mcpRequest( export async function mcpToolsCall( toolName: string, args: any, - options?: { token?: string; env?: Partial; agentId?: string } + options?: { token?: string; env?: Partial; agentId?: string; orgSlug?: string } ): Promise { const sessionId = await ensureMcpSession(options); + const mcpPath = options?.orgSlug ? `/mcp/${options.orgSlug}` : '/mcp'; - const response = await post('/mcp', { + const response = await post(mcpPath, { body: { jsonrpc: '2.0', id: 1, @@ -283,6 +302,8 @@ export async function mcpListTools(options?: { token?: string; env?: Partial; agentId?: string; + orgSlug?: string; + cookie?: string; }): Promise<{ tools: Array<{ name: string; description: string }> }> { const response = await mcpRequest('tools/list', {}, options); diff --git a/packages/owletto-backend/src/__tests__/setup/test-mcp-client.ts b/packages/owletto-backend/src/__tests__/setup/test-mcp-client.ts new file mode 100644 index 000000000..b22caa043 --- /dev/null +++ b/packages/owletto-backend/src/__tests__/setup/test-mcp-client.ts @@ -0,0 +1,265 @@ +/** + * Composable test clients for the post-#348 MCP surface. + * + * TestMcpClient — full HTTP/JSON-RPC round-trip. Exercises auth, session + * init, tool dispatch, and (for `run`/`query`) the + * isolated-vm sandbox. Use it for tests that have to + * verify MCP wire behavior (auth headers, JSON-RPC error + * framing, sandbox timeouts). Surface mirrors the public + * MCP tools: `search`, `searchKnowledge`, `saveKnowledge`, + * `querySql`, `resolvePath`, `listOrganizations`, `run`, + * `query`, plus a `raw()` escape hatch. + * + * TestApiClient — direct handler imports. Skips HTTP/sandbox; calls the + * same namespace builders the sandbox exposes. Fast, + * deterministic, and the right tool for CRUD permutations, + * cross-org isolation, and error-path edges. Surface is + * the typed SDK: `client.entities`, `client.watchers`, + * `client.classifiers`, etc. — `withAuth()` produces a new + * client with overridden role/scopes for denial-path tests. + * + * The two surfaces are deliberately not interchangeable. `TestMcpClient.run()` + * is the wire-level analogue of `TestApiClient.entities.create(...)`; pick + * the layer that matches what you're testing rather than swapping them. + */ + +import type { Env } from '../../index'; +import type { ToolContext, TokenType } from '../../tools/registry'; +import { + buildAuthProfilesNamespace, + buildClassifiersNamespace, + buildConnectionsNamespace, + buildEntitiesNamespace, + buildEntitySchemaNamespace, + buildFeedsNamespace, + buildKnowledgeNamespace, + buildOperationsNamespace, + buildOrganizationsNamespace, + buildViewTemplatesNamespace, + buildWatchersNamespace, +} from '../../sandbox/namespaces'; +import { initWorkspaceProvider } from '../../workspace'; +import { mcpRequest, mcpToolsCall } from './test-helpers'; + +/** + * Global init the namespace handlers depend on (URL building, slug lookup). + * The HTTP test-helpers do this lazily via `ensureWorkspaceProvider`; the + * direct-handler client has no request lifecycle, so we trigger the init + * the first time TestApiClient is constructed and cache the promise. + */ +let workspaceReady: Promise | null = null; +function ensureWorkspaceReady(): Promise { + if (!workspaceReady) { + workspaceReady = initWorkspaceProvider(); + } + return workspaceReady; +} + +// ── shared types ───────────────────────────────────────────────────────── + +export interface TestClientAuth { + /** Required for any non-org-agnostic call. */ + organizationId: string; + /** OAuth user; null for anonymous public reads. */ + userId: string | null; + /** Member role in the org; null for non-members reading a public workspace. */ + memberRole: 'owner' | 'admin' | 'member' | null; + /** OAuth scopes; defaults to full ['mcp:read', 'mcp:write', 'mcp:admin'] when + * not supplied. Tests asserting scope-denial paths must override this. */ + scopes?: string[]; + /** Optional durable agent identity for the session. */ + agentId?: string; + /** Token kind. Defaults to 'oauth' for authenticated users, 'anonymous' + * otherwise. Some handlers (e.g. cross-org `client.org()`) gate on this. */ + tokenType?: TokenType; + /** True when the MCP URL pinned an org slug (e.g. `/mcp/acme`). When the + * direct-handler client is used, set this to mirror the wire intent. */ + scopedToOrg?: boolean; +} + +const DEFAULT_TEST_ENV: Env = { + ENVIRONMENT: 'test', + DATABASE_URL: process.env.DATABASE_URL, + JWT_SECRET: 'test-jwt-secret-for-testing-only', + BETTER_AUTH_SECRET: 'test-auth-secret-for-testing-only', + MAX_CONSECUTIVE_FAILURES: '3', + RATE_LIMIT_ENABLED: 'false', + EMBEDDINGS_SERVICE_URL: process.env.EMBEDDINGS_SERVICE_URL, + EMBEDDINGS_SERVICE_TOKEN: process.env.EMBEDDINGS_SERVICE_TOKEN, + EMBEDDINGS_TIMEOUT_MS: process.env.EMBEDDINGS_TIMEOUT_MS, +}; + +// ── HTTP layer (MCP JSON-RPC) ─────────────────────────────────────────── + +/** + * Wire-level test client. Boots no HTTP server — calls go straight into the + * Hono app via `app.fetch`. Use when the test has to verify behavior the + * sandbox or auth layer adds on top of the raw handler. + */ +export class TestMcpClient { + constructor( + private readonly opts: { + token: string; + /** Pin the MCP session to `/mcp/{slug}`. Required for any tool other than + * `list_organizations`; without it, the auth middleware never derives an + * organizationId and every call fails with "Organization context required". */ + orgSlug?: string; + env?: Partial; + agentId?: string; + } + ) {} + + // ── direct MCP tools ───────────────────────────────────────────────── + + async listOrganizations(args: Record = {}) { + return mcpToolsCall('list_organizations', args, this.opts); + } + + async resolvePath(path: string) { + return mcpToolsCall('resolve_path', { path }, this.opts); + } + + async search(args: { query: string; limit?: number }) { + return mcpToolsCall('search', args, this.opts); + } + + async searchKnowledge(args: Record) { + return mcpToolsCall('search_knowledge', args, this.opts); + } + + async saveKnowledge(args: Record) { + return mcpToolsCall('save_knowledge', args, this.opts); + } + + async querySql(sql: string, args: Record = {}) { + return mcpToolsCall('query_sql', { sql, ...args }, this.opts); + } + + /** + * Run a sandboxed script with the FULL ClientSDK (mutations allowed). + * The script must `export default async (ctx, client) => ...`. + * Use `query()` instead for read-only scripts — it gates writes at the + * tool boundary so a bug in test setup can't accidentally mutate state. + */ + async run( + script: string, + options?: { timeout_ms?: number } + ): Promise { + return mcpToolsCall('run', { script, ...(options ?? {}) }, this.opts); + } + + /** Read-only counterpart of `run()` — see #432. */ + async query( + script: string, + options?: { timeout_ms?: number } + ): Promise { + return mcpToolsCall('query', { script, ...(options ?? {}) }, this.opts); + } + + /** + * Issue a raw JSON-RPC method (e.g. `tools/list`). Most callers don't + * need this — it exists for tests that assert wire-level behavior. + */ + async raw(method: string, params?: Record) { + return mcpRequest(method, params, this.opts); + } +} + +// ── direct-handler layer (no HTTP) ────────────────────────────────────── + +/** + * Direct-handler test client. Builds the same namespace surface the sandbox + * exposes, but bypasses HTTP and isolated-vm. Use this for CRUD permutations, + * error-path edges, and cross-org isolation tests where the MCP wire is not + * the thing under test. + */ +export class TestApiClient { + readonly auth_profiles: ReturnType; + readonly classifiers: ReturnType; + readonly connections: ReturnType; + readonly entities: ReturnType; + readonly entity_schema: ReturnType; + readonly feeds: ReturnType; + readonly knowledge: ReturnType; + readonly operations: ReturnType; + readonly organizations: ReturnType; + readonly view_templates: ReturnType; + readonly watchers: ReturnType; + + private constructor( + private readonly env: Env, + private readonly ctx: ToolContext + ) { + this.auth_profiles = buildAuthProfilesNamespace(ctx, env); + this.classifiers = buildClassifiersNamespace(ctx, env); + this.connections = buildConnectionsNamespace(ctx, env); + this.entities = buildEntitiesNamespace(ctx, env); + this.entity_schema = buildEntitySchemaNamespace(ctx, env); + this.feeds = buildFeedsNamespace(ctx, env); + this.knowledge = buildKnowledgeNamespace(ctx, env); + this.operations = buildOperationsNamespace(ctx, env); + this.organizations = buildOrganizationsNamespace(ctx); + this.view_templates = buildViewTemplatesNamespace(ctx, env); + this.watchers = buildWatchersNamespace(ctx, env); + } + + /** + * Create a client bound to a specific auth context. Pass the result of + * createTestUser/Organization fixtures to set `userId` / `organizationId`. + * Memberships are checked inside the namespace handlers, so the role and + * scopes here drive what the client is allowed to do. + * + * Triggers workspace-provider initialization on first call (lazy, idempotent). + */ + static async for(auth: TestClientAuth, env: Partial = {}): Promise { + await ensureWorkspaceReady(); + const tokenType: TokenType = + auth.tokenType ?? (auth.userId !== null ? 'oauth' : 'anonymous'); + const scopedToOrg = auth.scopedToOrg ?? true; + const ctx: ToolContext = { + organizationId: auth.organizationId, + userId: auth.userId, + memberRole: auth.memberRole, + agentId: auth.agentId ?? null, + isAuthenticated: auth.userId !== null, + clientId: null, + scopes: auth.scopes ?? ['mcp:read', 'mcp:write', 'mcp:admin'], + tokenType, + scopedToOrg, + // Match production: only OAuth tokens issued without an org-pin can do + // cross-org reads via `client.org()`. + allowCrossOrg: tokenType === 'oauth' && !scopedToOrg, + }; + return new TestApiClient({ ...DEFAULT_TEST_ENV, ...env }, ctx); + } + + /** + * Override auth on a fresh client without re-creating fixtures. Useful for + * verifying that a member role downgrade or scope removal blocks an action. + * Synchronous because workspace init is already cached after `.for()`. + */ + withAuth(overrides: Partial): TestApiClient { + const tokenType = + overrides.tokenType ?? + this.ctx.tokenType ?? + (this.ctx.userId !== null ? ('oauth' as TokenType) : ('anonymous' as TokenType)); + const scopedToOrg = overrides.scopedToOrg ?? this.ctx.scopedToOrg ?? true; + const ctx: ToolContext = { + organizationId: overrides.organizationId ?? this.ctx.organizationId, + userId: overrides.userId !== undefined ? overrides.userId : this.ctx.userId, + memberRole: + overrides.memberRole !== undefined + ? overrides.memberRole + : this.ctx.memberRole, + agentId: overrides.agentId ?? this.ctx.agentId ?? null, + isAuthenticated: + overrides.userId !== undefined ? overrides.userId !== null : this.ctx.isAuthenticated, + clientId: this.ctx.clientId ?? null, + scopes: overrides.scopes ?? this.ctx.scopes ?? null, + tokenType, + scopedToOrg, + allowCrossOrg: tokenType === 'oauth' && !scopedToOrg, + }; + return new TestApiClient(this.env, ctx); + } +} diff --git a/packages/owletto-backend/src/__tests__/setup/test-workspace.ts b/packages/owletto-backend/src/__tests__/setup/test-workspace.ts new file mode 100644 index 000000000..47f8cfd20 --- /dev/null +++ b/packages/owletto-backend/src/__tests__/setup/test-workspace.ts @@ -0,0 +1,102 @@ +/** + * Reusable workspace scenario builder for integration tests. + * + * Prefer this over hand-rolling org/user/client setup in each file. It keeps + * role fixtures consistent while still letting tests choose the layer they are + * exercising (`TestApiClient` for direct handlers, `TestMcpClient` for wire). + */ + +import { + addUserToOrganization, + createTestOrganization, + createTestUser, + type TestOrganization, + type TestUser, +} from './test-fixtures'; +import { TestApiClient, type TestClientAuth } from './test-mcp-client'; + +export type TestWorkspaceRole = 'owner' | 'admin' | 'member'; + +type RoleClients = Record; +type RoleUsers = Record; + +export class TestWorkspace { + private constructor( + readonly org: TestOrganization, + readonly users: RoleUsers, + private readonly clients: RoleClients + ) {} + + static async create(options: { + name?: string; + slug?: string; + visibility?: 'public' | 'private'; + } = {}): Promise { + const org = await createTestOrganization({ + name: options.name, + slug: options.slug, + visibility: options.visibility, + }); + + const users: RoleUsers = { + owner: await createTestUser(), + admin: await createTestUser(), + member: await createTestUser(), + }; + + await addUserToOrganization(users.owner.id, org.id, 'owner'); + await addUserToOrganization(users.admin.id, org.id, 'admin'); + await addUserToOrganization(users.member.id, org.id, 'member'); + + const clients: RoleClients = { + owner: await TestApiClient.for(TestWorkspace.authFor(org.id, users.owner.id, 'owner')), + admin: await TestApiClient.for(TestWorkspace.authFor(org.id, users.admin.id, 'admin')), + member: await TestApiClient.for(TestWorkspace.authFor(org.id, users.member.id, 'member')), + }; + + return new TestWorkspace(org, users, clients); + } + + static async pair(): Promise<{ a: TestWorkspace; b: TestWorkspace }> { + const a = await TestWorkspace.create({ name: 'Contract Org A' }); + const b = await TestWorkspace.create({ name: 'Contract Org B' }); + return { a, b }; + } + + get owner(): TestApiClient { + return this.clients.owner; + } + + get admin(): TestApiClient { + return this.clients.admin; + } + + get member(): TestApiClient { + return this.clients.member; + } + + client(role: TestWorkspaceRole): TestApiClient { + return this.clients[role]; + } + + asAnonymous(): TestApiClient { + return this.owner.withAuth({ userId: null, memberRole: null, tokenType: 'anonymous' }); + } + + withAuth(overrides: Partial): TestApiClient { + return this.owner.withAuth(overrides); + } + + private static authFor( + organizationId: string, + userId: string, + memberRole: TestWorkspaceRole + ): TestClientAuth { + return { + organizationId, + userId, + memberRole, + scopes: ['mcp:read', 'mcp:write', 'mcp:admin'], + }; + } +} diff --git a/packages/owletto-backend/src/__tests__/unit/connectors/whatsapp.test.ts b/packages/owletto-backend/src/__tests__/unit/connectors/whatsapp.test.ts index 4373e1b38..9deabc64f 100644 --- a/packages/owletto-backend/src/__tests__/unit/connectors/whatsapp.test.ts +++ b/packages/owletto-backend/src/__tests__/unit/connectors/whatsapp.test.ts @@ -10,12 +10,19 @@ * * Uses a string-built path for the dynamic import so tsc doesn't follow the * connector's `npm:baileys@...` specifier — that specifier is rewritten at - * install time by the connector compiler and isn't meant for tsc. + * install time by the connector compiler and isn't meant for tsc. The + * connector compiler must run before this test does; under raw bun/node + * the unrewritten specifier fails to resolve. CI runs unit tests via `bun + * test`, which does not run the connector compiler — so this file is + * skipped there. Run locally via `bun run test:connectors` when touching + * connector code. */ import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { beforeAll, describe, expect, it } from 'vitest'; +const ENABLED = process.env.RUN_CONNECTOR_TESTS === '1'; + type ToEventFn = ( m: unknown, chatNames: Map, @@ -31,9 +38,13 @@ let toEvent: ToEventFn; let jidToPhone: JidToPhoneFn; beforeAll(async () => { + if (!ENABLED) return; // see file header — connector compiler isn't run in CI // Build the path at runtime so tsc doesn't chase `npm:baileys@...` through - // the static import graph. Use a file:// URL so this works on Windows too. - const target = pathToFileURL(path.join(process.cwd(), 'connectors', 'whatsapp.ts')).href; + // the static import graph. Resolve relative to this file so it works whether + // process.cwd() is the repo root, the package, or a worktree. + const target = pathToFileURL( + path.resolve(__dirname, '../../../../../owletto-connectors/src/whatsapp.ts') + ).href; const mod = (await import(target)) as { toEvent: ToEventFn; jidToPhone: JidToPhoneFn; @@ -58,7 +69,7 @@ function makeMessage(overrides: Record): unknown { }; } -describe('jidToPhone', () => { +(ENABLED ? describe : describe.skip)('jidToPhone', () => { it('returns the digit string for a bare s.whatsapp.net JID', () => { expect(jidToPhone('14155551234@s.whatsapp.net')).toBe('14155551234'); }); @@ -90,7 +101,7 @@ describe('jidToPhone', () => { }); }); -describe('toEvent', () => { +(ENABLED ? describe : describe.skip)('toEvent', () => { it('emits sender_jid / sender_phone / push_name for an incoming 1:1 message', () => { const event = toEvent( makeMessage({ diff --git a/packages/owletto-backend/src/mcp-handler.ts b/packages/owletto-backend/src/mcp-handler.ts index 66e14de5e..8dd671c25 100644 --- a/packages/owletto-backend/src/mcp-handler.ts +++ b/packages/owletto-backend/src/mcp-handler.ts @@ -269,7 +269,9 @@ function buildPersistedSession( return { sessionId, userId: authCtx.userId, - clientId: authCtx.clientId, + // `mcp_sessions.client_id` references oauth_clients(id). PAT sessions are + // authenticated, but their synthetic `pat_` client id has no oauth row. + clientId: authCtx.tokenType === 'oauth' ? authCtx.clientId : null, organizationId: authCtx.organizationId, memberRole: authCtx.memberRole, requestedAgentId: authCtx.requestedAgentId, @@ -369,7 +371,7 @@ async function recordMcpClientActivity( capabilities: Record | null; } | null ): Promise { - if (!authCtx.clientId) return; + if (!authCtx.clientId || authCtx.tokenType !== 'oauth') return; const sql = createDbClientFromEnv(env); const clientsStore = new OAuthClientsStore(sql); diff --git a/packages/owletto-backend/src/tools/__tests__/search-cross-org.test.ts b/packages/owletto-backend/src/tools/__tests__/search-cross-org.test.ts index 702295095..85ca17e08 100644 --- a/packages/owletto-backend/src/tools/__tests__/search-cross-org.test.ts +++ b/packages/owletto-backend/src/tools/__tests__/search-cross-org.test.ts @@ -5,7 +5,7 @@ * the include_public_catalogs flag is on (default). */ -import { beforeEach, describe, expect, it } from 'vitest'; +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { cleanupTestDatabase } from '../../__tests__/setup/test-db'; import { addUserToOrganization, @@ -13,9 +13,14 @@ import { createTestOrganization, createTestUser, } from '../../__tests__/setup/test-fixtures'; +import { initWorkspaceProvider } from '../../workspace'; import { search } from '../search'; describe('search cross-org public catalog discovery', () => { + beforeAll(async () => { + // search() walks workspace metadata to attach org slugs. + await initWorkspaceProvider(); + }); beforeEach(async () => { await cleanupTestDatabase(); }); @@ -46,7 +51,7 @@ describe('search cross-org public catalog discovery', () => { { organizationId: tenant.id, userId: user.id } as Parameters[2] ); - const ids = result.entities.map((e: { id: number }) => e.id); + const ids = result.matches.map((e: { id: number }) => e.id); expect(ids).toContain(tenantEntity.id); expect(ids).toContain(publicEntity.id); }); @@ -82,7 +87,7 @@ describe('search cross-org public catalog discovery', () => { { organizationId: tenant.id, userId: user.id } as Parameters[2] ); - const ids = result.entities.map((e: { id: number }) => e.id); + const ids = result.matches.map((e: { id: number }) => e.id); expect(ids).not.toContain(publicEntity.id); }); @@ -107,7 +112,7 @@ describe('search cross-org public catalog discovery', () => { { organizationId: tenant.id, userId: user.id } as Parameters[2] ); - const ids = result.entities.map((e: { id: number }) => e.id); + const ids = result.matches.map((e: { id: number }) => e.id); expect(ids).not.toContain(privateEntity.id); }); }); diff --git a/packages/owletto-backend/src/tools/admin/manage_classifiers.ts b/packages/owletto-backend/src/tools/admin/manage_classifiers.ts index d9e42491a..7c908e61f 100644 --- a/packages/owletto-backend/src/tools/admin/manage_classifiers.ts +++ b/packages/owletto-backend/src/tools/admin/manage_classifiers.ts @@ -246,14 +246,14 @@ export async function manageClassifiers( ctx: ToolContext ): Promise { return routeAction('manage_classifiers', args.action, ctx, { - create: () => handleCreate(args, env), - create_version: () => handleCreateVersion(args, env), - list: () => handleList(args), - get_versions: () => handleGetVersions(args), - set_current_version: () => handleSetCurrentVersion(args), - generate_embeddings: () => handleGenerateEmbeddings(args, env), - delete: () => handleDelete(args), - classify: () => handleClassify(args), + create: () => handleCreate(args, env, ctx), + create_version: () => handleCreateVersion(args, env, ctx), + list: () => handleList(args, ctx), + get_versions: () => handleGetVersions(args, ctx), + set_current_version: () => handleSetCurrentVersion(args, ctx), + generate_embeddings: () => handleGenerateEmbeddings(args, env, ctx), + delete: () => handleDelete(args, ctx), + classify: () => handleClassify(args, ctx), }); } @@ -263,7 +263,8 @@ export async function manageClassifiers( async function handleCreate( args: ManageClassifiersArgs, - env: Env + env: Env, + ctx: ToolContext ): Promise { const sql = getDb(); @@ -285,8 +286,35 @@ async function handleCreate( const entityId = args.entity_id ?? null; + const watcher = await sql` + SELECT id FROM watchers + WHERE id = ${args.watcher_id} AND organization_id = ${ctx.organizationId} + `; + if (watcher.length === 0) { + return { + success: false, + action: 'create', + message: `Watcher not found: ${args.watcher_id}`, + }; + } + + if (entityId !== null) { + const entity = await sql` + SELECT id FROM entities + WHERE id = ${entityId} AND organization_id = ${ctx.organizationId} AND deleted_at IS NULL + `; + if (entity.length === 0) { + return { + success: false, + action: 'create', + message: `Entity not found: ${entityId}`, + }; + } + } + const existing = await sql` - SELECT id, slug FROM event_classifiers WHERE slug = ${args.slug} + SELECT id, slug FROM event_classifiers + WHERE slug = ${args.slug} AND organization_id = ${ctx.organizationId} `; if (existing.length > 0) { return { @@ -297,12 +325,18 @@ async function handleCreate( }; } + // created_by has a FK to user(id) — fall back to ctx.userId, not a literal + // string. Anonymous public reads can't reach this code path (the route is + // admin-gated), so ctx.userId is non-null here. + const createdBy = args.created_by ?? ctx.userId; const classifierResult = await sql` INSERT INTO event_classifiers ( - slug, name, description, attribute_key, status, created_by, entity_id, entity_ids, watcher_id + organization_id, slug, name, description, attribute_key, status, created_by, + entity_id, entity_ids, watcher_id ) VALUES ( + ${ctx.organizationId}, ${args.slug}, ${args.name}, ${args.description || null}, ${args.attribute_key}, - 'active', ${args.created_by ?? 'api'}, ${entityId}, + 'active', ${createdBy}, ${entityId}, CASE WHEN ${entityId}::bigint IS NULL THEN ARRAY[]::bigint[] ELSE ARRAY[${entityId}]::bigint[] END, ${args.watcher_id} ) @@ -320,7 +354,7 @@ async function handleCreate( classifier_id, version, is_current, attribute_values, min_similarity, fallback_value, change_notes, created_by ) VALUES ( ${classifier.id}, 1, true, ${sql.json(withEmbeddings)}, - ${args.min_similarity ?? 0.7}, ${args.fallback_value ?? null}, 'Initial version', ${args.created_by ?? 'api'} + ${args.min_similarity ?? 0.7}, ${args.fallback_value ?? null}, 'Initial version', ${createdBy} ) `; @@ -337,7 +371,8 @@ async function handleCreate( async function handleCreateVersion( args: ManageClassifiersArgs, - env: Env + env: Env, + ctx: ToolContext ): Promise { const sql = getDb(); @@ -349,8 +384,10 @@ async function handleCreateVersion( }; } - const classifier = - await sql`SELECT id, slug FROM event_classifiers WHERE id = ${args.classifier_id}`; + const classifier = await sql` + SELECT id, slug FROM event_classifiers + WHERE id = ${args.classifier_id} AND organization_id = ${ctx.organizationId} + `; if (classifier.length === 0) { return { success: false, @@ -408,14 +445,18 @@ async function handleCreateVersion( classifier_id, version, is_current, attribute_values, min_similarity, fallback_value, change_notes, created_by ) VALUES ( ${args.classifier_id}, ${nextVersion}, ${setAsCurrent}, ${sql.json(withEmbeddings)}, - ${args.min_similarity ?? 0.7}, ${args.fallback_value ?? null}, ${args.change_notes ?? 'New version'}, ${args.created_by ?? 'api'} + ${args.min_similarity ?? 0.7}, ${args.fallback_value ?? null}, ${args.change_notes ?? 'New version'}, ${args.created_by ?? ctx.userId} ) `; if (setAsCurrent) { try { const deleteResults = await sql` - DELETE FROM event_classifications WHERE classifier_id = ${args.classifier_id} RETURNING id + DELETE FROM event_classifications + WHERE classifier_version_id IN ( + SELECT id FROM event_classifier_versions WHERE classifier_id = ${args.classifier_id} + ) + RETURNING id `; logger.info( { @@ -465,14 +506,17 @@ async function handleCreateVersion( }; } -async function handleList(args: ManageClassifiersArgs): Promise { +async function handleList( + args: ManageClassifiersArgs, + ctx: ToolContext +): Promise { const sql = getDb(); const filterEntityId = args.entity_id ?? null; const statusFilter = args.status ?? null; - const conditions: string[] = ['fc.watcher_id IS NOT NULL']; - const params: unknown[] = []; - let paramIdx = 1; + const conditions: string[] = ['fc.watcher_id IS NOT NULL', 'fc.organization_id = $1']; + const params: unknown[] = [ctx.organizationId]; + let paramIdx = 2; if (statusFilter) { conditions.push(`fc.status = $${paramIdx++}`); @@ -524,7 +568,10 @@ async function handleList(args: ManageClassifiersArgs): Promise { +async function handleGetVersions( + args: ManageClassifiersArgs, + ctx: ToolContext +): Promise { const sql = getDb(); if (!args.classifier_id) { return { @@ -535,8 +582,13 @@ async function handleGetVersions(args: ManageClassifiersArgs): Promise { const sql = getDb(); if (!args.classifier_id || !args.version) { @@ -572,7 +625,9 @@ async function handleSetCurrentVersion( const versionCheck = await sql` SELECT fcv.id, fc.slug FROM event_classifier_versions fcv JOIN event_classifiers fc ON fc.id = fcv.classifier_id - WHERE fcv.classifier_id = ${args.classifier_id} AND fcv.version = ${args.version} + WHERE fcv.classifier_id = ${args.classifier_id} + AND fcv.version = ${args.version} + AND fc.organization_id = ${ctx.organizationId} `; if (versionCheck.length === 0) { return { @@ -589,7 +644,13 @@ async function handleSetCurrentVersion( try { const deleteResults = - await sql`DELETE FROM event_classifications WHERE classifier_id = ${args.classifier_id} RETURNING id`; + await sql` + DELETE FROM event_classifications + WHERE classifier_version_id IN ( + SELECT id FROM event_classifier_versions WHERE classifier_id = ${args.classifier_id} + ) + RETURNING id + `; logger.info( { deletedCount: deleteResults.length, classifierSlug, version: args.version }, 'Version change: deleted old classifications.' @@ -618,7 +679,8 @@ async function handleSetCurrentVersion( async function handleGenerateEmbeddings( args: ManageClassifiersArgs, - env: Env + env: Env, + ctx: ToolContext ): Promise { const sql = getDb(); if (!args.classifier_id) { @@ -630,8 +692,12 @@ async function handleGenerateEmbeddings( } const version = await sql` - SELECT id, version, attribute_values FROM event_classifier_versions - WHERE classifier_id = ${args.classifier_id} AND is_current = true + SELECT fcv.id, fcv.version, fcv.attribute_values + FROM event_classifier_versions fcv + JOIN event_classifiers fc ON fc.id = fcv.classifier_id + WHERE fcv.classifier_id = ${args.classifier_id} + AND fcv.is_current = true + AND fc.organization_id = ${ctx.organizationId} `; if (version.length === 0) { return { @@ -669,13 +735,28 @@ async function handleGenerateEmbeddings( }; } -async function handleDelete(args: ManageClassifiersArgs): Promise { +async function handleDelete( + args: ManageClassifiersArgs, + ctx: ToolContext +): Promise { const sql = getDb(); if (!args.classifier_id) { return { success: false, action: 'delete', message: 'Missing required field: classifier_id' }; } - await sql`UPDATE event_classifiers SET status = 'deprecated', updated_at = current_timestamp WHERE id = ${args.classifier_id}`; + const result = await sql` + UPDATE event_classifiers + SET status = 'deprecated', updated_at = current_timestamp + WHERE id = ${args.classifier_id} AND organization_id = ${ctx.organizationId} + RETURNING id + `; + if (result.length === 0) { + return { + success: false, + action: 'delete', + message: `Classifier not found: ${args.classifier_id}`, + }; + } return { success: true, @@ -689,7 +770,10 @@ async function handleDelete(args: ManageClassifiersArgs): Promise { +async function handleClassify( + args: ManageClassifiersArgs, + ctx: ToolContext +): Promise { const sql = getDb(); try { @@ -713,7 +797,9 @@ async function handleClassify(args: ManageClassifiersArgs): Promise; if (classifierResult.length === 0) { @@ -735,6 +821,7 @@ async function handleClassify(args: ManageClassifiersArgs): Promise updateSingleClassification( sql, + ctx.organizationId, item.content_id, classifier, item.value, @@ -821,13 +909,17 @@ async function handleClassify(args: ManageClassifiersArgs): Promise { - const contentCheck = await sql`SELECT id FROM events WHERE id = ${contentId}`; + const contentCheck = await sql` + SELECT id FROM events + WHERE id = ${contentId} AND organization_id = ${organizationId} + `; if (contentCheck.length === 0) { return { success: false, message: `Content not found: ${contentId}` }; } diff --git a/packages/owletto-backend/src/utils/__tests__/connector-catalog.test.ts b/packages/owletto-backend/src/utils/__tests__/connector-catalog.test.ts index 71941fac8..d0e995a97 100644 --- a/packages/owletto-backend/src/utils/__tests__/connector-catalog.test.ts +++ b/packages/owletto-backend/src/utils/__tests__/connector-catalog.test.ts @@ -14,8 +14,12 @@ describe('connector-catalog helpers', () => { expect(uris).toHaveLength(1); expect(uris[0].startsWith('file://')).toBe(true); - expect(fileURLToPath(uris[0]).endsWith('/connectors')).toBe(true); - expect(existsSync(fileURLToPath(uris[0]))).toBe(true); + const dir = fileURLToPath(uris[0]); + expect(existsSync(dir)).toBe(true); + // Path may be packages/owletto-connectors/src or any other repo-local + // candidate; just assert it resolves to a real directory containing + // connector definitions. + expect(existsSync(`${dir}/google_gmail.ts`)).toBe(true); }); it('normalizes both bare paths and file URIs', () => { diff --git a/packages/owletto-backend/src/utils/__tests__/mcp-install-targets.test.ts b/packages/owletto-backend/src/utils/__tests__/mcp-install-targets.test.ts index a325f5b63..20f7e0d31 100644 --- a/packages/owletto-backend/src/utils/__tests__/mcp-install-targets.test.ts +++ b/packages/owletto-backend/src/utils/__tests__/mcp-install-targets.test.ts @@ -31,7 +31,7 @@ describe('getMcpInstallTargets', () => { expect(openclaw?.actions).toContainEqual({ type: 'command', - label: 'Log in to Owletto', + label: 'Log in to Lobu memory', value: `owletto login --mcpUrl ${mcpUrl}`, }); expect(openclaw?.actions).toContainEqual({ diff --git a/packages/owletto-backend/src/utils/__tests__/migrations-format.test.ts b/packages/owletto-backend/src/utils/__tests__/migrations-format.test.ts index fdaf2bc61..35e4a275b 100644 --- a/packages/owletto-backend/src/utils/__tests__/migrations-format.test.ts +++ b/packages/owletto-backend/src/utils/__tests__/migrations-format.test.ts @@ -2,7 +2,8 @@ import fs from 'node:fs'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; -const MIGRATIONS_DIR = path.resolve(__dirname, '../../../db/migrations'); +// Migrations live at the repo root, not in any one package. +const MIGRATIONS_DIR = path.resolve(__dirname, '../../../../../db/migrations'); describe('migration files (dbmate format)', () => { const files = fs diff --git a/packages/owletto-backend/src/utils/__tests__/owletto-guidance-sync.test.ts b/packages/owletto-backend/src/utils/__tests__/owletto-guidance-sync.test.ts index 785cebcb0..dc4de5647 100644 --- a/packages/owletto-backend/src/utils/__tests__/owletto-guidance-sync.test.ts +++ b/packages/owletto-backend/src/utils/__tests__/owletto-guidance-sync.test.ts @@ -6,7 +6,10 @@ import { renderSkillMemorySection, } from '../../../../owletto-openclaw/src/owletto-guidance'; -const skillPath = resolve(process.cwd(), 'skills/owletto/SKILL.md'); +// Skill lives at /skills/owletto/SKILL.md. Resolve relative to this +// file so the test works regardless of `process.cwd()` (worktrees, vitest's +// per-package cwd, IDE runners). +const skillPath = resolve(__dirname, '../../../../../skills/owletto/SKILL.md'); const START_MARKER = ''; const END_MARKER = ''; diff --git a/packages/owletto-backend/src/utils/__tests__/table-schema.test.ts b/packages/owletto-backend/src/utils/__tests__/table-schema.test.ts index 4dafc9dd3..dfb69fc3b 100644 --- a/packages/owletto-backend/src/utils/__tests__/table-schema.test.ts +++ b/packages/owletto-backend/src/utils/__tests__/table-schema.test.ts @@ -112,6 +112,15 @@ describe.skipIf(!hasDb)('QUERYABLE_SCHEMA vs database (drift detection)', () => entities: new Set(['embedding', 'content_tsv', 'content_hash']), events: new Set([]), connections: new Set(['credentials']), + // Large per-connector JSONB blobs — too big and structure-dependent to expose + // via raw SQL. Callers should hit the typed connector handler instead. + connector_definitions: new Set([ + 'mcp_config', + 'api_config', + 'openapi_config', + 'default_connection_config', + 'entity_link_overrides', + ]), oauth_clients: new Set(['client_secret', 'client_secret_expires_at']), oauth_tokens: new Set(['token_hash']), feeds: new Set(['checkpoint']), diff --git a/packages/owletto-backend/src/utils/__tests__/url-builder.test.ts b/packages/owletto-backend/src/utils/__tests__/url-builder.test.ts index 42810ab9d..6d74d1c62 100644 --- a/packages/owletto-backend/src/utils/__tests__/url-builder.test.ts +++ b/packages/owletto-backend/src/utils/__tests__/url-builder.test.ts @@ -1,6 +1,17 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { buildEntityUrl, getPublicWebUrl } from '../url-builder'; - +import { HOSTED_UI_FALLBACK_ORIGIN } from '../public-origin'; + +/** + * Behavior contract for `getPublicWebUrl`: + * 1. Explicit `baseUrl` argument wins. + * 2. `PUBLIC_WEB_URL` (preferred) or `LOBU_URL` env wins next. + * 3. With no local frontend bundled, fall back to the hosted-UI origin + * (`HOSTED_UI_FALLBACK_ORIGIN`) so backend-only self-hosters still emit + * usable links. The `requestUrl` is only consulted when a local frontend + * is present — that's why most tests below assert the fallback even when + * a `requestUrl` is supplied. + */ describe('getPublicWebUrl', () => { const originalWebUrl = process.env.PUBLIC_WEB_URL; const originalLobuUrl = process.env.LOBU_URL; @@ -24,37 +35,25 @@ describe('getPublicWebUrl', () => { } }); - it('returns origin from requestUrl when provided', () => { - expect(getPublicWebUrl('https://app.owletto.com/mcp')).toBe('https://app.owletto.com'); - }); - - it('strips trailing slash from origin', () => { - expect(getPublicWebUrl('https://app.owletto.com/')).toBe('https://app.owletto.com'); - }); - - it('falls back to baseUrl when requestUrl is undefined', () => { - expect(getPublicWebUrl(undefined, 'https://fallback.owletto.com')).toBe( - 'https://fallback.owletto.com' + it('returns explicit baseUrl when provided', () => { + expect(getPublicWebUrl(undefined, 'https://configured.owletto.com')).toBe( + 'https://configured.owletto.com' ); }); - it('strips trailing slash from baseUrl fallback', () => { + it('strips trailing slash from baseUrl', () => { expect(getPublicWebUrl(undefined, 'https://fallback.owletto.com/')).toBe( 'https://fallback.owletto.com' ); }); - it('prefers baseUrl over requestUrl', () => { + it('prefers explicit baseUrl over requestUrl', () => { expect( getPublicWebUrl('https://request.owletto.com/mcp', 'https://configured.owletto.com') ).toBe('https://configured.owletto.com'); }); - it('falls back to requestUrl when baseUrl is not set', () => { - expect(getPublicWebUrl('https://request.owletto.com/mcp')).toBe('https://request.owletto.com'); - }); - - it('prefers PUBLIC_WEB_URL env var over requestUrl', () => { + it('prefers PUBLIC_WEB_URL env var when no explicit baseUrl', () => { process.env.PUBLIC_WEB_URL = 'https://env.owletto.com'; expect(getPublicWebUrl('https://request.owletto.com/mcp')).toBe('https://env.owletto.com'); }); @@ -64,36 +63,27 @@ describe('getPublicWebUrl', () => { expect(getPublicWebUrl('https://request.owletto.com/mcp')).toBe('https://community.lobu.ai'); }); - it('returns undefined when both are missing', () => { - expect(getPublicWebUrl(undefined, undefined)).toBeUndefined(); + it('falls back to HOSTED_UI_FALLBACK_ORIGIN when no env, no baseUrl, no local frontend', () => { + expect(getPublicWebUrl(undefined, undefined)).toBe(HOSTED_UI_FALLBACK_ORIGIN); }); - it('returns undefined when both are empty strings', () => { - expect(getPublicWebUrl(undefined, '')).toBeUndefined(); + it('falls back to HOSTED_UI_FALLBACK_ORIGIN even when requestUrl is given (backend-only host)', () => { + expect(getPublicWebUrl('https://request.owletto.com/mcp')).toBe(HOSTED_UI_FALLBACK_ORIGIN); }); }); describe('buildEntityUrl', () => { - it('builds URL with baseUrl from getPublicWebUrl fallback', () => { - const baseUrl = getPublicWebUrl(undefined, 'https://app.owletto.com'); + it('builds URL with provided baseUrl', () => { const url = buildEntityUrl( - { - ownerSlug: 'acme', - entityType: 'topic', - slug: 'test-topic', - }, - baseUrl + { ownerSlug: 'acme', entityType: 'topic', slug: 'test-topic' }, + 'https://app.owletto.com' ); expect(url).toBe('https://app.owletto.com/acme/topic/test-topic'); }); - it('builds relative URL when no base available', () => { + it('builds relative URL when no base provided', () => { const url = buildEntityUrl( - { - ownerSlug: 'acme', - entityType: 'topic', - slug: 'test-topic', - }, + { ownerSlug: 'acme', entityType: 'topic', slug: 'test-topic' }, undefined ); expect(url).toBe('/acme/topic/test-topic'); diff --git a/packages/owletto-backend/src/utils/table-schema.ts b/packages/owletto-backend/src/utils/table-schema.ts index 52bcfcf82..73849af55 100644 --- a/packages/owletto-backend/src/utils/table-schema.ts +++ b/packages/owletto-backend/src/utils/table-schema.ts @@ -33,6 +33,7 @@ export const QUERYABLE_SCHEMA = { columns: cols( 'id', 'entity_type', + 'entity_type_id', 'parent_id', 'name', 'slug', @@ -102,7 +103,12 @@ export const QUERYABLE_SCHEMA = { 'error_message', 'created_by', 'created_at', - 'updated_at' + 'updated_at', + 'auth_profile_id', + 'app_auth_profile_id', + 'visibility', + 'deleted_at', + 'agent_id' ), }, // watchers @@ -112,6 +118,7 @@ export const QUERYABLE_SCHEMA = { 'id', 'name', 'slug', + 'description', 'version', 'current_version_id', 'schedule', @@ -128,7 +135,10 @@ export const QUERYABLE_SCHEMA = { 'created_by', 'organization_id', 'reaction_script', - 'reaction_script_compiled' + 'reaction_script_compiled', + 'connection_id', + 'source_watcher_id', + 'watcher_group_id' ), }, // event_classifications @@ -173,7 +183,10 @@ export const QUERYABLE_SCHEMA = { 'reactions_guidance', 'change_notes', 'created_by', - 'created_at' + 'created_at', + 'required_source_types', + 'recommended_source_types', + 'version_sources' ), }, // watcher_windows @@ -196,7 +209,8 @@ export const QUERYABLE_SCHEMA = { 'source_window_ids', 'created_at', 'version_id', - 'depth' + 'depth', + 'run_id' ), }, // oauth_clients (excludes: client_secret, client_secret_expires_at) @@ -267,10 +281,18 @@ export const QUERYABLE_SCHEMA = { 'items_collected', 'created_at', 'updated_at', - 'pinned_version' + 'pinned_version', + 'display_name', + 'deleted_at', + 'repair_agent_id', + 'repair_thread_id', + 'repair_attempt_count', + 'last_repair_at', + 'first_failure_at', + 'last_repair_post_hash' ), }, - // connector_definitions + // connector_definitions (excludes: large *_config JSONB blobs) { name: 'connector_definitions', columns: cols( @@ -287,7 +309,10 @@ export const QUERYABLE_SCHEMA = { 'status', 'created_at', 'updated_at', - 'login_enabled' + 'login_enabled', + 'api_type', + 'favicon_domain', + 'default_repair_agent_id' ), }, ],