diff --git a/.agents/skills/add-block/SKILL.md b/.agents/skills/add-block/SKILL.md index 45780ca4a55..eaf22ab3ed1 100644 --- a/.agents/skills/add-block/SKILL.md +++ b/.agents/skills/add-block/SKILL.md @@ -144,6 +144,25 @@ export const {ServiceName}Block: BlockConfig = { **Scope descriptions:** When adding a new OAuth provider, also add human-readable descriptions for all scopes in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`. +**Service accounts (shared, app-level credentials):** A plain `oauth-input` already lets users *select* an existing service account — those credentials fold into the picker automatically (a Google service account created for any Google service appears in every Google block's picker). You only set `credentialKind` when you want to change the *connect* action: + +```typescript +{ + id: 'credential', + title: 'Account', + type: 'oauth-input', + serviceId: '{service}', + requiredScopes: getScopesForService('{service}'), + credentialKind: 'any', // omit | 'service-account' | 'any' +} +``` + +- **omit (default):** lists OAuth accounts + any existing service accounts; the only connect action is "Connect account" (OAuth). Use this for the common "let users pick a service account someone set up elsewhere, but don't offer inline setup" case — no config needed. +- **`'service-account'`:** service-account credentials *only*, plus an inline setup action that opens the provider's connect modal. Use when a block accepts *only* an app credential. +- **`'any'`:** merged picker — OAuth accounts *and* service accounts in one grouped dropdown, with a connect action for each. Use when a block supports both (e.g. Slack: a personal account *or* a custom bot). + +Optional companions: `credentialLabels` (override the picker's section/connect-row copy) and `allowServiceAccounts: true` (trigger-mode only — list service accounts, which triggers otherwise exclude; set only when the trigger's polling path can resolve a service-account token). The connect modal, provider families (Google JSON key, Atlassian token, token-paste, client-credential, Slack bot), and the preview gate are all resolved from `serviceAccountProviderId` — you don't wire them per block. + ### Selectors (with dynamic options) ```typescript // Channel selector (Slack, Discord, etc.) diff --git a/.claude/commands/add-block.md b/.claude/commands/add-block.md index 507b1b756cb..882da919fdc 100644 --- a/.claude/commands/add-block.md +++ b/.claude/commands/add-block.md @@ -143,6 +143,25 @@ export const {ServiceName}Block: BlockConfig = { **Scope descriptions:** When adding a new OAuth provider, also add human-readable descriptions for all scopes in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`. +**Service accounts (shared, app-level credentials):** A plain `oauth-input` already lets users *select* an existing service account — those credentials fold into the picker automatically (a Google service account created for any Google service appears in every Google block's picker). You only set `credentialKind` when you want to change the *connect* action: + +```typescript +{ + id: 'credential', + title: 'Account', + type: 'oauth-input', + serviceId: '{service}', + requiredScopes: getScopesForService('{service}'), + credentialKind: 'any', // omit | 'service-account' | 'any' +} +``` + +- **omit (default):** lists OAuth accounts + any existing service accounts; the only connect action is "Connect account" (OAuth). Use this for the common "let users pick a service account someone set up elsewhere, but don't offer inline setup" case — no config needed. +- **`'service-account'`:** service-account credentials *only*, plus an inline setup action that opens the provider's connect modal. Use when a block accepts *only* an app credential. +- **`'any'`:** merged picker — OAuth accounts *and* service accounts in one grouped dropdown, with a connect action for each. Use when a block supports both (e.g. Slack: a personal account *or* a custom bot). + +Optional companions: `credentialLabels` (override the picker's section/connect-row copy) and `allowServiceAccounts: true` (trigger-mode only — list service accounts, which triggers otherwise exclude; set only when the trigger's polling path can resolve a service-account token). The connect modal, provider families (Google JSON key, Atlassian token, token-paste, client-credential, Slack bot), and the preview gate are all resolved from `serviceAccountProviderId` — you don't wire them per block. + ### Selectors (with dynamic options) ```typescript // Channel selector (Slack, Discord, etc.) diff --git a/.claude/rules/sim-url-state.md b/.claude/rules/sim-url-state.md index a841f40098c..1c45eae5416 100644 --- a/.claude/rules/sim-url-state.md +++ b/.claude/rules/sim-url-state.md @@ -3,6 +3,8 @@ paths: - "apps/sim/app/**/*.tsx" - "apps/sim/app/**/*.ts" - "apps/sim/app/**/search-params.ts" + - "apps/sim/ee/**/*.tsx" + - "apps/sim/ee/**/*.ts" --- # URL / Query-Param State (nuqs) @@ -50,11 +52,13 @@ Co-locate a `search-params.ts` next to the feature. Export the parser map (and s Conventions: -- `.withDefault(...)` on every parser so reads are non-null. -- Filter / search / toggle / pagination options: `{ history: 'replace', shallow: true, clearOnDefault: true }` — clean URLs, no back-stack churn. +- `.withDefault(...)` on every parser so reads are non-null. A deliberately **nullable** parser (dynamic default, custom-range-only dates, nullable sort) must carry a comment saying why. +- Filter / search / toggle / pagination options: `{ history: 'replace', clearOnDefault: true }` — clean URLs, no back-stack churn. Note all three of `history: 'replace'`, `clearOnDefault: true`, and `shallow: true` are already the nuqs v2 defaults — writing the first two explicitly is documentation (and guards the groups whose options differ, e.g. `history: 'push'`), and `shallow: true` may be omitted entirely. - Navigations that belong in browser history (changing folder, opening a deep-linked entity): `{ history: 'push' }`. -- `shallow: false` **only** when a Server Component / loader must re-read the param. -- Short, stable, **kebab-case** URL keys. Renaming a key is a breaking change to shared links — treat it as one. +- `shallow: false` **only** when a Server Component / loader must re-read the param. For loading states during the server re-render, pass React's `startTransition` via `.withOptions({ startTransition, shallow: false })`. +- Short, stable, **kebab-case** URL keys. Renaming a key is a breaking change to shared links — treat it as one. When the parser-map key is camelCase (for clean destructuring), remap the wire key via the `urlKeys` option in the shared options object (see `files/search-params.ts` `uploadedBy: 'uploaded-by'`, `ee/audit-logs/search-params.ts` `timeRange: 'time-range'`); nuqs also exports a `UrlKeys` type helper for standalone mappings. +- `throttleMs` is deprecated in nuqs — rate-limit URL writes with `limitUrlUpdates: throttle(ms)` / `debounce(ms)` (the debounced-search hook below already does this). +- A parser **shared across surfaces with different defaults** (e.g. `parseAsTimeRange`) must `parse` unknown tokens to `null` — never to one surface's default — so each consumer's `.withDefault(...)` decides the fallback. - For an opaque/literal value use `parseAsStringLiteral([...] as const)`; for a custom wire format use [`createParser`](https://nuqs.dev/docs/parsers). - A `createParser` for a value **not** comparable with `===` (arrays, objects, `Date`) **must** define an `eq` — `clearOnDefault` uses it to detect the default, so without it an empty-array/object default never strips from the URL. Built-in `parseAsArrayOf(...)` already ships its own `eq`; only string/number/boolean custom parsers can omit it. Example (array): `eq: (a, b) => a.length === b.length && a.every((v, i) => v === b[i])`. @@ -75,11 +79,12 @@ export const thingsParsers = { /** Clean URLs, no back-stack churn for filter changes. */ export const thingsUrlKeys = { history: 'replace', - shallow: true, clearOnDefault: true, } as const ``` +(The `*UrlKeys` suffix is the repo's naming convention for a feature's shared **options** object — which may itself contain a nuqs `urlKeys` key-remapping entry; the two are different things.) + ### Client — `useQueryStates` (grouped) / `useQueryState` (single) ```typescript @@ -182,7 +187,7 @@ Sort params live alongside — not inside — the feature's grouped filter parse A date-only param (a calendar anchor, a date filter) is stored as `yyyy-MM-dd` — never serialize a full `Date`/timestamp when only the day matters. -**Local vs UTC — pick the parser that matches your date math.** nuqs's built-in `parseAsIsoDate` is **UTC-based** (`serialize` via `toISOString()`, `parse` to UTC midnight). If your `Date` is local-time (e.g. produced by local-time helpers and read by `date-fns` `startOfWeek`/`isSameDay`, which are all local), `parseAsIsoDate` will shift the day by ±1 in any non-UTC timezone on reload/deep-link/back-forward. For local-time date math, use a small local-date `createParser` that serializes/parses on local calendar fields (`getFullYear`/`getMonth`/`getDate` ↔ `new Date(y, m-1, d)`) with an `eq` comparing y/m/d. Only use `parseAsIsoDate` when the value is genuinely UTC/midnight-UTC. See `scheduled-tasks/search-params.ts` (`parseAsLocalDate`). +**Local vs UTC — pick the parser that matches your date math.** nuqs's built-in `parseAsIsoDate` is **UTC-based** (`serialize` via `toISOString().slice(0, 10)`, `parse` to UTC midnight). If your `Date` is local-time (e.g. produced by local-time helpers and read by `date-fns` `startOfWeek`/`isSameDay`, which are all local), `parseAsIsoDate` will shift the day by ±1 in any non-UTC timezone on reload/deep-link/back-forward. For local-time date math, use a small local-date `createParser` that serializes/parses on local calendar fields (`getFullYear`/`getMonth`/`getDate` ↔ `new Date(y, m-1, d)`) with an `eq` comparing y/m/d. Only use `parseAsIsoDate` when the value is genuinely UTC/midnight-UTC. See `scheduled-tasks/search-params.ts` (`parseAsLocalDate`). When the default is **dynamic** (e.g. "today"), make the param **nullable** (omit `.withDefault`) and derive the fallback in the hook (`const anchor = param ?? today`), so a clean URL means the dynamic default and navigating back to it writes `null` (clears the param). See `scheduled-tasks/hooks/use-calendar.ts`. @@ -200,7 +205,11 @@ const [skillId, setSkillId] = useQueryState(skillIdParam.key, { const editingSkill = skillId ? (skills.find((s) => s.id === skillId) ?? null) : null ``` -Open the panel/modal when the id resolves to a loaded entity; closing it calls `setSkillId(null)`. Because this reads `useSearchParams` it needs a **Suspense** boundary on the page (see below). A separate "create new" flow has no id and stays in local `useState`. +Open the panel/modal only when the id **resolves to a loaded entity** — never gate on the raw param alone, or a dead/stale id (deleted entity, old bookmark) renders a broken detail view and a still-loading list flashes one. A dead id simply falls back to the list; the lingering param is harmless. Because this reads `useSearchParams` it needs a **Suspense** boundary on the page (see "Suspense boundary" above). A separate "create new" flow has no id and stays in local `useState`. + +**Close with `replace`, open with `push`.** Opening pushed a history entry; closing must not push another. Close via the setter's per-call options — `setSkillId(null, { history: 'replace' })` — so Back from the list leaves the page instead of reopening the detail (see `mcp.tsx`, `workflow-mcp-servers.tsx`, access-control, custom-blocks, forks). Secondary params scoped to the detail view (e.g. its active tab, `server-tab`) are cleared in the same close handler with their own setter — nuqs batches same-tick writes into one URL update. + +**Reusable components** rendered both as a settings/list page and inside a modal (e.g. `BYOKKeyManager`) expose an optional controlled `searchTerm`/`onSearchTermChange` prop pair: the page consumer binds the URL (`useSettingsSearch()`), modal consumers omit the props and keep local state. Never bind URL state from inside a component that can mount in a non-destination context. ## Read-then-strip deep links @@ -217,8 +226,8 @@ The workflow editor (`apps/sim/app/workspace/[workspaceId]/w/**`) is realtime/so Borderline candidates that *look* shareable but currently stay in Zustand because moving them fights existing machinery: -- **Panel `activeTab`** and **`canvasMode`** — persisted local *preferences* wired into an SSR flash-prevention path (`data-panel-active-tab` + `_hasHydrated`). They are layout prefs, not destinations; moving them would unwind the SSR machinery and risk tab-flash on load. -- **`focusedBlockId`** ("look at this block") — the only genuinely shareable candidate, but it is entangled with the persisted editor store and panel-open orchestration. Adding it is a *new feature*, not a migration; ship it deliberately (with runtime verification against a live socket), not as part of a sweep. +- **Panel `activeTab`** — a persisted local *preference* wired into an SSR flash-prevention path (`data-panel-active-tab` + `_hasHydrated`); moving it would unwind that machinery and risk tab-flash on load. **Canvas mode** (`mode` on `useCanvasModeStore`) is likewise a persisted layout preference, not a destination. +- **The panel editor's `currentBlockId`** (`stores/panel/editor/store.ts` — a would-be "look at this block" deep link) — the only genuinely shareable candidate, but it is persisted and entangled with panel-open orchestration. Adding a URL param for it is a *new feature*, not a migration; ship it deliberately (with runtime verification against a live socket), not as part of a sweep. Rule of thumb for the editor: if state is socket-coupled, high-frequency, viewport-related, or a persisted resize/preference, it stays in Zustand. When in doubt, leave it and flag it — do not force fragile URL state into the canvas. diff --git a/.cursor/commands/add-block.md b/.cursor/commands/add-block.md index d244ef68704..875da1a0bdf 100644 --- a/.cursor/commands/add-block.md +++ b/.cursor/commands/add-block.md @@ -138,6 +138,25 @@ export const {ServiceName}Block: BlockConfig = { **Scope descriptions:** When adding a new OAuth provider, also add human-readable descriptions for all scopes in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`. +**Service accounts (shared, app-level credentials):** A plain `oauth-input` already lets users *select* an existing service account — those credentials fold into the picker automatically (a Google service account created for any Google service appears in every Google block's picker). You only set `credentialKind` when you want to change the *connect* action: + +```typescript +{ + id: 'credential', + title: 'Account', + type: 'oauth-input', + serviceId: '{service}', + requiredScopes: getScopesForService('{service}'), + credentialKind: 'any', // omit | 'service-account' | 'any' +} +``` + +- **omit (default):** lists OAuth accounts + any existing service accounts; the only connect action is "Connect account" (OAuth). Use this for the common "let users pick a service account someone set up elsewhere, but don't offer inline setup" case — no config needed. +- **`'service-account'`:** service-account credentials *only*, plus an inline setup action that opens the provider's connect modal. Use when a block accepts *only* an app credential. +- **`'any'`:** merged picker — OAuth accounts *and* service accounts in one grouped dropdown, with a connect action for each. Use when a block supports both (e.g. Slack: a personal account *or* a custom bot). + +Optional companions: `credentialLabels` (override the picker's section/connect-row copy) and `allowServiceAccounts: true` (trigger-mode only — list service accounts, which triggers otherwise exclude; set only when the trigger's polling path can resolve a service-account token). The connect modal, provider families (Google JSON key, Atlassian token, token-paste, client-credential, Slack bot), and the preview gate are all resolved from `serviceAccountProviderId` — you don't wire them per block. + ### Selectors (with dynamic options) ```typescript // Channel selector (Slack, Discord, etc.) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0aa7a40ed9e..1f0dc4a2cbf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,16 +34,144 @@ permissions: contents: read jobs: + # Promotion PRs (staging→main etc.) double-run the test suite: every commit + # on staging/main already gets a push-event run of the exact same test-build, + # and the pull_request "synchronize" run for the open release PR re-runs it on + # the same sha seconds later (~39 duplicate runs / 5 days measured Jul 2026). + # This gate skips the PR run's test-build ONLY when it can prove the identical + # work already passed elsewhere: + # 1. the PR base adds no file changes over the merge base with the head sha + # (compare head...base has an empty diff), so the merge result's tree is + # identical to the head tree the push run tested. Plain ancestry is not + # enough of a check here: main's merge-only ruleset leaves merge commits + # on main that staging lacks, so main...staging is permanently + # "diverged" — but those merge commits carry no tree delta. A real + # hotfix landed directly on the base makes the diff non-empty and we + # run tests here; + # 2. the push-event CI run at the same head sha finished its test jobs with + # conclusion success (polled, since push + PR runs start simultaneously). + # Fail-open by construction: any API error, timeout, missing run, or push-run + # failure leaves covered=false and the PR run tests normally, so the PR check + # is green only if tests passed either here or on the identical tree. Not a + # workflow-level filter on purpose — a job-level skip still reports a + # (successful) check context. NOTE: when test-build is skipped, its nested + # "Test and Build / ..." contexts are not created; verified 2026-07-22 that + # no required status checks are configured on main/staging (rulesets contain + # only pull_request/deletion/non_fast_forward). If required checks are ever + # added, require the caller "Test and Build" context, not the nested ones. + # dev is excluded: push runs on dev skip test-build, so dev-headed PRs have + # no push-run coverage to reuse. + dedup-promotion: + name: Dedup Promotion PR + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }} + timeout-minutes: 15 + if: >- + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository && + contains(fromJSON('["main", "staging"]'), github.event.pull_request.head.ref) + permissions: + contents: read + actions: read + outputs: + covered: ${{ steps.probe.outputs.covered }} + steps: + - name: Probe for a passing push run at the same sha + id: probe + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: | + COVERED=false + + # (1) Merge-tree equivalence: compare head...base diffs the merge + # base against the base tip. An empty diff means the base contributes + # nothing beyond what head already contains (merge commits only), so + # the PR merge tree equals the head tree the push run tested. Any + # error yields "unknown" and we run the tests. + BASE_DELTA="$(gh api "repos/${REPO}/compare/${HEAD_SHA}...${BASE_SHA}" --jq '.files | length' 2>/dev/null || echo unknown)" + if [ "$BASE_DELTA" != "0" ]; then + echo "Base tip changes ${BASE_DELTA} file(s) over the merge base; merge tree differs from head — running tests in this PR run." + echo "covered=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # (2) Poll the push-event CI run's test jobs (they start seconds after + # this run and take ~4 min). Any conclusion other than success, or + # deadline expiry, falls through to covered=false. + DEADLINE=$((SECONDS + 600)) + while [ "$SECONDS" -lt "$DEADLINE" ]; do + RUN_JSON="$(gh api "repos/${REPO}/actions/workflows/ci.yml/runs?event=push&head_sha=${HEAD_SHA}&per_page=5" 2>/dev/null || echo '')" + RUN_ID="$(printf '%s' "$RUN_JSON" | jq -r '.workflow_runs[0].id // empty' 2>/dev/null || echo '')" + if [ -n "$RUN_ID" ]; then + # Jobs from the reusable test-build workflow are prefixed + # "Test and Build /". If that name ever changes, fall back to the + # overall run conclusion (stricter, still correct). + STATE="$(gh api "repos/${REPO}/actions/runs/${RUN_ID}/jobs?per_page=100" --jq ' + [.jobs[] | select(.name | startswith("Test and Build /"))] as $t | + if ($t | length) == 0 then "nojobs" + elif all($t[]; .conclusion == "success") then "success" + elif any($t[]; .conclusion != null and .conclusion != "success") then "failed" + else "pending" end' 2>/dev/null || echo pending)" + if [ "$STATE" = "nojobs" ]; then + # Nested reusable-workflow jobs appear only after the caller + # starts, so an in-progress run with no "Test and Build /" + # jobs yet just needs another poll. Once the run has + # COMPLETED without them, fail closed: the job was renamed or + # tests were skipped — never infer coverage from the overall + # run conclusion. Update the prefix here on a rename. + RUN_STATUS="$(printf '%s' "$RUN_JSON" | jq -r '.workflow_runs[0].status // "unknown"' 2>/dev/null || echo unknown)" + if [ "$RUN_STATUS" = "completed" ]; then + echo "Push run ${RUN_ID} completed with no 'Test and Build /' jobs — running tests in this PR run (update the prefix if the job was renamed)." + break + fi + STATE="pending" + fi + if [ "$STATE" = "success" ]; then + # (3) Re-verify merge-tree equivalence against the LIVE base + # tip at decision time: the base branch may have gained real + # commits during the poll, in which case the frozen BASE_SHA + # check from step (1) is stale and the skip would be unsound. + # Any error yields "unknown" and we run the tests. + LIVE_DELTA="$(gh api "repos/${REPO}/compare/${HEAD_SHA}...heads/${BASE_REF}" --jq '.files | length' 2>/dev/null || echo unknown)" + if [ "$LIVE_DELTA" = "0" ]; then + COVERED=true + echo "Push run ${RUN_ID} passed its test jobs for ${HEAD_SHA} and the live base tip still adds no file changes — skipping duplicate test-build." + else + echo "Base branch moved during the poll (live delta: ${LIVE_DELTA}) — running tests in this PR run." + fi + break + elif [ "$STATE" = "failed" ]; then + echo "Push run ${RUN_ID} did not pass (state: ${STATE}) — running tests in this PR run." + break + fi + fi + sleep 20 + done + + echo "covered=${COVERED}" >> "$GITHUB_OUTPUT" + test-build: name: Test and Build - if: github.ref != 'refs/heads/dev' || github.event_name == 'pull_request' + needs: [dedup-promotion] + # !cancelled(): dedup-promotion is skipped on every non-promotion event and + # a skipped need would otherwise skip this job too. covered != 'true' is + # fail-open — empty (skipped/failed probe) means run the tests. + if: >- + !cancelled() && + (github.ref != 'refs/heads/dev' || github.event_name == 'pull_request') && + needs.dedup-promotion.outputs.covered != 'true' uses: ./.github/workflows/test-build.yml secrets: inherit # Detect if this is a version release commit (e.g., "v0.5.24: ...") + # Smallest runner on purpose: a few seconds of pure shell over the commit + # message, no checkout and no install. detect-version: name: Detect Version - runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }} timeout-minutes: 5 if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging' || github.ref == 'refs/heads/dev') outputs: @@ -486,9 +614,11 @@ jobs: fi # Check if docs changed + # Smallest runner on purpose: a depth-2 checkout plus a path filter, no + # install and no build. check-docs-changes: name: Check Docs Changes - runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }} timeout-minutes: 5 if: github.event_name == 'push' && github.ref == 'refs/heads/main' outputs: diff --git a/.github/workflows/companion-pr-check.yml b/.github/workflows/companion-pr-check.yml index 2d149210660..0cf31db934f 100644 --- a/.github/workflows/companion-pr-check.yml +++ b/.github/workflows/companion-pr-check.yml @@ -22,6 +22,17 @@ on: branches: [staging, main] workflow_dispatch: {} +# One live run per PR: a newer opened/edited/synchronize event supersedes the +# previous run's work entirely (the sticky comment/label upsert is idempotent +# and only the latest body matters), so cancel in-flight runs instead of +# letting them race the new one. workflow_dispatch bulk scans get a unique +# group via run_id and are never cancelled. No paths filter on purpose: the +# check reads the PR body and cross-repo PR state, not changed files, so a +# paths filter would both be semantically wrong and stop status refreshes. +concurrency: + group: companion-pr-check-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + permissions: pull-requests: write issues: write diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index ed348a41446..8e7ff3e9eab 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -236,13 +236,17 @@ jobs: key: ${{ github.repository }}-turbo-cache-build-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }} path: ./.turbo - - name: Restore Next.js build cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + # Turbopack's persistent build cache (NEXT_TURBOPACK_BUILD_CACHE below) + # writes ~5 GB into .next/cache — a sticky disk mounts it in ~1s where an + # actions/cache round-trip would eat the warm-build win. Same event/fork + # namespacing as the other mounts; the GitHub fallback inside cache-mount + # still uses actions/cache with a run_id-suffixed key. + - name: Mount Next.js build cache + uses: ./.github/actions/cache-mount with: + provider: ${{ vars.CI_PROVIDER }} + key: ${{ github.repository }}-nextjs-cache-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }} path: ./apps/sim/.next/cache - key: ${{ runner.os }}-nextjs-${{ hashFiles('bun.lock') }} - restore-keys: | - ${{ runner.os }}-nextjs- - name: Install dependencies run: bun install --frozen-lockfile @@ -258,4 +262,7 @@ jobs: AWS_REGION: 'us-west-2' ENCRYPTION_KEY: '7cf672e460e430c1fba707575c2b0e2ad5a99dddf9b7b7e3b5646e630861db1c' # dummy key for CI only TURBO_CACHE_DIR: .turbo - run: bunx turbo run build --filter=sim \ No newline at end of file + # Opt into Turbopack's persistent build cache (beta) for this CI + # check build only — measured 105s cold vs 22s warm locally. + NEXT_TURBOPACK_BUILD_CACHE: '1' + run: bunx turbo run build --filter=sim diff --git a/apps/docs/content/docs/en/platform/enterprise/meta.json b/apps/docs/content/docs/en/platform/enterprise/meta.json index c8a33a7e287..924a8263768 100644 --- a/apps/docs/content/docs/en/platform/enterprise/meta.json +++ b/apps/docs/content/docs/en/platform/enterprise/meta.json @@ -3,6 +3,7 @@ "pages": [ "index", "sso", + "session-policies", "access-control", "custom-blocks", "whitelabeling", diff --git a/apps/docs/content/docs/en/platform/enterprise/session-policies.mdx b/apps/docs/content/docs/en/platform/enterprise/session-policies.mdx new file mode 100644 index 00000000000..fb26c2f0a32 --- /dev/null +++ b/apps/docs/content/docs/en/platform/enterprise/session-policies.mdx @@ -0,0 +1,76 @@ +--- +title: Session Policies +description: Set session lifetime limits and sign out every member of your organization at once +--- + +import { FAQ } from '@/components/ui/faq' + +Session Policies let organization owners and admins on Enterprise plans control how long member sign-in sessions last, and sign out every member org-wide in one action. Policies apply to every member of the organization on every device. + +--- + +## Setup + +Go to **Settings → Security → Session policies** in your organization settings. + +Both limits are optional. Leave a field empty to keep the default behavior: sessions last 30 days and extend automatically while a member stays active. + +--- + +## Settings + +### Max session lifetime + +Caps how long a session can exist from the moment a member signs in, regardless of activity. When the limit is reached, the member must sign in again. + +Use this to enforce periodic re-authentication — for example, a value of `168` requires everyone to sign in again at least weekly. Accepts 1 to 8760 hours (1 year). + +### Idle timeout + +Signs a member out after this many hours without activity. Activity extends the session, so members who use Sim regularly stay signed in; dormant sessions expire. + +Accepts 48 to 8760 hours. The 48-hour minimum exists because session activity is recorded at most once per day — a shorter window could sign out members who are actively working. + +### Sign out all members + +The **Sign out all members** action immediately revokes every member session in the organization except your own. Members are signed out on their next request — typically within a minute — and must sign in again. + +Use this after a security incident, an offboarding wave, or before tightening a policy you want to take effect everywhere at once. + +--- + +## How enforcement works + +- **New sign-ins** get an expiry that respects the policy from the moment the session is created. +- **Existing sessions** are shortened immediately when you save a tighter policy — no member keeps a longer session than the new policy allows. +- **Loosening a policy never extends existing sessions.** Members pick up the longer limit the next time they sign in. +- Changes propagate to active members within about a minute; there is no need to redeploy or wait for sessions to naturally expire. + +--- + +## FAQ + + diff --git a/apps/docs/content/docs/en/workflows/blocks/api.mdx b/apps/docs/content/docs/en/workflows/blocks/api.mdx index 3d8fd7c22e3..8a0354ae5cb 100644 --- a/apps/docs/content/docs/en/workflows/blocks/api.mdx +++ b/apps/docs/content/docs/en/workflows/blocks/api.mdx @@ -40,6 +40,7 @@ The request payload for POST, PUT, and PATCH, sent as JSON. Type it directly, or - **Retries.** Number of retry attempts on timeouts, `429` responses, and `5xx` errors. Defaults to 0. - **Retry delay / Max retry delay (ms).** The exponential-backoff bounds used between retries. - **Retry non-idempotent methods.** Off by default, so POST and PATCH are not retried, which avoids duplicate writes. Turn it on only when a repeated request is safe. +- **Proxy URL.** Optional `http://` proxy the request egresses through, such as a residential proxy for targets that block datacenter IPs. The proxy host must be publicly reachable, and only the `http://` scheme is supported (an `https://` target still tunnels through it securely). Keep proxy credentials in an environment variable and reference it as `{{PROXY_URL}}` rather than typing them into the field. ## Outputs diff --git a/apps/sim/.env.example b/apps/sim/.env.example index 08003939323..795a002a6ad 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -19,6 +19,7 @@ BETTER_AUTH_URL=http://localhost:3000 NEXT_PUBLIC_APP_URL=http://localhost:3000 # INTERNAL_API_BASE_URL=http://sim-app.default.svc.cluster.local:3000 # Optional: internal URL for server-side /api self-calls; defaults to NEXT_PUBLIC_APP_URL # TRUSTED_ORIGINS=https://www.example.com,https://app.example.com # Optional: comma-separated additional public origins to trust for auth (apex+www, alias domains). Merged into Better Auth trustedOrigins. +# AUTH_TRUSTED_PROXIES=10.0.0.0/24,192.0.2.10 # Optional: reverse-proxy IPs/CIDRs in front of the app. Better Auth walks x-forwarded-for right to left, skips these hops, and uses the first untrusted address as the client IP (prevents forwarded-header spoofing). Use your proxies' actual addresses, not broad private ranges that also cover clients. # Security (Required) ENCRYPTION_KEY=your_encryption_key # Use `openssl rand -hex 32` to generate, used to encrypt environment variables diff --git a/apps/sim/app/(landing)/integrations/search-params.ts b/apps/sim/app/(landing)/integrations/search-params.ts index 1672807299c..d93113b80d0 100644 --- a/apps/sim/app/(landing)/integrations/search-params.ts +++ b/apps/sim/app/(landing)/integrations/search-params.ts @@ -6,8 +6,8 @@ import { createSearchParamsCache, parseAsString } from 'nuqs/server' * so the filtered view is server-rendered for shareable, crawlable `?category=`/`?q=` * URLs — the same SSR pattern the blog index uses. * - * - `q` is the search filter; its URL write is debounced on the setter, never - * written per keystroke. + * - `q` is the search filter; its URL write is debounced via + * `useDebouncedSearchSetter`, never written per keystroke. * - `category` filters by integration type (`''` = all). */ export const integrationsParsers = { diff --git a/apps/sim/app/(landing)/models/search-params.ts b/apps/sim/app/(landing)/models/search-params.ts index 21c7ee09ec6..210556703ab 100644 --- a/apps/sim/app/(landing)/models/search-params.ts +++ b/apps/sim/app/(landing)/models/search-params.ts @@ -6,8 +6,8 @@ import { createSearchParamsCache, parseAsString } from 'nuqs/server' * so the filtered view is server-rendered for shareable, crawlable `?provider=`/`?q=` * URLs — the same SSR pattern the blog index uses. * - * - `q` is the search filter; its URL write is debounced on the setter, never - * written per keystroke. + * - `q` is the search filter; its URL write is debounced via + * `useDebouncedSearchSetter`, never written per keystroke. * - `provider` filters by provider id (`''` = all). */ export const modelsParsers = { diff --git a/apps/sim/app/account/settings/billing/credit-usage/page.test.ts b/apps/sim/app/account/settings/billing/credit-usage/page.test.ts index cfa256daba4..0c2781bf469 100644 --- a/apps/sim/app/account/settings/billing/credit-usage/page.test.ts +++ b/apps/sim/app/account/settings/billing/credit-usage/page.test.ts @@ -1,30 +1,21 @@ /** * @vitest-environment node */ +import { authMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCreditUsageLoading, - mockGetPersonalSubscription, - mockGetSession, - mockIsEnterprise, - mockRedirect, -} = vi.hoisted(() => ({ - mockCreditUsageLoading: vi.fn(() => null), - mockGetPersonalSubscription: vi.fn(), - mockGetSession: vi.fn(), - mockIsEnterprise: vi.fn(), - mockRedirect: vi.fn(), -})) +const { mockCreditUsageLoading, mockGetPersonalSubscription, mockIsEnterprise, mockRedirect } = + vi.hoisted(() => ({ + mockCreditUsageLoading: vi.fn(() => null), + mockGetPersonalSubscription: vi.fn(), + mockIsEnterprise: vi.fn(), + mockRedirect: vi.fn(), + })) vi.mock('next/navigation', () => ({ redirect: mockRedirect, })) -vi.mock('@/lib/auth', () => ({ - getSession: mockGetSession, -})) - vi.mock('@/lib/billing/core/plan', () => ({ getHighestPriorityPersonalSubscription: mockGetPersonalSubscription, })) @@ -44,6 +35,8 @@ vi.mock('@/app/workspace/[workspaceId]/settings/billing/credit-usage/loading', ( import AccountCreditUsagePage from '@/app/account/settings/billing/credit-usage/page' +const mockGetSession = authMockFns.mockGetSession + describe('AccountCreditUsagePage', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/app/api/audit-logs/export/route.test.ts b/apps/sim/app/api/audit-logs/export/route.test.ts index 712d8db9e81..367c04cb86f 100644 --- a/apps/sim/app/api/audit-logs/export/route.test.ts +++ b/apps/sim/app/api/audit-logs/export/route.test.ts @@ -1,18 +1,16 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' +import { authMockFns, createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { - mockGetSession, mockValidateEnterpriseAuditAccess, mockBuildOrgScopeCondition, mockGetOrgWorkspaceIds, mockQueryAuditLogs, mockBuildFilterConditions, } = vi.hoisted(() => ({ - mockGetSession: vi.fn(), mockValidateEnterpriseAuditAccess: vi.fn(), mockBuildOrgScopeCondition: vi.fn(), mockGetOrgWorkspaceIds: vi.fn(), @@ -20,11 +18,6 @@ const { mockBuildFilterConditions: vi.fn(), })) -vi.mock('@/lib/auth', () => ({ - auth: { api: { getSession: vi.fn() } }, - getSession: mockGetSession, -})) - vi.mock('@/app/api/v1/audit-logs/auth', () => ({ validateEnterpriseAuditAccess: mockValidateEnterpriseAuditAccess, })) @@ -38,6 +31,8 @@ vi.mock('@/app/api/v1/audit-logs/query', () => ({ import { GET } from '@/app/api/audit-logs/export/route' +const mockGetSession = authMockFns.mockGetSession + const ORG_ID = 'org-1' const MEMBER_IDS = ['admin-1'] const SCOPE_SENTINEL = { type: 'org-scope-sentinel' } diff --git a/apps/sim/app/api/auth/[...all]/route.test.ts b/apps/sim/app/api/auth/[...all]/route.test.ts index 0d82eac149d..4c577409990 100644 --- a/apps/sim/app/api/auth/[...all]/route.test.ts +++ b/apps/sim/app/api/auth/[...all]/route.test.ts @@ -1,8 +1,8 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createMockRequest, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const handlerMocks = vi.hoisted(() => ({ betterAuthGET: vi.fn(), @@ -12,7 +12,6 @@ const handlerMocks = vi.hoisted(() => ({ user: { id: 'anon' }, session: { id: 'anon-session' }, })), - isAuthDisabled: false, })) vi.mock('better-auth/next-js', () => ({ @@ -31,22 +30,18 @@ vi.mock('@/lib/auth/anonymous', () => ({ createAnonymousSession: handlerMocks.createAnonymousSession, })) -vi.mock('@/lib/core/config/env-flags', () => ({ - get isAuthDisabled() { - return handlerMocks.isAuthDisabled - }, -})) - import { GET, POST } from '@/app/api/auth/[...all]/route' +afterAll(resetEnvFlagsMock) + describe('auth catch-all route (DISABLE_AUTH get-session)', () => { beforeEach(() => { vi.clearAllMocks() - handlerMocks.isAuthDisabled = false + setEnvFlags({ isAuthDisabled: false }) }) it('returns anonymous session in better-auth response envelope when auth is disabled', async () => { - handlerMocks.isAuthDisabled = true + setEnvFlags({ isAuthDisabled: true }) const req = createMockRequest( 'GET', @@ -67,7 +62,7 @@ describe('auth catch-all route (DISABLE_AUTH get-session)', () => { }) it('delegates to better-auth handler when auth is enabled', async () => { - handlerMocks.isAuthDisabled = false + setEnvFlags({ isAuthDisabled: false }) const { NextResponse } = await import('next/server') handlerMocks.betterAuthGET.mockResolvedValueOnce( diff --git a/apps/sim/app/api/auth/forget-password/route.test.ts b/apps/sim/app/api/auth/forget-password/route.test.ts index e7dc5612554..7b43e1fbc5a 100644 --- a/apps/sim/app/api/auth/forget-password/route.test.ts +++ b/apps/sim/app/api/auth/forget-password/route.test.ts @@ -3,8 +3,8 @@ * * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createMockRequest, resetEnvMock, setEnv } from '@sim/testing' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { mockRequestPasswordReset, mockLogger } = vi.hoisted(() => { const logger = { @@ -22,9 +22,6 @@ const { mockRequestPasswordReset, mockLogger } = vi.hoisted(() => { } }) -vi.mock('@/lib/core/utils/urls', () => ({ - getBaseUrl: vi.fn(() => 'https://app.example.com'), -})) vi.mock('@/lib/auth', () => ({ auth: { api: { @@ -43,9 +40,14 @@ import { POST } from '@/app/api/auth/forget-password/route' describe('Forget Password API Route', () => { beforeEach(() => { vi.clearAllMocks() + setEnv({ NEXT_PUBLIC_APP_URL: 'https://app.example.com' }) mockRequestPasswordReset.mockResolvedValue(undefined) }) + afterAll(() => { + resetEnvMock() + }) + afterEach(() => { vi.clearAllMocks() }) diff --git a/apps/sim/app/api/auth/oauth/connections/route.test.ts b/apps/sim/app/api/auth/oauth/connections/route.test.ts index e5e5d742711..593079aa20c 100644 --- a/apps/sim/app/api/auth/oauth/connections/route.test.ts +++ b/apps/sim/app/api/auth/oauth/connections/route.test.ts @@ -25,10 +25,6 @@ vi.mock('@sim/db', () => ({ eq: mockEq, })) -vi.mock('drizzle-orm', () => ({ - eq: mockEq, -})) - vi.mock('jose', () => ({ decodeJwt: mockDecodeJwt, })) diff --git a/apps/sim/app/api/auth/oauth/disconnect/route.test.ts b/apps/sim/app/api/auth/oauth/disconnect/route.test.ts index 97215a0d923..757ea76c9df 100644 --- a/apps/sim/app/api/auth/oauth/disconnect/route.test.ts +++ b/apps/sim/app/api/auth/oauth/disconnect/route.test.ts @@ -7,14 +7,11 @@ import { auditMock, authMockFns, createMockRequest, - dbChainMock, dbChainMockFns, resetDbChainMock, } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@sim/audit', () => auditMock) import { POST } from '@/app/api/auth/oauth/disconnect/route' diff --git a/apps/sim/app/api/auth/oauth/utils.test.ts b/apps/sim/app/api/auth/oauth/utils.test.ts index 43ea672f9d1..4a22a97b8fc 100644 --- a/apps/sim/app/api/auth/oauth/utils.test.ts +++ b/apps/sim/app/api/auth/oauth/utils.test.ts @@ -4,7 +4,7 @@ * @vitest-environment node */ -import { redisConfigMock, redisConfigMockFns } from '@sim/testing' +import { redisConfigMockFns } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/lib/oauth/oauth', () => ({ @@ -12,8 +12,6 @@ vi.mock('@/lib/oauth/oauth', () => ({ OAUTH_PROVIDERS: {}, })) -vi.mock('@/lib/core/config/redis', () => redisConfigMock) - const { mockDecryptSecret } = vi.hoisted(() => ({ mockDecryptSecret: vi.fn() })) vi.mock('@/lib/core/security/encryption', () => ({ decryptSecret: mockDecryptSecret, diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts index 894e543c3b2..8b4abab40b0 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts @@ -1,8 +1,14 @@ /** * @vitest-environment node */ -import { createMockRequest, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + createMockRequest, + dbChainMockFns, + resetDbChainMock, + resetEnvMock, + setEnv, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetSession, @@ -16,8 +22,6 @@ const { mockGetCredentialActorContext: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/auth/auth', () => ({ auth: { api: { oAuth2LinkAccount: mockOAuth2LinkAccount } }, getSession: mockGetSession, @@ -70,10 +74,14 @@ function oauthCredentialActor(overrides: Record = {}) { } describe('OAuth2 authorize route', () => { + afterAll(() => { + resetEnvMock() + }) + beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - process.env.NEXT_PUBLIC_APP_URL = BASE_URL + setEnv({ NEXT_PUBLIC_APP_URL: BASE_URL }) mockGetSession.mockResolvedValue({ user: { id: USER_ID } }) mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: true, diff --git a/apps/sim/app/api/auth/sso/register/route.test.ts b/apps/sim/app/api/auth/sso/register/route.test.ts index cdacc7c6c88..67b4beb8b13 100644 --- a/apps/sim/app/api/auth/sso/register/route.test.ts +++ b/apps/sim/app/api/auth/sso/register/route.test.ts @@ -1,8 +1,17 @@ /** * @vitest-environment node */ -import { createEnvMock, createMockRequest } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + createMockRequest, + dbChainMock, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + resetEnvMock, + schemaMock, + setEnv, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetSession, @@ -10,58 +19,29 @@ const { mockHasSSOAccess, mockValidateUrlWithDNS, mockSecureFetchWithPinnedIP, - dbState, - memberTable, - ssoProviderTable, } = vi.hoisted(() => ({ mockGetSession: vi.fn(), mockRegisterSSOProvider: vi.fn(), mockHasSSOAccess: vi.fn(), mockValidateUrlWithDNS: vi.fn(), mockSecureFetchWithPinnedIP: vi.fn(), - dbState: { members: [] as any[], providers: [] as any[] }, - memberTable: { - userId: 'member.userId', - organizationId: 'member.organizationId', - role: 'member.role', - }, - ssoProviderTable: { - id: 'sso.id', - providerId: 'sso.providerId', - domain: 'sso.domain', - issuer: 'sso.issuer', - userId: 'sso.userId', - organizationId: 'sso.organizationId', - oidcConfig: 'sso.oidcConfig', - samlConfig: 'sso.samlConfig', - }, })) -function makeBuilder(rows: any[]): any { - const thenable: any = Promise.resolve(rows) - thenable.where = (condition: any) => { - const values = condition?.values - if (Array.isArray(values) && values.length > 0) { - const target = String(values[values.length - 1]).toLowerCase() - return makeBuilder(rows.filter((r) => String(r.domain ?? '').toLowerCase() === target)) - } - return makeBuilder(rows) - } - thenable.limit = () => Promise.resolve(rows) - thenable.orderBy = () => Promise.resolve(rows) - return thenable +vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) + +/** Queues the caller's org membership row(s) for the admin/owner check. */ +function queueMembers(rows: Array>) { + queueTableRows(schemaMock.member, rows) } -vi.mock('@sim/db', () => ({ - db: { - select: () => ({ - from: (table: unknown) => - makeBuilder(table === memberTable ? dbState.members : dbState.providers), - }), - }, - member: memberTable, - ssoProvider: ssoProviderTable, -})) +/** + * Queues existing SSO provider rows for BOTH domain-conflict lookups (the + * pre-registration check and the post-registration re-check). + */ +function queueProviders(rows: Array>) { + queueTableRows(schemaMock.ssoProvider, rows) + queueTableRows(schemaMock.ssoProvider, rows) +} vi.mock('@/lib/auth', () => ({ getSession: mockGetSession, @@ -85,8 +65,6 @@ vi.mock('@/lib/core/security/input-validation.server', () => ({ secureFetchWithPinnedIP: mockSecureFetchWithPinnedIP, })) -vi.mock('@/lib/core/config/env', () => createEnvMock({ SSO_ENABLED: 'true' })) - import { POST } from '@/app/api/auth/sso/register/route' const OIDC_BODY = { @@ -109,8 +87,8 @@ function request(body: Record) { describe('POST /api/auth/sso/register', () => { beforeEach(() => { vi.clearAllMocks() - dbState.members = [] - dbState.providers = [] + resetDbChainMock() + setEnv({ SSO_ENABLED: 'true' }) mockGetSession.mockResolvedValue({ user: { id: 'u1' } }) mockHasSSOAccess.mockResolvedValue(true) mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '1.2.3.4' }) @@ -118,6 +96,11 @@ describe('POST /api/auth/sso/register', () => { mockRegisterSSOProvider.mockResolvedValue({ providerId: 'acme-oidc' }) }) + afterAll(() => { + resetDbChainMock() + resetEnvMock() + }) + it('rejects callers without an Enterprise plan', async () => { mockHasSSOAccess.mockResolvedValue(false) const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) @@ -126,22 +109,22 @@ describe('POST /api/auth/sso/register', () => { }) it('rejects callers who are not an admin/owner of the target org', async () => { - dbState.members = [{ organizationId: 'org1', role: 'member' }] + queueMembers([{ organizationId: 'org1', role: 'member' }]) const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) expect(res.status).toBe(403) expect(mockRegisterSSOProvider).not.toHaveBeenCalled() }) it('rejects an invalid domain', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] + queueMembers([{ organizationId: 'org1', role: 'owner' }]) const res = await POST(request({ ...OIDC_BODY, domain: 'not-a-domain', orgId: 'org1' })) expect(res.status).toBe(400) expect(mockRegisterSSOProvider).not.toHaveBeenCalled() }) it('rejects a domain already registered by another organization', async () => { - dbState.members = [{ organizationId: 'org-attacker', role: 'owner' }] - dbState.providers = [{ domain: 'acme.com', userId: 'u-victim', organizationId: 'org-victim' }] + queueMembers([{ organizationId: 'org-attacker', role: 'owner' }]) + queueProviders([{ domain: 'acme.com', userId: 'u-victim', organizationId: 'org-victim' }]) const res = await POST(request({ ...OIDC_BODY, orgId: 'org-attacker' })) const json = await res.json() expect(res.status).toBe(409) @@ -150,46 +133,51 @@ describe('POST /api/auth/sso/register', () => { }) it('matches conflicts across casing variants', async () => { - dbState.members = [{ organizationId: 'org-attacker', role: 'owner' }] - dbState.providers = [{ domain: 'ACME.com', userId: 'u-victim', organizationId: 'org-victim' }] + queueMembers([{ organizationId: 'org-attacker', role: 'owner' }]) + queueProviders([{ domain: 'ACME.com', userId: 'u-victim', organizationId: 'org-victim' }]) const res = await POST(request({ ...OIDC_BODY, orgId: 'org-attacker' })) expect(res.status).toBe(409) expect(mockRegisterSSOProvider).not.toHaveBeenCalled() + // The conflict lookup itself must be case-insensitive: lower(domain) = . + const conflictWhere = dbChainMockFns.where.mock.calls.find(([condition]) => + condition?.strings?.join('?').includes('lower(') + ) + expect(conflictWhere?.[0]?.values).toContain('acme.com') }) it('registers when the domain is unclaimed', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] + queueMembers([{ organizationId: 'org1', role: 'owner' }]) const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) expect(res.status).toBe(200) expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1) }) it('allows the owning tenant to update its own provider for the same domain', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] - dbState.providers = [{ domain: 'acme.com', userId: 'u1', organizationId: 'org1' }] + queueMembers([{ organizationId: 'org1', role: 'owner' }]) + queueProviders([{ domain: 'acme.com', userId: 'u1', organizationId: 'org1' }]) const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) expect(res.status).toBe(200) expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1) }) it('lets an org admin adopt their own user-scoped provider for the same domain', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] - dbState.providers = [{ domain: 'acme.com', userId: 'u1', organizationId: null }] + queueMembers([{ organizationId: 'org1', role: 'owner' }]) + queueProviders([{ domain: 'acme.com', userId: 'u1', organizationId: null }]) const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) expect(res.status).toBe(200) expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1) }) it("still blocks an org admin from claiming another user's user-scoped domain", async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] - dbState.providers = [{ domain: 'acme.com', userId: 'someone-else', organizationId: null }] + queueMembers([{ organizationId: 'org1', role: 'owner' }]) + queueProviders([{ domain: 'acme.com', userId: 'someone-else', organizationId: null }]) const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) expect(res.status).toBe(409) expect(mockRegisterSSOProvider).not.toHaveBeenCalled() }) it('normalizes the domain before persisting it', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] + queueMembers([{ organizationId: 'org1', role: 'owner' }]) const res = await POST(request({ ...OIDC_BODY, domain: 'ACME.com', orgId: 'org1' })) expect(res.status).toBe(200) expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1) @@ -198,7 +186,7 @@ describe('POST /api/auth/sso/register', () => { }) it('passes skipDiscovery since Sim already resolved and validated the OIDC endpoints', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] + queueMembers([{ organizationId: 'org1', role: 'owner' }]) const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) expect(res.status).toBe(200) const config = mockRegisterSSOProvider.mock.calls[0][0].body @@ -206,7 +194,7 @@ describe('POST /api/auth/sso/register', () => { }) it('omits userInfoEndpoint when skipUserInfoEndpoint is requested, forcing ID token claims', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] + queueMembers([{ organizationId: 'org1', role: 'owner' }]) const res = await POST(request({ ...OIDC_BODY, skipUserInfoEndpoint: true, orgId: 'org1' })) expect(res.status).toBe(200) const config = mockRegisterSSOProvider.mock.calls[0][0].body @@ -214,7 +202,7 @@ describe('POST /api/auth/sso/register', () => { }) it('does not SSRF-validate userInfoEndpoint when skipUserInfoEndpoint is requested', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] + queueMembers([{ organizationId: 'org1', role: 'owner' }]) mockValidateUrlWithDNS.mockImplementation(async (url: string, label: string) => { if (label === 'OIDC userInfoEndpoint') { return { isValid: false, error: 'resolves to a private IP address' } @@ -228,7 +216,7 @@ describe('POST /api/auth/sso/register', () => { }) it('does not SSRF-validate a discovered userinfo_endpoint when skipUserInfoEndpoint is requested', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] + queueMembers([{ organizationId: 'org1', role: 'owner' }]) mockValidateUrlWithDNS.mockImplementation(async (url: string, label: string) => { if (label === 'OIDC userinfo_endpoint') { return { isValid: false, error: 'resolves to a private IP address' } @@ -258,7 +246,7 @@ describe('POST /api/auth/sso/register', () => { }) it('keeps userInfoEndpoint when skipUserInfoEndpoint is not requested', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] + queueMembers([{ organizationId: 'org1', role: 'owner' }]) const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) expect(res.status).toBe(200) const config = mockRegisterSSOProvider.mock.calls[0][0].body @@ -266,7 +254,7 @@ describe('POST /api/auth/sso/register', () => { }) it('selects tokenEndpointAuthentication from the discovery document when endpoints are auto-discovered', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] + queueMembers([{ organizationId: 'org1', role: 'owner' }]) mockSecureFetchWithPinnedIP.mockResolvedValue({ ok: true, json: async () => ({ @@ -290,7 +278,7 @@ describe('POST /api/auth/sso/register', () => { }) it('still selects tokenEndpointAuthentication from discovery when all endpoints are explicit', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] + queueMembers([{ organizationId: 'org1', role: 'owner' }]) mockSecureFetchWithPinnedIP.mockResolvedValue({ ok: true, json: async () => ({ @@ -305,7 +293,7 @@ describe('POST /api/auth/sso/register', () => { }) it('registers successfully when discovery is unreachable and all endpoints are explicit', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] + queueMembers([{ organizationId: 'org1', role: 'owner' }]) mockSecureFetchWithPinnedIP.mockRejectedValue(new Error('ECONNREFUSED')) const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' })) expect(res.status).toBe(200) @@ -316,7 +304,7 @@ describe('POST /api/auth/sso/register', () => { }) it('prefers client_secret_post over client_secret_basic when an IdP supports both', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] + queueMembers([{ organizationId: 'org1', role: 'owner' }]) mockSecureFetchWithPinnedIP.mockResolvedValue({ ok: true, json: async () => ({ @@ -330,7 +318,7 @@ describe('POST /api/auth/sso/register', () => { }) it('defaults to client_secret_post when discovery advertises no auth methods', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] + queueMembers([{ organizationId: 'org1', role: 'owner' }]) mockSecureFetchWithPinnedIP.mockResolvedValue({ ok: true, json: async () => ({}), @@ -342,7 +330,7 @@ describe('POST /api/auth/sso/register', () => { }) it('surfaces the specific discovery failure reason when endpoints are missing', async () => { - dbState.members = [{ organizationId: 'org1', role: 'owner' }] + queueMembers([{ organizationId: 'org1', role: 'owner' }]) mockValidateUrlWithDNS.mockImplementation(async (url: string, label: string) => { if (label === 'OIDC discovery URL') { return { isValid: false, error: 'resolves to a private IP address' } diff --git a/apps/sim/app/api/billing/invoices/route.test.ts b/apps/sim/app/api/billing/invoices/route.test.ts index 58a95c888ca..db1aa12a181 100644 --- a/apps/sim/app/api/billing/invoices/route.test.ts +++ b/apps/sim/app/api/billing/invoices/route.test.ts @@ -1,29 +1,23 @@ /** * @vitest-environment node */ -import { createMockRequest, dbChainMock, dbChainMockFns } from '@sim/testing' +import { authMockFns, createMockRequest, dbChainMockFns } from '@sim/testing' import { generateShortId } from '@sim/utils/id' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetSession, mockGetStripeClient, mockStripeInvoicesList } = vi.hoisted(() => ({ - mockGetSession: vi.fn(), +const { mockGetStripeClient, mockStripeInvoicesList } = vi.hoisted(() => ({ mockGetStripeClient: vi.fn(), mockStripeInvoicesList: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - -vi.mock('@/lib/auth', () => ({ - auth: { api: { getSession: vi.fn() } }, - getSession: mockGetSession, -})) - vi.mock('@/lib/billing/stripe-client', () => ({ getStripeClient: mockGetStripeClient, })) import { GET } from '@/app/api/billing/invoices/route' +const mockGetSession = authMockFns.mockGetSession + function makeInvoice(overrides: Record = {}) { return { id: `in_${generateShortId()}`, diff --git a/apps/sim/app/api/billing/route.test.ts b/apps/sim/app/api/billing/route.test.ts index ee339be12c8..68d52a9c13e 100644 --- a/apps/sim/app/api/billing/route.test.ts +++ b/apps/sim/app/api/billing/route.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { createMockRequest, dbChainMock, dbChainMockFns } from '@sim/testing' +import { authMockFns, createMockRequest, dbChainMock, dbChainMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -9,24 +9,15 @@ const { mockGetOrganizationBillingData, mockGetOrganizationSubscription, mockGetPersonalBillingSummary, - mockGetSession, mockResolveBillingInterval, } = vi.hoisted(() => ({ mockGetCreditBalanceForEntity: vi.fn(), mockGetOrganizationBillingData: vi.fn(), mockGetOrganizationSubscription: vi.fn(), mockGetPersonalBillingSummary: vi.fn(), - mockGetSession: vi.fn(), mockResolveBillingInterval: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - -vi.mock('@/lib/auth', () => ({ - auth: { api: { getSession: vi.fn() } }, - getSession: mockGetSession, -})) - vi.mock('@/lib/billing/core/billing', () => ({ getOrganizationSubscription: mockGetOrganizationSubscription, getPersonalBillingSummary: mockGetPersonalBillingSummary, @@ -46,6 +37,8 @@ vi.mock('@/lib/billing/credits/balance', () => ({ import { GET } from '@/app/api/billing/route' +const mockGetSession = authMockFns.mockGetSession + const PERSONAL_SUMMARY = { type: 'individual', plan: 'pro_6000', diff --git a/apps/sim/app/api/billing/switch-plan/route.test.ts b/apps/sim/app/api/billing/switch-plan/route.test.ts index 778020298d4..6b1b4d7a814 100644 --- a/apps/sim/app/api/billing/switch-plan/route.test.ts +++ b/apps/sim/app/api/billing/switch-plan/route.test.ts @@ -1,28 +1,21 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { authMockFns, createMockRequest, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockCanManageWorkspaceBilling, mockGetEffectiveBillingStatus, mockGetOrganizationSubscription, - mockGetSession, mockGetWorkspaceHostContextForViewer, } = vi.hoisted(() => ({ mockCanManageWorkspaceBilling: vi.fn(), mockGetEffectiveBillingStatus: vi.fn(), mockGetOrganizationSubscription: vi.fn(), - mockGetSession: vi.fn(), mockGetWorkspaceHostContextForViewer: vi.fn(), })) -vi.mock('@/lib/auth', () => ({ - auth: { api: { getSession: vi.fn() } }, - getSession: mockGetSession, -})) - vi.mock('@/lib/billing/core/access', () => ({ getEffectiveBillingStatus: mockGetEffectiveBillingStatus, })) @@ -48,10 +41,6 @@ vi.mock('@/lib/billing/workspace-permissions', () => ({ canManageWorkspaceBilling: mockCanManageWorkspaceBilling, })) -vi.mock('@/lib/core/config/env-flags', () => ({ - isBillingEnabled: true, -})) - vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn(), })) @@ -62,6 +51,14 @@ vi.mock('@/lib/workspaces/host-context', () => ({ import { POST } from '@/app/api/billing/switch-plan/route' +const mockGetSession = authMockFns.mockGetSession + +beforeAll(() => { + setEnvFlags({ isBillingEnabled: true }) +}) + +afterAll(resetEnvFlagsMock) + describe('POST /api/billing/switch-plan', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/app/api/billing/update-cost/route.test.ts b/apps/sim/app/api/billing/update-cost/route.test.ts index df2a53a61d1..2edb84d2a31 100644 --- a/apps/sim/app/api/billing/update-cost/route.test.ts +++ b/apps/sim/app/api/billing/update-cost/route.test.ts @@ -1,8 +1,8 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createMockRequest, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockCheckInternalApiKey, @@ -15,7 +15,6 @@ const { mockToBillingContext, MockCumulativeUsageContextMismatchError, MockThresholdSettlementError, - billingState, } = vi.hoisted(() => ({ mockCheckInternalApiKey: vi.fn(), mockRecordCumulativeUsage: vi.fn(), @@ -36,10 +35,6 @@ const { this.code = code } }, - billingState: { - isBillingEnabled: true, - isCopilotBillingProtocolRequired: false, - }, })) vi.mock('@/lib/copilot/request/http', () => ({ @@ -82,18 +77,11 @@ vi.mock('@/lib/billing/threshold-billing', () => ({ ThresholdSettlementError: MockThresholdSettlementError, })) -vi.mock('@/lib/core/config/env-flags', () => ({ - get isBillingEnabled() { - return billingState.isBillingEnabled - }, - get isCopilotBillingProtocolRequired() { - return billingState.isCopilotBillingProtocolRequired - }, -})) - import { billingUpdateCostBodySchema } from '@/lib/api/contracts/subscription' import { POST } from '@/app/api/billing/update-cost/route' +afterAll(resetEnvFlagsMock) + const ACCOUNT_BILLING_DECISION = { userId: 'user-1', billingEntity: { type: 'organization' as const, id: 'account-org' }, @@ -159,8 +147,7 @@ const KEYLESS_UPDATE_COST_BODY = { describe('POST /api/billing/update-cost — workspaceId attribution', () => { beforeEach(() => { vi.clearAllMocks() - billingState.isBillingEnabled = true - billingState.isCopilotBillingProtocolRequired = false + setEnvFlags({ isBillingEnabled: true, isCopilotBillingProtocolRequired: false }) mockCheckInternalApiKey.mockReturnValue({ success: true }) mockRecordCumulativeUsage.mockResolvedValue({ billed: true, delta: 0.5, total: 0.5 }) mockCheckAndBillOverageThreshold.mockResolvedValue(undefined) @@ -178,7 +165,7 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => { }) it('returns 401 for a billing-disabled request without valid internal auth', async () => { - billingState.isBillingEnabled = false + setEnvFlags({ isBillingEnabled: false }) mockCheckInternalApiKey.mockReturnValue({ success: false, error: 'Invalid internal API key' }) const res = await POST( @@ -200,7 +187,7 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => { }) it('returns no-op success for old markerless Go when billing is disabled', async () => { - billingState.isBillingEnabled = false + setEnvFlags({ isBillingEnabled: false }) const res = await POST( createMockRequest( @@ -303,7 +290,7 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => { }) it('rejects markerless callbacks only when protocol-required is explicitly enabled', async () => { - billingState.isCopilotBillingProtocolRequired = true + setEnvFlags({ isCopilotBillingProtocolRequired: true }) const res = await POST( createMockRequest('POST', OLD_GO_HOSTED_UPDATE_COST_BODY, { 'x-api-key': 'internal' }) ) @@ -328,7 +315,7 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => { }) it('rejects explicitly labeled legacy callbacks without admission attribution', async () => { - billingState.isCopilotBillingProtocolRequired = true + setEnvFlags({ isCopilotBillingProtocolRequired: true }) const res = await POST( createMockRequest('POST', EXPLICIT_LEGACY_HOSTED_UPDATE_COST_BODY, { 'x-api-key': 'internal', @@ -343,7 +330,7 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => { }) it('bills explicitly labeled legacy callbacks from their admission attribution', async () => { - billingState.isCopilotBillingProtocolRequired = true + setEnvFlags({ isCopilotBillingProtocolRequired: true }) const res = await POST( createMockRequest('POST', EXPLICIT_LEGACY_HOSTED_UPDATE_COST_BODY, { 'x-api-key': 'internal', diff --git a/apps/sim/app/api/blocks/visibility/route.test.ts b/apps/sim/app/api/blocks/visibility/route.test.ts index 1a59df82aa5..3d14370168a 100644 --- a/apps/sim/app/api/blocks/visibility/route.test.ts +++ b/apps/sim/app/api/blocks/visibility/route.test.ts @@ -1,21 +1,17 @@ /** * @vitest-environment node */ +import { authMockFns } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetSession, mockCheckWorkspaceAccess, mockIsPlatformAdmin, mockGetBlockVisibility } = - vi.hoisted(() => ({ - mockGetSession: vi.fn(), +const { mockCheckWorkspaceAccess, mockIsPlatformAdmin, mockGetBlockVisibility } = vi.hoisted( + () => ({ mockCheckWorkspaceAccess: vi.fn(), mockIsPlatformAdmin: vi.fn(), mockGetBlockVisibility: vi.fn(), - })) - -vi.mock('@/lib/auth', () => ({ - auth: { api: { getSession: vi.fn() } }, - getSession: mockGetSession, -})) + }) +) vi.mock('@/lib/workspaces/permissions/utils', () => ({ checkWorkspaceAccess: mockCheckWorkspaceAccess, @@ -31,6 +27,8 @@ vi.mock('@/lib/core/config/block-visibility', () => ({ import { GET } from '@/app/api/blocks/visibility/route' +const mockGetSession = authMockFns.mockGetSession + const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' function request(workspaceId = WORKSPACE_ID) { diff --git a/apps/sim/app/api/chat/[identifier]/otp/route.test.ts b/apps/sim/app/api/chat/[identifier]/otp/route.test.ts index 6d32eb61f6b..1c60db08fa3 100644 --- a/apps/sim/app/api/chat/[identifier]/otp/route.test.ts +++ b/apps/sim/app/api/chat/[identifier]/otp/route.test.ts @@ -4,14 +4,20 @@ * @vitest-environment node */ import { - redisConfigMock, + dbChainMockFns, + envMockFns, + queueTableRows, redisConfigMockFns, requestUtilsMockFns, + resetDbChainMock, + resetEnvMock, + schemaMock, + setEnv, workflowsApiUtilsMock, workflowsApiUtilsMockFns, } from '@sim/testing' import { NextRequest } from 'next/server' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { mockRedisSet, @@ -20,16 +26,11 @@ const { mockRedisTtl, mockRedisEval, mockRedisClient, - mockDbSelect, - mockDbInsert, - mockDbDelete, - mockDbUpdate, mockSendEmail, mockRenderOTPEmail, mockSetChatAuthCookie, mockGetStorageMethod, mockZodParse, - mockGetEnv, } = vi.hoisted(() => { const mockRedisSet = vi.fn() const mockRedisGet = vi.fn() @@ -43,16 +44,11 @@ const { ttl: mockRedisTtl, eval: mockRedisEval, } - const mockDbSelect = vi.fn() - const mockDbInsert = vi.fn() - const mockDbDelete = vi.fn() - const mockDbUpdate = vi.fn() const mockSendEmail = vi.fn() const mockRenderOTPEmail = vi.fn() const mockSetChatAuthCookie = vi.fn() const mockGetStorageMethod = vi.fn() const mockZodParse = vi.fn() - const mockGetEnv = vi.fn() return { mockRedisSet, @@ -61,50 +57,19 @@ const { mockRedisTtl, mockRedisEval, mockRedisClient, - mockDbSelect, - mockDbInsert, - mockDbDelete, - mockDbUpdate, mockSendEmail, mockRenderOTPEmail, mockSetChatAuthCookie, mockGetStorageMethod, mockZodParse, - mockGetEnv, } }) const mockGetRedisClient = redisConfigMockFns.mockGetRedisClient +const mockGetEnv = envMockFns.getEnv const mockCreateSuccessResponse = workflowsApiUtilsMockFns.mockCreateSuccessResponse const mockCreateErrorResponse = workflowsApiUtilsMockFns.mockCreateErrorResponse -vi.mock('@/lib/core/config/redis', () => redisConfigMock) - -vi.mock('@sim/db', () => ({ - db: { - select: mockDbSelect, - insert: mockDbInsert, - delete: mockDbDelete, - update: mockDbUpdate, - transaction: vi.fn(async (callback: (tx: Record) => unknown) => { - return callback({ - select: mockDbSelect, - insert: mockDbInsert, - delete: mockDbDelete, - update: mockDbUpdate, - }) - }), - }, -})) - -vi.mock('drizzle-orm', () => ({ - eq: vi.fn((field: string, value: string) => ({ field, value, type: 'eq' })), - and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })), - gt: vi.fn((field: string, value: string) => ({ field, value, type: 'gt' })), - lt: vi.fn((field: string, value: string) => ({ field, value, type: 'lt' })), - isNull: vi.fn((field: unknown) => ({ field, type: 'isNull' })), -})) - vi.mock('@/lib/core/storage', () => ({ getStorageMethod: mockGetStorageMethod, })) @@ -145,16 +110,6 @@ vi.mock('@/app/api/chat/utils', () => ({ vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock) -vi.mock('@/lib/core/config/env', () => ({ - env: { - NEXT_PUBLIC_APP_URL: 'http://localhost:3000', - NODE_ENV: 'test', - }, - getEnv: mockGetEnv, - isTruthy: vi.fn().mockReturnValue(false), - isFalsy: vi.fn().mockReturnValue(true), -})) - vi.mock('zod', () => { class ZodError extends Error { errors: Array<{ message: string }> @@ -201,8 +156,21 @@ describe('Chat OTP API Route', () => { const mockIdentifier = 'test-chat' const mockOTP = '123456' + /** Queues the chat-deployment row the route reads before touching OTP storage. */ + const queueDeployment = (row: Record) => { + queueTableRows(schemaMock.chat, [row]) + } + + const emailDeployment = { + id: mockChatId, + authType: 'email', + allowedEmails: [mockEmail], + title: 'Test Chat', + } + beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() vi.spyOn(Math, 'random').mockReturnValue(0.123456) vi.spyOn(Date, 'now').mockReturnValue(1640995200000) @@ -218,27 +186,6 @@ describe('Chat OTP API Route', () => { mockRedisDel.mockResolvedValue(1) mockRedisTtl.mockResolvedValue(600) - const createDbChain = (result: unknown) => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue(result), - }), - }), - }) - - mockDbSelect.mockImplementation(() => createDbChain([])) - mockDbInsert.mockImplementation(() => ({ - values: vi.fn().mockResolvedValue(undefined), - })) - mockDbDelete.mockImplementation(() => ({ - where: vi.fn().mockResolvedValue(undefined), - })) - mockDbUpdate.mockImplementation(() => ({ - set: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue(undefined), - }), - })) - mockGetStorageMethod.mockReturnValue('redis') mockSendEmail.mockResolvedValue({ success: true }) @@ -264,6 +211,7 @@ describe('Chat OTP API Route', () => { mockZodParse.mockImplementation((data: unknown) => data) + setEnv({ NEXT_PUBLIC_APP_URL: 'http://localhost:3000', NODE_ENV: 'test' }) mockGetEnv.mockReturnValue('http://localhost:3000') }) @@ -271,26 +219,18 @@ describe('Chat OTP API Route', () => { vi.restoreAllMocks() }) + afterAll(() => { + resetDbChainMock() + resetEnvMock() + }) + describe('POST - Store OTP (Redis path)', () => { beforeEach(() => { mockGetStorageMethod.mockReturnValue('redis') }) it('should store OTP in Redis when storage method is redis', async () => { - mockDbSelect.mockImplementationOnce(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([ - { - id: mockChatId, - authType: 'email', - allowedEmails: [mockEmail], - title: 'Test Chat', - }, - ]), - }), - }), - })) + queueDeployment(emailDeployment) const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'POST', @@ -306,27 +246,11 @@ describe('Chat OTP API Route', () => { 900 // 15 minutes ) - expect(mockDbInsert).not.toHaveBeenCalled() + expect(dbChainMockFns.insert).not.toHaveBeenCalled() }) }) describe('POST - Rate limiting', () => { - const buildDeploymentSelect = () => - mockDbSelect.mockImplementationOnce(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([ - { - id: mockChatId, - authType: 'email', - allowedEmails: [mockEmail], - title: 'Test Chat', - }, - ]), - }), - }), - })) - it('returns 429 with Retry-After when IP rate limit is exceeded', async () => { mockCheckRateLimitDirect.mockResolvedValueOnce({ allowed: false, @@ -354,7 +278,7 @@ describe('Chat OTP API Route', () => { expect(response.status).toBe(429) expect(headerSet).toHaveBeenCalledWith('Retry-After', '900') expect(mockSendEmail).not.toHaveBeenCalled() - expect(mockDbSelect).not.toHaveBeenCalled() + expect(dbChainMockFns.select).not.toHaveBeenCalled() }) it('returns 429 with Retry-After when email rate limit is exceeded', async () => { @@ -378,7 +302,7 @@ describe('Chat OTP API Route', () => { headers: { set: headerSet }, })) - buildDeploymentSelect() + queueDeployment(emailDeployment) const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'POST', @@ -420,7 +344,7 @@ describe('Chat OTP API Route', () => { it('folds spoofed `unknown` client IPs into a single shared bucket', async () => { requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce('unknown') - buildDeploymentSelect() + queueDeployment(emailDeployment) const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'POST', @@ -448,30 +372,7 @@ describe('Chat OTP API Route', () => { }) it('should store OTP in database when storage method is database', async () => { - mockDbSelect.mockImplementationOnce(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([ - { - id: mockChatId, - authType: 'email', - allowedEmails: [mockEmail], - title: 'Test Chat', - }, - ]), - }), - }), - })) - - const mockInsertValues = vi.fn().mockResolvedValue(undefined) - mockDbInsert.mockImplementationOnce(() => ({ - values: mockInsertValues, - })) - - const mockDeleteWhere = vi.fn().mockResolvedValue(undefined) - mockDbDelete.mockImplementation(() => ({ - where: mockDeleteWhere, - })) + queueDeployment(emailDeployment) const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'POST', @@ -480,10 +381,10 @@ describe('Chat OTP API Route', () => { await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) - expect(mockDbDelete).toHaveBeenCalled() + expect(dbChainMockFns.delete).toHaveBeenCalled() - expect(mockDbInsert).toHaveBeenCalled() - expect(mockInsertValues).toHaveBeenCalledWith({ + expect(dbChainMockFns.insert).toHaveBeenCalled() + expect(dbChainMockFns.values).toHaveBeenCalledWith({ id: expect.any(String), identifier: `chat-otp:${mockChatId}:${mockEmail}`, value: expect.any(String), @@ -503,18 +404,7 @@ describe('Chat OTP API Route', () => { }) it('should retrieve OTP from Redis and verify successfully', async () => { - mockDbSelect.mockImplementationOnce(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([ - { - id: mockChatId, - authType: 'email', - }, - ]), - }), - }), - })) + queueDeployment({ id: mockChatId, authType: 'email' }) const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'PUT', @@ -525,7 +415,7 @@ describe('Chat OTP API Route', () => { expect(mockRedisGet).toHaveBeenCalledWith(`otp:${mockEmail}:${mockChatId}`) expect(mockRedisDel).toHaveBeenCalledWith(`otp:${mockEmail}:${mockChatId}`) - expect(mockDbSelect).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.select).toHaveBeenCalledTimes(1) }) }) @@ -536,19 +426,11 @@ describe('Chat OTP API Route', () => { }) it('rejects verification when the chat has switched away from email auth', async () => { - mockDbSelect.mockImplementationOnce(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([ - { - id: mockChatId, - authType: 'password', - password: 'encrypted-password', - }, - ]), - }), - }), - })) + queueDeployment({ + id: mockChatId, + authType: 'password', + password: 'encrypted-password', + }) const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'PUT', @@ -573,36 +455,13 @@ describe('Chat OTP API Route', () => { }) it('should retrieve OTP from database and verify successfully', async () => { - let selectCallCount = 0 - - mockDbSelect.mockImplementation(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockImplementation(() => { - selectCallCount++ - if (selectCallCount === 1) { - return Promise.resolve([ - { - id: mockChatId, - authType: 'email', - }, - ]) - } - return Promise.resolve([ - { - value: `${mockOTP}:0`, - expiresAt: new Date(Date.now() + 10 * 60 * 1000), - }, - ]) - }), - }), - }), - })) - - const mockDeleteWhere = vi.fn().mockResolvedValue(undefined) - mockDbDelete.mockImplementation(() => ({ - where: mockDeleteWhere, - })) + queueDeployment({ id: mockChatId, authType: 'email' }) + queueTableRows(schemaMock.verification, [ + { + value: `${mockOTP}:0`, + expiresAt: new Date(Date.now() + 10 * 60 * 1000), + }, + ]) const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'PUT', @@ -611,34 +470,16 @@ describe('Chat OTP API Route', () => { await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) - expect(mockDbSelect).toHaveBeenCalledTimes(2) + expect(dbChainMockFns.select).toHaveBeenCalledTimes(2) - expect(mockDbDelete).toHaveBeenCalled() + expect(dbChainMockFns.delete).toHaveBeenCalled() expect(mockRedisGet).not.toHaveBeenCalled() }) it('should reject expired OTP from database', async () => { - let selectCallCount = 0 - - mockDbSelect.mockImplementation(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockImplementation(() => { - selectCallCount++ - if (selectCallCount === 1) { - return Promise.resolve([ - { - id: mockChatId, - authType: 'email', - }, - ]) - } - return Promise.resolve([]) - }), - }), - }), - })) + queueDeployment({ id: mockChatId, authType: 'email' }) + queueTableRows(schemaMock.verification, []) const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'PUT', @@ -662,18 +503,7 @@ describe('Chat OTP API Route', () => { it('should delete OTP from Redis after verification', async () => { mockRedisGet.mockResolvedValue(`${mockOTP}:0`) - mockDbSelect.mockImplementationOnce(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([ - { - id: mockChatId, - authType: 'email', - }, - ]), - }), - }), - })) + queueDeployment({ id: mockChatId, authType: 'email' }) const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'PUT', @@ -683,7 +513,7 @@ describe('Chat OTP API Route', () => { await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) expect(mockRedisDel).toHaveBeenCalledWith(`otp:${mockEmail}:${mockChatId}`) - expect(mockDbDelete).not.toHaveBeenCalled() + expect(dbChainMockFns.delete).not.toHaveBeenCalled() }) }) @@ -694,27 +524,10 @@ describe('Chat OTP API Route', () => { }) it('should delete OTP from database after verification', async () => { - let selectCallCount = 0 - mockDbSelect.mockImplementation(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockImplementation(() => { - selectCallCount++ - if (selectCallCount === 1) { - return Promise.resolve([{ id: mockChatId, authType: 'email' }]) - } - return Promise.resolve([ - { value: `${mockOTP}:0`, expiresAt: new Date(Date.now() + 10 * 60 * 1000) }, - ]) - }), - }), - }), - })) - - const mockDeleteWhere = vi.fn().mockResolvedValue(undefined) - mockDbDelete.mockImplementation(() => ({ - where: mockDeleteWhere, - })) + queueDeployment({ id: mockChatId, authType: 'email' }) + queueTableRows(schemaMock.verification, [ + { value: `${mockOTP}:0`, expiresAt: new Date(Date.now() + 10 * 60 * 1000) }, + ]) const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'PUT', @@ -723,7 +536,7 @@ describe('Chat OTP API Route', () => { await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) }) - expect(mockDbDelete).toHaveBeenCalled() + expect(dbChainMockFns.delete).toHaveBeenCalled() expect(mockRedisDel).not.toHaveBeenCalled() }) }) @@ -737,13 +550,7 @@ describe('Chat OTP API Route', () => { mockRedisGet.mockResolvedValue('654321:0') mockRedisEval.mockResolvedValue('654321:1') - mockDbSelect.mockImplementationOnce(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([{ id: mockChatId, authType: 'email' }]), - }), - }), - })) + queueDeployment({ id: mockChatId, authType: 'email' }) const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'PUT', @@ -765,13 +572,7 @@ describe('Chat OTP API Route', () => { mockRedisGet.mockResolvedValue('654321:4') mockRedisEval.mockResolvedValue('LOCKED') - mockDbSelect.mockImplementationOnce(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([{ id: mockChatId, authType: 'email' }]), - }), - }), - })) + queueDeployment({ id: mockChatId, authType: 'email' }) const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'PUT', @@ -788,20 +589,7 @@ describe('Chat OTP API Route', () => { }) it('should store OTP with zero attempts on generation', async () => { - mockDbSelect.mockImplementationOnce(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([ - { - id: mockChatId, - authType: 'email', - allowedEmails: [mockEmail], - title: 'Test Chat', - }, - ]), - }), - }), - })) + queueDeployment(emailDeployment) const request = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'POST', @@ -824,13 +612,7 @@ describe('Chat OTP API Route', () => { mockGetStorageMethod.mockReturnValue('redis') mockRedisGet.mockResolvedValue(null) - mockDbSelect.mockImplementation(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([{ id: mockChatId, authType: 'email' }]), - }), - }), - })) + queueDeployment({ id: mockChatId, authType: 'email' }) const requestRedis = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'PUT', @@ -850,20 +632,7 @@ describe('Chat OTP API Route', () => { mockGetStorageMethod.mockReturnValue('redis') - mockDbSelect.mockImplementation(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([ - { - id: mockChatId, - authType: 'email', - allowedEmails: [mockEmail], - title: 'Test Chat', - }, - ]), - }), - }), - })) + queueDeployment(emailDeployment) const requestRedis = new NextRequest('http://localhost:3000/api/chat/test/otp', { method: 'POST', diff --git a/apps/sim/app/api/chat/manage/[id]/route.test.ts b/apps/sim/app/api/chat/manage/[id]/route.test.ts index bc765862b4c..bedfbece4a6 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.test.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.test.ts @@ -6,11 +6,14 @@ import { auditMock, authMockFns, - dbChainMock, dbChainMockFns, encryptionMock, encryptionMockFns, resetDbChainMock, + resetEnvFlagsMock, + resetEnvMock, + setEnv, + setEnvFlags, workflowsApiUtilsMock, workflowsApiUtilsMockFns, workflowsOrchestrationMock, @@ -18,7 +21,7 @@ import { workflowsPersistenceUtilsMock, } from '@sim/testing' import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockCheckChatAccess, mockValidateChatDeployAuth } = vi.hoisted(() => ({ mockCheckChatAccess: vi.fn(), @@ -37,17 +40,8 @@ const mockNotifySocketDeploymentChanged = workflowsOrchestrationMockFns.mockNotifySocketDeploymentChanged vi.mock('@sim/audit', () => auditMock) -vi.mock('@/lib/core/config/env-flags', () => ({ - isDev: true, - isHosted: false, - isProd: false, -})) -vi.mock('@sim/db', () => dbChainMock) vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock) vi.mock('@/lib/core/security/encryption', () => encryptionMock) -vi.mock('@/lib/core/utils/urls', () => ({ - getEmailDomain: vi.fn().mockReturnValue('localhost:3000'), -})) vi.mock('@/app/api/chat/utils', () => ({ checkChatAccess: mockCheckChatAccess, })) @@ -66,6 +60,16 @@ vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock) import { DELETE, GET, PATCH } from '@/app/api/chat/manage/[id]/route' import { ChatDeployAuthNotAllowedError } from '@/ee/access-control/utils/permission-check' +beforeAll(() => { + setEnvFlags({ isDev: true }) + setEnv({ NEXT_PUBLIC_APP_URL: 'http://localhost:3000' }) +}) + +afterAll(() => { + resetEnvFlagsMock() + resetEnvMock() +}) + describe('Chat Edit API Route', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/app/api/chat/route.test.ts b/apps/sim/app/api/chat/route.test.ts index d85f62bd2b4..ab4ebded2cf 100644 --- a/apps/sim/app/api/chat/route.test.ts +++ b/apps/sim/app/api/chat/route.test.ts @@ -5,16 +5,16 @@ */ import { authMockFns, - createEnvMock, - dbChainMock, dbChainMockFns, + resetEnvMock, + setEnv, workflowsApiUtilsMock, workflowsApiUtilsMockFns, workflowsOrchestrationMock, workflowsOrchestrationMockFns, } from '@sim/testing' import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockCheckWorkflowAccessForChatCreation, mockValidateChatDeployAuth } = vi.hoisted(() => ({ mockCheckWorkflowAccessForChatCreation: vi.fn(), @@ -25,8 +25,6 @@ const mockCreateSuccessResponse = workflowsApiUtilsMockFns.mockCreateSuccessResp const mockCreateErrorResponse = workflowsApiUtilsMockFns.mockCreateErrorResponse const mockPerformChatDeploy = workflowsOrchestrationMockFns.mockPerformChatDeploy -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock) vi.mock('@/app/api/chat/utils', () => ({ @@ -45,19 +43,17 @@ vi.mock('@/ee/access-control/utils/permission-check', () => { vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock) -vi.mock('@/lib/core/config/env', () => - createEnvMock({ - NODE_ENV: 'development', - NEXT_PUBLIC_APP_URL: 'http://localhost:3000', - }) -) - import { GET, POST } from '@/app/api/chat/route' import { ChatDeployAuthNotAllowedError } from '@/ee/access-control/utils/permission-check' describe('Chat API Route', () => { + afterAll(() => { + resetEnvMock() + }) + beforeEach(() => { vi.clearAllMocks() + setEnv({ NODE_ENV: 'development', NEXT_PUBLIC_APP_URL: 'http://localhost:3000' }) mockCreateSuccessResponse.mockImplementation((data) => { return new Response(JSON.stringify(data), { diff --git a/apps/sim/app/api/chat/utils.test.ts b/apps/sim/app/api/chat/utils.test.ts index 4e457c1181d..6c41eeb21cc 100644 --- a/apps/sim/app/api/chat/utils.test.ts +++ b/apps/sim/app/api/chat/utils.test.ts @@ -4,7 +4,7 @@ * @vitest-environment node */ import { - dbChainMock, + authMockFns, encryptionMock, encryptionMockFns, loggingSessionMock, @@ -19,7 +19,6 @@ const { mockValidateAuthToken, mockSetDeploymentAuthCookie, mockIsEmailAllowed, - mockGetSession, mockCheckRateLimitDirect, } = vi.hoisted(() => ({ mockMergeSubblockStateWithValues: vi.fn().mockReturnValue({}), @@ -27,23 +26,15 @@ const { mockValidateAuthToken: vi.fn().mockReturnValue(false), mockSetDeploymentAuthCookie: vi.fn(), mockIsEmailAllowed: vi.fn(), - mockGetSession: vi.fn(), mockCheckRateLimitDirect: vi.fn().mockResolvedValue({ allowed: true }), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/core/rate-limiter', () => ({ RateLimiter: class { checkRateLimitDirect = mockCheckRateLimitDirect }, })) -vi.mock('@/lib/auth', () => ({ - auth: { api: { getSession: vi.fn() } }, - getSession: mockGetSession, -})) - const mockDecryptSecret = encryptionMockFns.mockDecryptSecret vi.mock('@/lib/logs/execution/logging-session', () => loggingSessionMock) @@ -75,6 +66,8 @@ vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) import { decryptSecret } from '@/lib/core/security/encryption' import { setChatAuthCookie, validateChatAuth } from '@/app/api/chat/utils' +const mockGetSession = authMockFns.mockGetSession + describe('Chat API Utils', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/app/api/copilot/api-keys/route.test.ts b/apps/sim/app/api/copilot/api-keys/route.test.ts index e6c4b6cab60..d5bb8b49a1f 100644 --- a/apps/sim/app/api/copilot/api-keys/route.test.ts +++ b/apps/sim/app/api/copilot/api-keys/route.test.ts @@ -3,9 +3,9 @@ * * @vitest-environment node */ -import { authMockFns, createEnvMock } from '@sim/testing' +import { authMockFns, resetEnvMock, setEnv } from '@sim/testing' import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockFetch, mockGetMothershipBaseURL } = vi.hoisted(() => ({ mockFetch: vi.fn(), @@ -23,8 +23,6 @@ vi.mock('@/lib/copilot/server/agent-url', () => ({ getMothershipBaseURL: mockGetMothershipBaseURL, })) -vi.mock('@/lib/core/config/env', () => createEnvMock({ COPILOT_API_KEY: 'test-api-key' })) - import { DELETE, GET } from '@/app/api/copilot/api-keys/route' // `fetchGo` reads `response.status` and `response.headers.get('content-length')` @@ -46,10 +44,15 @@ function buildMockResponse(init: { describe('Copilot API Keys API Route', () => { beforeEach(() => { vi.clearAllMocks() + setEnv({ COPILOT_API_KEY: 'test-api-key' }) mockGetMothershipBaseURL.mockResolvedValue('https://agent.sim.example.com') global.fetch = mockFetch }) + afterAll(() => { + resetEnvMock() + }) + describe('GET', () => { it('should return 401 when user is not authenticated', async () => { authMockFns.mockGetSession.mockResolvedValue(null) diff --git a/apps/sim/app/api/copilot/api-keys/validate/route.test.ts b/apps/sim/app/api/copilot/api-keys/validate/route.test.ts index ea9c68b2b58..d0faa279153 100644 --- a/apps/sim/app/api/copilot/api-keys/validate/route.test.ts +++ b/apps/sim/app/api/copilot/api-keys/validate/route.test.ts @@ -1,11 +1,17 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + createMockRequest, + queueTableRows, + resetDbChainMock, + resetEnvFlagsMock, + schemaMock, + setEnvFlags, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { - mockDbLimit, mockCheckInternalApiKey, mockCheckAttributedUsageLimits, mockCheckServerSideUsageLimits, @@ -19,9 +25,7 @@ const { mockSerializeBillingAttributionHeader, mockGetUserEntityPermissions, mockGetWorkspaceBillingSettings, - mockFlags, } = vi.hoisted(() => ({ - mockDbLimit: vi.fn(), mockCheckInternalApiKey: vi.fn(), mockCheckAttributedUsageLimits: vi.fn(), mockCheckServerSideUsageLimits: vi.fn(), @@ -35,9 +39,6 @@ const { mockSerializeBillingAttributionHeader: vi.fn(), mockGetUserEntityPermissions: vi.fn(), mockGetWorkspaceBillingSettings: vi.fn(), - mockFlags: { - isCopilotBillingProtocolRequired: false, - }, })) const ATTRIBUTION = { @@ -77,12 +78,6 @@ const OLD_GO_OPAQUE_WORKSPACE_VALIDATE_BODY = { workspaceId: 'local-self-hosted-workspace', } as const -vi.mock('@sim/db', () => ({ - db: { - select: () => ({ from: () => ({ where: () => ({ limit: mockDbLimit }) }) }), - }, -})) - vi.mock('@/lib/billing/core/billing-attribution', () => ({ BILLING_ACCOUNT_DECISION_HEADER: 'x-sim-billing-account-decision', BILLING_ATTRIBUTION_HEADER: 'x-sim-billing-attribution', @@ -127,12 +122,6 @@ vi.mock('@/lib/copilot/request/otel', () => ({ ) => fn({ setAttribute: vi.fn(), setAttributes: vi.fn() }), })) -vi.mock('@/lib/core/config/env-flags', () => ({ - get isCopilotBillingProtocolRequired() { - return mockFlags.isCopilotBillingProtocolRequired - }, -})) - vi.mock('@/lib/workspaces/permissions/utils', () => ({ getUserEntityPermissions: mockGetUserEntityPermissions, })) @@ -144,6 +133,8 @@ vi.mock('@/lib/workspaces/utils', () => ({ import { validateCopilotApiKeyBodySchema } from '@/lib/api/contracts/copilot' import { POST } from '@/app/api/copilot/api-keys/validate/route' +afterAll(resetEnvFlagsMock) + function request(body: Record, headers: Record = {}) { return createMockRequest('POST', body, { 'x-api-key': 'internal', ...headers }) } @@ -151,9 +142,10 @@ function request(body: Record, headers: Record describe('POST /api/copilot/api-keys/validate billing protocols', () => { beforeEach(() => { vi.clearAllMocks() - mockFlags.isCopilotBillingProtocolRequired = false + resetDbChainMock() + setEnvFlags({ isCopilotBillingProtocolRequired: false }) mockCheckInternalApiKey.mockReturnValue({ success: true }) - mockDbLimit.mockResolvedValue([{ id: 'user-1' }]) + queueTableRows(schemaMock.user, [{ id: 'user-1' }]) mockResolveBillingAttribution.mockResolvedValue(ATTRIBUTION) mockResolveLegacyV0BillingAttribution.mockResolvedValue(ATTRIBUTION) mockSerializeBillingAttributionHeader.mockReturnValue('serialized-attribution') @@ -193,6 +185,10 @@ describe('POST /api/copilot/api-keys/validate billing protocols', () => { }) }) + afterAll(() => { + resetDbChainMock() + }) + it('keeps the exact old-Go validate bodies contract-compatible', () => { expect(validateCopilotApiKeyBodySchema.safeParse(OLD_GO_HOSTED_VALIDATE_BODY).success).toBe( true @@ -261,7 +257,7 @@ describe('POST /api/copilot/api-keys/validate billing protocols', () => { }) it('rejects markerless admission only when protocol-required is explicitly enabled', async () => { - mockFlags.isCopilotBillingProtocolRequired = true + setEnvFlags({ isCopilotBillingProtocolRequired: true }) const res = await POST(request(OLD_GO_HOSTED_VALIDATE_BODY)) expect(res.status).toBe(400) @@ -270,7 +266,7 @@ describe('POST /api/copilot/api-keys/validate billing protocols', () => { }) it('allows explicitly labeled legacy requests when markerless traffic is disabled', async () => { - mockFlags.isCopilotBillingProtocolRequired = true + setEnvFlags({ isCopilotBillingProtocolRequired: true }) const res = await POST( request(OLD_GO_HOSTED_VALIDATE_BODY, { 'x-sim-billing-protocol': 'legacy-v0' }) ) diff --git a/apps/sim/app/api/copilot/chat/delete/route.test.ts b/apps/sim/app/api/copilot/chat/delete/route.test.ts index df25c952463..6783fdbd265 100644 --- a/apps/sim/app/api/copilot/chat/delete/route.test.ts +++ b/apps/sim/app/api/copilot/chat/delete/route.test.ts @@ -3,7 +3,7 @@ * * @vitest-environment node */ -import { authMockFns, dbChainMock, dbChainMockFns } from '@sim/testing' +import { authMockFns, dbChainMockFns } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -19,8 +19,6 @@ const { mockGetAccessibleCopilotChatAuth: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/copilot/chat/lifecycle', () => ({ getAccessibleCopilotChat: mockGetAccessibleCopilotChat, getAccessibleCopilotChatAuth: mockGetAccessibleCopilotChatAuth, diff --git a/apps/sim/app/api/copilot/chat/stop/route.test.ts b/apps/sim/app/api/copilot/chat/stop/route.test.ts index e734cd9cde4..7c35ca8d355 100644 --- a/apps/sim/app/api/copilot/chat/stop/route.test.ts +++ b/apps/sim/app/api/copilot/chat/stop/route.test.ts @@ -1,12 +1,10 @@ /** * @vitest-environment node */ -import { authMockFns, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { authMockFns, dbChainMockFns, resetDbChainMock } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@sim/db', () => dbChainMock) - const { mockAppendCopilotChatMessages, mockPublishStatusChanged } = vi.hoisted(() => ({ mockAppendCopilotChatMessages: vi.fn(), mockPublishStatusChanged: vi.fn(), diff --git a/apps/sim/app/api/copilot/chat/update-messages/route.test.ts b/apps/sim/app/api/copilot/chat/update-messages/route.test.ts index b6ec71a7a9a..ab842b87480 100644 --- a/apps/sim/app/api/copilot/chat/update-messages/route.test.ts +++ b/apps/sim/app/api/copilot/chat/update-messages/route.test.ts @@ -3,52 +3,24 @@ * * @vitest-environment node */ -import { authMockFns } from '@sim/testing' +import { + authMockFns, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' import { NextRequest } from 'next/server' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockSelect, - mockFrom, - mockWhere, - mockLimit, - mockUpdate, - mockSet, - mockUpdateWhere, - mockReturning, - mockReplaceCopilotChatMessages, -} = vi.hoisted(() => ({ - mockSelect: vi.fn(), - mockFrom: vi.fn(), - mockWhere: vi.fn(), - mockLimit: vi.fn(), - mockUpdate: vi.fn(), - mockSet: vi.fn(), - mockUpdateWhere: vi.fn(), - mockReturning: vi.fn(), - mockReplaceCopilotChatMessages: vi.fn(), -})) +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@sim/db', () => ({ - db: { - select: mockSelect, - update: mockUpdate, - transaction: async ( - cb: (tx: { update: typeof mockUpdate; select: typeof mockSelect }) => unknown - ) => cb({ update: mockUpdate, select: mockSelect }), - }, +const { mockReplaceCopilotChatMessages } = vi.hoisted(() => ({ + mockReplaceCopilotChatMessages: vi.fn(), })) vi.mock('@/lib/copilot/chat/messages-store', () => ({ replaceCopilotChatMessages: mockReplaceCopilotChatMessages, })) -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })), - eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), - isNull: vi.fn((field: unknown) => ({ field, type: 'isNull' })), -})) - import { POST } from '@/app/api/copilot/chat/update-messages/route' function createMockRequest(method: string, body: Record): NextRequest { @@ -62,21 +34,15 @@ function createMockRequest(method: string, body: Record): NextR describe('Copilot Chat Update Messages API Route', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() authMockFns.mockGetSession.mockResolvedValue(null) - mockSelect.mockReturnValue({ from: mockFrom }) - mockFrom.mockReturnValue({ where: mockWhere }) - mockWhere.mockReturnValue({ limit: mockLimit }) - mockLimit.mockResolvedValue([]) - mockUpdate.mockReturnValue({ set: mockSet }) - mockSet.mockReturnValue({ where: mockUpdateWhere }) - mockUpdateWhere.mockReturnValue({ returning: mockReturning }) - mockReturning.mockResolvedValue([{ model: 'gpt-4' }]) + dbChainMockFns.returning.mockResolvedValue([{ model: 'gpt-4' }]) }) - afterEach(() => { - vi.restoreAllMocks() + afterAll(() => { + resetDbChainMock() }) describe('POST', () => { @@ -181,7 +147,7 @@ describe('Copilot Chat Update Messages API Route', () => { it('should return 404 when chat is not found', async () => { authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - mockLimit.mockResolvedValueOnce([]) + queueTableRows(schemaMock.copilotChats, []) const req = createMockRequest('POST', { chatId: 'non-existent-chat', @@ -205,7 +171,7 @@ describe('Copilot Chat Update Messages API Route', () => { it('should return 404 when chat belongs to different user', async () => { authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - mockLimit.mockResolvedValueOnce([]) + queueTableRows(schemaMock.copilotChats, []) const req = createMockRequest('POST', { chatId: 'other-user-chat', @@ -234,7 +200,7 @@ describe('Copilot Chat Update Messages API Route', () => { userId: 'user-123', messages: [], } - mockLimit.mockResolvedValueOnce([existingChat]) + queueTableRows(schemaMock.copilotChats, [existingChat]) const messages = [ { @@ -265,9 +231,9 @@ describe('Copilot Chat Update Messages API Route', () => { messageCount: 2, }) - expect(mockSelect).toHaveBeenCalled() - expect(mockUpdate).toHaveBeenCalled() - expect(mockSet).toHaveBeenCalledWith({ updatedAt: expect.any(Date) }) + expect(dbChainMockFns.select).toHaveBeenCalled() + expect(dbChainMockFns.update).toHaveBeenCalled() + expect(dbChainMockFns.set).toHaveBeenCalledWith({ updatedAt: expect.any(Date) }) expect(mockReplaceCopilotChatMessages).toHaveBeenCalledWith( 'chat-123', messages, @@ -284,7 +250,7 @@ describe('Copilot Chat Update Messages API Route', () => { userId: 'user-123', messages: [], } - mockLimit.mockResolvedValueOnce([existingChat]) + queueTableRows(schemaMock.copilotChats, [existingChat]) const messages = [ { @@ -328,7 +294,7 @@ describe('Copilot Chat Update Messages API Route', () => { messageCount: 2, }) - expect(mockSet).toHaveBeenCalledWith({ updatedAt: expect.any(Date) }) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ updatedAt: expect.any(Date) }) expect(mockReplaceCopilotChatMessages).toHaveBeenCalledWith( 'chat-456', [ @@ -373,7 +339,7 @@ describe('Copilot Chat Update Messages API Route', () => { userId: 'user-123', messages: [], } - mockLimit.mockResolvedValueOnce([existingChat]) + queueTableRows(schemaMock.copilotChats, [existingChat]) const req = createMockRequest('POST', { chatId: 'chat-789', @@ -389,7 +355,7 @@ describe('Copilot Chat Update Messages API Route', () => { messageCount: 0, }) - expect(mockSet).toHaveBeenCalledWith({ updatedAt: expect.any(Date) }) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ updatedAt: expect.any(Date) }) expect(mockReplaceCopilotChatMessages).toHaveBeenCalledWith( 'chat-789', [], @@ -401,7 +367,7 @@ describe('Copilot Chat Update Messages API Route', () => { it('should handle database errors during chat lookup', async () => { authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - mockLimit.mockRejectedValueOnce(new Error('Database connection failed')) + dbChainMockFns.limit.mockRejectedValueOnce(new Error('Database connection failed')) const req = createMockRequest('POST', { chatId: 'chat-123', @@ -430,11 +396,9 @@ describe('Copilot Chat Update Messages API Route', () => { userId: 'user-123', messages: [], } - mockLimit.mockResolvedValueOnce([existingChat]) + queueTableRows(schemaMock.copilotChats, [existingChat]) - mockSet.mockReturnValueOnce({ - where: vi.fn().mockRejectedValue(new Error('Update operation failed')), - }) + dbChainMockFns.returning.mockRejectedValueOnce(new Error('Update operation failed')) const req = createMockRequest('POST', { chatId: 'chat-123', @@ -481,7 +445,7 @@ describe('Copilot Chat Update Messages API Route', () => { userId: 'user-123', messages: [], } - mockLimit.mockResolvedValueOnce([existingChat]) + queueTableRows(schemaMock.copilotChats, [existingChat]) const messages = Array.from({ length: 100 }, (_, i) => ({ id: `msg-${i + 1}`, @@ -504,7 +468,7 @@ describe('Copilot Chat Update Messages API Route', () => { messageCount: 100, }) - expect(mockSet).toHaveBeenCalledWith({ updatedAt: expect.any(Date) }) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ updatedAt: expect.any(Date) }) expect(mockReplaceCopilotChatMessages).toHaveBeenCalledWith( 'chat-large', messages, @@ -521,7 +485,7 @@ describe('Copilot Chat Update Messages API Route', () => { userId: 'user-123', messages: [], } - mockLimit.mockResolvedValueOnce([existingChat]) + queueTableRows(schemaMock.copilotChats, [existingChat]) const messages = [ { diff --git a/apps/sim/app/api/copilot/chats/route.test.ts b/apps/sim/app/api/copilot/chats/route.test.ts index 2ce59e2a41d..b0d55f718a6 100644 --- a/apps/sim/app/api/copilot/chats/route.test.ts +++ b/apps/sim/app/api/copilot/chats/route.test.ts @@ -3,32 +3,15 @@ * * @vitest-environment node */ -import { copilotHttpMock, copilotHttpMockFns } from '@sim/testing' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockSelectDistinctOn, mockFrom, mockLeftJoin, mockWhere, mockOrderBy } = vi.hoisted(() => ({ - mockSelectDistinctOn: vi.fn(), - mockFrom: vi.fn(), - mockLeftJoin: vi.fn(), - mockWhere: vi.fn(), - mockOrderBy: vi.fn(), -})) - -vi.mock('@sim/db', () => ({ - db: { - selectDistinctOn: mockSelectDistinctOn, - }, -})) - -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })), - eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), - or: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'or' })), - inArray: vi.fn((field: unknown, values: unknown) => ({ field, values, type: 'inArray' })), - isNull: vi.fn((field: unknown) => ({ field, type: 'isNull' })), - desc: vi.fn((field: unknown) => ({ field, type: 'desc' })), - sql: vi.fn(), -})) +import { + copilotHttpMock, + copilotHttpMockFns, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) @@ -45,16 +28,11 @@ import { GET } from '@/app/api/copilot/chats/route' describe('Copilot Chats List API Route', () => { beforeEach(() => { vi.clearAllMocks() - - mockSelectDistinctOn.mockReturnValue({ from: mockFrom }) - mockFrom.mockReturnValue({ leftJoin: mockLeftJoin }) - mockLeftJoin.mockReturnValue({ leftJoin: mockLeftJoin, where: mockWhere }) - mockWhere.mockReturnValue({ orderBy: mockOrderBy }) - mockOrderBy.mockResolvedValue([]) + resetDbChainMock() }) - afterEach(() => { - vi.restoreAllMocks() + afterAll(() => { + resetDbChainMock() }) describe('GET', () => { @@ -78,7 +56,7 @@ describe('Copilot Chats List API Route', () => { isAuthenticated: true, }) - mockOrderBy.mockResolvedValueOnce([]) + queueTableRows(schemaMock.copilotChats, []) const request = new Request('http://localhost:3000/api/copilot/chats') const response = await GET(request as any) @@ -111,7 +89,7 @@ describe('Copilot Chats List API Route', () => { updatedAt: new Date('2024-01-01'), }, ] - mockOrderBy.mockResolvedValueOnce(mockChats) + queueTableRows(schemaMock.copilotChats, mockChats) const request = new Request('http://localhost:3000/api/copilot/chats') const response = await GET(request as any) @@ -151,7 +129,7 @@ describe('Copilot Chats List API Route', () => { updatedAt: new Date('2024-01-01'), }, ] - mockOrderBy.mockResolvedValueOnce(mockChats) + queueTableRows(schemaMock.copilotChats, mockChats) const request = new Request('http://localhost:3000/api/copilot/chats') const response = await GET(request as any) @@ -176,7 +154,7 @@ describe('Copilot Chats List API Route', () => { updatedAt: new Date('2024-01-01'), }, ] - mockOrderBy.mockResolvedValueOnce(mockChats) + queueTableRows(schemaMock.copilotChats, mockChats) const request = new Request('http://localhost:3000/api/copilot/chats') const response = await GET(request as any) @@ -192,7 +170,7 @@ describe('Copilot Chats List API Route', () => { isAuthenticated: true, }) - mockOrderBy.mockRejectedValueOnce(new Error('Database connection failed')) + dbChainMockFns.orderBy.mockRejectedValueOnce(new Error('Database connection failed')) const request = new Request('http://localhost:3000/api/copilot/chats') const response = await GET(request as any) @@ -216,13 +194,13 @@ describe('Copilot Chats List API Route', () => { updatedAt: new Date('2024-01-01'), }, ] - mockOrderBy.mockResolvedValueOnce(mockChats) + queueTableRows(schemaMock.copilotChats, mockChats) const request = new Request('http://localhost:3000/api/copilot/chats') await GET(request as any) - expect(mockSelectDistinctOn).toHaveBeenCalled() - expect(mockWhere).toHaveBeenCalled() + expect(dbChainMockFns.selectDistinctOn).toHaveBeenCalled() + expect(dbChainMockFns.where).toHaveBeenCalled() }) it('should return 401 when userId is null despite isAuthenticated being true', async () => { diff --git a/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts b/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts index 9ce957da1ef..785718e9ba2 100644 --- a/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts +++ b/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts @@ -3,33 +3,22 @@ * * @vitest-environment node */ -import { authMockFns, workflowAuthzMockFns, workflowsUtilsMock } from '@sim/testing' +import { + authMockFns, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + resetEnvMock, + schemaMock, + setEnv, + workflowAuthzMockFns, + workflowsUtilsMock, +} from '@sim/testing' import { NextRequest } from 'next/server' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockSelect, - mockFrom, - mockWhere, - mockThen, - mockDelete, - mockDeleteWhere, - mockGetAccessibleCopilotChat, -} = vi.hoisted(() => ({ - mockSelect: vi.fn(), - mockFrom: vi.fn(), - mockWhere: vi.fn(), - mockThen: vi.fn(), - mockDelete: vi.fn(), - mockDeleteWhere: vi.fn(), - mockGetAccessibleCopilotChat: vi.fn(), -})) +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@/lib/core/utils/urls', () => ({ - getBaseUrl: vi.fn(() => 'http://localhost:3000'), - getInternalApiBaseUrl: vi.fn(() => 'http://localhost:3000'), - getBaseDomain: vi.fn(() => 'localhost:3000'), - getEmailDomain: vi.fn(() => 'localhost:3000'), +const { mockGetAccessibleCopilotChat } = vi.hoisted(() => ({ + mockGetAccessibleCopilotChat: vi.fn(), })) vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) @@ -39,28 +28,13 @@ vi.mock('@/lib/copilot/chat/lifecycle', () => ({ getAccessibleCopilotChatAuth: mockGetAccessibleCopilotChat, })) -vi.mock('@sim/db', () => ({ - db: { - select: mockSelect, - delete: mockDelete, - }, -})) - -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })), - eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), -})) - import { POST } from '@/app/api/copilot/checkpoints/revert/route' describe('Copilot Checkpoints Revert API Route', () => { - /** Queued results for successive `.then()` calls in the db select chain */ - let thenResults: unknown[] - beforeEach(() => { vi.clearAllMocks() - - thenResults = [] + resetDbChainMock() + setEnv({ NEXT_PUBLIC_APP_URL: 'http://localhost:3000' }) authMockFns.mockGetSession.mockResolvedValue(null) @@ -69,24 +43,6 @@ describe('Copilot Checkpoints Revert API Route', () => { status: 200, }) - mockSelect.mockReturnValue({ from: mockFrom }) - mockFrom.mockReturnValue({ where: mockWhere }) - mockWhere.mockReturnValue({ then: mockThen }) - - // Drizzle's .then() is a thenable: it receives a callback like (rows) => rows[0]. - // We invoke the callback with our mock rows array so the route gets the expected value. - mockThen.mockImplementation((callback: (rows: unknown[]) => unknown) => { - const result = thenResults.shift() - if (result instanceof Error) { - return Promise.reject(result) - } - const rows = result === undefined ? [] : [result] - return Promise.resolve(callback(rows)) - }) - - // Mock delete chain - mockDelete.mockReturnValue({ where: mockDeleteWhere }) - mockDeleteWhere.mockResolvedValue(undefined) mockGetAccessibleCopilotChat.mockResolvedValue({ id: 'chat-123', userId: 'user-123' }) global.fetch = vi.fn() @@ -114,10 +70,14 @@ describe('Copilot Checkpoints Revert API Route', () => { }) afterEach(() => { - vi.clearAllMocks() vi.restoreAllMocks() }) + afterAll(() => { + resetDbChainMock() + resetEnvMock() + }) + /** Helper to set authenticated state */ function setAuthenticated(user = { id: 'user-123', email: 'test@example.com' }) { authMockFns.mockGetSession.mockResolvedValue({ user }) @@ -180,8 +140,7 @@ describe('Copilot Checkpoints Revert API Route', () => { it('should return 404 when checkpoint is not found', async () => { setAuthenticated() - // Mock checkpoint not found - thenResults.push(undefined) + queueTableRows(schemaMock.workflowCheckpoints, []) const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { method: 'POST', @@ -199,8 +158,7 @@ describe('Copilot Checkpoints Revert API Route', () => { it('should return 404 when checkpoint belongs to different user', async () => { setAuthenticated() - // Mock checkpoint not found (due to user mismatch in query) - thenResults.push(undefined) + queueTableRows(schemaMock.workflowCheckpoints, []) const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { method: 'POST', @@ -225,8 +183,8 @@ describe('Copilot Checkpoints Revert API Route', () => { workflowState: { blocks: {}, edges: [] }, } - thenResults.push(mockCheckpoint) // Checkpoint found - thenResults.push(undefined) // Workflow not found + queueTableRows(schemaMock.workflowCheckpoints, [mockCheckpoint]) + queueTableRows(schemaMock.workflow, []) const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { method: 'POST', @@ -256,8 +214,8 @@ describe('Copilot Checkpoints Revert API Route', () => { userId: 'different-user', } - thenResults.push(mockCheckpoint) // Checkpoint found - thenResults.push(mockWorkflow) // Workflow found but different user + queueTableRows(schemaMock.workflowCheckpoints, [mockCheckpoint]) + queueTableRows(schemaMock.workflow, [mockWorkflow]) workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ allowed: false, @@ -298,8 +256,8 @@ describe('Copilot Checkpoints Revert API Route', () => { userId: 'user-123', } - thenResults.push(mockCheckpoint) // Checkpoint found - thenResults.push(mockWorkflow) // Workflow found + queueTableRows(schemaMock.workflowCheckpoints, [mockCheckpoint]) + queueTableRows(schemaMock.workflow, [mockWorkflow]) ;(global.fetch as any).mockResolvedValue({ ok: true, @@ -380,8 +338,8 @@ describe('Copilot Checkpoints Revert API Route', () => { userId: 'user-123', } - thenResults.push(mockCheckpoint) - thenResults.push(mockWorkflow) + queueTableRows(schemaMock.workflowCheckpoints, [mockCheckpoint]) + queueTableRows(schemaMock.workflow, [mockWorkflow]) ;(global.fetch as any).mockResolvedValue({ ok: true, @@ -422,8 +380,8 @@ describe('Copilot Checkpoints Revert API Route', () => { userId: 'user-123', } - thenResults.push(mockCheckpoint) - thenResults.push(mockWorkflow) + queueTableRows(schemaMock.workflowCheckpoints, [mockCheckpoint]) + queueTableRows(schemaMock.workflow, [mockWorkflow]) ;(global.fetch as any).mockResolvedValue({ ok: true, @@ -464,8 +422,8 @@ describe('Copilot Checkpoints Revert API Route', () => { userId: 'user-123', } - thenResults.push(mockCheckpoint) - thenResults.push(mockWorkflow) + queueTableRows(schemaMock.workflowCheckpoints, [mockCheckpoint]) + queueTableRows(schemaMock.workflow, [mockWorkflow]) ;(global.fetch as any).mockResolvedValue({ ok: true, @@ -509,8 +467,8 @@ describe('Copilot Checkpoints Revert API Route', () => { userId: 'user-123', } - thenResults.push(mockCheckpoint) - thenResults.push(mockWorkflow) + queueTableRows(schemaMock.workflowCheckpoints, [mockCheckpoint]) + queueTableRows(schemaMock.workflow, [mockWorkflow]) ;(global.fetch as any).mockResolvedValue({ ok: false, @@ -533,8 +491,9 @@ describe('Copilot Checkpoints Revert API Route', () => { it('should handle database errors during checkpoint lookup', async () => { setAuthenticated() - // Mock database error - thenResults.push(new Error('Database connection failed')) + dbChainMockFns.where.mockReturnValueOnce( + Promise.reject(new Error('Database connection failed')) + ) const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { method: 'POST', @@ -559,8 +518,10 @@ describe('Copilot Checkpoints Revert API Route', () => { workflowState: { blocks: {}, edges: [] }, } - thenResults.push(mockCheckpoint) // Checkpoint found - thenResults.push(new Error('Database error during workflow lookup')) // Workflow lookup fails + dbChainMockFns.where.mockReturnValueOnce(Promise.resolve([mockCheckpoint])) + dbChainMockFns.where.mockReturnValueOnce( + Promise.reject(new Error('Database error during workflow lookup')) + ) const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { method: 'POST', @@ -590,8 +551,8 @@ describe('Copilot Checkpoints Revert API Route', () => { userId: 'user-123', } - thenResults.push(mockCheckpoint) - thenResults.push(mockWorkflow) + queueTableRows(schemaMock.workflowCheckpoints, [mockCheckpoint]) + queueTableRows(schemaMock.workflow, [mockWorkflow]) ;(global.fetch as any).mockRejectedValue(new Error('Network error')) @@ -641,8 +602,8 @@ describe('Copilot Checkpoints Revert API Route', () => { userId: 'user-123', } - thenResults.push(mockCheckpoint) - thenResults.push(mockWorkflow) + queueTableRows(schemaMock.workflowCheckpoints, [mockCheckpoint]) + queueTableRows(schemaMock.workflow, [mockWorkflow]) ;(global.fetch as any).mockResolvedValue({ ok: true, @@ -690,8 +651,8 @@ describe('Copilot Checkpoints Revert API Route', () => { userId: 'user-123', } - thenResults.push(mockCheckpoint) - thenResults.push(mockWorkflow) + queueTableRows(schemaMock.workflowCheckpoints, [mockCheckpoint]) + queueTableRows(schemaMock.workflow, [mockWorkflow]) ;(global.fetch as any).mockResolvedValue({ ok: true, @@ -758,8 +719,8 @@ describe('Copilot Checkpoints Revert API Route', () => { userId: 'user-123', } - thenResults.push(mockCheckpoint) - thenResults.push(mockWorkflow) + queueTableRows(schemaMock.workflowCheckpoints, [mockCheckpoint]) + queueTableRows(schemaMock.workflow, [mockWorkflow]) ;(global.fetch as any).mockResolvedValue({ ok: true, diff --git a/apps/sim/app/api/copilot/checkpoints/route.test.ts b/apps/sim/app/api/copilot/checkpoints/route.test.ts index f7dc748786b..8ee9df0bc5d 100644 --- a/apps/sim/app/api/copilot/checkpoints/route.test.ts +++ b/apps/sim/app/api/copilot/checkpoints/route.test.ts @@ -3,43 +3,20 @@ * * @vitest-environment node */ -import { authMockFns, workflowAuthzMockFns, workflowsUtilsMock } from '@sim/testing' +import { + authMockFns, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, + workflowAuthzMockFns, + workflowsUtilsMock, +} from '@sim/testing' import { NextRequest } from 'next/server' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockSelect, - mockFrom, - mockWhere, - mockLimit, - mockOrderBy, - mockInsert, - mockValues, - mockReturning, - mockGetAccessibleCopilotChat, -} = vi.hoisted(() => ({ - mockSelect: vi.fn(), - mockFrom: vi.fn(), - mockWhere: vi.fn(), - mockLimit: vi.fn(), - mockOrderBy: vi.fn(), - mockInsert: vi.fn(), - mockValues: vi.fn(), - mockReturning: vi.fn(), - mockGetAccessibleCopilotChat: vi.fn(), -})) - -vi.mock('@sim/db', () => ({ - db: { - select: mockSelect, - insert: mockInsert, - }, -})) +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })), - eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), - desc: vi.fn((field: unknown) => ({ field, type: 'desc' })), +const { mockGetAccessibleCopilotChat } = vi.hoisted(() => ({ + mockGetAccessibleCopilotChat: vi.fn(), })) vi.mock('@/lib/copilot/chat/lifecycle', () => ({ @@ -62,19 +39,10 @@ function createMockRequest(method: string, body: Record): NextR describe('Copilot Checkpoints API Route', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() authMockFns.mockGetSession.mockResolvedValue(null) - mockSelect.mockReturnValue({ from: mockFrom }) - mockFrom.mockReturnValue({ where: mockWhere }) - mockWhere.mockReturnValue({ - orderBy: mockOrderBy, - limit: mockLimit, - }) - mockOrderBy.mockResolvedValue([]) - mockLimit.mockResolvedValue([]) - mockInsert.mockReturnValue({ values: mockValues }) - mockValues.mockReturnValue({ returning: mockReturning }) mockGetAccessibleCopilotChat.mockResolvedValue({ id: 'chat-123', userId: 'user-123', @@ -85,8 +53,8 @@ describe('Copilot Checkpoints API Route', () => { }) }) - afterEach(() => { - vi.restoreAllMocks() + afterAll(() => { + resetDbChainMock() }) describe('POST', () => { @@ -165,7 +133,7 @@ describe('Copilot Checkpoints API Route', () => { createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-01-01'), } - mockReturning.mockResolvedValue([checkpoint]) + dbChainMockFns.returning.mockResolvedValueOnce([checkpoint]) const workflowState = { blocks: [], connections: [] } const req = createMockRequest('POST', { @@ -192,8 +160,8 @@ describe('Copilot Checkpoints API Route', () => { }, }) - expect(mockInsert).toHaveBeenCalled() - expect(mockValues).toHaveBeenCalledWith({ + expect(dbChainMockFns.insert).toHaveBeenCalled() + expect(dbChainMockFns.values).toHaveBeenCalledWith({ userId: 'user-123', workflowId: 'workflow-123', chatId: 'chat-123', @@ -214,7 +182,7 @@ describe('Copilot Checkpoints API Route', () => { createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-01-01'), } - mockReturning.mockResolvedValue([checkpoint]) + dbChainMockFns.returning.mockResolvedValueOnce([checkpoint]) const workflowState = { blocks: [] } const req = createMockRequest('POST', { @@ -234,7 +202,7 @@ describe('Copilot Checkpoints API Route', () => { it('should handle database errors during checkpoint creation', async () => { authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - mockReturning.mockRejectedValue(new Error('Database insert failed')) + dbChainMockFns.returning.mockRejectedValueOnce(new Error('Database insert failed')) const req = createMockRequest('POST', { workflowId: 'workflow-123', @@ -317,7 +285,7 @@ describe('Copilot Checkpoints API Route', () => { }, ] - mockOrderBy.mockResolvedValue(mockCheckpoints) + queueTableRows(schemaMock.workflowCheckpoints, mockCheckpoints) const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints?chatId=chat-123') @@ -349,15 +317,15 @@ describe('Copilot Checkpoints API Route', () => { ], }) - expect(mockSelect).toHaveBeenCalled() - expect(mockWhere).toHaveBeenCalled() - expect(mockOrderBy).toHaveBeenCalled() + expect(dbChainMockFns.select).toHaveBeenCalled() + expect(dbChainMockFns.where).toHaveBeenCalled() + expect(dbChainMockFns.orderBy).toHaveBeenCalled() }) it('should handle database errors when fetching checkpoints', async () => { authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - mockOrderBy.mockRejectedValue(new Error('Database query failed')) + dbChainMockFns.orderBy.mockRejectedValueOnce(new Error('Database query failed')) const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints?chatId=chat-123') @@ -371,7 +339,7 @@ describe('Copilot Checkpoints API Route', () => { it('should return empty array when no checkpoints found', async () => { authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - mockOrderBy.mockResolvedValue([]) + queueTableRows(schemaMock.workflowCheckpoints, []) const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints?chatId=chat-123') diff --git a/apps/sim/app/api/copilot/feedback/route.test.ts b/apps/sim/app/api/copilot/feedback/route.test.ts index b1121ee4b8c..910bbed28a2 100644 --- a/apps/sim/app/api/copilot/feedback/route.test.ts +++ b/apps/sim/app/api/copilot/feedback/route.test.ts @@ -3,18 +3,10 @@ * * @vitest-environment node */ -import { - copilotHttpMock, - copilotHttpMockFns, - dbChainMock, - dbChainMockFns, - resetDbChainMock, -} from '@sim/testing' +import { copilotHttpMock, copilotHttpMockFns, dbChainMockFns, resetDbChainMock } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) import { GET, POST } from '@/app/api/copilot/feedback/route' diff --git a/apps/sim/app/api/cron/renew-subscriptions/route.test.ts b/apps/sim/app/api/cron/renew-subscriptions/route.test.ts index 1340ec486d7..6bdd19602ac 100644 --- a/apps/sim/app/api/cron/renew-subscriptions/route.test.ts +++ b/apps/sim/app/api/cron/renew-subscriptions/route.test.ts @@ -6,9 +6,7 @@ import { authOAuthUtilsMock, createMockRequest, - dbChainMock, dbChainMockFns, - redisConfigMock, redisConfigMockFns, resetDbChainMock, } from '@sim/testing' @@ -23,8 +21,6 @@ vi.mock('@/lib/auth/internal', () => ({ verifyCronAuth: mockVerifyCronAuth, })) -vi.mock('@/lib/core/config/redis', () => redisConfigMock) -vi.mock('@sim/db', () => dbChainMock) vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock) import { GET } from './route' diff --git a/apps/sim/app/api/custom-blocks/[id]/usages/route.test.ts b/apps/sim/app/api/custom-blocks/[id]/usages/route.test.ts index 836f7960969..458e5f220b7 100644 --- a/apps/sim/app/api/custom-blocks/[id]/usages/route.test.ts +++ b/apps/sim/app/api/custom-blocks/[id]/usages/route.test.ts @@ -1,22 +1,16 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' +import { authMockFns, createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetSession, mockIsFeatureEnabled, mockHasWorkspaceAdminAccess, mockOperations } = - vi.hoisted(() => ({ - mockGetSession: vi.fn(), - mockIsFeatureEnabled: vi.fn(), - mockHasWorkspaceAdminAccess: vi.fn(), - mockOperations: { - getCustomBlockManageContext: vi.fn(), - getCustomBlockUsageCounts: vi.fn(), - }, - })) - -vi.mock('@/lib/auth', () => ({ - getSession: mockGetSession, +const { mockIsFeatureEnabled, mockHasWorkspaceAdminAccess, mockOperations } = vi.hoisted(() => ({ + mockIsFeatureEnabled: vi.fn(), + mockHasWorkspaceAdminAccess: vi.fn(), + mockOperations: { + getCustomBlockManageContext: vi.fn(), + getCustomBlockUsageCounts: vi.fn(), + }, })) vi.mock('@/lib/core/config/feature-flags', () => ({ @@ -31,6 +25,8 @@ vi.mock('@/lib/workflows/custom-blocks/operations', () => mockOperations) import { GET } from '@/app/api/custom-blocks/[id]/usages/route' +const mockGetSession = authMockFns.mockGetSession + const MANAGE_CONTEXT = { organizationId: 'org-1', sourceWorkspaceId: 'ws-1', diff --git a/apps/sim/app/api/files/authorization.test.ts b/apps/sim/app/api/files/authorization.test.ts index e894bb387a0..8cce46882fc 100644 --- a/apps/sim/app/api/files/authorization.test.ts +++ b/apps/sim/app/api/files/authorization.test.ts @@ -9,7 +9,7 @@ * * @vitest-environment node */ -import { dbChainMock, dbChainMockFns } from '@sim/testing' +import { dbChainMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetFileMetadataByKey, mockGetUserEntityPermissions, mockGetFileMetadata } = vi.hoisted( @@ -20,8 +20,6 @@ const { mockGetFileMetadataByKey, mockGetUserEntityPermissions, mockGetFileMetad }) ) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/uploads', () => ({ getFileMetadata: mockGetFileMetadata, })) diff --git a/apps/sim/app/api/files/delete/route.test.ts b/apps/sim/app/api/files/delete/route.test.ts index d843b573651..7dc868d0767 100644 --- a/apps/sim/app/api/files/delete/route.test.ts +++ b/apps/sim/app/api/files/delete/route.test.ts @@ -23,31 +23,6 @@ const mocks = vi.hoisted(() => { } }) -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })), - eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), - or: vi.fn((...conditions: unknown[]) => ({ type: 'or', conditions })), - gte: vi.fn((field: unknown, value: unknown) => ({ type: 'gte', field, value })), - lte: vi.fn((field: unknown, value: unknown) => ({ type: 'lte', field, value })), - gt: vi.fn((field: unknown, value: unknown) => ({ type: 'gt', field, value })), - lt: vi.fn((field: unknown, value: unknown) => ({ type: 'lt', field, value })), - ne: vi.fn((field: unknown, value: unknown) => ({ type: 'ne', field, value })), - asc: vi.fn((field: unknown) => ({ field, type: 'asc' })), - desc: vi.fn((field: unknown) => ({ field, type: 'desc' })), - isNull: vi.fn((field: unknown) => ({ field, type: 'isNull' })), - isNotNull: vi.fn((field: unknown) => ({ field, type: 'isNotNull' })), - inArray: vi.fn((field: unknown, values: unknown) => ({ field, values, type: 'inArray' })), - notInArray: vi.fn((field: unknown, values: unknown) => ({ field, values, type: 'notInArray' })), - like: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'like' })), - ilike: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'ilike' })), - count: vi.fn((field: unknown) => ({ field, type: 'count' })), - sum: vi.fn((field: unknown) => ({ field, type: 'sum' })), - avg: vi.fn((field: unknown) => ({ field, type: 'avg' })), - min: vi.fn((field: unknown) => ({ field, type: 'min' })), - max: vi.fn((field: unknown) => ({ field, type: 'max' })), - sql: vi.fn((strings: unknown, ...values: unknown[]) => ({ type: 'sql', sql: strings, values })), -})) - vi.mock('@sim/utils/id', () => ({ generateId: vi.fn(() => 'test-uuid'), generateShortId: vi.fn(() => 'mock-short-id'), diff --git a/apps/sim/app/api/files/upload/route.test.ts b/apps/sim/app/api/files/upload/route.test.ts index 357f5014b4a..034efb5eb70 100644 --- a/apps/sim/app/api/files/upload/route.test.ts +++ b/apps/sim/app/api/files/upload/route.test.ts @@ -48,31 +48,6 @@ const mocks = vi.hoisted(() => { } }) -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })), - eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), - or: vi.fn((...conditions: unknown[]) => ({ type: 'or', conditions })), - gte: vi.fn((field: unknown, value: unknown) => ({ type: 'gte', field, value })), - lte: vi.fn((field: unknown, value: unknown) => ({ type: 'lte', field, value })), - gt: vi.fn((field: unknown, value: unknown) => ({ type: 'gt', field, value })), - lt: vi.fn((field: unknown, value: unknown) => ({ type: 'lt', field, value })), - ne: vi.fn((field: unknown, value: unknown) => ({ type: 'ne', field, value })), - asc: vi.fn((field: unknown) => ({ field, type: 'asc' })), - desc: vi.fn((field: unknown) => ({ field, type: 'desc' })), - isNull: vi.fn((field: unknown) => ({ field, type: 'isNull' })), - isNotNull: vi.fn((field: unknown) => ({ field, type: 'isNotNull' })), - inArray: vi.fn((field: unknown, values: unknown) => ({ field, values, type: 'inArray' })), - notInArray: vi.fn((field: unknown, values: unknown) => ({ field, values, type: 'notInArray' })), - like: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'like' })), - ilike: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'ilike' })), - count: vi.fn((field: unknown) => ({ field, type: 'count' })), - sum: vi.fn((field: unknown) => ({ field, type: 'sum' })), - avg: vi.fn((field: unknown) => ({ field, type: 'avg' })), - min: vi.fn((field: unknown) => ({ field, type: 'min' })), - max: vi.fn((field: unknown) => ({ field, type: 'max' })), - sql: vi.fn((strings: unknown, ...values: unknown[]) => ({ type: 'sql', sql: strings, values })), -})) - vi.mock('@sim/utils/id', () => ({ generateId: vi.fn(() => 'test-uuid'), generateShortId: vi.fn(() => 'mock-short-id'), diff --git a/apps/sim/app/api/folders/[id]/route.test.ts b/apps/sim/app/api/folders/[id]/route.test.ts index 95cb3d53b05..aed839f8743 100644 --- a/apps/sim/app/api/folders/[id]/route.test.ts +++ b/apps/sim/app/api/folders/[id]/route.test.ts @@ -7,17 +7,21 @@ import { auditMock, authMockFns, createMockRequest, + dbChainMockFns, type MockUser, permissionsMock, permissionsMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, workflowsOrchestrationMock, workflowsOrchestrationMockFns, workflowsUtilsMock, workflowsUtilsMockFns, } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockLogger, mockDbRef } = vi.hoisted(() => { +const { mockLogger } = vi.hoisted(() => { const logger = { info: vi.fn(), warn: vi.fn(), @@ -29,7 +33,6 @@ const { mockLogger, mockDbRef } = vi.hoisted(() => { } return { mockLogger: logger, - mockDbRef: { current: null as any }, } }) @@ -45,23 +48,11 @@ vi.mock('@sim/logger', () => ({ getRequestContext: () => undefined, })) vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) -vi.mock('@sim/db', () => ({ - get db() { - return mockDbRef.current - }, -})) vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock) vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) import { DELETE, PUT } from '@/app/api/folders/[id]/route' -interface FolderDbMockOptions { - folderLookupResult?: any - updateResult?: any[] - throwError?: boolean - circularCheckResults?: any[] -} - const TEST_USER: MockUser = { id: 'user-123', email: 'test@example.com', @@ -80,57 +71,16 @@ const mockFolder = { updatedAt: new Date('2024-01-01T00:00:00Z'), } -function createFolderDbMock(options: FolderDbMockOptions = {}) { - const { - folderLookupResult = mockFolder, - updateResult = [{ ...mockFolder, name: 'Updated Folder' }], - throwError = false, - circularCheckResults = [], - } = options - - let callCount = 0 - - const mockSelect = vi.fn().mockImplementation(() => ({ - from: vi.fn().mockImplementation(() => ({ - where: vi.fn().mockImplementation(() => ({ - then: vi.fn().mockImplementation((callback) => { - if (throwError) { - throw new Error('Database error') - } - - callCount++ - if (callCount === 1) { - const result = folderLookupResult === undefined ? [] : [folderLookupResult] - return Promise.resolve(callback(result)) - } - if (callCount > 1 && circularCheckResults.length > 0) { - const index = callCount - 2 - const result = circularCheckResults[index] ? [circularCheckResults[index]] : [] - return Promise.resolve(callback(result)) - } - return Promise.resolve(callback([])) - }), - })), - })), - })) - - const mockUpdate = vi.fn().mockImplementation(() => ({ - set: vi.fn().mockImplementation(() => ({ - where: vi.fn().mockImplementation(() => ({ - returning: vi.fn().mockReturnValue(updateResult), - })), - })), - })) - - const mockDelete = vi.fn().mockImplementation(() => ({ - where: vi.fn().mockImplementation(() => Promise.resolve()), - })) +/** Queues the folder-existence lookup the route runs before authorizing. */ +function queueFolderLookup(folder: Record = mockFolder) { + queueTableRows(schemaMock.workflowFolder, [folder]) +} - return { - select: mockSelect, - update: mockUpdate, - delete: mockDelete, - } +/** Makes the next folder lookup throw, exercising the route's 500 path. */ +function failFolderLookup() { + dbChainMockFns.where.mockImplementationOnce(() => { + throw new Error('Database error') + }) } function mockAuthenticatedUser(user?: MockUser) { @@ -142,11 +92,15 @@ function mockUnauthenticated() { } describe('Individual Folder API Route', () => { + afterAll(() => { + resetDbChainMock() + }) + beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() mockGetUserEntityPermissions.mockResolvedValue('admin') - mockDbRef.current = createFolderDbMock() mockPerformDeleteFolder.mockResolvedValue({ success: true, deletedItems: { folders: 1, workflows: 0 }, @@ -193,6 +147,7 @@ describe('Individual Folder API Route', () => { it('should update folder successfully', async () => { mockAuthenticatedUser() + queueFolderLookup() const req = createMockRequest('PUT', { name: 'Updated Folder Name', color: '#FF0000', @@ -213,6 +168,7 @@ describe('Individual Folder API Route', () => { it('should update parent folder successfully', async () => { mockAuthenticatedUser() + queueFolderLookup() const req = createMockRequest('PUT', { name: 'Updated Folder', parentId: 'parent-folder-1', @@ -244,6 +200,7 @@ describe('Individual Folder API Route', () => { mockAuthenticatedUser() mockGetUserEntityPermissions.mockResolvedValue('read') + queueFolderLookup() const req = createMockRequest('PUT', { name: 'Updated Folder', }) @@ -261,6 +218,7 @@ describe('Individual Folder API Route', () => { mockAuthenticatedUser() mockGetUserEntityPermissions.mockResolvedValue('write') + queueFolderLookup() const req = createMockRequest('PUT', { name: 'Updated Folder', }) @@ -278,6 +236,7 @@ describe('Individual Folder API Route', () => { mockAuthenticatedUser() mockGetUserEntityPermissions.mockResolvedValue('admin') + queueFolderLookup() const req = createMockRequest('PUT', { name: 'Updated Folder', }) @@ -294,6 +253,7 @@ describe('Individual Folder API Route', () => { it('should return 400 when trying to set folder as its own parent', async () => { mockAuthenticatedUser() + queueFolderLookup() const req = createMockRequest('PUT', { name: 'Updated Folder', parentId: 'folder-1', @@ -311,6 +271,7 @@ describe('Individual Folder API Route', () => { it('should trim folder name when updating', async () => { mockAuthenticatedUser() + queueFolderLookup() const req = createMockRequest('PUT', { name: ' Folder With Spaces ', }) @@ -325,9 +286,7 @@ describe('Individual Folder API Route', () => { it('should handle database errors gracefully', async () => { mockAuthenticatedUser() - mockDbRef.current = createFolderDbMock({ - throwError: true, - }) + failFolderLookup() const req = createMockRequest('PUT', { name: 'Updated Folder', @@ -350,6 +309,7 @@ describe('Individual Folder API Route', () => { it('should handle empty folder name', async () => { mockAuthenticatedUser() + queueFolderLookup() const req = createMockRequest('PUT', { name: '', }) @@ -383,13 +343,11 @@ describe('Individual Folder API Route', () => { it('should prevent circular references when updating parent', async () => { mockAuthenticatedUser() - mockDbRef.current = createFolderDbMock({ - folderLookupResult: { - id: 'folder-3', - parentId: null, - name: 'Folder 3', - workspaceId: 'workspace-123', - }, + queueFolderLookup({ + id: 'folder-3', + parentId: null, + name: 'Folder 3', + workspaceId: 'workspace-123', }) workflowsUtilsMockFns.mockCheckForCircularReference.mockResolvedValue(true) @@ -417,9 +375,7 @@ describe('Individual Folder API Route', () => { it('should delete folder and all contents successfully', async () => { mockAuthenticatedUser() - mockDbRef.current = createFolderDbMock({ - folderLookupResult: mockFolder, - }) + queueFolderLookup() const req = createMockRequest('DELETE') const params = Promise.resolve({ id: 'folder-1' }) @@ -457,6 +413,7 @@ describe('Individual Folder API Route', () => { mockAuthenticatedUser() mockGetUserEntityPermissions.mockResolvedValue('read') + queueFolderLookup() const req = createMockRequest('DELETE') const params = Promise.resolve({ id: 'folder-1' }) @@ -472,9 +429,7 @@ describe('Individual Folder API Route', () => { mockAuthenticatedUser() mockGetUserEntityPermissions.mockResolvedValue('write') - mockDbRef.current = createFolderDbMock({ - folderLookupResult: mockFolder, - }) + queueFolderLookup() const req = createMockRequest('DELETE') const params = Promise.resolve({ id: 'folder-1' }) @@ -492,9 +447,7 @@ describe('Individual Folder API Route', () => { mockAuthenticatedUser() mockGetUserEntityPermissions.mockResolvedValue('admin') - mockDbRef.current = createFolderDbMock({ - folderLookupResult: mockFolder, - }) + queueFolderLookup() const req = createMockRequest('DELETE') const params = Promise.resolve({ id: 'folder-1' }) @@ -511,9 +464,7 @@ describe('Individual Folder API Route', () => { it('should handle database errors during deletion', async () => { mockAuthenticatedUser() - mockDbRef.current = createFolderDbMock({ - throwError: true, - }) + failFolderLookup() const req = createMockRequest('DELETE') const params = Promise.resolve({ id: 'folder-1' }) diff --git a/apps/sim/app/api/folders/reorder/route.test.ts b/apps/sim/app/api/folders/reorder/route.test.ts index 61b56e6fa50..c396bd5e46d 100644 --- a/apps/sim/app/api/folders/reorder/route.test.ts +++ b/apps/sim/app/api/folders/reorder/route.test.ts @@ -4,7 +4,6 @@ * @vitest-environment node */ import { authMockFns, createMockRequest, permissionsMock, permissionsMockFns } from '@sim/testing' -import { drizzleOrmMock } from '@sim/testing/mocks' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockLogger } = vi.hoisted(() => ({ @@ -21,12 +20,6 @@ const { mockLogger } = vi.hoisted(() => ({ const mockGetUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions -vi.mock('drizzle-orm', () => drizzleOrmMock) -vi.mock('@sim/logger', () => ({ - createLogger: vi.fn().mockReturnValue(mockLogger), - runWithRequestContext: (_ctx: unknown, fn: () => T): T => fn(), - getRequestContext: () => undefined, -})) vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) import { db } from '@sim/db' diff --git a/apps/sim/app/api/folders/route.test.ts b/apps/sim/app/api/folders/route.test.ts index f7eb512da68..8c0210e12b9 100644 --- a/apps/sim/app/api/folders/route.test.ts +++ b/apps/sim/app/api/folders/route.test.ts @@ -11,7 +11,6 @@ import { permissionsMockFns, workflowAuthzMockFns, } from '@sim/testing' -import { drizzleOrmMock } from '@sim/testing/mocks' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockLogger } = vi.hoisted(() => { @@ -32,10 +31,6 @@ const { mockLogger } = vi.hoisted(() => { const mockGetUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions vi.mock('@sim/audit', () => auditMock) -vi.mock('drizzle-orm', () => ({ - ...drizzleOrmMock, - min: vi.fn((field) => ({ type: 'min', field })), -})) vi.mock('@sim/logger', () => ({ createLogger: vi.fn().mockReturnValue(mockLogger), runWithRequestContext: (_ctx: unknown, fn: () => T): T => fn(), diff --git a/apps/sim/app/api/function/execute/route.test.ts b/apps/sim/app/api/function/execute/route.test.ts index 012b6f9f458..4b5c2194584 100644 --- a/apps/sim/app/api/function/execute/route.test.ts +++ b/apps/sim/app/api/function/execute/route.test.ts @@ -7,10 +7,11 @@ import { createMockRequest, envFlagsMock, hybridAuthMockFns, + resetEnvFlagsMock, workflowsUtilsMock, } from '@sim/testing' import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockExecuteInE2B, @@ -101,14 +102,14 @@ vi.mock('@/lib/uploads', () => ({ vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) -vi.mock('@/lib/core/config/env-flags', () => envFlagsMock) - import { validateProxyUrl } from '@/lib/core/security/input-validation' import { clearLargeValueCacheForTests } from '@/lib/execution/payloads/cache' import { isLargeArrayManifest } from '@/lib/execution/payloads/large-array-manifest-metadata' import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref' import { POST } from '@/app/api/function/execute/route' +afterAll(resetEnvFlagsMock) + describe('Function Execute API Route', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/app/api/guardrails/mask-batch/route.test.ts b/apps/sim/app/api/guardrails/mask-batch/route.test.ts index 976ec1ec80b..6a37e3b9d1b 100644 --- a/apps/sim/app/api/guardrails/mask-batch/route.test.ts +++ b/apps/sim/app/api/guardrails/mask-batch/route.test.ts @@ -1,17 +1,14 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' +import { createMockRequest, hybridAuthMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCheckInternalAuth, mockMaskPIIBatch } = vi.hoisted(() => ({ - mockCheckInternalAuth: vi.fn(), +const { mockMaskPIIBatch } = vi.hoisted(() => ({ mockMaskPIIBatch: vi.fn(), })) -vi.mock('@/lib/auth/hybrid', () => ({ - checkInternalAuth: mockCheckInternalAuth, -})) +const mockCheckInternalAuth = hybridAuthMockFns.mockCheckInternalAuth vi.mock('@/lib/guardrails/validate_pii', () => ({ maskPIIBatch: mockMaskPIIBatch, diff --git a/apps/sim/app/api/invitations/[id]/accept/route.test.ts b/apps/sim/app/api/invitations/[id]/accept/route.test.ts index dd6f3833f2b..951b769389d 100644 --- a/apps/sim/app/api/invitations/[id]/accept/route.test.ts +++ b/apps/sim/app/api/invitations/[id]/accept/route.test.ts @@ -1,16 +1,11 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' +import { authMockFns, createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockAcceptInvitation, mockGetSession } = vi.hoisted(() => ({ +const { mockAcceptInvitation } = vi.hoisted(() => ({ mockAcceptInvitation: vi.fn(), - mockGetSession: vi.fn(), -})) - -vi.mock('@/lib/auth', () => ({ - getSession: mockGetSession, })) vi.mock('@/lib/invitations/core', () => ({ @@ -19,6 +14,8 @@ vi.mock('@/lib/invitations/core', () => ({ import { POST } from '@/app/api/invitations/[id]/accept/route' +const mockGetSession = authMockFns.mockGetSession + function createInvitationRequest() { return createMockRequest( 'POST', diff --git a/apps/sim/app/api/jobs/[jobId]/route.test.ts b/apps/sim/app/api/jobs/[jobId]/route.test.ts index 189e03b1052..5fc7de85435 100644 --- a/apps/sim/app/api/jobs/[jobId]/route.test.ts +++ b/apps/sim/app/api/jobs/[jobId]/route.test.ts @@ -1,13 +1,17 @@ /** * @vitest-environment node */ -import { hybridAuthMockFns, workflowsUtilsMock, workflowsUtilsMockFns } from '@sim/testing' +import { + hybridAuthMockFns, + workflowAuthzMockFns, + workflowsUtilsMock, + workflowsUtilsMockFns, +} from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetJobQueue, mockAuthorizeWorkflow, mockGetJob } = vi.hoisted(() => ({ +const { mockGetJobQueue, mockGetJob } = vi.hoisted(() => ({ mockGetJobQueue: vi.fn(), - mockAuthorizeWorkflow: vi.fn(), mockGetJob: vi.fn(), })) @@ -15,9 +19,7 @@ vi.mock('@/lib/core/async-jobs', () => ({ getJobQueue: mockGetJobQueue, })) -vi.mock('@sim/platform-authz/workflow', () => ({ - authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflow, -})) +const mockAuthorizeWorkflow = workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/documents/route.test.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/documents/route.test.ts index 4f39294d055..b31ec4e5ea1 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/documents/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/documents/route.test.ts @@ -4,31 +4,18 @@ import { auditMock, createMockRequest, + dbChainMockFns, hybridAuthMockFns, knowledgeApiUtilsMock, knowledgeApiUtilsMockFns, requestUtilsMockFns, + resetDbChainMock, } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockDbChain } = vi.hoisted(() => { - const chain = { - select: vi.fn().mockReturnThis(), - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - orderBy: vi.fn().mockResolvedValue([]), - limit: vi.fn().mockResolvedValue([]), - update: vi.fn().mockReturnThis(), - set: vi.fn().mockReturnThis(), - returning: vi.fn().mockResolvedValue([]), - } - return { mockDbChain: chain } -}) +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const mockCheckAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseAccess const mockCheckWriteAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseWriteAccess -vi.mock('@sim/db', () => ({ db: mockDbChain })) vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock) vi.mock('@sim/audit', () => auditMock) @@ -39,15 +26,12 @@ describe('Connector Documents API Route', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('test-req-id') - mockDbChain.select.mockReturnThis() - mockDbChain.from.mockReturnThis() - mockDbChain.where.mockReturnThis() - mockDbChain.orderBy.mockResolvedValue([]) - mockDbChain.limit.mockResolvedValue([]) - mockDbChain.update.mockReturnThis() - mockDbChain.set.mockReturnThis() - mockDbChain.returning.mockResolvedValue([]) + }) + + afterAll(() => { + resetDbChainMock() }) describe('GET', () => { @@ -69,7 +53,7 @@ describe('Connector Documents API Route', () => { userId: 'user-1', }) mockCheckAccess.mockResolvedValue({ hasAccess: true }) - mockDbChain.limit.mockResolvedValueOnce([]) + dbChainMockFns.limit.mockResolvedValueOnce([]) const req = createMockRequest('GET') const response = await GET(req as never, { params: mockParams }) @@ -85,8 +69,8 @@ describe('Connector Documents API Route', () => { mockCheckAccess.mockResolvedValue({ hasAccess: true }) const doc = { id: 'doc-1', filename: 'test.txt', userExcluded: false } - mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }]) - mockDbChain.orderBy.mockResolvedValueOnce([doc]) + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-456' }]) + dbChainMockFns.orderBy.mockResolvedValueOnce([doc]) const url = 'http://localhost/api/knowledge/kb-123/connectors/conn-456/documents' const req = createMockRequest('GET', undefined, undefined, url) @@ -106,8 +90,8 @@ describe('Connector Documents API Route', () => { }) mockCheckAccess.mockResolvedValue({ hasAccess: true }) - mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }]) - mockDbChain.orderBy + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-456' }]) + dbChainMockFns.orderBy .mockResolvedValueOnce([{ id: 'doc-1', userExcluded: false }]) .mockResolvedValueOnce([{ id: 'doc-2', userExcluded: true }]) @@ -143,7 +127,7 @@ describe('Connector Documents API Route', () => { userId: 'user-1', }) mockCheckWriteAccess.mockResolvedValue({ hasAccess: true }) - mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }]) + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-456' }]) const req = createMockRequest('PATCH', { documentIds: [] }) const response = await PATCH(req as never, { params: mockParams }) @@ -157,7 +141,7 @@ describe('Connector Documents API Route', () => { userId: 'user-1', }) mockCheckWriteAccess.mockResolvedValue({ hasAccess: true }) - mockDbChain.limit.mockResolvedValueOnce([]) + dbChainMockFns.limit.mockResolvedValueOnce([]) const req = createMockRequest('PATCH', { operation: 'restore', documentIds: ['doc-1'] }) const response = await PATCH(req as never, { params: mockParams }) @@ -176,8 +160,8 @@ describe('Connector Documents API Route', () => { hasAccess: true, knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' }, }) - mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }]) - mockDbChain.returning.mockResolvedValueOnce([{ id: 'doc-1' }]) + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-456' }]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'doc-1' }]) const req = createMockRequest('PATCH', { operation: 'restore', documentIds: ['doc-1'] }) const response = await PATCH(req as never, { params: mockParams }) @@ -198,8 +182,8 @@ describe('Connector Documents API Route', () => { hasAccess: true, knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' }, }) - mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }]) - mockDbChain.returning.mockResolvedValueOnce([{ id: 'doc-2' }, { id: 'doc-3' }]) + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-456' }]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'doc-2' }, { id: 'doc-3' }]) const req = createMockRequest('PATCH', { operation: 'exclude', diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts index 4f8f7e7fca4..bf347078d73 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts @@ -5,39 +5,24 @@ import { auditMock, authOAuthUtilsMock, createMockRequest, + dbChainMockFns, hybridAuthMockFns, knowledgeApiUtilsMock, knowledgeApiUtilsMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockDbChain, mockHasWorkspaceLiveSyncAccess, mockValidateConfig } = vi.hoisted(() => { - const chain = { - select: vi.fn().mockReturnThis(), - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - orderBy: vi.fn().mockReturnThis(), - limit: vi.fn().mockResolvedValue([]), - execute: vi.fn().mockResolvedValue(undefined), - transaction: vi.fn(), - insert: vi.fn().mockReturnThis(), - values: vi.fn().mockResolvedValue(undefined), - update: vi.fn().mockReturnThis(), - delete: vi.fn().mockReturnThis(), - set: vi.fn().mockReturnThis(), - returning: vi.fn().mockResolvedValue([]), - } - return { - mockDbChain: chain, - mockHasWorkspaceLiveSyncAccess: vi.fn(), - mockValidateConfig: vi.fn(), - } -}) +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockHasWorkspaceLiveSyncAccess, mockValidateConfig } = vi.hoisted(() => ({ + mockHasWorkspaceLiveSyncAccess: vi.fn(), + mockValidateConfig: vi.fn(), +})) const mockCheckAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseAccess const mockCheckWriteAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseWriteAccess -vi.mock('@sim/db', () => ({ db: mockDbChain })) vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock) vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock) vi.mock('@/connectors/registry.server', () => ({ @@ -63,19 +48,11 @@ describe('Knowledge Connector By ID API Route', () => { beforeEach(() => { vi.clearAllMocks() - mockDbChain.select.mockReturnThis() - mockDbChain.from.mockReturnThis() - mockDbChain.where.mockReturnThis() - mockDbChain.orderBy.mockReturnThis() - mockDbChain.limit.mockResolvedValue([]) - mockDbChain.execute.mockResolvedValue(undefined) - mockDbChain.transaction.mockImplementation( - async (callback: (tx: typeof mockDbChain) => unknown) => callback(mockDbChain) - ) - mockDbChain.update.mockReturnThis() - mockDbChain.delete.mockReturnThis() - mockDbChain.set.mockReturnThis() - mockDbChain.returning.mockResolvedValue([]) + resetDbChainMock() + }) + + afterAll(() => { + resetDbChainMock() }) describe('GET', () => { @@ -110,7 +87,7 @@ describe('Knowledge Connector By ID API Route', () => { userId: 'user-1', }) mockCheckAccess.mockResolvedValue({ hasAccess: true }) - mockDbChain.limit.mockResolvedValueOnce([]) + dbChainMockFns.limit.mockResolvedValueOnce([]) const req = createMockRequest('GET') const response = await GET(req, { params: mockParams }) @@ -128,7 +105,7 @@ describe('Knowledge Connector By ID API Route', () => { const mockConnector = { id: 'conn-456', connectorType: 'jira', status: 'active' } const mockLogs = [{ id: 'log-1', status: 'completed' }] - mockDbChain.limit.mockResolvedValueOnce([mockConnector]).mockResolvedValueOnce(mockLogs) + dbChainMockFns.limit.mockResolvedValueOnce([mockConnector]).mockResolvedValueOnce(mockLogs) const req = createMockRequest('GET') const response = await GET(req, { params: mockParams }) @@ -175,7 +152,7 @@ describe('Knowledge Connector By ID API Route', () => { userId: 'user-1', }) mockCheckWriteAccess.mockResolvedValue({ hasAccess: true }) - mockDbChain.limit.mockResolvedValueOnce([]) + dbChainMockFns.limit.mockResolvedValueOnce([]) const req = createMockRequest('PATCH', { sourceConfig: { project: 'NEW' } }) const response = await PATCH(req, { params: mockParams }) @@ -197,7 +174,7 @@ describe('Knowledge Connector By ID API Route', () => { mockHasWorkspaceLiveSyncAccess.mockResolvedValue(true) const updatedConnector = { id: 'conn-456', status: 'paused', syncIntervalMinutes: 5 } - mockDbChain.limit.mockResolvedValueOnce([updatedConnector]) + dbChainMockFns.limit.mockResolvedValueOnce([updatedConnector]) const req = createMockRequest('PATCH', { status: 'paused', syncIntervalMinutes: 5 }) const response = await PATCH(req, { params: mockParams }) @@ -252,12 +229,9 @@ describe('Knowledge Connector By ID API Route', () => { hasAccess: true, knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' }, }) - mockDbChain.where - .mockReturnValueOnce(mockDbChain) - .mockResolvedValueOnce([{ id: 'doc-1', fileUrl: '/api/uploads/test.txt' }]) - .mockReturnValueOnce(mockDbChain) - mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456', connectorType: 'jira' }]) - mockDbChain.returning.mockResolvedValueOnce([{ id: 'conn-456' }]) + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-456', connectorType: 'jira' }]) + queueTableRows(schemaMock.document, [{ id: 'doc-1', fileUrl: '/api/uploads/test.txt' }]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'conn-456' }]) const req = createMockRequest('DELETE') const response = await DELETE(req, { params: mockParams }) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts index e84f5cd4da6..c79c85df58a 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.test.ts @@ -4,33 +4,22 @@ import { auditMock, createMockRequest, + dbChainMockFns, hybridAuthMockFns, knowledgeApiUtilsMock, knowledgeApiUtilsMockFns, requestUtilsMockFns, + resetDbChainMock, } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockDispatchSync, mockDbChain, mockResolveBillingAttribution } = vi.hoisted(() => { - const chain = { - select: vi.fn().mockReturnThis(), - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - orderBy: vi.fn().mockResolvedValue([]), - limit: vi.fn().mockResolvedValue([]), - update: vi.fn().mockReturnThis(), - set: vi.fn().mockReturnThis(), - } - return { - mockDispatchSync: vi.fn().mockResolvedValue(undefined), - mockDbChain: chain, - mockResolveBillingAttribution: vi.fn(), - } -}) +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockDispatchSync, mockResolveBillingAttribution } = vi.hoisted(() => ({ + mockDispatchSync: vi.fn().mockResolvedValue(undefined), + mockResolveBillingAttribution: vi.fn(), +})) const mockCheckWriteAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseWriteAccess -vi.mock('@sim/db', () => ({ db: mockDbChain })) vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock) vi.mock('@/lib/billing/core/billing-attribution', () => ({ requireBillingAttributionHeader: vi.fn(), @@ -48,14 +37,12 @@ describe('Connector Manual Sync API Route', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('test-req-id') - mockDbChain.select.mockReturnThis() - mockDbChain.from.mockReturnThis() - mockDbChain.where.mockReturnThis() - mockDbChain.orderBy.mockResolvedValue([]) - mockDbChain.limit.mockResolvedValue([]) - mockDbChain.update.mockReturnThis() - mockDbChain.set.mockReturnThis() + }) + + afterAll(() => { + resetDbChainMock() }) it('returns 401 when unauthenticated', async () => { @@ -76,7 +63,7 @@ describe('Connector Manual Sync API Route', () => { userId: 'user-1', }) mockCheckWriteAccess.mockResolvedValue({ hasAccess: true }) - mockDbChain.limit.mockResolvedValueOnce([]) + dbChainMockFns.limit.mockResolvedValueOnce([]) const req = createMockRequest('POST') const response = await POST(req as never, { params: mockParams }) @@ -90,7 +77,7 @@ describe('Connector Manual Sync API Route', () => { userId: 'user-1', }) mockCheckWriteAccess.mockResolvedValue({ hasAccess: true }) - mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456', status: 'syncing' }]) + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-456', status: 'syncing' }]) const req = createMockRequest('POST') const response = await POST(req as never, { params: mockParams }) @@ -122,7 +109,7 @@ describe('Connector Manual Sync API Route', () => { hasAccess: true, knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' }, }) - mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456', status: 'active' }]) + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-456', status: 'active' }]) mockResolveBillingAttribution.mockResolvedValue(billingAttribution) const req = createMockRequest('POST') @@ -166,7 +153,7 @@ describe('Connector Manual Sync API Route', () => { hasAccess: true, knowledgeBase: { workspaceId: 'ws-1', name: 'Test KB' }, }) - mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456', status: 'active' }]) + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'conn-456', status: 'active' }]) mockResolveBillingAttribution.mockResolvedValue(billingAttribution) const req = createMockRequest( diff --git a/apps/sim/app/api/knowledge/[id]/connectors/route.test.ts b/apps/sim/app/api/knowledge/[id]/connectors/route.test.ts index 2ac24c18df0..6087572fb40 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/route.test.ts @@ -5,45 +5,32 @@ import { auditMock, authOAuthUtilsMock, createMockRequest, + dbChainMockFns, hybridAuthMockFns, knowledgeApiUtilsMock, knowledgeApiUtilsMockFns, + resetDbChainMock, } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockCaptureServerEvent, - mockDbChain, mockDispatchSync, mockEncryptApiKey, mockHasWorkspaceLiveSyncAccess, mockResolveBillingAttribution, mockValidateConfig, -} = vi.hoisted(() => { - const chain = { - select: vi.fn().mockReturnThis(), - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - limit: vi.fn(), - execute: vi.fn(), - transaction: vi.fn(), - insert: vi.fn().mockReturnThis(), - values: vi.fn(), - } - return { - mockCaptureServerEvent: vi.fn(), - mockDbChain: chain, - mockDispatchSync: vi.fn(), - mockEncryptApiKey: vi.fn(), - mockHasWorkspaceLiveSyncAccess: vi.fn(), - mockResolveBillingAttribution: vi.fn(), - mockValidateConfig: vi.fn(), - } -}) +} = vi.hoisted(() => ({ + mockCaptureServerEvent: vi.fn(), + mockDispatchSync: vi.fn(), + mockEncryptApiKey: vi.fn(), + mockHasWorkspaceLiveSyncAccess: vi.fn(), + mockResolveBillingAttribution: vi.fn(), + mockValidateConfig: vi.fn(), +})) const mockCheckWriteAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseWriteAccess -vi.mock('@sim/db', () => ({ db: mockDbChain })) vi.mock('@sim/audit', () => auditMock) vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock) vi.mock('@/app/api/auth/oauth/utils', () => authOAuthUtilsMock) @@ -103,20 +90,16 @@ describe('Knowledge Connectors API Route', () => { beforeEach(() => { vi.clearAllMocks() - mockDbChain.select.mockReturnThis() - mockDbChain.from.mockReturnThis() - mockDbChain.where.mockReturnThis() - mockDbChain.insert.mockReturnThis() - mockDbChain.execute.mockResolvedValue(undefined) - mockDbChain.values.mockResolvedValue(undefined) - mockDbChain.transaction.mockImplementation( - async (callback: (tx: typeof mockDbChain) => unknown) => callback(mockDbChain) - ) + resetDbChainMock() mockDispatchSync.mockResolvedValue(undefined) mockEncryptApiKey.mockResolvedValue({ encrypted: 'encrypted-api-key' }) mockValidateConfig.mockResolvedValue({ valid: true }) }) + afterAll(() => { + resetDbChainMock() + }) + it('queues the authenticated actor with the paid workspace payer', async () => { hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: true, @@ -135,7 +118,7 @@ describe('Knowledge Connectors API Route', () => { }) mockHasWorkspaceLiveSyncAccess.mockResolvedValue(true) mockResolveBillingAttribution.mockResolvedValue(BILLING_ATTRIBUTION) - mockDbChain.limit.mockResolvedValueOnce([{ id: 'knowledge-base-1' }]).mockResolvedValueOnce([ + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'knowledge-base-1' }]).mockResolvedValueOnce([ { id: 'connector-1', knowledgeBaseId: 'knowledge-base-1', diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.test.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.test.ts index 7e08b67fa71..84bdd9736e5 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.test.ts @@ -3,26 +3,14 @@ * * @vitest-environment node */ -import { auditMock, authMockFns, createMockRequest, knowledgeApiUtilsMock } from '@sim/testing' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockDbChain } = vi.hoisted(() => { - const mockDbChain = { - select: vi.fn().mockReturnThis(), - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - limit: vi.fn().mockReturnThis(), - update: vi.fn().mockReturnThis(), - set: vi.fn().mockReturnThis(), - delete: vi.fn().mockReturnThis(), - transaction: vi.fn(), - } - return { mockDbChain } -}) - -vi.mock('@sim/db', () => ({ - db: mockDbChain, -})) +import { + auditMock, + authMockFns, + createMockRequest, + knowledgeApiUtilsMock, + resetDbChainMock, +} from '@sim/testing' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock) @@ -82,20 +70,9 @@ describe('Document By ID API Route', () => { deletedAt: null, } - const resetMocks = () => { - vi.clearAllMocks() - Object.values(mockDbChain).forEach((fn) => { - if (typeof fn === 'function') { - fn.mockClear().mockReset() - if (fn !== mockDbChain.transaction) { - fn.mockReturnThis() - } - } - }) - } - beforeEach(() => { - resetMocks() + vi.clearAllMocks() + resetDbChainMock() vi.stubGlobal('crypto', { randomUUID: vi.fn().mockReturnValue('mock-uuid-1234-5678'), @@ -106,6 +83,10 @@ describe('Document By ID API Route', () => { vi.clearAllMocks() }) + afterAll(() => { + resetDbChainMock() + }) + describe('GET /api/knowledge/[id]/documents/[documentId]', () => { const mockParams = Promise.resolve({ id: 'kb-123', documentId: 'doc-123' }) diff --git a/apps/sim/app/api/knowledge/[id]/documents/route.test.ts b/apps/sim/app/api/knowledge/[id]/documents/route.test.ts index 80af0ba8cc1..971c4a8f28b 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/route.test.ts @@ -3,29 +3,14 @@ * * @vitest-environment node */ -import { auditMock, authMockFns, createMockRequest, knowledgeApiUtilsMock } from '@sim/testing' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockDbChain } = vi.hoisted(() => { - const mockDbChain = { - select: vi.fn().mockReturnThis(), - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - orderBy: vi.fn().mockReturnThis(), - limit: vi.fn().mockReturnThis(), - offset: vi.fn().mockReturnThis(), - insert: vi.fn().mockReturnThis(), - values: vi.fn().mockReturnThis(), - update: vi.fn().mockReturnThis(), - set: vi.fn().mockReturnThis(), - transaction: vi.fn(), - } - return { mockDbChain } -}) - -vi.mock('@sim/db', () => ({ - db: mockDbChain, -})) +import { + auditMock, + authMockFns, + createMockRequest, + knowledgeApiUtilsMock, + resetDbChainMock, +} from '@sim/testing' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock) @@ -96,20 +81,9 @@ describe('Knowledge Base Documents API Route', () => { deletedAt: null, } - const resetMocks = () => { - vi.clearAllMocks() - Object.values(mockDbChain).forEach((fn) => { - if (typeof fn === 'function') { - fn.mockClear().mockReset() - if (fn !== mockDbChain.transaction) { - fn.mockReturnThis() - } - } - }) - } - beforeEach(() => { - resetMocks() + vi.clearAllMocks() + resetDbChainMock() vi.stubGlobal('crypto', { randomUUID: vi.fn().mockReturnValue('mock-uuid-1234-5678'), @@ -120,6 +94,10 @@ describe('Knowledge Base Documents API Route', () => { vi.clearAllMocks() }) + afterAll(() => { + resetDbChainMock() + }) + describe('GET /api/knowledge/[id]/documents', () => { const mockParams = Promise.resolve({ id: 'kb-123' }) diff --git a/apps/sim/app/api/knowledge/[id]/documents/upsert/route.test.ts b/apps/sim/app/api/knowledge/[id]/documents/upsert/route.test.ts index 3feda21aa92..ddd9c80cb7f 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/upsert/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/upsert/route.test.ts @@ -6,24 +6,12 @@ import { auditMock, createMockRequest, - hybridAuthMock, hybridAuthMockFns, knowledgeApiUtilsMock, + resetDbChainMock, } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockDbChain } = vi.hoisted(() => { - const chain = { - select: vi.fn().mockReturnThis(), - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - limit: vi.fn().mockResolvedValue([]), - } - return { mockDbChain: chain } -}) - -vi.mock('@sim/db', () => ({ db: mockDbChain })) -vi.mock('@/lib/auth/hybrid', () => hybridAuthMock) vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock) vi.mock('@sim/audit', () => auditMock) @@ -57,10 +45,7 @@ describe('POST /api/knowledge/[id]/documents/upsert', () => { beforeEach(() => { vi.clearAllMocks() - mockDbChain.select.mockReturnThis() - mockDbChain.from.mockReturnThis() - mockDbChain.where.mockReturnThis() - mockDbChain.limit.mockResolvedValue([]) + resetDbChainMock() hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: true, @@ -81,6 +66,10 @@ describe('POST /api/knowledge/[id]/documents/upsert', () => { vi.mocked(processDocumentsWithQueue).mockResolvedValue(undefined as any) }) + afterAll(() => { + resetDbChainMock() + }) + const baseBody = { filename: 'note.txt', fileSize: 11, diff --git a/apps/sim/app/api/knowledge/[id]/route.test.ts b/apps/sim/app/api/knowledge/[id]/route.test.ts index 2d0dc1ce2e2..882a1df6f0d 100644 --- a/apps/sim/app/api/knowledge/[id]/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/route.test.ts @@ -3,24 +3,14 @@ * * @vitest-environment node */ -import { auditMock, authMockFns, createMockRequest, knowledgeApiUtilsMock } from '@sim/testing' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockDbChain } = vi.hoisted(() => { - const mockDbChain = { - select: vi.fn().mockReturnThis(), - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - limit: vi.fn().mockReturnThis(), - update: vi.fn().mockReturnThis(), - set: vi.fn().mockReturnThis(), - } - return { mockDbChain } -}) - -vi.mock('@sim/db', () => ({ - db: mockDbChain, -})) +import { + auditMock, + authMockFns, + createMockRequest, + knowledgeApiUtilsMock, + resetDbChainMock, +} from '@sim/testing' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@sim/audit', () => auditMock) @@ -64,15 +54,12 @@ describe('Knowledge Base By ID API Route', () => { const resetMocks = () => { vi.clearAllMocks() - Object.values(mockDbChain).forEach((fn) => { - if (typeof fn === 'function') { - fn.mockClear().mockReset().mockReturnThis() - } - }) + resetDbChainMock() } beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() vi.stubGlobal('crypto', { randomUUID: vi.fn().mockReturnValue('mock-uuid-1234-5678'), @@ -83,6 +70,10 @@ describe('Knowledge Base By ID API Route', () => { vi.clearAllMocks() }) + afterAll(() => { + resetDbChainMock() + }) + describe('GET /api/knowledge/[id]', () => { const mockParams = Promise.resolve({ id: 'kb-123' }) diff --git a/apps/sim/app/api/knowledge/route.test.ts b/apps/sim/app/api/knowledge/route.test.ts index 4ad2aad2acf..3c8f8083b79 100644 --- a/apps/sim/app/api/knowledge/route.test.ts +++ b/apps/sim/app/api/knowledge/route.test.ts @@ -7,29 +7,12 @@ import { auditMock, authMockFns, createMockRequest, + dbChainMockFns, permissionsMock, permissionsMockFns, + resetDbChainMock, } from '@sim/testing' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockDbChain } = vi.hoisted(() => { - const mockDbChain = { - select: vi.fn().mockReturnThis(), - from: vi.fn().mockReturnThis(), - leftJoin: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - groupBy: vi.fn().mockReturnThis(), - orderBy: vi.fn().mockResolvedValue([]), - limit: vi.fn().mockResolvedValue([]), - insert: vi.fn().mockReturnThis(), - values: vi.fn().mockResolvedValue(undefined), - } - return { mockDbChain } -}) - -vi.mock('@sim/db', () => ({ - db: mockDbChain, -})) +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@sim/audit', () => auditMock) @@ -40,15 +23,7 @@ import { GET, POST } from '@/app/api/knowledge/route' describe('Knowledge Base API Route', () => { beforeEach(() => { vi.clearAllMocks() - - Object.values(mockDbChain).forEach((fn) => { - if (typeof fn === 'function') { - fn.mockClear() - if (fn !== mockDbChain.orderBy && fn !== mockDbChain.values && fn !== mockDbChain.limit) { - fn.mockReturnThis() - } - } - }) + resetDbChainMock() permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('admin') @@ -61,6 +36,10 @@ describe('Knowledge Base API Route', () => { vi.clearAllMocks() }) + afterAll(() => { + resetDbChainMock() + }) + describe('GET /api/knowledge', () => { it('should return unauthorized for unauthenticated user', async () => { authMockFns.mockGetSession.mockResolvedValue(null) @@ -77,7 +56,7 @@ describe('Knowledge Base API Route', () => { authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' }, }) - mockDbChain.orderBy.mockRejectedValue(new Error('Database error')) + dbChainMockFns.orderBy.mockRejectedValueOnce(new Error('Database error')) const req = createMockRequest('GET') const response = await GET(req) @@ -113,7 +92,7 @@ describe('Knowledge Base API Route', () => { expect(data.success).toBe(true) expect(data.data.name).toBe(validKnowledgeBaseData.name) expect(data.data.description).toBe(validKnowledgeBaseData.description) - expect(mockDbChain.insert).toHaveBeenCalled() + expect(dbChainMockFns.insert).toHaveBeenCalled() }) it('should return unauthorized for unauthenticated user', async () => { @@ -169,7 +148,7 @@ describe('Knowledge Base API Route', () => { expect(data.error).toBe( 'User does not have permission to create knowledge bases in this workspace' ) - expect(mockDbChain.insert).not.toHaveBeenCalled() + expect(dbChainMockFns.insert).not.toHaveBeenCalled() }) it('should validate chunking config constraints', async () => { @@ -219,7 +198,7 @@ describe('Knowledge Base API Route', () => { authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123', email: 'test@example.com' }, }) - mockDbChain.values.mockRejectedValue(new Error('Database error')) + dbChainMockFns.values.mockRejectedValueOnce(new Error('Database error')) const req = createMockRequest('POST', validKnowledgeBaseData) const response = await POST(req) diff --git a/apps/sim/app/api/knowledge/search/route.test.ts b/apps/sim/app/api/knowledge/search/route.test.ts index 385fd1a3ac1..88e379dafe7 100644 --- a/apps/sim/app/api/knowledge/search/route.test.ts +++ b/apps/sim/app/api/knowledge/search/route.test.ts @@ -6,18 +6,20 @@ * @vitest-environment node */ import { - createEnvMock, createMockRequest, + dbChainMockFns, hybridAuthMockFns, knowledgeApiUtilsMock, knowledgeApiUtilsMockFns, + resetDbChainMock, + resetEnvMock, + setEnv, workflowAuthzMockFns, workflowsUtilsMock, } from '@sim/testing' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { - mockDbChain, mockGetDocumentTagDefinitions, mockHandleTagOnlySearch, mockHandleVectorOnlySearch, @@ -26,17 +28,6 @@ const { mockGenerateSearchEmbedding, mockGetDocumentMetadataByIds, } = vi.hoisted(() => ({ - mockDbChain: { - select: vi.fn().mockReturnThis(), - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - orderBy: vi.fn().mockReturnThis(), - limit: vi.fn().mockReturnThis(), - innerJoin: vi.fn().mockReturnThis(), - leftJoin: vi.fn().mockReturnThis(), - groupBy: vi.fn().mockReturnThis(), - having: vi.fn().mockReturnThis(), - }, mockGetDocumentTagDefinitions: vi.fn(), mockHandleTagOnlySearch: vi.fn(), mockHandleVectorOnlySearch: vi.fn(), @@ -48,26 +39,8 @@ const { const mockCheckKnowledgeBaseAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseAccess -vi.mock('drizzle-orm', () => ({ - and: vi.fn().mockImplementation((...args) => ({ and: args })), - eq: vi.fn().mockImplementation((a, b) => ({ eq: [a, b] })), - inArray: vi.fn().mockImplementation((field, values) => ({ inArray: [field, values] })), - isNull: vi.fn().mockImplementation((arg) => ({ isNull: arg })), - sql: vi.fn().mockImplementation((strings, ...values) => ({ - sql: strings, - values, - as: vi.fn().mockReturnValue({ sql: strings, values, alias: 'mocked_alias' }), - })), -})) - -vi.mock('@sim/db', () => ({ - db: mockDbChain, -})) - vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) -vi.mock('@/lib/core/config/env', () => createEnvMock({ OPENAI_API_KEY: 'test-api-key' })) - vi.mock('@/lib/documents/utils', () => ({ retryWithExponentialBackoff: vi.fn().mockImplementation((fn) => fn()), })) @@ -142,12 +115,8 @@ describe('Knowledge Search API Route', () => { beforeEach(() => { vi.clearAllMocks() - - Object.values(mockDbChain).forEach((fn) => { - if (typeof fn === 'function') { - fn.mockClear().mockReturnThis() - } - }) + resetDbChainMock() + setEnv({ OPENAI_API_KEY: 'test-api-key' }) mockHandleTagOnlySearch.mockClear() mockHandleVectorOnlySearch.mockClear() @@ -187,6 +156,11 @@ describe('Knowledge Search API Route', () => { vi.clearAllMocks() }) + afterAll(() => { + resetDbChainMock() + resetEnvMock() + }) + describe('POST /api/knowledge/search', () => { const validSearchData = { knowledgeBaseIds: 'kb-123', @@ -216,7 +190,7 @@ describe('Knowledge Search API Route', () => { }, }) - mockDbChain.limit.mockResolvedValue([]) + dbChainMockFns.limit.mockResolvedValue([]) mockHandleVectorOnlySearch.mockResolvedValue(mockSearchResults) @@ -263,7 +237,7 @@ describe('Knowledge Search API Route', () => { .mockResolvedValueOnce({ hasAccess: true, knowledgeBase: multiKbs[0] }) .mockResolvedValueOnce({ hasAccess: true, knowledgeBase: multiKbs[1] }) - mockDbChain.limit.mockResolvedValue([]) + dbChainMockFns.limit.mockResolvedValue([]) mockHandleVectorOnlySearch.mockResolvedValue(mockSearchResults) @@ -308,7 +282,7 @@ describe('Knowledge Search API Route', () => { }, }) - mockDbChain.limit.mockResolvedValue([]) + dbChainMockFns.limit.mockResolvedValue([]) mockHandleVectorOnlySearch.mockResolvedValue(mockSearchResults) @@ -506,7 +480,7 @@ describe('Knowledge Search API Route', () => { }, }) - mockDbChain.limit.mockResolvedValueOnce(mockSearchResults) // Search results + dbChainMockFns.limit.mockResolvedValueOnce(mockSearchResults) // Search results mockFetch.mockResolvedValue({ ok: true, @@ -526,7 +500,7 @@ describe('Knowledge Search API Route', () => { it.concurrent('should handle OpenAI API errors', async () => { mockGetUserId.mockResolvedValue('user-123') - mockDbChain.limit.mockResolvedValueOnce(mockKnowledgeBases) + dbChainMockFns.limit.mockResolvedValueOnce(mockKnowledgeBases) mockGenerateSearchEmbedding.mockRejectedValueOnce( new Error('OpenAI API error: 401 Unauthorized - Invalid API key') @@ -542,7 +516,7 @@ describe('Knowledge Search API Route', () => { it.concurrent('should handle missing OpenAI API key', async () => { mockGetUserId.mockResolvedValue('user-123') - mockDbChain.limit.mockResolvedValueOnce(mockKnowledgeBases) + dbChainMockFns.limit.mockResolvedValueOnce(mockKnowledgeBases) mockGenerateSearchEmbedding.mockRejectedValueOnce(new Error('OPENAI_API_KEY not configured')) @@ -556,7 +530,7 @@ describe('Knowledge Search API Route', () => { it.concurrent('should handle database errors during search', async () => { mockGetUserId.mockResolvedValue('user-123') - mockDbChain.limit.mockResolvedValueOnce(mockKnowledgeBases) + dbChainMockFns.limit.mockResolvedValueOnce(mockKnowledgeBases) mockHandleVectorOnlySearch.mockRejectedValueOnce(new Error('Database error')) @@ -570,7 +544,7 @@ describe('Knowledge Search API Route', () => { it.concurrent('should handle invalid OpenAI response format', async () => { mockGetUserId.mockResolvedValue('user-123') - mockDbChain.limit.mockResolvedValueOnce(mockKnowledgeBases) + dbChainMockFns.limit.mockResolvedValueOnce(mockKnowledgeBases) mockGenerateSearchEmbedding.mockRejectedValueOnce( new Error('Invalid response format from OpenAI embeddings API') @@ -599,7 +573,7 @@ describe('Knowledge Search API Route', () => { }, }) - mockDbChain.limit.mockResolvedValueOnce(mockSearchResults) + dbChainMockFns.limit.mockResolvedValueOnce(mockSearchResults) mockFetch.mockResolvedValue({ ok: true, @@ -647,7 +621,7 @@ describe('Knowledge Search API Route', () => { }, }) - mockDbChain.limit.mockResolvedValueOnce(mockSearchResults) + dbChainMockFns.limit.mockResolvedValueOnce(mockSearchResults) mockFetch.mockResolvedValue({ ok: true, @@ -702,7 +676,7 @@ describe('Knowledge Search API Route', () => { }, }) - mockDbChain.limit.mockResolvedValueOnce(mockSearchResults) + dbChainMockFns.limit.mockResolvedValueOnce(mockSearchResults) mockFetch.mockResolvedValue({ ok: true, @@ -774,7 +748,7 @@ describe('Knowledge Search API Route', () => { mockGetDocumentTagDefinitions.mockResolvedValue(mockTagDefinitions) - mockDbChain.limit.mockResolvedValueOnce(mockTagDefinitions) + dbChainMockFns.limit.mockResolvedValueOnce(mockTagDefinitions) mockHandleTagOnlySearch.mockResolvedValue(mockTaggedResults) @@ -820,7 +794,7 @@ describe('Knowledge Search API Route', () => { mockGetDocumentTagDefinitions.mockResolvedValue(mockTagDefinitions) - mockDbChain.limit.mockResolvedValueOnce(mockTagDefinitions) + dbChainMockFns.limit.mockResolvedValueOnce(mockTagDefinitions) mockHandleTagAndVectorSearch.mockResolvedValue(mockSearchResults) @@ -957,7 +931,7 @@ describe('Knowledge Search API Route', () => { }, }) - mockDbChain.limit.mockResolvedValueOnce(mockSearchResults) + dbChainMockFns.limit.mockResolvedValueOnce(mockSearchResults) mockFetch.mockResolvedValue({ ok: true, @@ -1015,7 +989,7 @@ describe('Knowledge Search API Route', () => { mockHandleTagOnlySearch.mockResolvedValue(mockTaggedResults) - mockDbChain.limit.mockResolvedValueOnce(mockTagDefinitions) + dbChainMockFns.limit.mockResolvedValueOnce(mockTagDefinitions) const req = createMockRequest('POST', multiKbTagData) const response = await POST(req) @@ -1080,7 +1054,7 @@ describe('Knowledge Search API Route', () => { from: vi.fn().mockReturnThis(), where: vi.fn().mockResolvedValue([]), } - mockDbChain.select.mockReturnValueOnce(mockTagDefs) + dbChainMockFns.select.mockReturnValueOnce(mockTagDefs) const req = createMockRequest('POST', { knowledgeBaseIds: ['kb-123'], @@ -1154,7 +1128,7 @@ describe('Knowledge Search API Route', () => { .fn() .mockResolvedValue([{ tagSlot: 'tag1', displayName: 'tag1', fieldType: 'text' }]), } - mockDbChain.select.mockReturnValueOnce(mockTagDefs) + dbChainMockFns.select.mockReturnValueOnce(mockTagDefs) const req = createMockRequest('POST', { knowledgeBaseIds: ['kb-123'], @@ -1227,7 +1201,7 @@ describe('Knowledge Search API Route', () => { .fn() .mockResolvedValue([{ tagSlot: 'tag1', displayName: 'tag1', fieldType: 'text' }]), } - mockDbChain.select.mockReturnValueOnce(mockTagDefs) + dbChainMockFns.select.mockReturnValueOnce(mockTagDefs) const req = createMockRequest('POST', { knowledgeBaseIds: ['kb-123'], diff --git a/apps/sim/app/api/knowledge/search/utils.test.ts b/apps/sim/app/api/knowledge/search/utils.test.ts index 3a2bcd0898b..0c0d4128d09 100644 --- a/apps/sim/app/api/knowledge/search/utils.test.ts +++ b/apps/sim/app/api/knowledge/search/utils.test.ts @@ -4,16 +4,39 @@ * * @vitest-environment node */ -import { createEnvMock } from '@sim/testing' -import { mockNextFetchResponse } from '@sim/testing/mocks' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockNextFetchResponse, setupGlobalFetchMock } from '@sim/testing/mocks' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { env } from '@/lib/core/config/env' +import * as documentsUtilsModule from '@/lib/knowledge/documents/utils' -vi.mock('drizzle-orm') -vi.mock('@/lib/knowledge/documents/utils', () => ({ - retryWithExponentialBackoff: (fn: any) => fn(), -})) +/** + * Spy on the real documents/utils namespace instead of vi.mock: the shared + * `@/lib/knowledge/embeddings` module may be cached bound to the real module, + * so patching the namespace is the only wiring that always applies. + */ +const retrySpy = vi + .spyOn(documentsUtilsModule, 'retryWithExponentialBackoff') + .mockImplementation(((fn: () => unknown) => fn()) as never) + +afterAll(() => { + retrySpy.mockRestore() +}) -vi.mock('@/lib/core/config/env', () => createEnvMock()) +/** + * Under `isolate: false` the shared `@/lib/knowledge/embeddings` module may be + * cached bound to the REAL env module, so tests mutate the real `env` object + * (the tests below clear and assign it per case) instead of vi.mock'ing a + * file-local replacement that a cached consumer would never see. The snapshot + * restores whatever the worker started with after every test. + */ +const envSnapshot = { ...env } + +afterEach(() => { + for (const key of Object.keys(env)) { + delete (env as Record)[key] + } + Object.assign(env, envSnapshot) +}) import { generateSearchEmbedding, @@ -25,6 +48,11 @@ import { describe('Knowledge Search Utils', () => { beforeEach(() => { vi.clearAllMocks() + // The worker-level fetch stub from vitest.setup.ts is removed after the + // first test by `unstubGlobals: true`; re-stub it per test so + // mockNextFetchResponse always operates on a mocked fetch. + setupGlobalFetchMock({ json: {} }) + retrySpy.mockImplementation(((fn: () => unknown) => fn()) as never) }) describe('handleTagOnlySearch', () => { diff --git a/apps/sim/app/api/knowledge/utils.test.ts b/apps/sim/app/api/knowledge/utils.test.ts index 3bf7f5ce352..f7a0297ef52 100644 --- a/apps/sim/app/api/knowledge/utils.test.ts +++ b/apps/sim/app/api/knowledge/utils.test.ts @@ -6,52 +6,83 @@ * This file contains unit tests for the knowledge base utility functions, * including access checks, document processing, and embedding generation. */ -import { createEnvMock } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('drizzle-orm', () => ({ - and: (...args: any[]) => args, - eq: (...args: any[]) => args, - isNull: () => true, - sql: (strings: TemplateStringsArray, ...expr: any[]) => ({ strings, expr }), -})) - -vi.mock('@/lib/core/config/env', () => createEnvMock({ OPENAI_API_KEY: 'test-key' })) - -vi.mock('@/lib/knowledge/documents/utils', () => ({ - retryWithExponentialBackoff: (fn: any) => fn(), -})) - -vi.mock('@/lib/workspaces/utils', () => ({ - getWorkspaceBilledAccountUserId: vi.fn().mockResolvedValue('user1'), -})) +import { + dbChainMockFns, + defaultMockEnv, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import * as billingAttributionModule from '@/lib/billing/core/billing-attribution' +import { env } from '@/lib/core/config/env' +import * as documentsUtilsModule from '@/lib/knowledge/documents/utils' +import * as workspacesUtilsModule from '@/lib/workspaces/utils' + +const envSnapshot = { ...env } + +afterAll(() => { + for (const key of Object.keys(env)) { + delete (env as Record)[key] + } + Object.assign(env, envSnapshot) + resetDbChainMock() + retrySpy.mockRestore() + vi.mocked(workspacesUtilsModule.getWorkspaceBilledAccountUserId).mockRestore() + vi.mocked(billingAttributionModule.assertBillingAttributionSnapshot).mockRestore() + vi.mocked(billingAttributionModule.checkAttributedUsageLimits).mockRestore() + vi.mocked(billingAttributionModule.resolveBillingAttribution).mockRestore() + vi.mocked(billingAttributionModule.toBillingContext).mockRestore() +}) -vi.mock('@/lib/billing/core/billing-attribution', () => ({ - assertBillingAttributionSnapshot: vi.fn((value) => value), - checkAttributedUsageLimits: vi.fn().mockResolvedValue({ +/** + * Spy on the real documents/utils namespace instead of vi.mock: the shared + * `@/lib/knowledge/embeddings` module may be cached bound to the real module, + * so patching the namespace is the only wiring that always applies. + */ +const retrySpy = vi + .spyOn(documentsUtilsModule, 'retryWithExponentialBackoff') + .mockImplementation(((fn: () => unknown) => fn()) as never) + +const BILLING_ATTRIBUTION_FIXTURE = { + actorUserId: 'billing-user-1', + billedAccountUserId: 'billing-user-1', + billingEntity: { type: 'user', id: 'billing-user-1' }, + billingPeriod: { + start: '2026-07-01T00:00:00.000Z', + end: '2026-08-01T00:00:00.000Z', + }, + organizationId: null, + payerSubscription: null, + workspaceId: 'workspace1', +} as never + +function applyBillingSpies() { + vi.spyOn(workspacesUtilsModule, 'getWorkspaceBilledAccountUserId').mockResolvedValue('user1') + vi.spyOn(billingAttributionModule, 'assertBillingAttributionSnapshot').mockImplementation( + ((value: unknown) => value) as never + ) + vi.spyOn(billingAttributionModule, 'checkAttributedUsageLimits').mockResolvedValue({ isExceeded: false, payerUsage: { currentUsage: 0, limit: 100 }, - }), - resolveBillingAttribution: vi.fn().mockResolvedValue({ - actorUserId: 'billing-user-1', - billedAccountUserId: 'billing-user-1', - billingEntity: { type: 'user', id: 'billing-user-1' }, - billingPeriod: { - start: '2026-07-01T00:00:00.000Z', - end: '2026-08-01T00:00:00.000Z', - }, - organizationId: null, - payerSubscription: null, - workspaceId: 'workspace1', - }), - toBillingContext: vi.fn(() => ({ + } as never) + vi.spyOn(billingAttributionModule, 'resolveBillingAttribution').mockResolvedValue( + BILLING_ATTRIBUTION_FIXTURE + ) + vi.spyOn(billingAttributionModule, 'toBillingContext').mockImplementation((() => ({ billingEntity: { type: 'user', id: 'billing-user-1' }, billingPeriod: { start: new Date('2026-07-01T00:00:00.000Z'), end: new Date('2026-08-01T00:00:00.000Z'), }, - })), -})) + })) as never) +} + +/** + * Billing helpers are spied on the real namespaces (not vi.mock'd) for the + * same shared-consumer reason as the retry spy above. + */ +applyBillingSpies() vi.mock('@/lib/knowledge/documents/document-processor', () => ({ processDocument: vi.fn().mockResolvedValue({ @@ -79,29 +110,8 @@ vi.mock('@/lib/knowledge/documents/document-processor', () => ({ }), })) -const dbOps: { - order: string[] - insertRecords: any[][] - updatePayloads: any[] -} = { - order: [], - insertRecords: [], - updatePayloads: [], -} - -let kbRows: any[] = [] -let docRows: any[] = [] -let chunkRows: any[] = [] - -function resetDatasets() { - kbRows = [] - docRows = [] - chunkRows = [] -} - -vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ +function createEmbeddingFetchMock() { + return vi.fn().mockResolvedValue({ ok: true, json: async () => ({ data: [ @@ -111,140 +121,9 @@ vi.stubGlobal( usage: { prompt_tokens: 2, total_tokens: 2 }, }), }) -) - -vi.mock('@sim/db', async () => { - const { schemaMock } = (await import('@sim/testing')) as typeof import('@sim/testing') - const tableNameFor = (table: any) => { - if (table === schemaMock.knowledgeBase) return 'knowledge_base' - if (table === schemaMock.document) return 'document' - if (table === schemaMock.embedding) return 'embedding' - return '' - } - const selectBuilder = { - from(table: any) { - return { - where() { - return { - limit(n: number) { - const tableName = tableNameFor(table) - - if (tableName === 'knowledge_base') { - return Promise.resolve(kbRows.slice(0, n)) - } - if (tableName === 'document') { - return Promise.resolve(docRows.slice(0, n)) - } - if (tableName === 'embedding') { - return Promise.resolve(chunkRows.slice(0, n)) - } - - return Promise.resolve([]) - }, - } - }, - innerJoin() { - // document × knowledge_base context JOIN — return the first kb and - // doc row merged (covers processDocumentAsync's prefetch). - return { - leftJoin: () => ({ - where: () => ({ - limit: (n: number) => - Promise.resolve( - kbRows.length > 0 && docRows.length > 0 - ? [ - { ...kbRows[0], ...docRows[0], billedAccountUserId: 'billing-user-1' }, - ].slice(0, n) - : [] - ), - }), - }), - where: () => ({ - limit: (n: number) => - Promise.resolve( - kbRows.length > 0 && docRows.length > 0 - ? [{ ...kbRows[0], ...docRows[0] }].slice(0, n) - : [] - ), - }), - } - }, - } - }, - } +} - return { - db: { - select: vi.fn(() => selectBuilder), - update: (table: any) => ({ - set: (payload: any) => ({ - where: () => { - const tableName = tableNameFor(table) - if (tableName === 'knowledge_base') { - dbOps.order.push('updateKb') - dbOps.updatePayloads.push(payload) - } else if (tableName === 'document') { - if (payload.processingStatus !== 'processing') { - dbOps.order.push('updateDoc') - dbOps.updatePayloads.push(payload) - } - } - return Promise.resolve() - }, - }), - }), - delete: () => ({ - where: () => Promise.resolve(), - }), - insert: () => ({ - values: (records: any) => { - dbOps.order.push('insert') - dbOps.insertRecords.push(records) - return Promise.resolve() - }, - }), - transaction: vi.fn(async (fn: any) => { - await fn({ - select: () => ({ - from: () => ({ - innerJoin: () => ({ - where: () => ({ - limit: () => Promise.resolve([{ id: 'doc1' }]), - }), - }), - where: () => ({ - limit: () => Promise.resolve([{}]), - }), - }), - }), - delete: () => ({ - where: () => Promise.resolve(), - }), - insert: () => ({ - values: (records: any) => { - dbOps.order.push('insert') - dbOps.insertRecords.push(records) - return Promise.resolve() - }, - }), - update: () => ({ - set: (payload: any) => ({ - where: () => { - dbOps.updatePayloads.push(payload) - const label = payload.processingStatus !== undefined ? 'updateDoc' : 'updateKb' - dbOps.order.push(label) - return Promise.resolve() - }, - }), - }), - }) - }), - }, - document: {}, - knowledgeBase: {}, - embedding: {}, - } -}) +vi.stubGlobal('fetch', createEmbeddingFetchMock()) import { processDocumentAsync } from '@/lib/knowledge/documents/service' import { generateEmbeddings } from '@/lib/knowledge/embeddings' @@ -256,23 +135,38 @@ import { describe('Knowledge Utils', () => { beforeEach(() => { - dbOps.order.length = 0 - dbOps.insertRecords.length = 0 - dbOps.updatePayloads.length = 0 - resetDatasets() vi.clearAllMocks() + resetDbChainMock() + // `unstubGlobals: true` removes the module-scope fetch stub after the + // first test in the worker; re-stub it per test. + vi.stubGlobal('fetch', createEmbeddingFetchMock()) + // Under `isolate: false` the shared `@/lib/knowledge/embeddings` module may + // be cached bound to the REAL env module, so reset the real `env` object + // per test instead of vi.mock'ing a file-local replacement that a cached + // consumer would never see. + for (const key of Object.keys(env)) { + delete (env as Record)[key] + } + Object.assign(env, { ...defaultMockEnv, OPENAI_API_KEY: 'test-key' }) + retrySpy.mockImplementation(((fn: () => unknown) => fn()) as never) + applyBillingSpies() }) describe('processDocumentAsync', () => { - it.concurrent('should insert embeddings before updating document counters', async () => { - kbRows.push({ - id: 'kb1', - userId: 'user1', - workspaceId: 'workspace1', - embeddingModel: 'text-embedding-3-small', - chunkingConfig: { maxSize: 1024, minSize: 1, overlap: 200 }, - }) - docRows.push({ id: 'doc1', knowledgeBaseId: 'kb1' }) + it('should insert embeddings before updating document counters', async () => { + /** Context prefetch JOIN (document × knowledge_base × workspace). */ + queueTableRows(schemaMock.document, [ + { + workspaceId: 'workspace1', + knowledgeBaseUserId: 'user1', + chunkingConfig: { maxSize: 1024, minSize: 1, overlap: 200 }, + embeddingModel: 'text-embedding-3-small', + billedAccountUserId: 'billing-user-1', + uploadedBy: null, + }, + ]) + /** In-transaction active-document recheck. */ + queueTableRows(schemaMock.document, [{ id: 'doc1' }]) await processDocumentAsync( 'kb1', @@ -298,25 +192,29 @@ describe('Knowledge Utils', () => { } ) - // Embeddings are inserted first, then the document counter update. A - // usage_log billing insert (recordUsage) may trail after updateDoc and is - // irrelevant to this ordering invariant, so assert position rather than - // exact array equality. - expect(dbOps.order[0]).toBe('insert') - expect(dbOps.order.indexOf('updateDoc')).toBeGreaterThan(0) - - expect(dbOps.updatePayloads[0]).toMatchObject({ + /** + * Embeddings are inserted first, then the document counter update. The + * status→'processing' update precedes both and a usage_log billing insert + * (recordUsage) may trail after — assert relative order via the shared + * spies' invocation order rather than exact call sequences. + */ + const setPayloads = dbChainMockFns.set.mock.calls.map((call) => call[0]) + const completedIndex = setPayloads.findIndex((p) => p?.processingStatus === 'completed') + expect(setPayloads[completedIndex]).toMatchObject({ processingStatus: 'completed', chunkCount: 2, }) - expect(dbOps.insertRecords[0].length).toBe(2) + expect(dbChainMockFns.values.mock.calls[0][0]).toHaveLength(2) + expect(dbChainMockFns.values.mock.invocationCallOrder[0]).toBeLessThan( + dbChainMockFns.set.mock.invocationCallOrder[completedIndex] + ) }) }) describe('checkKnowledgeBaseAccess', () => { - it.concurrent('should return success for owner', async () => { - kbRows.push({ id: 'kb1', userId: 'user1' }) + it('should return success for owner', async () => { + queueTableRows(schemaMock.knowledgeBase, [{ id: 'kb1', userId: 'user1' }]) const result = await checkKnowledgeBaseAccess('kb1', 'user1') expect(result.hasAccess).toBe(true) @@ -331,8 +229,8 @@ describe('Knowledge Utils', () => { }) describe('checkDocumentAccess', () => { - it.concurrent('should return unauthorized when user mismatch', async () => { - kbRows.push({ id: 'kb1', userId: 'owner' }) + it('should return unauthorized when user mismatch', async () => { + queueTableRows(schemaMock.knowledgeBase, [{ id: 'kb1', userId: 'owner' }]) const result = await checkDocumentAccess('kb1', 'doc1', 'intruder') expect(result.hasAccess).toBe(false) @@ -343,9 +241,11 @@ describe('Knowledge Utils', () => { }) describe('checkChunkAccess', () => { - it.concurrent('should fail when document is not completed', async () => { - kbRows.push({ id: 'kb1', userId: 'user1' }) - docRows.push({ id: 'doc1', knowledgeBaseId: 'kb1', processingStatus: 'processing' }) + it('should fail when document is not completed', async () => { + queueTableRows(schemaMock.knowledgeBase, [{ id: 'kb1', userId: 'user1' }]) + queueTableRows(schemaMock.document, [ + { id: 'doc1', knowledgeBaseId: 'kb1', processingStatus: 'processing' }, + ]) const result = await checkChunkAccess('kb1', 'doc1', 'chunk1', 'user1') @@ -356,9 +256,11 @@ describe('Knowledge Utils', () => { }) it('should return success for valid access', async () => { - kbRows.push({ id: 'kb1', userId: 'user1' }) - docRows.push({ id: 'doc1', knowledgeBaseId: 'kb1', processingStatus: 'completed' }) - chunkRows.push({ id: 'chunk1', documentId: 'doc1' }) + queueTableRows(schemaMock.knowledgeBase, [{ id: 'kb1', userId: 'user1' }]) + queueTableRows(schemaMock.document, [ + { id: 'doc1', knowledgeBaseId: 'kb1', processingStatus: 'completed' }, + ]) + queueTableRows(schemaMock.embedding, [{ id: 'chunk1', documentId: 'doc1' }]) const result = await checkChunkAccess('kb1', 'doc1', 'chunk1', 'user1') @@ -370,7 +272,7 @@ describe('Knowledge Utils', () => { }) describe('generateEmbeddings', () => { - it.concurrent('should return same length as input', async () => { + it('should return same length as input', async () => { const result = await generateEmbeddings(['a', 'b']) expect(result.embeddings.length).toBe(2) diff --git a/apps/sim/app/api/mcp/oauth/callback/route.test.ts b/apps/sim/app/api/mcp/oauth/callback/route.test.ts index 96a7de727fe..1cad45541bd 100644 --- a/apps/sim/app/api/mcp/oauth/callback/route.test.ts +++ b/apps/sim/app/api/mcp/oauth/callback/route.test.ts @@ -3,12 +3,10 @@ */ import { authMockFns, - dbChainMock, dbChainMockFns, mcpOauthMock, mcpOauthMockFns, resetDbChainMock, - schemaMock, } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -17,13 +15,6 @@ const { mockDiscoverServerTools } = vi.hoisted(() => ({ mockDiscoverServerTools: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) -vi.mock('@sim/db/schema', () => schemaMock) -vi.mock('drizzle-orm', () => ({ - and: vi.fn(), - eq: vi.fn(), - isNull: vi.fn(), -})) vi.mock('@/lib/mcp/oauth', () => mcpOauthMock) vi.mock('@/lib/mcp/service', () => ({ mcpService: { discoverServerTools: mockDiscoverServerTools }, diff --git a/apps/sim/app/api/mcp/oauth/callback/route.ts b/apps/sim/app/api/mcp/oauth/callback/route.ts index 7694f23b010..9d63191fb32 100644 --- a/apps/sim/app/api/mcp/oauth/callback/route.ts +++ b/apps/sim/app/api/mcp/oauth/callback/route.ts @@ -16,55 +16,17 @@ import { loadOauthRowByState, loadPreregisteredClient, type McpOauthCallbackReason, + makeTimedStep, mcpAuthGuarded, SimMcpOauthProvider, } from '@/lib/mcp/oauth' import { mcpService } from '@/lib/mcp/service' const logger = createLogger('McpOauthCallbackAPI') +const timedStep = makeTimedStep(logger) export const dynamic = 'force-dynamic' -class OauthCallbackStepTimeout extends Error { - constructor(step: string, ms: number) { - super(`MCP OAuth callback step "${step}" did not settle within ${ms}ms`) - this.name = 'OauthCallbackStepTimeout' - } -} - -/** - * Times and bounds one awaited step of the callback so a stalled operation - * surfaces as a labeled, logged error instead of hanging the request forever. - * The losing promise is not cancelled (a wedged DB/socket op can't be), so it - * settles in the background with its rejection swallowed; the point is that the - * request stops waiting on it and the logs name the exact step that stalled. - */ -async function timedStep(step: string, ms: number, fn: () => Promise): Promise { - const start = Date.now() - logger.info(`OAuth callback step start: ${step}`) - const work = Promise.resolve(fn()) - work.catch(() => {}) - let timer: ReturnType | undefined - try { - const value = await Promise.race([ - work, - new Promise((_, reject) => { - timer = setTimeout(() => reject(new OauthCallbackStepTimeout(step, ms)), ms) - timer.unref?.() - }), - ]) - logger.info(`OAuth callback step done: ${step} (${Date.now() - start}ms)`) - return value - } catch (error) { - logger.error(`OAuth callback step failed: ${step} (${Date.now() - start}ms)`, { - error: toError(error).message, - }) - throw error - } finally { - clearTimeout(timer) - } -} - function escapeHtml(value: string): string { return value .replace(/&/g, '&') @@ -125,12 +87,19 @@ export const GET = withRouteHandler(async (request: NextRequest) => { serverId?: string ) => htmlClose(message, ok, reason, serverId, state) - const initialRow = state ? await loadOauthRowByState(state).catch(() => null) : null + const initialRow = state + ? await timedStep('loadOauthRowByState', 15_000, () => loadOauthRowByState(state)).catch( + () => null + ) + : null const stateRowServerId = initialRow?.mcpServerId if (errorParam) { logger.warn(`MCP OAuth callback received error: ${errorParam}`) - if (initialRow) await clearState(initialRow.id, 'callback:provider_error').catch(() => {}) + if (initialRow) + await timedStep('clearState(provider_error)', 10_000, () => + clearState(initialRow.id, 'callback:provider_error') + ).catch(() => {}) return respond(`Authorization failed: ${errorParam}`, false, 'provider_error', stateRowServerId) } if (!state || !code) { @@ -144,7 +113,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { let serverId: string | undefined try { - const session = await getSession() + const session = await timedStep('getSession', 15_000, () => getSession()) if (!session?.user?.id) { return respond( 'You must be signed in to complete authorization.', @@ -169,11 +138,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) } - const [server] = await db - .select({ id: mcpServers.id, url: mcpServers.url, workspaceId: mcpServers.workspaceId }) - .from(mcpServers) - .where(and(eq(mcpServers.id, row.mcpServerId), isNull(mcpServers.deletedAt))) - .limit(1) + const [server] = await timedStep('loadServer', 15_000, () => + db + .select({ id: mcpServers.id, url: mcpServers.url, workspaceId: mcpServers.workspaceId }) + .from(mcpServers) + .where(and(eq(mcpServers.id, row.mcpServerId), isNull(mcpServers.deletedAt))) + .limit(1) + ) if (!server || !server.url) { return respond('Server no longer exists.', false, 'server_gone', serverId) } diff --git a/apps/sim/app/api/mcp/oauth/start/route.test.ts b/apps/sim/app/api/mcp/oauth/start/route.test.ts index 6b0df17c66e..c9b09937fbe 100644 --- a/apps/sim/app/api/mcp/oauth/start/route.test.ts +++ b/apps/sim/app/api/mcp/oauth/start/route.test.ts @@ -2,29 +2,19 @@ * @vitest-environment node */ import { - dbChainMock, dbChainMockFns, - hybridAuthMock, hybridAuthMockFns, McpOauthRedirectRequiredMock, mcpOauthMock, mcpOauthMockFns, + OauthStepTimeoutErrorMock, permissionsMock, permissionsMockFns, resetDbChainMock, - schemaMock, } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@sim/db', () => dbChainMock) -vi.mock('@sim/db/schema', () => schemaMock) -vi.mock('drizzle-orm', () => ({ - and: vi.fn(), - eq: vi.fn(), - isNull: vi.fn(), -})) -vi.mock('@/lib/auth/hybrid', () => hybridAuthMock) vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) vi.mock('@/lib/mcp/oauth', () => mcpOauthMock) @@ -86,6 +76,54 @@ describe('MCP OAuth start route', () => { ) }) + it('returns 504 (not a retry) when the auth step times out', async () => { + // The stall is intentionally NOT auto-retried — a lingering attempt shares the OAuth row and + // could corrupt the retry's PKCE/state. The bounded step fails fast; the user re-clicks. + mcpOauthMockFns.mockMcpAuthGuarded.mockImplementationOnce(() => { + throw new OauthStepTimeoutErrorMock('mcpAuthGuarded', 12_000) + }) + const request = new NextRequest( + 'http://localhost:3000/api/mcp/oauth/start?workspaceId=workspace-1&serverId=server-1' + ) + + const response = await GET(request) + + expect(mcpOauthMockFns.mockMcpAuthGuarded).toHaveBeenCalledTimes(1) + expect(response.status).toBe(504) + }) + + it('returns 504 (not a generic 500) when a DB step times out', async () => { + // DB-step timeouts are bounded too; their OauthStepTimeoutError must reach the same + // 504 handler, not fall through to the generic 500. + mcpOauthMockFns.mockGetOrCreateOauthRow.mockImplementationOnce(() => { + throw new OauthStepTimeoutErrorMock('getOrCreateOauthRow', 5_000) + }) + const request = new NextRequest( + 'http://localhost:3000/api/mcp/oauth/start?workspaceId=workspace-1&serverId=server-1' + ) + + const response = await GET(request) + + expect(response.status).toBe(504) + expect(mcpOauthMockFns.mockMcpAuthGuarded).not.toHaveBeenCalled() + }) + + it('returns the authorize URL without error-logging the success redirect throw', async () => { + mcpOauthMockFns.mockMcpAuthGuarded.mockRejectedValueOnce( + new McpOauthRedirectRequiredMock('https://mcp.exa.ai/authorize') + ) + const request = new NextRequest( + 'http://localhost:3000/api/mcp/oauth/start?workspaceId=workspace-1&serverId=server-1' + ) + + const response = await GET(request) + const body = await response.json() + + expect(mcpOauthMockFns.mockMcpAuthGuarded).toHaveBeenCalledTimes(1) + expect(response.status).toBe(200) + expect(body).toEqual({ status: 'redirect', authorizationUrl: 'https://mcp.exa.ai/authorize' }) + }) + it('requires workspace write permission via MCP auth middleware', async () => { const request = new NextRequest( 'http://localhost:3000/api/mcp/oauth/start?workspaceId=workspace-1&serverId=server-1' diff --git a/apps/sim/app/api/mcp/oauth/start/route.ts b/apps/sim/app/api/mcp/oauth/start/route.ts index 5bc08af493f..3a936167650 100644 --- a/apps/sim/app/api/mcp/oauth/start/route.ts +++ b/apps/sim/app/api/mcp/oauth/start/route.ts @@ -16,14 +16,31 @@ import { loadPreregisteredClient, McpOauthInsecureUrlError, McpOauthRedirectRequired, + makeTimedStep, mcpAuthGuarded, + OauthStepTimeoutError, SimMcpOauthProvider, setOauthRowUser, } from '@/lib/mcp/oauth' import { createMcpErrorResponse } from '@/lib/mcp/utils' const logger = createLogger('McpOauthStartAPI') +const timedStep = makeTimedStep(logger) const OAUTH_START_TTL_MS = 10 * 60 * 1000 +/** + * Per-step budgets, kept small so the whole request stays under the client's 30s `/oauth/start` + * deadline even in the worst case: up to four bounded DB steps (loadServer, getOrCreateOauthRow, + * setOauthRowUser, loadPreregisteredClient) + the auth step = 4×4 + 10 = 26s, leaving margin for + * middleware and network. OAuth discovery + DCR occasionally hits the transient + * headers-then-stalled-body class documented for CDN-fronted MCP hosts; the bound turns that into + * a fast, labeled failure so the popup closes with a clear error and the user can retry (a fresh + * click = a fresh connection that dodges the per-connection stall) rather than the popup hanging + * blank. We deliberately do NOT auto-retry here: `timedStep` can't cancel a wedged attempt, and a + * lingering first attempt sharing this server's OAuth row could overwrite the retry's PKCE + * verifier / state and break the callback. + */ +const DB_STEP_MS = 4_000 +const MCP_AUTH_STEP_MS = 10_000 const MAX_SURFACED_ERROR_LENGTH = 250 const DCR_UNSUPPORTED_MESSAGE = "This server doesn't support automatic OAuth client registration. Add a pre-registered OAuth client ID and secret, or configure a token instead." @@ -81,18 +98,21 @@ export const GET = withRouteHandler( const parsed = await parseRequest(startMcpOauthContract, request, {}) if (!parsed.success) return parsed.response const { serverId } = parsed.data.query + logger.info(`Starting MCP OAuth flow for server ${serverId}`) - const [server] = await db - .select() - .from(mcpServers) - .where( - and( - eq(mcpServers.id, serverId), - eq(mcpServers.workspaceId, workspaceId), - isNull(mcpServers.deletedAt) + const [server] = await timedStep('loadServer', DB_STEP_MS, () => + db + .select() + .from(mcpServers) + .where( + and( + eq(mcpServers.id, serverId), + eq(mcpServers.workspaceId, workspaceId), + isNull(mcpServers.deletedAt) + ) ) - ) - .limit(1) + .limit(1) + ) if (!server) { return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) @@ -107,8 +127,9 @@ export const GET = withRouteHandler( if (!server.url) { return createMcpErrorResponse(new Error('Server has no URL'), 'Missing server URL', 400) } + const serverUrl = server.url try { - assertSafeOauthServerUrl(server.url) + assertSafeOauthServerUrl(serverUrl) } catch (e) { if (e instanceof McpOauthInsecureUrlError) { return createMcpErrorResponse( @@ -120,11 +141,13 @@ export const GET = withRouteHandler( throw e } - const row = await getOrCreateOauthRow({ - mcpServerId: server.id, - userId, - workspaceId, - }) + const row = await timedStep('getOrCreateOauthRow', DB_STEP_MS, () => + getOrCreateOauthRow({ + mcpServerId: server.id, + userId, + workspaceId, + }) + ) const hasActiveFlow = !!row.state && !!row.stateCreatedAt && @@ -137,17 +160,38 @@ export const GET = withRouteHandler( ) } if (row.userId !== userId) { - await setOauthRowUser(row.id, userId) + await timedStep('setOauthRowUser', DB_STEP_MS, () => setOauthRowUser(row.id, userId)) row.userId = userId } - const preregistered = await loadPreregisteredClient(server.id) + const preregistered = await timedStep('loadPreregisteredClient', DB_STEP_MS, () => + loadPreregisteredClient(server.id) + ) const provider = new SimMcpOauthProvider({ row, preregistered }) try { - const result = await mcpAuthGuarded(provider, { - serverUrl: server.url, + // OAuth discovery + DCR through the guarded fetch, bounded so a transient stall fails + // fast with a labeled log instead of hanging the popup. `McpOauthRedirectRequired` is + // the SUCCESS signal (a throw carrying the authorize URL), so we catch it INSIDE the + // bounded step and return it as a normal value — otherwise timedStep would error-log + // every successful authorize. Only a real error or a timeout escapes as a throw. + const authOutcome = await timedStep('mcpAuthGuarded', MCP_AUTH_STEP_MS, async () => { + try { + return { kind: 'result' as const, value: await mcpAuthGuarded(provider, { serverUrl }) } + } catch (e) { + if (e instanceof McpOauthRedirectRequired) { + return { kind: 'redirect' as const, authorizationUrl: e.authorizationUrl } + } + throw e + } }) - if (result === 'AUTHORIZED') { + if (authOutcome.kind === 'redirect') { + logger.info(`OAuth redirect for server ${serverId}`) + return NextResponse.json({ + status: 'redirect', + authorizationUrl: authOutcome.authorizationUrl, + }) + } + if (authOutcome.value === 'AUTHORIZED') { return NextResponse.json({ status: 'already_authorized' }) } return createMcpErrorResponse( @@ -156,19 +200,26 @@ export const GET = withRouteHandler( 500 ) } catch (e) { - if (e instanceof McpOauthRedirectRequired) { - logger.info(`OAuth redirect for server ${serverId}`) - return NextResponse.json({ - status: 'redirect', - authorizationUrl: e.authorizationUrl, - }) - } if (isDynamicClientRegistrationUnsupported(e)) { return createMcpErrorResponse(toError(e), DCR_UNSUPPORTED_MESSAGE, 422) } throw e } } catch (error) { + // Any bounded step timing out (DB reads or the auth step) is a stall, not a bug — + // surface it as a fast 504 so the popup closes with a clear "try again" rather than a + // generic 500. A fresh retry is a clean flow: the callback correlates on the `state` + // nonce, so even if a lingering timed-out attempt later overwrites the row's state, the + // user's authorize URL (carrying the fresh nonce) simply fails `invalid_state` — a clean + // retry, never silent corruption. + if (error instanceof OauthStepTimeoutError) { + logger.warn('MCP OAuth start stalled') + return createMcpErrorResponse( + error, + 'Authorization is taking too long — please try again.', + 504 + ) + } logger.error('Error starting MCP OAuth flow:', error) // Only surface OAuth-flow errors verbatim; everything else (DB, decryption, // network) gets a generic message to avoid leaking internal details. diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts index a67cb62ba37..99bf39603b6 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts @@ -4,15 +4,16 @@ * @vitest-environment node */ import { - dbChainMock, dbChainMockFns, hybridAuthMockFns, permissionsMock, permissionsMockFns, resetDbChainMock, + resetEnvMock, + setEnv, } from '@sim/testing' import { NextRequest } from 'next/server' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { mockAssertBillingAttributionSnapshot, @@ -54,27 +55,12 @@ function createBillingAttribution(actorUserId: string, workspaceId: string) { } } -vi.mock('@sim/db', () => dbChainMock) -vi.mock('drizzle-orm', () => ({ - and: vi.fn(), - asc: vi.fn(), - eq: vi.fn(), - gt: vi.fn(), - isNull: vi.fn(), - sql: vi.fn(), -})) - vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) vi.mock('@/lib/auth/internal', () => ({ generateInternalToken: mockGenerateInternalToken, })) -vi.mock('@/lib/core/utils/urls', () => ({ - getBaseUrl: () => 'http://localhost:3000', - getInternalApiBaseUrl: () => 'http://localhost:3000', -})) - vi.mock('@/lib/core/execution-limits', () => ({ getMaxExecutionTimeout: () => 10_000, })) @@ -82,9 +68,14 @@ vi.mock('@/lib/core/execution-limits', () => ({ import { DELETE, GET, POST } from '@/app/api/mcp/serve/[serverId]/route' describe('MCP Serve Route', () => { + afterAll(() => { + resetEnvMock() + }) + beforeEach(() => { vi.clearAllMocks() resetDbChainMock() + setEnv({ NEXT_PUBLIC_APP_URL: 'http://localhost:3000' }) vi.stubGlobal('fetch', fetchMock) mockResolveBillingAttribution.mockImplementation( ({ actorUserId, workspaceId }: { actorUserId: string; workspaceId: string }) => diff --git a/apps/sim/app/api/mcp/servers/[id]/refresh/route.test.ts b/apps/sim/app/api/mcp/servers/[id]/refresh/route.test.ts index e3f217fbdde..0feba60d055 100644 --- a/apps/sim/app/api/mcp/servers/[id]/refresh/route.test.ts +++ b/apps/sim/app/api/mcp/servers/[id]/refresh/route.test.ts @@ -1,21 +1,13 @@ /** * @vitest-environment node */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import type { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockClearCache, mockDiscoverServerTools, mockSelect, mockUpdateSet } = vi.hoisted(() => ({ +const { mockClearCache, mockDiscoverServerTools } = vi.hoisted(() => ({ mockClearCache: vi.fn(), mockDiscoverServerTools: vi.fn(), - mockSelect: vi.fn(), - mockUpdateSet: vi.fn(), -})) - -vi.mock('@sim/db', () => ({ - db: { - select: mockSelect, - update: vi.fn().mockReturnValue({ set: mockUpdateSet }), - }, })) vi.mock('@/lib/core/utils/with-route-handler', () => ({ @@ -68,23 +60,16 @@ const persistedServer = { toolCount: 0, } -function selectRows(rows: unknown[]) { - return { - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue(rows), - }), - }), - } -} - describe('MCP server refresh route', () => { beforeEach(() => { vi.clearAllMocks() - mockSelect.mockReturnValueOnce(selectRows([initialServer])) - mockUpdateSet.mockReturnValue({ - where: vi.fn().mockReturnValue({ returning: vi.fn().mockResolvedValue([persistedServer]) }), - }) + resetDbChainMock() + dbChainMockFns.limit.mockResolvedValueOnce([initialServer]) + dbChainMockFns.returning.mockResolvedValue([persistedServer]) + }) + + afterAll(() => { + resetDbChainMock() }) it('preserves the service-persisted OAuth pending status', async () => { @@ -102,7 +87,7 @@ describe('MCP server refresh route', () => { error: null, }) ) - expect(mockUpdateSet).not.toHaveBeenCalledWith( + expect(dbChainMockFns.set).not.toHaveBeenCalledWith( expect.objectContaining({ connectionStatus: expect.anything() }) ) }) @@ -112,11 +97,7 @@ describe('MCP server refresh route', () => { mockDiscoverServerTools.mockRejectedValueOnce( new Error(`Upstream reflected ${reflectedSecret}`) ) - mockUpdateSet.mockReturnValueOnce({ - where: vi.fn().mockReturnValue({ - returning: vi.fn().mockResolvedValue([initialServer]), - }), - }) + dbChainMockFns.returning.mockResolvedValueOnce([initialServer]) const request = new Request('http://localhost/api/mcp/servers/server-1/refresh', { method: 'POST', @@ -142,11 +123,7 @@ describe('MCP server refresh route', () => { lastConnected: new Date(Date.now() + 60_000), toolCount: 7, } - mockUpdateSet.mockReturnValueOnce({ - where: vi.fn().mockReturnValue({ - returning: vi.fn().mockResolvedValue([newerSuccessfulServer]), - }), - }) + dbChainMockFns.returning.mockResolvedValueOnce([newerSuccessfulServer]) const request = new Request('http://localhost/api/mcp/servers/server-1/refresh', { method: 'POST', @@ -175,12 +152,17 @@ describe('MCP server refresh route', () => { serverName: 'OAuth Server', }, ]) - // The route's server lookup consumes the first select (beforeEach). The sync's - // workflow select is left unmocked, so it throws — exercising the guard that - // keeps a secondary sync failure from turning a successful refresh into a 500. - mockUpdateSet.mockReturnValueOnce({ - where: vi.fn().mockReturnValue({ returning: vi.fn().mockResolvedValue([initialServer]) }), - }) + // The route's server lookup consumes the first from() (verbatim mini-builder); + // the sync's workflow select then throws — exercising the guard that keeps a + // secondary sync failure from turning a successful refresh into a 500. + dbChainMockFns.from + .mockImplementationOnce(() => ({ + where: () => ({ limit: () => Promise.resolve([initialServer]) }), + })) + .mockImplementationOnce(() => { + throw new Error('workflow select unavailable') + }) + dbChainMockFns.returning.mockResolvedValueOnce([initialServer]) const request = new Request('http://localhost/api/mcp/servers/server-1/refresh', { method: 'POST', diff --git a/apps/sim/app/api/mcp/servers/route.test.ts b/apps/sim/app/api/mcp/servers/route.test.ts index e831c802e0a..beb8187dd41 100644 --- a/apps/sim/app/api/mcp/servers/route.test.ts +++ b/apps/sim/app/api/mcp/servers/route.test.ts @@ -1,19 +1,14 @@ /** * @vitest-environment node */ +import { resetDbChainMock } from '@sim/testing' import type { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockPerformDeleteMcpServer } = vi.hoisted(() => ({ mockPerformDeleteMcpServer: vi.fn(), })) -vi.mock('@sim/db', () => ({ - db: { - select: vi.fn(), - }, -})) - vi.mock('@/lib/mcp/middleware', () => ({ getParsedBody: () => undefined, withMcpAuth: @@ -57,6 +52,11 @@ function createDeleteRequest(serverId = 'server-1') { describe('MCP servers DELETE route', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() + }) + + afterAll(() => { + resetDbChainMock() }) it('returns 404 when orchestration reports a missing server', async () => { diff --git a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.test.ts b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.test.ts index a3c42f0f07e..9b4c80e23e9 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.test.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.test.ts @@ -1,13 +1,18 @@ /** * @vitest-environment node */ -import { copilotHttpMock, copilotHttpMockFns } from '@sim/testing' +import { + copilotHttpMock, + copilotHttpMockFns, + dbChainMockFns, + resetDbChainMock, + resetEnvMock, + setEnv, +} from '@sim/testing' import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { - mockTransaction, - mockSelectRows, mockFilterForkableChatFiles, mockListForkableChatFiles, mockPlanChatFileCopies, @@ -18,11 +23,8 @@ const { mockFetchGo, mockPublishStatusChanged, mockCaptureServerEvent, - mockDeleteWhere, mockRemoveChatResources, } = vi.hoisted(() => ({ - mockTransaction: vi.fn(), - mockSelectRows: vi.fn(), // Real (pure) cut semantics so tests drive selection through row.messageId: // rows with a NULL/undefined messageId are kept in every fork. mockFilterForkableChatFiles: vi.fn( @@ -38,52 +40,9 @@ const { mockFetchGo: vi.fn(), mockPublishStatusChanged: vi.fn(), mockCaptureServerEvent: vi.fn(), - mockDeleteWhere: vi.fn(), mockRemoveChatResources: vi.fn(), })) -vi.mock('@sim/db', () => ({ - db: { - select: () => ({ - from: () => ({ - where: () => ({ - limit: () => mockSelectRows(), - }), - }), - }), - delete: () => ({ - where: mockDeleteWhere, - }), - transaction: mockTransaction, - }, -})) - -vi.mock('@sim/db/schema', () => ({ - copilotChats: { - id: 'copilotChats.id', - userId: 'copilotChats.userId', - type: 'copilotChats.type', - workspaceId: 'copilotChats.workspaceId', - title: 'copilotChats.title', - model: 'copilotChats.model', - resources: 'copilotChats.resources', - previewYaml: 'copilotChats.previewYaml', - planArtifact: 'copilotChats.planArtifact', - config: 'copilotChats.config', - deletedAt: 'copilotChats.deletedAt', - }, - workspaceFiles: { - id: 'workspaceFiles.id', - }, -})) - -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })), - eq: vi.fn((field: unknown, value: unknown) => ({ type: 'eq', field, value })), - inArray: vi.fn((field: unknown, values: unknown) => ({ type: 'inArray', field, values })), - isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })), -})) - vi.mock('@/lib/copilot/resources/persistence', () => ({ removeChatResources: mockRemoveChatResources, })) @@ -118,8 +77,6 @@ vi.mock('@/lib/copilot/server/agent-url', () => ({ getMothershipSourceEnvHeaders: vi.fn().mockReturnValue({}), })) -vi.mock('@/lib/core/config/env', () => ({ env: {} })) - vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCaptureServerEvent, })) @@ -168,30 +125,6 @@ const threeMessages = [ }, ] -/** Chat rows inserted through the mock transaction, captured for title assertions. */ -let insertedChatRows: Array> = [] -/** tx.update(...).set(...) payloads, captured for resource-rewrite assertions. */ -let updatedChatRows: Array> = [] - -function makeTx() { - return { - insert: () => ({ - values: (v: Record) => { - insertedChatRows.push(v) - return { - returning: async () => [{ id: 'row-id', workspaceId: 'ws-1' }], - } - }, - }), - update: () => ({ - set: (v: Record) => { - updatedChatRows.push(v) - return { where: async () => undefined } - }, - }), - } -} - function createRequest(chatId: string, body?: unknown) { return new NextRequest(`http://localhost:3000/api/mothership/chats/${chatId}/fork`, { method: 'POST', @@ -207,13 +140,14 @@ function makeContext(chatId: string) { describe('POST /api/mothership/chats/[chatId]/fork', () => { beforeEach(() => { vi.clearAllMocks() - insertedChatRows = [] - updatedChatRows = [] + resetDbChainMock() + setEnv({ COPILOT_API_KEY: undefined }) copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({ userId: 'user-1', isAuthenticated: true, }) - mockSelectRows.mockResolvedValue([parentRow]) + dbChainMockFns.limit.mockResolvedValue([parentRow]) + dbChainMockFns.returning.mockResolvedValue([{ id: 'row-id', workspaceId: 'ws-1' }]) mockListForkableChatFiles.mockResolvedValue([]) mockLoadCopilotChatMessages.mockResolvedValue(threeMessages) mockPlanChatFileCopies.mockResolvedValue({ @@ -223,13 +157,14 @@ describe('POST /api/mothership/chats/[chatId]/fork', () => { }) mockExecuteChatFileBlobCopies.mockResolvedValue({ copied: 0, failed: 0, failedCopyIds: [] }) mockAppendCopilotChatMessages.mockResolvedValue(undefined) - mockDeleteWhere.mockResolvedValue(undefined) mockRemoveChatResources.mockResolvedValue(undefined) mockAssertActiveWorkspaceAccess.mockResolvedValue(undefined) mockFetchGo.mockResolvedValue({ ok: true }) - mockTransaction.mockImplementation(async (cb: (tx: unknown) => Promise) => - cb(makeTx()) - ) + }) + + afterAll(() => { + resetDbChainMock() + resetEnvMock() }) it('rejects unauthenticated callers', async () => { @@ -242,14 +177,14 @@ describe('POST /api/mothership/chats/[chatId]/fork', () => { }) it('404s when the chat belongs to another user', async () => { - mockSelectRows.mockResolvedValue([{ ...parentRow, userId: 'someone-else' }]) + dbChainMockFns.limit.mockResolvedValue([{ ...parentRow, userId: 'someone-else' }]) const res = await POST(createRequest('chat-1'), makeContext('chat-1')) expect(res.status).toBe(404) - expect(mockTransaction).not.toHaveBeenCalled() + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() }) it('404s for non-mothership chats', async () => { - mockSelectRows.mockResolvedValue([{ ...parentRow, type: 'copilot' }]) + dbChainMockFns.limit.mockResolvedValue([{ ...parentRow, type: 'copilot' }]) const res = await POST(createRequest('chat-1'), makeContext('chat-1')) expect(res.status).toBe(404) }) @@ -257,13 +192,13 @@ describe('POST /api/mothership/chats/[chatId]/fork', () => { it('400s when upToMessageId is missing', async () => { const res = await POST(createRequest('chat-1', {}), makeContext('chat-1')) expect(res.status).toBe(400) - expect(mockTransaction).not.toHaveBeenCalled() + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() }) it('400s when upToMessageId is an empty string', async () => { const res = await POST(createRequest('chat-1', { upToMessageId: '' }), makeContext('chat-1')) expect(res.status).toBe(400) - expect(mockTransaction).not.toHaveBeenCalled() + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() }) it('400s when the message is not in the chat', async () => { @@ -272,7 +207,7 @@ describe('POST /api/mothership/chats/[chatId]/fork', () => { makeContext('chat-1') ) expect(res.status).toBe(400) - expect(mockTransaction).not.toHaveBeenCalled() + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() }) it('applies the timeline cut: kept message ids drive the file selection', async () => { @@ -370,7 +305,7 @@ describe('POST /api/mothership/chats/[chatId]/fork', () => { ) // Forks are titled "Fork | ". - expect(insertedChatRows[0].title).toBe('Fork | Generate Logs') + expect(dbChainMockFns.values.mock.calls[0][0].title).toBe('Fork | Generate Logs') }) it('still succeeds when the copilot-service clone fails (best-effort)', async () => { @@ -393,9 +328,9 @@ describe('POST /api/mothership/chats/[chatId]/fork', () => { // The dead rows (committed, but no bytes behind them) are hard-deleted so // they vanish from VFS listings and name resolution… - expect(mockDeleteWhere).toHaveBeenCalledWith({ + expect(dbChainMockFns.where).toHaveBeenCalledWith({ type: 'inArray', - field: 'workspaceFiles.id', + column: 'id', values: ['wf_dead1', 'wf_dead2'], }) // …and their resource chips are dropped from the new chat. @@ -411,7 +346,7 @@ describe('POST /api/mothership/chats/[chatId]/fork', () => { const cleanRes = await POST(createRequest('chat-1'), makeContext('chat-1')) expect('failedFileCopies' in (await cleanRes.json())).toBe(false) - expect(mockDeleteWhere).not.toHaveBeenCalled() + expect(dbChainMockFns.delete).not.toHaveBeenCalled() expect(mockRemoveChatResources).not.toHaveBeenCalled() }) @@ -421,7 +356,7 @@ describe('POST /api/mothership/chats/[chatId]/fork', () => { // copies the kept upload AND the pre-cut apple; only the post-cut banana // stays behind, so only its resource is dropped — not left pointing at // the source chat. - mockSelectRows.mockResolvedValue([ + dbChainMockFns.limit.mockResolvedValue([ { ...parentRow, resources: [ @@ -457,8 +392,8 @@ describe('POST /api/mothership/chats/[chatId]/fork', () => { OLD_FILE_ID, 'wf_apple', ]) - expect(updatedChatRows).toHaveLength(1) - expect(updatedChatRows[0].resources).toEqual([ + expect(dbChainMockFns.set).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.set.mock.calls[0][0].resources).toEqual([ { type: 'file', id: NEW_FILE_ID, title: 'cat.png' }, { type: 'file', id: 'wf_apple_copy', title: 'apple.png' }, { type: 'file', id: 'wf_shared', title: 'shared.pdf' }, @@ -469,7 +404,7 @@ describe('POST /api/mothership/chats/[chatId]/fork', () => { it('drops ghosts even when the fork copies no files at all', async () => { // Fork cut before the chat's only upload arrived: a guard that skips the // resources update when idMap is empty would leave the ghost in place. - mockSelectRows.mockResolvedValue([ + dbChainMockFns.limit.mockResolvedValue([ { ...parentRow, resources: [{ type: 'file', id: 'wf_banana', title: 'banana.png' }], @@ -482,7 +417,7 @@ describe('POST /api/mothership/chats/[chatId]/fork', () => { const res = await POST(createRequest('chat-1'), makeContext('chat-1')) expect(res.status).toBe(200) - expect(updatedChatRows).toHaveLength(1) - expect(updatedChatRows[0].resources).toEqual([]) + expect(dbChainMockFns.set).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.set.mock.calls[0][0].resources).toEqual([]) }) }) diff --git a/apps/sim/app/api/mothership/chats/[chatId]/restore/route.test.ts b/apps/sim/app/api/mothership/chats/[chatId]/restore/route.test.ts index 31d49d8a653..ee9438038b8 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/restore/route.test.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/restore/route.test.ts @@ -1,59 +1,15 @@ /** * @vitest-environment node */ -import { copilotHttpMock, copilotHttpMockFns } from '@sim/testing' +import { copilotHttpMock, copilotHttpMockFns, dbChainMockFns, resetDbChainMock } from '@sim/testing' import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockDbSelect, - mockSelectFrom, - mockSelectWhere, - mockSelectLimit, - mockDbUpdate, - mockDbSet, - mockUpdateWhere, - mockDbReturning, - mockAssertActiveWorkspaceAccess, - mockPublishStatusChanged, -} = vi.hoisted(() => ({ - mockDbSelect: vi.fn(), - mockSelectFrom: vi.fn(), - mockSelectWhere: vi.fn(), - mockSelectLimit: vi.fn(), - mockDbUpdate: vi.fn(), - mockDbSet: vi.fn(), - mockUpdateWhere: vi.fn(), - mockDbReturning: vi.fn(), +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockAssertActiveWorkspaceAccess, mockPublishStatusChanged } = vi.hoisted(() => ({ mockAssertActiveWorkspaceAccess: vi.fn(), mockPublishStatusChanged: vi.fn(), })) -vi.mock('@sim/db', () => ({ - db: { - select: mockDbSelect, - update: mockDbUpdate, - }, -})) - -vi.mock('@sim/db/schema', () => ({ - copilotChats: { - id: 'copilotChats.id', - userId: 'copilotChats.userId', - type: 'copilotChats.type', - workspaceId: 'copilotChats.workspaceId', - updatedAt: 'copilotChats.updatedAt', - lastSeenAt: 'copilotChats.lastSeenAt', - deletedAt: 'copilotChats.deletedAt', - }, -})) - -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })), - eq: vi.fn((field: unknown, value: unknown) => ({ type: 'eq', field, value })), - isNotNull: vi.fn((field: unknown) => ({ type: 'isNotNull', field })), -})) - vi.mock('@/lib/copilot/request/http', () => ({ ...copilotHttpMock, createForbiddenResponse: vi.fn((message: string) => ({ @@ -92,21 +48,20 @@ function makeContext(chatId: string) { describe('POST /api/mothership/chats/[chatId]/restore', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({ userId: 'user-1', isAuthenticated: true, }) - mockSelectLimit.mockResolvedValue([{ workspaceId: 'workspace-1' }]) - mockSelectWhere.mockReturnValue({ limit: mockSelectLimit }) - mockSelectFrom.mockReturnValue({ where: mockSelectWhere }) - mockDbSelect.mockReturnValue({ from: mockSelectFrom }) - mockDbReturning.mockResolvedValue([{ workspaceId: 'workspace-1' }]) - mockUpdateWhere.mockReturnValue({ returning: mockDbReturning }) - mockDbSet.mockReturnValue({ where: mockUpdateWhere }) - mockDbUpdate.mockReturnValue({ set: mockDbSet }) + dbChainMockFns.limit.mockResolvedValue([{ workspaceId: 'workspace-1' }]) + dbChainMockFns.returning.mockResolvedValue([{ workspaceId: 'workspace-1' }]) mockAssertActiveWorkspaceAccess.mockResolvedValue(undefined) }) + afterAll(() => { + resetDbChainMock() + }) + it('returns 401 when unauthenticated', async () => { copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({ userId: null, @@ -115,16 +70,16 @@ describe('POST /api/mothership/chats/[chatId]/restore', () => { const response = await POST(makeRequest('chat-1'), makeContext('chat-1')) expect(response.status).toBe(401) - expect(mockDbUpdate).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() }) it('returns 404 when no soft-deleted chat is owned by the caller', async () => { - mockSelectLimit.mockResolvedValueOnce([]) + dbChainMockFns.limit.mockResolvedValueOnce([]) const response = await POST(makeRequest('chat-missing'), makeContext('chat-missing')) expect(response.status).toBe(404) expect(mockAssertActiveWorkspaceAccess).not.toHaveBeenCalled() - expect(mockDbUpdate).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() }) it('returns 403 when the caller lost access to the workspace', async () => { @@ -132,7 +87,7 @@ describe('POST /api/mothership/chats/[chatId]/restore', () => { const response = await POST(makeRequest('chat-1'), makeContext('chat-1')) expect(response.status).toBe(403) - expect(mockDbUpdate).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() }) it('restores the chat, bumping updatedAt and lastSeenAt, and publishes the event', async () => { @@ -141,7 +96,7 @@ describe('POST /api/mothership/chats/[chatId]/restore', () => { expect(response.status).toBe(200) expect(await response.json()).toEqual({ success: true }) expect(mockAssertActiveWorkspaceAccess).toHaveBeenCalledWith('workspace-1', 'user-1') - expect(mockDbSet).toHaveBeenCalledWith({ + expect(dbChainMockFns.set).toHaveBeenCalledWith({ deletedAt: null, updatedAt: expect.any(Date), lastSeenAt: expect.any(Date), @@ -154,7 +109,7 @@ describe('POST /api/mothership/chats/[chatId]/restore', () => { }) it('returns 404 when the chat is restored concurrently before the update lands', async () => { - mockDbReturning.mockResolvedValueOnce([]) + dbChainMockFns.returning.mockResolvedValueOnce([]) const response = await POST(makeRequest('chat-1'), makeContext('chat-1')) expect(response.status).toBe(404) diff --git a/apps/sim/app/api/mothership/chats/[chatId]/route.test.ts b/apps/sim/app/api/mothership/chats/[chatId]/route.test.ts index 095c5a11cc5..d0f2a64c00e 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/route.test.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/route.test.ts @@ -1,15 +1,11 @@ /** * @vitest-environment node */ -import { copilotHttpMock, copilotHttpMockFns } from '@sim/testing' +import { copilotHttpMock, copilotHttpMockFns, dbChainMockFns, resetDbChainMock } from '@sim/testing' import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { - mockDbUpdate, - mockDbSet, - mockDbReturning, - mockDbWhere, mockDecrementStorageUsageForBillingContext, mockDecrementStorageUsageForBillingContextInTx, mockGetAccessibleCopilotChat, @@ -18,10 +14,6 @@ const { mockReadFilePreviewSessions, mockGetLatestRunForStream, } = vi.hoisted(() => ({ - mockDbUpdate: vi.fn(), - mockDbSet: vi.fn(), - mockDbReturning: vi.fn(), - mockDbWhere: vi.fn(), mockDecrementStorageUsageForBillingContext: vi.fn(), mockDecrementStorageUsageForBillingContextInTx: vi.fn(), mockGetAccessibleCopilotChat: vi.fn(), @@ -31,38 +23,6 @@ const { mockGetLatestRunForStream: vi.fn(), })) -vi.mock('@sim/db', () => ({ - db: { - update: mockDbUpdate, - }, -})) - -vi.mock('@sim/db/schema', () => ({ - copilotChats: { - id: 'copilotChats.id', - userId: 'copilotChats.userId', - type: 'copilotChats.type', - updatedAt: 'copilotChats.updatedAt', - lastSeenAt: 'copilotChats.lastSeenAt', - workspaceId: 'copilotChats.workspaceId', - deletedAt: 'copilotChats.deletedAt', - }, -})) - -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })), - eq: vi.fn((field: unknown, value: unknown) => ({ type: 'eq', field, value })), - isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })), - sql: Object.assign( - vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ - type: 'sql', - strings, - values, - })), - { raw: vi.fn() } - ), -})) - vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) vi.mock('@/lib/copilot/chat/lifecycle', () => ({ @@ -123,9 +83,14 @@ function createRequest(chatId: string) { }) } +afterAll(() => { + resetDbChainMock() +}) + describe('GET /api/mothership/chats/[chatId]', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({ userId: 'user-1', isAuthenticated: true, @@ -314,6 +279,7 @@ describe('GET /api/mothership/chats/[chatId]', () => { describe('DELETE /api/mothership/chats/[chatId]', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({ userId: 'user-1', isAuthenticated: true, @@ -323,10 +289,7 @@ describe('DELETE /api/mothership/chats/[chatId]', () => { type: 'mothership', workspaceId: 'workspace-1', }) - mockDbUpdate.mockReturnValue({ set: mockDbSet }) - mockDbSet.mockReturnValue({ where: mockDbWhere }) - mockDbWhere.mockReturnValue({ returning: mockDbReturning }) - mockDbReturning.mockResolvedValue([{ workspaceId: 'workspace-1' }]) + dbChainMockFns.returning.mockResolvedValue([{ workspaceId: 'workspace-1' }]) }) it('soft-deletes an unbilled chat without decrementing workspace or payer storage', async () => { @@ -338,8 +301,8 @@ describe('DELETE /api/mothership/chats/[chatId]', () => { ) expect(response.status).toBe(200) - expect(mockDbUpdate).toHaveBeenCalled() - expect(mockDbSet).toHaveBeenCalledWith({ deletedAt: expect.any(Date) }) + expect(dbChainMockFns.update).toHaveBeenCalled() + expect(dbChainMockFns.set).toHaveBeenCalledWith({ deletedAt: expect.any(Date) }) expect(mockDecrementStorageUsageForBillingContext).not.toHaveBeenCalled() expect(mockDecrementStorageUsageForBillingContextInTx).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/api/mothership/chats/read/route.test.ts b/apps/sim/app/api/mothership/chats/read/route.test.ts index ba2270510ab..f8e4a1b40ea 100644 --- a/apps/sim/app/api/mothership/chats/read/route.test.ts +++ b/apps/sim/app/api/mothership/chats/read/route.test.ts @@ -1,39 +1,14 @@ /** * @vitest-environment node */ -import { copilotHttpMock, copilotHttpMockFns } from '@sim/testing' +import { copilotHttpMock, copilotHttpMockFns, dbChainMockFns, resetDbChainMock } from '@sim/testing' import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockUpdate, mockSet, mockWhere, mockParseRequest } = vi.hoisted(() => ({ - mockUpdate: vi.fn(), - mockSet: vi.fn(), - mockWhere: vi.fn(), +const { mockParseRequest } = vi.hoisted(() => ({ mockParseRequest: vi.fn(), })) -vi.mock('@sim/db', () => ({ - db: { update: mockUpdate }, -})) - -vi.mock('@sim/db/schema', () => ({ - copilotChats: { - id: 'copilotChats.id', - userId: 'copilotChats.userId', - updatedAt: 'copilotChats.updatedAt', - lastSeenAt: 'copilotChats.lastSeenAt', - }, -})) - -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })), - eq: vi.fn((field: unknown, value: unknown) => ({ type: 'eq', field, value })), - or: vi.fn((...conditions: unknown[]) => ({ type: 'or', conditions })), - isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })), - lt: vi.fn((field: unknown, value: unknown) => ({ type: 'lt', field, value })), - sql: vi.fn(() => ({ type: 'sql' })), -})) - vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) vi.mock('@/lib/api/server', () => ({ parseRequest: mockParseRequest })) vi.mock('@/lib/api/contracts/mothership-chats', () => ({ markMothershipChatReadContract: {} })) @@ -50,22 +25,24 @@ function createRequest() { describe('POST /api/mothership/chats/read', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({ userId: 'user-1', isAuthenticated: true, }) mockParseRequest.mockResolvedValue({ success: true, data: { body: { chatId: 'chat-1' } } }) - mockWhere.mockResolvedValue(undefined) - mockSet.mockReturnValue({ where: mockWhere }) - mockUpdate.mockReturnValue({ set: mockSet }) + }) + + afterAll(() => { + resetDbChainMock() }) it('guards the lastSeenAt write with the unread predicate (only writes when unread)', async () => { const res = await POST(createRequest()) expect(res.status).toBe(200) - expect(mockUpdate).toHaveBeenCalledTimes(1) - const whereArg = mockWhere.mock.calls[0][0] as { + expect(dbChainMockFns.update).toHaveBeenCalledTimes(1) + const whereArg = dbChainMockFns.where.mock.calls[0][0] as { type: string conditions: Array<{ type: string; conditions?: unknown[] }> } @@ -75,8 +52,8 @@ describe('POST /api/mothership/chats/read', () => { expect(orClause).toBeDefined() expect(orClause?.conditions).toEqual( expect.arrayContaining([ - { type: 'isNull', field: 'copilotChats.lastSeenAt' }, - { type: 'lt', field: 'copilotChats.lastSeenAt', value: 'copilotChats.updatedAt' }, + { type: 'isNull', column: 'lastSeenAt' }, + { type: 'lt', left: 'lastSeenAt', right: 'updatedAt' }, ]) ) }) @@ -88,6 +65,6 @@ describe('POST /api/mothership/chats/read', () => { }) const res = await POST(createRequest()) expect(res.status).toBe(401) - expect(mockUpdate).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/mothership/chats/route.test.ts b/apps/sim/app/api/mothership/chats/route.test.ts index 890e228a0b5..84296f0dd5f 100644 --- a/apps/sim/app/api/mothership/chats/route.test.ts +++ b/apps/sim/app/api/mothership/chats/route.test.ts @@ -1,47 +1,20 @@ /** * @vitest-environment node */ -import { copilotHttpMock, copilotHttpMockFns, permissionsMock } from '@sim/testing' +import { + copilotHttpMock, + copilotHttpMockFns, + dbChainMockFns, + permissionsMock, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockSelect, mockFrom, mockWhere, mockOrderBy, mockReconcileChatStreamMarkers } = vi.hoisted( - () => ({ - mockSelect: vi.fn(), - mockFrom: vi.fn(), - mockWhere: vi.fn(), - mockOrderBy: vi.fn(), - mockReconcileChatStreamMarkers: vi.fn(), - }) -) - -vi.mock('@sim/db', () => ({ - db: { - select: mockSelect, - }, -})) - -vi.mock('@sim/db/schema', () => ({ - copilotChats: { - id: 'copilotChats.id', - title: 'copilotChats.title', - userId: 'copilotChats.userId', - workspaceId: 'copilotChats.workspaceId', - type: 'copilotChats.type', - updatedAt: 'copilotChats.updatedAt', - conversationId: 'copilotChats.conversationId', - lastSeenAt: 'copilotChats.lastSeenAt', - pinned: 'copilotChats.pinned', - deletedAt: 'copilotChats.deletedAt', - }, -})) +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })), - desc: vi.fn((field: unknown) => ({ type: 'desc', field })), - eq: vi.fn((field: unknown, value: unknown) => ({ type: 'eq', field, value })), - isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })), - isNotNull: vi.fn((field: unknown) => ({ type: 'isNotNull', field })), +const { mockReconcileChatStreamMarkers } = vi.hoisted(() => ({ + mockReconcileChatStreamMarkers: vi.fn(), })) vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) @@ -70,17 +43,13 @@ function createRequest(workspaceId: string) { describe('GET /api/mothership/chats', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({ userId: 'user-1', isAuthenticated: true, }) - mockOrderBy.mockResolvedValue([]) - mockWhere.mockReturnValue({ orderBy: mockOrderBy }) - mockFrom.mockReturnValue({ where: mockWhere }) - mockSelect.mockReturnValue({ from: mockFrom }) - mockReconcileChatStreamMarkers.mockImplementation( async (candidates: Array<{ chatId: string; streamId: string | null }>) => new Map( @@ -98,7 +67,7 @@ describe('GET /api/mothership/chats', () => { it('clears activeStreamId on chats whose redis lock has expired (stuck-yellow bug)', async () => { const now = new Date('2026-05-11T12:00:00Z') - mockOrderBy.mockResolvedValueOnce([ + queueTableRows(schemaMock.copilotChats, [ { id: 'chat-stuck', title: 'Stuck chat', @@ -151,7 +120,7 @@ describe('GET /api/mothership/chats', () => { it('preserves chats when no chat has a stream marker set', async () => { const now = new Date('2026-05-11T12:00:00Z') - mockOrderBy.mockResolvedValueOnce([ + queueTableRows(schemaMock.copilotChats, [ { id: 'chat-1', title: null, updatedAt: now, activeStreamId: null, lastSeenAt: null }, { id: 'chat-2', title: null, updatedAt: now, activeStreamId: null, lastSeenAt: null }, ]) @@ -175,7 +144,7 @@ describe('GET /api/mothership/chats', () => { it('leaves activeStreamId untouched when redis confirms every lock is live', async () => { const now = new Date('2026-05-11T12:00:00Z') - mockOrderBy.mockResolvedValueOnce([ + queueTableRows(schemaMock.copilotChats, [ { id: 'chat-a', title: null, updatedAt: now, activeStreamId: 'stream-a', lastSeenAt: null }, { id: 'chat-b', title: null, updatedAt: now, activeStreamId: 'stream-b', lastSeenAt: null }, ]) @@ -191,7 +160,7 @@ describe('GET /api/mothership/chats', () => { it('uses Redis lock owner when it differs from a stale activeStreamId', async () => { const now = new Date('2026-05-11T12:00:00Z') - mockOrderBy.mockResolvedValueOnce([ + queueTableRows(schemaMock.copilotChats, [ { id: 'chat-mismatch', title: null, @@ -223,7 +192,11 @@ describe('GET /api/mothership/chats', () => { const response = await GET(createRequest('ws-1')) expect(response.status).toBe(401) - expect(mockSelect).not.toHaveBeenCalled() + expect(dbChainMockFns.select).not.toHaveBeenCalled() expect(mockReconcileChatStreamMarkers).not.toHaveBeenCalled() }) + + afterAll(() => { + resetDbChainMock() + }) }) diff --git a/apps/sim/app/api/organizations/[id]/invitations/route.test.ts b/apps/sim/app/api/organizations/[id]/invitations/route.test.ts index 43f4ff6ce8b..e4592e1318f 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/route.test.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/route.test.ts @@ -1,12 +1,18 @@ /** * @vitest-environment node */ -import { auditMock, createMockRequest, createSession, loggerMock } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { member, organization, permissions, user, workspace } from '@sim/db/schema' +import { + auditMock, + authMockFns, + createMockRequest, + createSession, + queueTableRows, + resetDbChainMock, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { - mockDbState, - mockGetSession, mockValidateInvitationsAllowed, mockValidateSeatAvailability, mockCreatePendingInvitation, @@ -14,10 +20,6 @@ const { mockCancelPendingInvitation, mockGrantWorkspaceAccessDirectly, } = vi.hoisted(() => ({ - mockDbState: { - selectResults: [] as any[], - }, - mockGetSession: vi.fn(), mockValidateInvitationsAllowed: vi.fn(), mockValidateSeatAvailability: vi.fn(), mockCreatePendingInvitation: vi.fn(), @@ -26,86 +28,8 @@ const { mockGrantWorkspaceAccessDirectly: vi.fn(), })) -function createSelectChain() { - const chain: any = {} - chain.from = vi.fn().mockReturnValue(chain) - chain.innerJoin = vi.fn().mockReturnValue(chain) - chain.leftJoin = vi.fn().mockReturnValue(chain) - chain.where = vi.fn().mockReturnValue(chain) - chain.orderBy = vi.fn().mockReturnValue(chain) - chain.limit = vi - .fn() - .mockImplementation(() => Promise.resolve(mockDbState.selectResults.shift() ?? [])) - chain.then = vi.fn().mockImplementation((callback: (rows: any[]) => unknown) => { - const rows = mockDbState.selectResults.shift() ?? [] - return Promise.resolve(callback(rows)) - }) - return chain -} - -vi.mock('@sim/db', () => ({ - db: { - select: vi.fn().mockImplementation(() => createSelectChain()), - }, -})) - -vi.mock('@sim/db/schema', () => ({ - invitation: { - id: 'invitation.id', - organizationId: 'invitation.organizationId', - status: 'invitation.status', - email: 'invitation.email', - kind: 'invitation.kind', - role: 'invitation.role', - inviterId: 'invitation.inviterId', - expiresAt: 'invitation.expiresAt', - createdAt: 'invitation.createdAt', - }, - member: { - organizationId: 'member.organizationId', - userId: 'member.userId', - role: 'member.role', - }, - organization: { - id: 'organization.id', - name: 'organization.name', - }, - user: { - id: 'user.id', - name: 'user.name', - email: 'user.email', - }, - workspace: { - id: 'workspace.id', - name: 'workspace.name', - organizationId: 'workspace.organizationId', - workspaceMode: 'workspace.workspaceMode', - }, - permissions: { - entityId: 'permissions.entityId', - entityType: 'permissions.entityType', - userId: 'permissions.userId', - }, - invitationWorkspaceGrant: { - invitationId: 'invitationWorkspaceGrant.invitationId', - workspaceId: 'invitationWorkspaceGrant.workspaceId', - }, -})) - -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })), - eq: vi.fn((field: unknown, value: unknown) => ({ field, value })), - inArray: vi.fn((field: unknown, values: unknown[]) => ({ field, values })), -})) - -vi.mock('@sim/logger', () => loggerMock) - vi.mock('@sim/audit', () => auditMock) -vi.mock('@/lib/auth', () => ({ - getSession: mockGetSession, -})) - vi.mock('@/lib/billing/validation/seat-management', () => ({ validateBulkInvitations: vi.fn(), validateSeatAvailability: mockValidateSeatAvailability, @@ -140,10 +64,25 @@ vi.mock('@/ee/access-control/utils/permission-check', () => ({ import { POST } from '@/app/api/organizations/[id]/invitations/route' +const mockGetSession = authMockFns.mockGetSession + +/** Queues the caller's admin-role check followed by the org-name lookup. */ +function queueOwnerAndOrg() { + queueTableRows(member, [{ role: 'owner' }]) + queueTableRows(organization, [{ name: 'Org One' }]) +} + +/** Queues the inviter-details lookup that precedes invitation/email sends. */ +function queueInviterRow() { + queueTableRows(user, [{ name: 'Owner', email: 'owner@example.com' }]) +} + +afterAll(resetDbChainMock) + describe('POST /api/organizations/[id]/invitations', () => { beforeEach(() => { vi.clearAllMocks() - mockDbState.selectResults = [] + resetDbChainMock() mockValidateInvitationsAllowed.mockResolvedValue(undefined) mockValidateSeatAvailability.mockResolvedValue({ canInvite: true, @@ -164,13 +103,11 @@ describe('POST /api/organizations/[id]/invitations', () => { mockGetSession.mockResolvedValue( createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' }) ) - mockDbState.selectResults = [ - [{ role: 'owner' }], - [{ name: 'Org One' }], - [], - [], - [{ name: 'Owner', email: 'owner@example.com' }], - ] + queueOwnerAndOrg() + // Explicit empty existing-members set: the query joins `user`, so it must + // not fall through to the inviter row queued on the user table. + queueTableRows(member, []) + queueInviterRow() const response = await POST( createMockRequest( @@ -202,17 +139,16 @@ describe('POST /api/organizations/[id]/invitations', () => { mockGetSession.mockResolvedValue( createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' }) ) - mockDbState.selectResults = [ - [{ role: 'owner' }], - [{ name: 'Org One' }], - [{ id: 'ws-1', name: 'Workspace 1', organizationId: 'org-1', workspaceMode: 'organization' }], - [{ id: 'ws-2', name: 'Workspace 2', organizationId: 'org-1', workspaceMode: 'organization' }], - [{ userId: 'user-2', userEmail: 'member@example.com' }], - [], - [{ userId: 'user-2', workspaceId: 'ws-1' }], - [], - [{ name: 'Owner', email: 'owner@example.com' }], - ] + queueOwnerAndOrg() + queueTableRows(workspace, [ + { id: 'ws-1', name: 'Workspace 1', organizationId: 'org-1', workspaceMode: 'organization' }, + ]) + queueTableRows(workspace, [ + { id: 'ws-2', name: 'Workspace 2', organizationId: 'org-1', workspaceMode: 'organization' }, + ]) + queueTableRows(member, [{ userId: 'user-2', userEmail: 'member@example.com' }]) + queueTableRows(permissions, [{ userId: 'user-2', workspaceId: 'ws-1' }]) + queueInviterRow() const response = await POST( createMockRequest( @@ -259,17 +195,15 @@ describe('POST /api/organizations/[id]/invitations', () => { mockGrantWorkspaceAccessDirectly .mockResolvedValueOnce({ outcome: 'added', permission: 'write' }) .mockRejectedValueOnce(new Error('db blip')) - mockDbState.selectResults = [ - [{ role: 'owner' }], - [{ name: 'Org One' }], - [{ id: 'ws-1', name: 'Workspace 1', organizationId: 'org-1', workspaceMode: 'organization' }], - [{ id: 'ws-2', name: 'Workspace 2', organizationId: 'org-1', workspaceMode: 'organization' }], - [{ userId: 'user-2', userEmail: 'member@example.com' }], - [], - [], - [], - [{ name: 'Owner', email: 'owner@example.com' }], - ] + queueOwnerAndOrg() + queueTableRows(workspace, [ + { id: 'ws-1', name: 'Workspace 1', organizationId: 'org-1', workspaceMode: 'organization' }, + ]) + queueTableRows(workspace, [ + { id: 'ws-2', name: 'Workspace 2', organizationId: 'org-1', workspaceMode: 'organization' }, + ]) + queueTableRows(member, [{ userId: 'user-2', userEmail: 'member@example.com' }]) + queueInviterRow() const response = await POST( createMockRequest( @@ -301,19 +235,15 @@ describe('POST /api/organizations/[id]/invitations', () => { mockGrantWorkspaceAccessDirectly .mockResolvedValueOnce({ outcome: 'added', permission: 'write' }) .mockRejectedValueOnce(new Error('db blip')) - mockDbState.selectResults = [ - [{ role: 'owner' }], - [{ name: 'Org One' }], - [{ id: 'ws-1', name: 'Workspace 1', organizationId: 'org-1', workspaceMode: 'organization' }], - [ - { userId: 'user-a', userEmail: 'a@example.com' }, - { userId: 'user-b', userEmail: 'b@example.com' }, - ], - [], - [], - [], - [{ name: 'Owner', email: 'owner@example.com' }], - ] + queueOwnerAndOrg() + queueTableRows(workspace, [ + { id: 'ws-1', name: 'Workspace 1', organizationId: 'org-1', workspaceMode: 'organization' }, + ]) + queueTableRows(member, [ + { userId: 'user-a', userEmail: 'a@example.com' }, + { userId: 'user-b', userEmail: 'b@example.com' }, + ]) + queueInviterRow() const response = await POST( createMockRequest( @@ -340,15 +270,12 @@ describe('POST /api/organizations/[id]/invitations', () => { mockGetSession.mockResolvedValue( createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' }) ) - mockDbState.selectResults = [ - [{ role: 'owner' }], - [{ name: 'Org One' }], - [{ id: 'ws-1', organizationId: 'org-1', workspaceMode: 'organization' }], - [{ userId: 'user-2', userEmail: 'member@example.com' }], - [], - [{ userId: 'user-2', workspaceId: 'ws-1' }], - [], - ] + queueOwnerAndOrg() + queueTableRows(workspace, [ + { id: 'ws-1', organizationId: 'org-1', workspaceMode: 'organization' }, + ]) + queueTableRows(member, [{ userId: 'user-2', userEmail: 'member@example.com' }]) + queueTableRows(permissions, [{ userId: 'user-2', workspaceId: 'ws-1' }]) const response = await POST( createMockRequest( @@ -373,16 +300,12 @@ describe('POST /api/organizations/[id]/invitations', () => { mockGetSession.mockResolvedValue( createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' }) ) - mockDbState.selectResults = [ - [{ role: 'owner' }], - [{ name: 'Org One' }], - [{ id: 'ws-1', name: 'Workspace 1', organizationId: 'org-1', workspaceMode: 'organization' }], - [{ userId: 'user-2', userEmail: 'member@example.com' }], - [], - [], - [], - [{ name: 'Owner', email: 'owner@example.com' }], - ] + queueOwnerAndOrg() + queueTableRows(workspace, [ + { id: 'ws-1', name: 'Workspace 1', organizationId: 'org-1', workspaceMode: 'organization' }, + ]) + queueTableRows(member, [{ userId: 'user-2', userEmail: 'member@example.com' }]) + queueInviterRow() const response = await POST( createMockRequest( @@ -427,12 +350,8 @@ describe('POST /api/organizations/[id]/invitations', () => { mockGetSession.mockResolvedValue( createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' }) ) - mockDbState.selectResults = [ - [{ role: 'owner' }], - [{ name: 'Org One' }], - [{ userId: 'user-2', userEmail: 'member@example.com' }], - [], - ] + queueOwnerAndOrg() + queueTableRows(member, [{ userId: 'user-2', userEmail: 'member@example.com' }]) const response = await POST( createMockRequest( @@ -456,13 +375,11 @@ describe('POST /api/organizations/[id]/invitations', () => { mockGetSession.mockResolvedValue( createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' }) ) - mockDbState.selectResults = [ - [{ role: 'owner' }], - [{ name: 'Org One' }], - [], - [], - [{ name: 'Owner', email: 'owner@example.com' }], - ] + queueOwnerAndOrg() + // Explicit empty existing-members set: the query joins `user`, so it must + // not fall through to the inviter row queued on the user table. + queueTableRows(member, []) + queueInviterRow() mockSendInvitationEmail.mockResolvedValue({ success: false, error: 'mailer unavailable' }) const response = await POST( diff --git a/apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.test.ts b/apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.test.ts index 753fb41b249..d2898b60821 100644 --- a/apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.test.ts +++ b/apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.test.ts @@ -1,30 +1,28 @@ /** * @vitest-environment node */ -import { auditMock, createMockRequest, createSession } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + auditMock, + authMockFns, + createMockRequest, + createSession, + resetEnvFlagsMock, + setEnvFlags, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { - mockGetSession, mockIsOrganizationOwnerOrAdmin, mockGetOrgMemberUsageLimit, mockGetOrgMemberUsageForCurrentPeriod, mockSetOrgMemberUsageLimit, mockGetOrganizationSubscription, - mockFlags, } = vi.hoisted(() => ({ - mockGetSession: vi.fn(), mockIsOrganizationOwnerOrAdmin: vi.fn(), mockGetOrgMemberUsageLimit: vi.fn(), mockGetOrgMemberUsageForCurrentPeriod: vi.fn(), mockSetOrgMemberUsageLimit: vi.fn(), mockGetOrganizationSubscription: vi.fn(), - mockFlags: { isHosted: true }, -})) - -vi.mock('@/lib/auth', () => ({ - auth: { api: { getSession: vi.fn() } }, - getSession: mockGetSession, })) vi.mock('@sim/audit', () => auditMock) @@ -43,14 +41,12 @@ vi.mock('@/lib/billing/core/billing', () => ({ getOrganizationSubscription: mockGetOrganizationSubscription, })) -vi.mock('@/lib/core/config/env-flags', () => ({ - get isHosted() { - return mockFlags.isHosted - }, -})) - import { GET, PUT } from '@/app/api/organizations/[id]/members/[memberId]/usage-limit/route' +const mockGetSession = authMockFns.mockGetSession + +afterAll(resetEnvFlagsMock) + function context() { return { params: Promise.resolve({ id: 'org-1', memberId: 'user-2' }) } } @@ -66,7 +62,7 @@ function getRequest() { describe('GET /api/organizations/[id]/members/[memberId]/usage-limit', () => { beforeEach(() => { vi.clearAllMocks() - mockFlags.isHosted = true + setEnvFlags({ isHosted: true }) mockGetSession.mockResolvedValue(createSession({ userId: 'admin-1' })) mockIsOrganizationOwnerOrAdmin.mockResolvedValue(true) mockGetOrgMemberUsageForCurrentPeriod.mockResolvedValue(1) // $1 -> 200 credits @@ -81,7 +77,7 @@ describe('GET /api/organizations/[id]/members/[memberId]/usage-limit', () => { }) it('returns 404 when not hosted', async () => { - mockFlags.isHosted = false + setEnvFlags({ isHosted: false }) const res = await GET(getRequest(), context()) expect(res.status).toBe(404) }) @@ -144,14 +140,14 @@ describe('GET /api/organizations/[id]/members/[memberId]/usage-limit', () => { describe('PUT /api/organizations/[id]/members/[memberId]/usage-limit', () => { beforeEach(() => { vi.clearAllMocks() - mockFlags.isHosted = true + setEnvFlags({ isHosted: true }) mockGetSession.mockResolvedValue(createSession({ userId: 'admin-1' })) mockIsOrganizationOwnerOrAdmin.mockResolvedValue(true) mockSetOrgMemberUsageLimit.mockResolvedValue(undefined) }) it('returns 404 when not hosted', async () => { - mockFlags.isHosted = false + setEnvFlags({ isHosted: false }) const res = await PUT(putRequest({ creditLimit: 400 }), context()) expect(res.status).toBe(404) expect(mockSetOrgMemberUsageLimit).not.toHaveBeenCalled() diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts b/apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts index 43f46c0beea..6e762e3bd2a 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts @@ -1,32 +1,13 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockIsOrganizationAdminOrOwner, - mockIsOrganizationOnEnterprisePlan, - mockConflictRows, - mockAllMembersRows, -} = vi.hoisted(() => ({ +import { permissionGroup, permissionGroupMember } from '@sim/db/schema' +import { queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockIsOrganizationAdminOrOwner, mockIsOrganizationOnEnterprisePlan } = vi.hoisted(() => ({ mockIsOrganizationAdminOrOwner: vi.fn<() => Promise>(), mockIsOrganizationOnEnterprisePlan: vi.fn<() => Promise>(), - mockConflictRows: { - value: [] as Array<{ - userId: string - userName: string | null - userEmail: string | null - otherGroupId: string - otherGroupName: string - }>, - }, - mockAllMembersRows: { - value: [] as Array<{ - conflictingGroupId: string - conflictingGroupName: string - workspaceName: string - }>, - }, })) vi.mock('@/lib/billing', () => ({ @@ -37,51 +18,18 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ isOrganizationAdminOrOwner: mockIsOrganizationAdminOrOwner, })) -vi.mock('@sim/db', () => ({ - db: { - select: vi.fn(() => { - const chain: Record = {} - chain.from = vi.fn(() => chain) - chain.innerJoin = vi.fn(() => chain) - chain.leftJoin = vi.fn(() => chain) - chain.where = vi.fn(() => chain) - chain.orderBy = vi.fn(() => chain) - // findAllMembersWorkspaceConflict ends in `.limit(1)`; findScopeConflicts - // awaits the builder directly after `.where()`. - chain.limit = vi.fn(() => Promise.resolve(mockAllMembersRows.value)) - chain.then = (onFulfilled: (rows: unknown) => unknown) => - Promise.resolve(mockConflictRows.value).then(onFulfilled) - return chain - }), - }, -})) - -vi.mock('@sim/db/schema', () => ({ - permissionGroup: {}, - permissionGroupMember: {}, - permissionGroupWorkspace: {}, - user: {}, - workspace: {}, -})) - -vi.mock('drizzle-orm', () => ({ - and: vi.fn(), - asc: vi.fn(), - eq: vi.fn(), - inArray: vi.fn(), - ne: vi.fn(), - sql: vi.fn(), -})) - import { authorizeOrgAccessControl, findAllMembersWorkspaceConflict, findScopeConflicts, -} from './utils' +} from '@/app/api/organizations/[id]/permission-groups/utils' + +afterAll(resetDbChainMock) describe('authorizeOrgAccessControl', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() }) it('returns a 403 when the user is not an organization admin/owner', async () => { @@ -122,7 +70,7 @@ describe('authorizeOrgAccessControl', () => { describe('findScopeConflicts', () => { beforeEach(() => { vi.clearAllMocks() - mockConflictRows.value = [] + resetDbChainMock() }) const baseParams = { @@ -141,7 +89,7 @@ describe('findScopeConflicts', () => { }) it('returns no conflicts when there are no candidate users', async () => { - mockConflictRows.value = [conflictRow('user-1')] + queueTableRows(permissionGroupMember, [conflictRow('user-1')]) const conflicts = await findScopeConflicts({ ...baseParams, candidateUserIds: [] }) @@ -149,7 +97,7 @@ describe('findScopeConflicts', () => { }) it('returns no conflicts when there are no target workspaces', async () => { - mockConflictRows.value = [conflictRow('user-1')] + queueTableRows(permissionGroupMember, [conflictRow('user-1')]) const conflicts = await findScopeConflicts({ ...baseParams, workspaceIds: [] }) @@ -157,7 +105,7 @@ describe('findScopeConflicts', () => { }) it('flags a candidate already in another group that shares a workspace', async () => { - mockConflictRows.value = [conflictRow('user-1')] + queueTableRows(permissionGroupMember, [conflictRow('user-1')]) const conflicts = await findScopeConflicts(baseParams) @@ -166,7 +114,10 @@ describe('findScopeConflicts', () => { }) it('returns at most one conflict per user', async () => { - mockConflictRows.value = [conflictRow('user-1', 'Marketing'), conflictRow('user-1', 'Sales')] + queueTableRows(permissionGroupMember, [ + conflictRow('user-1', 'Marketing'), + conflictRow('user-1', 'Sales'), + ]) const conflicts = await findScopeConflicts(baseParams) @@ -175,8 +126,6 @@ describe('findScopeConflicts', () => { }) it('returns no conflicts when the query finds no overlapping memberships', async () => { - mockConflictRows.value = [] - const conflicts = await findScopeConflicts(baseParams) expect(conflicts).toEqual([]) @@ -186,7 +135,7 @@ describe('findScopeConflicts', () => { describe('findAllMembersWorkspaceConflict', () => { beforeEach(() => { vi.clearAllMocks() - mockAllMembersRows.value = [] + resetDbChainMock() }) const baseParams = { @@ -196,9 +145,9 @@ describe('findAllMembersWorkspaceConflict', () => { } it('returns null when there are no target workspaces', async () => { - mockAllMembersRows.value = [ + queueTableRows(permissionGroup, [ { conflictingGroupId: 'group-2', conflictingGroupName: 'Marketing', workspaceName: 'Acme' }, - ] + ]) const conflict = await findAllMembersWorkspaceConflict({ ...baseParams, workspaceIds: [] }) @@ -206,9 +155,9 @@ describe('findAllMembersWorkspaceConflict', () => { }) it('returns the conflicting all-members group sharing a workspace', async () => { - mockAllMembersRows.value = [ + queueTableRows(permissionGroup, [ { conflictingGroupId: 'group-2', conflictingGroupName: 'Marketing', workspaceName: 'Acme' }, - ] + ]) const conflict = await findAllMembersWorkspaceConflict(baseParams) @@ -220,8 +169,6 @@ describe('findAllMembersWorkspaceConflict', () => { }) it('returns null when no other all-members group targets the workspaces', async () => { - mockAllMembersRows.value = [] - const conflict = await findAllMembersWorkspaceConflict(baseParams) expect(conflict).toBeNull() diff --git a/apps/sim/app/api/organizations/[id]/roster/route.test.ts b/apps/sim/app/api/organizations/[id]/roster/route.test.ts index 82a3eef2fa8..ac698fe3606 100644 --- a/apps/sim/app/api/organizations/[id]/roster/route.test.ts +++ b/apps/sim/app/api/organizations/[id]/roster/route.test.ts @@ -1,112 +1,38 @@ /** * @vitest-environment node */ -import { createMockRequest, createSession, loggerMock } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockDbState, mockExpireStaleInvitations, mockGetSession } = vi.hoisted(() => ({ - mockDbState: { - selectResults: [] as unknown[][], - }, +import { + invitation, + invitationWorkspaceGrant, + member, + permissions, + workspace, +} from '@sim/db/schema' +import { + authMockFns, + createMockRequest, + createSession, + queueTableRows, + resetDbChainMock, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockExpireStaleInvitations } = vi.hoisted(() => ({ mockExpireStaleInvitations: vi.fn(), - mockGetSession: vi.fn(), -})) - -function createSelectChain() { - const chain = { - from: vi.fn(), - innerJoin: vi.fn(), - leftJoin: vi.fn(), - where: vi.fn(), - limit: vi.fn(), - then: vi.fn(), - } - chain.from.mockReturnValue(chain) - chain.innerJoin.mockReturnValue(chain) - chain.leftJoin.mockReturnValue(chain) - chain.where.mockReturnValue(chain) - chain.limit.mockImplementation(() => Promise.resolve(mockDbState.selectResults.shift() ?? [])) - chain.then.mockImplementation((resolve: (rows: unknown[]) => unknown) => - Promise.resolve(resolve(mockDbState.selectResults.shift() ?? [])) - ) - return chain -} - -vi.mock('@sim/db', () => ({ - db: { - select: vi.fn(() => createSelectChain()), - }, })) -vi.mock('@sim/db/schema', () => ({ - invitation: { - id: 'invitation.id', - email: 'invitation.email', - role: 'invitation.role', - kind: 'invitation.kind', - membershipIntent: 'invitation.membershipIntent', - organizationId: 'invitation.organizationId', - status: 'invitation.status', - createdAt: 'invitation.createdAt', - expiresAt: 'invitation.expiresAt', - }, - invitationWorkspaceGrant: { - invitationId: 'invitationWorkspaceGrant.invitationId', - workspaceId: 'invitationWorkspaceGrant.workspaceId', - permission: 'invitationWorkspaceGrant.permission', - }, - member: { - id: 'member.id', - organizationId: 'member.organizationId', - userId: 'member.userId', - role: 'member.role', - createdAt: 'member.createdAt', - }, - permissions: { - userId: 'permissions.userId', - entityId: 'permissions.entityId', - entityType: 'permissions.entityType', - permissionType: 'permissions.permissionType', - createdAt: 'permissions.createdAt', - }, - user: { - id: 'user.id', - name: 'user.name', - email: 'user.email', - image: 'user.image', - }, - workspace: { - id: 'workspace.id', - name: 'workspace.name', - organizationId: 'workspace.organizationId', - archivedAt: 'workspace.archivedAt', - }, -})) - -vi.mock('@sim/logger', () => loggerMock) - vi.mock('@sim/platform-authz/workspace', () => ({ isOrgAdminRole: (role: string | null | undefined) => role === 'owner' || role === 'admin', })) -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })), - eq: vi.fn((field: unknown, value: unknown) => ({ field, value })), - inArray: vi.fn((field: unknown, values: unknown[]) => ({ field, values })), - isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })), - sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values })), -})) - -vi.mock('@/lib/auth', () => ({ - getSession: mockGetSession, -})) - vi.mock('@/lib/invitations/core', () => ({ expireStalePendingInvitationsForOrganization: mockExpireStaleInvitations, })) import { GET } from '@/app/api/organizations/[id]/roster/route' +const mockGetSession = authMockFns.mockGetSession + const MEMBER_ROWS = [ { memberId: 'member-admin', @@ -128,16 +54,19 @@ const MEMBER_ROWS = [ }, ] +afterAll(resetDbChainMock) + describe('GET /api/organizations/[id]/roster', () => { beforeEach(() => { vi.clearAllMocks() - mockDbState.selectResults = [] + resetDbChainMock() mockExpireStaleInvitations.mockResolvedValue(undefined) }) it('returns a redacted roster to a target-organization member', async () => { mockGetSession.mockResolvedValue(createSession({ userId: 'user-reader' })) - mockDbState.selectResults = [[{ role: 'member' }], MEMBER_ROWS] + queueTableRows(member, [{ role: 'member' }]) + queueTableRows(member, MEMBER_ROWS) const response = await GET( createMockRequest('GET', undefined, {}, 'http://localhost/api/organizations/org-1/roster'), @@ -179,7 +108,6 @@ describe('GET /api/organizations/[id]/roster', () => { it('denies a workspace collaborator who is not a target-organization member', async () => { mockGetSession.mockResolvedValue(createSession({ userId: 'external-user' })) - mockDbState.selectResults = [[]] const response = await GET( createMockRequest('GET', undefined, {}, 'http://localhost/api/organizations/org-1/roster'), @@ -195,37 +123,39 @@ describe('GET /api/organizations/[id]/roster', () => { it('preserves the full management roster for organization admins', async () => { mockGetSession.mockResolvedValue(createSession({ userId: 'user-admin' })) - mockDbState.selectResults = [ - [{ role: 'admin' }], - MEMBER_ROWS, - [{ id: 'workspace-1', name: 'Workspace One' }], - [{ userId: 'user-reader', workspaceId: 'workspace-1', permission: 'write' }], - [ - { - userId: 'external-user', - userName: 'External User', - userEmail: 'external@example.com', - userImage: null, - workspaceId: 'workspace-1', - permission: 'read', - createdAt: new Date('2026-03-01T00:00:00.000Z'), - }, - ], - [ - { - id: 'invitation-1', - email: 'pending@example.com', - role: 'member', - kind: 'workspace', - membershipIntent: 'external', - createdAt: new Date('2026-04-01T00:00:00.000Z'), - expiresAt: new Date('2026-04-08T00:00:00.000Z'), - inviteeName: null, - inviteeImage: null, - }, - ], - [{ invitationId: 'invitation-1', workspaceId: 'workspace-1', permission: 'read' }], - ] + queueTableRows(member, [{ role: 'admin' }]) + queueTableRows(member, MEMBER_ROWS) + queueTableRows(workspace, [{ id: 'workspace-1', name: 'Workspace One' }]) + queueTableRows(permissions, [ + { userId: 'user-reader', workspaceId: 'workspace-1', permission: 'write' }, + ]) + queueTableRows(permissions, [ + { + userId: 'external-user', + userName: 'External User', + userEmail: 'external@example.com', + userImage: null, + workspaceId: 'workspace-1', + permission: 'read', + createdAt: new Date('2026-03-01T00:00:00.000Z'), + }, + ]) + queueTableRows(invitation, [ + { + id: 'invitation-1', + email: 'pending@example.com', + role: 'member', + kind: 'workspace', + membershipIntent: 'external', + createdAt: new Date('2026-04-01T00:00:00.000Z'), + expiresAt: new Date('2026-04-08T00:00:00.000Z'), + inviteeName: null, + inviteeImage: null, + }, + ]) + queueTableRows(invitationWorkspaceGrant, [ + { invitationId: 'invitation-1', workspaceId: 'workspace-1', permission: 'read' }, + ]) const response = await GET( createMockRequest('GET', undefined, {}, 'http://localhost/api/organizations/org-1/roster'), diff --git a/apps/sim/app/api/organizations/[id]/session-policy/route.test.ts b/apps/sim/app/api/organizations/[id]/session-policy/route.test.ts new file mode 100644 index 00000000000..c8722a51154 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/session-policy/route.test.ts @@ -0,0 +1,171 @@ +/** + * @vitest-environment node + */ +import { member, organization } from '@sim/db/schema' +import { + authMockFns, + createMockRequest, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + resetEnvFlagsMock, + setEnvFlags, +} from '@sim/testing' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockIsEnterprise, mockEagerClamp, mockRecordAudit } = vi.hoisted(() => ({ + mockIsEnterprise: vi.fn(), + mockEagerClamp: vi.fn(), + mockRecordAudit: vi.fn(), +})) + +vi.mock('@/lib/auth/session-policy', () => ({ + eagerClampOrgSessions: mockEagerClamp, + invalidateSessionPolicyCache: vi.fn(), +})) + +vi.mock('@/lib/auth/security-policy', () => ({ + invalidateSecurityPolicyVersionCache: vi.fn(), +})) + +vi.mock('@/lib/billing/core/subscription', () => ({ + isOrganizationOnEnterprisePlan: mockIsEnterprise, +})) + +vi.mock('@sim/audit', () => ({ + recordAudit: mockRecordAudit, + AuditAction: { + ORGANIZATION_SESSION_POLICY_UPDATED: 'organization.session_policy.updated', + }, + AuditResourceType: { ORGANIZATION: 'organization' }, +})) + +import { GET, PUT } from '@/app/api/organizations/[id]/session-policy/route' + +const mockGetSession = authMockFns.mockGetSession + +const ORG_ID = 'org-1' +const routeContext = { params: Promise.resolve({ id: ORG_ID }) } + +beforeAll(() => { + setEnvFlags({ isBillingEnabled: true }) +}) + +afterAll(resetEnvFlagsMock) + +describe('session policy route', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockGetSession.mockResolvedValue({ + user: { id: 'user-1', name: 'Admin', email: 'admin@acme.dev' }, + session: { token: 'tok-1' }, + }) + mockIsEnterprise.mockResolvedValue(true) + }) + + describe('GET', () => { + it('returns 401 when unauthenticated', async () => { + mockGetSession.mockResolvedValue(null) + const response = await GET(createMockRequest('GET'), routeContext) + expect(response.status).toBe(401) + }) + + it('returns 403 for non-members', async () => { + queueTableRows(member, []) + const response = await GET(createMockRequest('GET'), routeContext) + expect(response.status).toBe(403) + }) + + it('returns the configured policy for members', async () => { + queueTableRows(member, [{ id: 'member-1' }]) + queueTableRows(organization, [ + { sessionPolicySettings: { maxSessionHours: 72, idleTimeoutHours: null } }, + ]) + const response = await GET(createMockRequest('GET'), routeContext) + expect(response.status).toBe(200) + const body = await response.json() + expect(body.data).toEqual({ + isEnterprise: true, + configured: { maxSessionHours: 72, idleTimeoutHours: null }, + }) + }) + }) + + describe('PUT', () => { + function putRequest(body: unknown) { + return createMockRequest('PUT', body) + } + + it('rejects non-admin members', async () => { + queueTableRows(member, [{ role: 'member' }]) + const response = await PUT( + putRequest({ maxSessionHours: 72, idleTimeoutHours: null }), + routeContext + ) + expect(response.status).toBe(403) + }) + + it('rejects an idle timeout below the cookie-cache window', async () => { + queueTableRows(member, [{ role: 'admin' }]) + const response = await PUT( + putRequest({ maxSessionHours: null, idleTimeoutHours: 5 }), + routeContext + ) + expect(response.status).toBe(400) + }) + + it('rejects non-enterprise organizations', async () => { + queueTableRows(member, [{ role: 'owner' }]) + mockIsEnterprise.mockResolvedValue(false) + const response = await PUT( + putRequest({ maxSessionHours: 72, idleTimeoutHours: null }), + routeContext + ) + expect(response.status).toBe(403) + }) + + it('saves the policy, eagerly clamps sessions, and bumps the version', async () => { + queueTableRows(member, [{ role: 'owner' }]) + queueTableRows(organization, [{ name: 'Acme' }]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: ORG_ID }]) + + const response = await PUT( + putRequest({ maxSessionHours: 72, idleTimeoutHours: 48 }), + routeContext + ) + expect(response.status).toBe(200) + const body = await response.json() + expect(body.data.configured).toEqual({ maxSessionHours: 72, idleTimeoutHours: 48 }) + expect(mockEagerClamp).toHaveBeenCalledWith( + ORG_ID, + { maxSessionHours: 72, idleTimeoutHours: 48 }, + expect.anything() + ) + // The version bump rides the settings UPDATE (single round trip). + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ securityPolicyVersion: expect.anything() }) + ) + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ action: 'organization.session_policy.updated' }) + ) + }) + + it('clearing both fields still saves and delegates the no-op to the clamp', async () => { + queueTableRows(member, [{ role: 'owner' }]) + queueTableRows(organization, [{ name: 'Acme' }]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: ORG_ID }]) + + const response = await PUT( + putRequest({ maxSessionHours: null, idleTimeoutHours: null }), + routeContext + ) + expect(response.status).toBe(200) + expect(mockEagerClamp).toHaveBeenCalledWith( + ORG_ID, + { maxSessionHours: null, idleTimeoutHours: null }, + expect.anything() + ) + }) + }) +}) diff --git a/apps/sim/app/api/organizations/[id]/session-policy/route.ts b/apps/sim/app/api/organizations/[id]/session-policy/route.ts new file mode 100644 index 00000000000..d3ce9d53bc3 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/session-policy/route.ts @@ -0,0 +1,190 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import type { SessionPolicySettings } from '@sim/db/schema' +import { member, organization } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { isOrgAdminRole } from '@sim/platform-authz/workspace' +import { and, eq, sql } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { updateOrganizationSessionPolicyContract } from '@/lib/api/contracts/organization' +import { parseRequest, validationErrorResponse } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { invalidateSecurityPolicyVersionCache } from '@/lib/auth/security-policy' +import { eagerClampOrgSessions, invalidateSessionPolicyCache } from '@/lib/auth/session-policy' +import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' +import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('SessionPolicyAPI') + +function normalizeConfigured(settings: SessionPolicySettings | null | undefined) { + return { + maxSessionHours: settings?.maxSessionHours ?? null, + idleTimeoutHours: settings?.idleTimeoutHours ?? null, + } +} + +/** + * GET /api/organizations/[id]/session-policy + * Returns the organization's session policy. Accessible by any member. + */ +export const GET = withRouteHandler( + async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: organizationId } = await params + + const [memberEntry] = await db + .select({ id: member.id }) + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) + .limit(1) + + if (!memberEntry) { + return NextResponse.json( + { error: 'Forbidden - Not a member of this organization' }, + { status: 403 } + ) + } + + const [org] = await db + .select({ sessionPolicySettings: organization.sessionPolicySettings }) + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1) + + if (!org) { + return NextResponse.json({ error: 'Organization not found' }, { status: 404 }) + } + + const isEnterprise = !isBillingEnabled || (await isOrganizationOnEnterprisePlan(organizationId)) + + return NextResponse.json({ + success: true, + data: { + isEnterprise, + configured: normalizeConfigured(org.sessionPolicySettings), + }, + }) + } +) + +/** + * PUT /api/organizations/[id]/session-policy + * Updates the organization's session policy and bumps the security-policy + * version so existing cached session cookies re-validate against the new + * policy. Requires enterprise plan and owner/admin role. + */ +export const PUT = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(updateOrganizationSessionPolicyContract, request, context, { + validationErrorResponse: (err) => validationErrorResponse(err, 'Invalid request body'), + }) + if (!parsed.success) return parsed.response + + const { id: organizationId } = parsed.data.params + const body = parsed.data.body + + const [memberEntry] = await db + .select({ role: member.role }) + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) + .limit(1) + + if (!memberEntry) { + return NextResponse.json( + { error: 'Forbidden - Not a member of this organization' }, + { status: 403 } + ) + } + + if (!isOrgAdminRole(memberEntry.role)) { + return NextResponse.json( + { error: 'Forbidden - Only organization owners and admins can update the session policy' }, + { status: 403 } + ) + } + + if (isBillingEnabled) { + const hasEnterprise = await isOrganizationOnEnterprisePlan(organizationId) + if (!hasEnterprise) { + return NextResponse.json( + { error: 'Session policies are available on Enterprise plans only' }, + { status: 403 } + ) + } + } + + const [currentOrg] = await db + .select({ name: organization.name }) + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1) + + if (!currentOrg) { + return NextResponse.json({ error: 'Organization not found' }, { status: 404 }) + } + + const merged = { + maxSessionHours: body.maxSessionHours, + idleTimeoutHours: body.idleTimeoutHours, + } + + // Settings write (with the version bump riding the same row) and the + // eager clamp of existing sessions commit atomically — a stored policy is + // never left unenforced by a partial failure. + const updated = await db.transaction(async (tx) => { + const [row] = await tx + .update(organization) + .set({ + sessionPolicySettings: merged, + securityPolicyVersion: sql`${organization.securityPolicyVersion} + 1`, + updatedAt: new Date(), + }) + .where(eq(organization.id, organizationId)) + .returning({ id: organization.id }) + if (!row) return null + await eagerClampOrgSessions(organizationId, merged, tx) + return row + }) + + if (!updated) { + return NextResponse.json({ error: 'Organization not found' }, { status: 404 }) + } + + invalidateSessionPolicyCache(organizationId) + invalidateSecurityPolicyVersionCache(organizationId) + + logger.info('Updated organization session policy', { organizationId }) + + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.ORGANIZATION_SESSION_POLICY_UPDATED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: currentOrg.name, + description: 'Updated session policy', + metadata: { changes: body }, + request, + }) + + return NextResponse.json({ + success: true, + data: { + isEnterprise: true, + configured: normalizeConfigured(merged), + }, + }) + } +) diff --git a/apps/sim/app/api/organizations/[id]/sessions/revoke/route.ts b/apps/sim/app/api/organizations/[id]/sessions/revoke/route.ts new file mode 100644 index 00000000000..2731d2882ac --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/sessions/revoke/route.ts @@ -0,0 +1,134 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { member, organization, session as sessionTable } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { isOrgAdminRole } from '@sim/platform-authz/workspace' +import { and, eq, inArray, isNull, ne, sql } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { revokeOrganizationSessionsContract } from '@/lib/api/contracts/organization' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { invalidateSecurityPolicyVersionCache } from '@/lib/auth/security-policy' +import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' +import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('OrgSessionsRevokeAPI') + +/** + * POST /api/organizations/[id]/sessions/revoke + * Deletes every member session in the organization except the caller's + * current one, then bumps the security-policy version so cached session + * cookies invalidate on their next request. Requires enterprise plan and + * owner/admin role. Impersonation sessions are platform tooling and spared. + */ +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(revokeOrganizationSessionsContract, request, context) + if (!parsed.success) return parsed.response + const { id: organizationId } = parsed.data.params + + const [memberEntry] = await db + .select({ role: member.role }) + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) + .limit(1) + + if (!memberEntry) { + return NextResponse.json( + { error: 'Forbidden - Not a member of this organization' }, + { status: 403 } + ) + } + + if (!isOrgAdminRole(memberEntry.role)) { + return NextResponse.json( + { error: 'Forbidden - Only organization owners and admins can revoke sessions' }, + { status: 403 } + ) + } + + if (isBillingEnabled) { + const hasEnterprise = await isOrganizationOnEnterprisePlan(organizationId) + if (!hasEnterprise) { + return NextResponse.json( + { error: 'Session management is available on Enterprise plans only' }, + { status: 403 } + ) + } + } + + const [org] = await db + .select({ name: organization.name }) + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1) + + if (!org) { + return NextResponse.json({ error: 'Organization not found' }, { status: 404 }) + } + + // When the caller is a platform admin impersonating an org member, the + // current token is the impersonation session — also spare the admin's own + // real sessions so ending impersonation doesn't leave them signed out. + const impersonatorId = + (session.session as { impersonatedBy?: string | null }).impersonatedBy ?? null + // Delete and version bump commit atomically: a bump failure must roll the + // delete back, or members would stay authenticated from the cookie cache + // for up to 24h with their DB sessions already gone. + const revoked = await db.transaction(async (tx) => { + const deleted = await tx + .delete(sessionTable) + .where( + and( + inArray( + sessionTable.userId, + tx + .select({ userId: member.userId }) + .from(member) + .where(eq(member.organizationId, organizationId)) + ), + isNull(sessionTable.impersonatedBy), + ne(sessionTable.token, session.session.token), + ...(impersonatorId ? [ne(sessionTable.userId, impersonatorId)] : []) + ) + ) + .returning({ id: sessionTable.id }) + await tx + .update(organization) + .set({ securityPolicyVersion: sql`${organization.securityPolicyVersion} + 1` }) + .where(eq(organization.id, organizationId)) + return deleted + }) + invalidateSecurityPolicyVersionCache(organizationId) + + logger.info('Revoked organization sessions', { + organizationId, + revokedSessions: revoked.length, + }) + + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.ORGANIZATION_SESSIONS_REVOKED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: org.name, + description: `Revoked ${revoked.length} member session${revoked.length === 1 ? '' : 's'}`, + metadata: { revokedSessions: revoked.length }, + request, + }) + + return NextResponse.json({ + success: true, + data: { revokedSessions: revoked.length }, + }) + } +) diff --git a/apps/sim/app/api/organizations/route.test.ts b/apps/sim/app/api/organizations/route.test.ts index a3a17208d84..bbe8e36990e 100644 --- a/apps/sim/app/api/organizations/route.test.ts +++ b/apps/sim/app/api/organizations/route.test.ts @@ -1,22 +1,23 @@ /** * @vitest-environment node */ -import { auditMock, createSession, loggerMock } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { member, subscription } from '@sim/db/schema' +import { + auditMock, + authMockFns, + createSession, + queueTableRows, + resetDbChainMock, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { - mockDbState, - mockGetSession, mockSetActiveOrganizationForCurrentSession, mockCreateOrganizationForTeamPlan, mockEnsureOrganizationForTeamSubscription, mockAttachOwnedWorkspacesToOrganization, WorkspaceOrganizationMembershipConflictError, } = vi.hoisted(() => ({ - mockDbState: { - selectResults: [] as any[], - }, - mockGetSession: vi.fn(), mockSetActiveOrganizationForCurrentSession: vi.fn().mockResolvedValue(undefined), mockCreateOrganizationForTeamPlan: vi.fn(), mockEnsureOrganizationForTeamSubscription: vi.fn(), @@ -24,59 +25,8 @@ const { WorkspaceOrganizationMembershipConflictError: class WorkspaceOrganizationMembershipConflictError extends Error {}, })) -vi.mock('@sim/db', () => ({ - db: { - select: vi.fn().mockImplementation(() => { - const chain: any = {} - chain.from = vi.fn().mockReturnValue(chain) - chain.where = vi.fn().mockReturnValue(chain) - chain.limit = vi - .fn() - .mockImplementation(() => Promise.resolve(mockDbState.selectResults.shift() ?? [])) - chain.then = vi - .fn() - .mockImplementation((callback: (rows: any[]) => any) => - Promise.resolve(callback(mockDbState.selectResults.shift() ?? [])) - ) - return chain - }), - }, -})) - -vi.mock('@sim/db/schema', () => ({ - member: { - organizationId: 'member.organizationId', - role: 'member.role', - userId: 'member.userId', - }, - organization: { - id: 'organization.id', - name: 'organization.name', - }, - subscription: { - id: 'subscription.id', - plan: 'subscription.plan', - referenceId: 'subscription.referenceId', - status: 'subscription.status', - seats: 'subscription.seats', - }, -})) - -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })), - eq: vi.fn((field: unknown, value: unknown) => ({ field, value })), - inArray: vi.fn((field: unknown, value: unknown[]) => ({ field, value })), - or: vi.fn((...conditions: unknown[]) => ({ type: 'or', conditions })), -})) - -vi.mock('@sim/logger', () => loggerMock) - vi.mock('@sim/audit', () => auditMock) -vi.mock('@/lib/auth', () => ({ - getSession: mockGetSession, -})) - vi.mock('@/lib/auth/active-organization', () => ({ setActiveOrganizationForCurrentSession: mockSetActiveOrganizationForCurrentSession, })) @@ -106,10 +56,14 @@ vi.mock('@/lib/workspaces/organization-workspaces', () => ({ import { POST } from '@/app/api/organizations/route' +const mockGetSession = authMockFns.mockGetSession + +afterAll(resetDbChainMock) + describe('POST /api/organizations', () => { beforeEach(() => { vi.clearAllMocks() - mockDbState.selectResults = [] + resetDbChainMock() }) it('recovers an owner org when the subscription was already moved onto the organization', async () => { @@ -120,10 +74,10 @@ describe('POST /api/organizations', () => { name: 'Owner', }) ) - mockDbState.selectResults = [ - [{ organizationId: 'legacy-org-id', role: 'owner' }], - [{ id: 'sub-1', plan: 'team', referenceId: 'legacy-org-id', status: 'active', seats: 5 }], - ] + queueTableRows(member, [{ organizationId: 'legacy-org-id', role: 'owner' }]) + queueTableRows(subscription, [ + { id: 'sub-1', plan: 'team', referenceId: 'legacy-org-id', status: 'active', seats: 5 }, + ]) const response = await POST( new Request('http://localhost/api/organizations', { @@ -164,10 +118,10 @@ describe('POST /api/organizations', () => { status: 'active', seats: 5, }) - mockDbState.selectResults = [ - [{ organizationId: 'legacy-org-id', role: 'owner' }], - [{ id: 'sub-1', plan: 'team', referenceId: 'user-1', status: 'active', seats: 5 }], - ] + queueTableRows(member, [{ organizationId: 'legacy-org-id', role: 'owner' }]) + queueTableRows(subscription, [ + { id: 'sub-1', plan: 'team', referenceId: 'user-1', status: 'active', seats: 5 }, + ]) const response = await POST( new Request('http://localhost/api/organizations', { @@ -202,7 +156,7 @@ describe('POST /api/organizations', () => { name: 'Member', }) ) - mockDbState.selectResults = [[{ organizationId: 'org-1', role: 'member' }]] + queueTableRows(member, [{ organizationId: 'org-1', role: 'member' }]) const response = await POST( new Request('http://localhost/api/organizations', { @@ -230,10 +184,10 @@ describe('POST /api/organizations', () => { name: 'Owner', }) ) - mockDbState.selectResults = [ - [{ organizationId: 'legacy-org-id', role: 'owner' }], - [{ id: 'sub-1', plan: 'team', referenceId: 'legacy-org-id', status: 'active', seats: 5 }], - ] + queueTableRows(member, [{ organizationId: 'legacy-org-id', role: 'owner' }]) + queueTableRows(subscription, [ + { id: 'sub-1', plan: 'team', referenceId: 'legacy-org-id', status: 'active', seats: 5 }, + ]) mockAttachOwnedWorkspacesToOrganization.mockRejectedValueOnce( new WorkspaceOrganizationMembershipConflictError([ { userId: 'user-2', organizationId: 'org-2' }, diff --git a/apps/sim/app/api/providers/baseten/models/route.test.ts b/apps/sim/app/api/providers/baseten/models/route.test.ts index 8ada3c427ea..fe53568ed51 100644 --- a/apps/sim/app/api/providers/baseten/models/route.test.ts +++ b/apps/sim/app/api/providers/baseten/models/route.test.ts @@ -1,27 +1,21 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { authMockFns, createMockRequest, resetEnvMock, setEnv } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockFilterBlacklistedModels, mockIsProviderBlacklisted, mockGetBYOKKey, - mockGetSession, mockGetUserEntityPermissions, - mutableEnv, } = vi.hoisted(() => ({ mockFilterBlacklistedModels: vi.fn(), mockIsProviderBlacklisted: vi.fn(), mockGetBYOKKey: vi.fn(), - mockGetSession: vi.fn(), mockGetUserEntityPermissions: vi.fn(), - mutableEnv: { BASETEN_API_KEY: undefined as string | undefined }, })) -vi.mock('@/lib/core/config/env', () => ({ env: mutableEnv })) - vi.mock('@/providers/utils', () => ({ filterBlacklistedModels: mockFilterBlacklistedModels, isProviderBlacklisted: mockIsProviderBlacklisted, @@ -31,16 +25,14 @@ vi.mock('@/lib/api-key/byok', () => ({ getBYOKKey: mockGetBYOKKey, })) -vi.mock('@/lib/auth', () => ({ - getSession: mockGetSession, -})) - vi.mock('@/lib/workspaces/permissions/utils', () => ({ getUserEntityPermissions: mockGetUserEntityPermissions, })) import { GET } from '@/app/api/providers/baseten/models/route' +const mockGetSession = authMockFns.mockGetSession + const BASETEN_MODELS_URL = 'https://inference.baseten.co/v1/models' function jsonResponse(body: unknown, init: { ok?: boolean; status?: number } = {}): Response { @@ -55,7 +47,7 @@ function jsonResponse(body: unknown, init: { ok?: boolean; status?: number } = { } function setEnvKey(value: string | undefined): void { - mutableEnv.BASETEN_API_KEY = value + setEnv({ BASETEN_API_KEY: value }) } function authHeaderFromLastFetch(mockFetch: ReturnType): unknown { @@ -80,6 +72,10 @@ describe('GET /api/providers/baseten/models', () => { setEnvKey(undefined) }) + afterAll(() => { + resetEnvMock() + }) + it('returns empty models without fetching when the provider is blacklisted', async () => { mockIsProviderBlacklisted.mockReturnValue(true) setEnvKey('env-key') diff --git a/apps/sim/app/api/providers/ollama-cloud/models/route.test.ts b/apps/sim/app/api/providers/ollama-cloud/models/route.test.ts index fd70d1ae8b1..3b563a8f7cb 100644 --- a/apps/sim/app/api/providers/ollama-cloud/models/route.test.ts +++ b/apps/sim/app/api/providers/ollama-cloud/models/route.test.ts @@ -1,21 +1,19 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' +import { authMockFns, createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockFilterBlacklistedModels, mockIsProviderBlacklisted, mockGetBYOKKey, - mockGetSession, mockGetUserEntityPermissions, mockFetch, } = vi.hoisted(() => ({ mockFilterBlacklistedModels: vi.fn(), mockIsProviderBlacklisted: vi.fn(), mockGetBYOKKey: vi.fn(), - mockGetSession: vi.fn(), mockGetUserEntityPermissions: vi.fn(), mockFetch: vi.fn(), })) @@ -29,16 +27,14 @@ vi.mock('@/lib/api-key/byok', () => ({ getBYOKKey: mockGetBYOKKey, })) -vi.mock('@/lib/auth', () => ({ - getSession: mockGetSession, -})) - vi.mock('@/lib/workspaces/permissions/utils', () => ({ getUserEntityPermissions: mockGetUserEntityPermissions, })) import { GET } from '@/app/api/providers/ollama-cloud/models/route' +const mockGetSession = authMockFns.mockGetSession + const OLLAMA_CLOUD_TAGS_URL = 'https://ollama.com/api/tags' const okResponse = (body: unknown) => ({ diff --git a/apps/sim/app/api/providers/together/models/route.test.ts b/apps/sim/app/api/providers/together/models/route.test.ts index ae801bb7c56..b7516070a0e 100644 --- a/apps/sim/app/api/providers/together/models/route.test.ts +++ b/apps/sim/app/api/providers/together/models/route.test.ts @@ -1,25 +1,21 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { authMockFns, createMockRequest, resetEnvMock, setEnv } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockFilterBlacklistedModels, mockIsProviderBlacklisted, mockGetBYOKKey, - mockGetSession, mockGetUserEntityPermissions, mockFetch, - mutableEnv, } = vi.hoisted(() => ({ mockFilterBlacklistedModels: vi.fn(), mockIsProviderBlacklisted: vi.fn(), mockGetBYOKKey: vi.fn(), - mockGetSession: vi.fn(), mockGetUserEntityPermissions: vi.fn(), mockFetch: vi.fn(), - mutableEnv: { TOGETHER_API_KEY: undefined as string | undefined }, })) vi.mock('@/providers/utils', () => ({ @@ -31,20 +27,14 @@ vi.mock('@/lib/api-key/byok', () => ({ getBYOKKey: mockGetBYOKKey, })) -vi.mock('@/lib/auth', () => ({ - getSession: mockGetSession, -})) - vi.mock('@/lib/workspaces/permissions/utils', () => ({ getUserEntityPermissions: mockGetUserEntityPermissions, })) -vi.mock('@/lib/core/config/env', () => ({ - env: mutableEnv, -})) - import { GET } from '@/app/api/providers/together/models/route' +const mockGetSession = authMockFns.mockGetSession + const TOGETHER_MODELS_URL = 'https://api.together.ai/v1/models' const okResponse = (body: unknown) => ({ @@ -84,7 +74,7 @@ describe('GET /api/providers/together/models', () => { vi.clearAllMocks() vi.stubGlobal('fetch', mockFetch) - mutableEnv.TOGETHER_API_KEY = undefined + setEnv({ TOGETHER_API_KEY: undefined }) mockIsProviderBlacklisted.mockReturnValue(false) mockFilterBlacklistedModels.mockImplementation((models: string[]) => models) mockGetBYOKKey.mockResolvedValue(null) @@ -92,6 +82,10 @@ describe('GET /api/providers/together/models', () => { mockGetUserEntityPermissions.mockResolvedValue(null) }) + afterAll(() => { + resetEnvMock() + }) + it('returns empty models without calling fetch when the provider is blacklisted', async () => { mockIsProviderBlacklisted.mockReturnValue(true) @@ -111,7 +105,7 @@ describe('GET /api/providers/together/models', () => { }) it('fetches with the env key and prefixes each model id with together/', async () => { - mutableEnv.TOGETHER_API_KEY = 'env-together-key' + setEnv({ TOGETHER_API_KEY: 'env-together-key' }) mockFetch.mockResolvedValue( okResponse([{ id: 'moonshotai/Kimi-K2-Instruct' }, { id: 'Qwen/Qwen2.5-72B-Instruct-Turbo' }]) ) @@ -129,7 +123,7 @@ describe('GET /api/providers/together/models', () => { }) it('uses the BYOK key when a workspace, session, and permission are present', async () => { - mutableEnv.TOGETHER_API_KEY = 'env-together-key' + setEnv({ TOGETHER_API_KEY: 'env-together-key' }) mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) mockGetUserEntityPermissions.mockResolvedValue('admin') mockGetBYOKKey.mockResolvedValue({ apiKey: 'byok-together-key' }) @@ -145,7 +139,7 @@ describe('GET /api/providers/together/models', () => { }) it('falls back to the env key when a workspaceId is given but there is no session', async () => { - mutableEnv.TOGETHER_API_KEY = 'env-together-key' + setEnv({ TOGETHER_API_KEY: 'env-together-key' }) mockGetSession.mockResolvedValue(null) mockFetch.mockResolvedValue(okResponse([{ id: 'moonshotai/Kimi-K2-Instruct' }])) @@ -158,7 +152,7 @@ describe('GET /api/providers/together/models', () => { }) it('falls back to the env key when the session user lacks workspace permission', async () => { - mutableEnv.TOGETHER_API_KEY = 'env-together-key' + setEnv({ TOGETHER_API_KEY: 'env-together-key' }) mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) mockGetUserEntityPermissions.mockResolvedValue(null) mockFetch.mockResolvedValue(okResponse([{ id: 'moonshotai/Kimi-K2-Instruct' }])) @@ -172,7 +166,7 @@ describe('GET /api/providers/together/models', () => { }) it('returns empty models when the upstream fetch responds non-ok', async () => { - mutableEnv.TOGETHER_API_KEY = 'env-together-key' + setEnv({ TOGETHER_API_KEY: 'env-together-key' }) mockFetch.mockResolvedValue(errorResponse(401, 'Unauthorized')) const res = await GET(requestWithWorkspace()) @@ -182,7 +176,7 @@ describe('GET /api/providers/together/models', () => { }) it('returns empty models when the upstream fetch throws', async () => { - mutableEnv.TOGETHER_API_KEY = 'env-together-key' + setEnv({ TOGETHER_API_KEY: 'env-together-key' }) mockFetch.mockRejectedValue(new Error('network down')) const res = await GET(requestWithWorkspace()) @@ -201,7 +195,7 @@ describe('GET /api/providers/together/models', () => { }) it('dedupes duplicate model ids from the upstream array', async () => { - mutableEnv.TOGETHER_API_KEY = 'env-together-key' + setEnv({ TOGETHER_API_KEY: 'env-together-key' }) mockFetch.mockResolvedValue( okResponse([ { id: 'moonshotai/Kimi-K2-Instruct' }, @@ -219,7 +213,7 @@ describe('GET /api/providers/together/models', () => { }) it('applies the blacklist filter to the deduped model list', async () => { - mutableEnv.TOGETHER_API_KEY = 'env-together-key' + setEnv({ TOGETHER_API_KEY: 'env-together-key' }) mockFilterBlacklistedModels.mockImplementation((models: string[]) => models.filter((m) => !m.includes('Qwen')) ) @@ -238,7 +232,7 @@ describe('GET /api/providers/together/models', () => { }) it('filters out non-chat model types (image, embedding, rerank, etc.)', async () => { - mutableEnv.TOGETHER_API_KEY = 'env-together-key' + setEnv({ TOGETHER_API_KEY: 'env-together-key' }) mockFetch.mockResolvedValue( okResponse([ { id: 'meta-llama/Llama-3.3-70B-Instruct-Turbo', type: 'chat' }, diff --git a/apps/sim/app/api/resume/poll/route.test.ts b/apps/sim/app/api/resume/poll/route.test.ts index aee6f33055b..8e175aeff29 100644 --- a/apps/sim/app/api/resume/poll/route.test.ts +++ b/apps/sim/app/api/resume/poll/route.test.ts @@ -1,13 +1,17 @@ /** * @vitest-environment node */ +import { + dbChainMockFns, + redisConfigMockFns, + resetDbChainMock, + resetRedisConfigMock, +} from '@sim/testing' import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { - acquireLockMock, assertBillingAttributionSnapshotMock, - dbSelectMock, dueRowsLimitMock, enqueueOrStartResumeMock, executionSnapshotFromJsonMock, @@ -17,14 +21,11 @@ const { lteMock, preprocessExecutionMock, processQueuedResumesMock, - releaseLockMock, setAutomaticResumeWaitingMock, setNextResumeAtMock, sqlMock, } = vi.hoisted(() => ({ - acquireLockMock: vi.fn(), assertBillingAttributionSnapshotMock: vi.fn((value: unknown) => value), - dbSelectMock: vi.fn(), dueRowsLimitMock: vi.fn(), enqueueOrStartResumeMock: vi.fn(), executionSnapshotFromJsonMock: vi.fn(), @@ -34,7 +35,6 @@ const { lteMock: vi.fn(), preprocessExecutionMock: vi.fn(), processQueuedResumesMock: vi.fn(), - releaseLockMock: vi.fn(), setAutomaticResumeWaitingMock: vi.fn(), setNextResumeAtMock: vi.fn(), sqlMock: vi.fn((strings: TemplateStringsArray) => @@ -42,25 +42,8 @@ const { ), })) -vi.mock('@sim/db', () => ({ - db: { - select: dbSelectMock, - }, -})) - -vi.mock('@sim/db/schema', () => ({ - pausedExecutions: { - id: 'id', - executionId: 'executionId', - workflowId: 'workflowId', - pausePoints: 'pausePoints', - metadata: 'metadata', - executionSnapshot: 'executionSnapshot', - status: 'status', - nextResumeAt: 'nextResumeAt', - automaticResumeRetryCount: 'automaticResumeRetryCount', - }, -})) +const acquireLockMock = redisConfigMockFns.mockAcquireLock +const releaseLockMock = redisConfigMockFns.mockReleaseLock vi.mock('drizzle-orm', () => ({ and: vi.fn(), @@ -79,11 +62,6 @@ vi.mock('@/lib/billing/core/billing-attribution', () => ({ assertBillingAttributionSnapshot: assertBillingAttributionSnapshotMock, })) -vi.mock('@/lib/core/config/redis', () => ({ - acquireLock: acquireLockMock, - releaseLock: releaseLockMock, -})) - vi.mock('@/lib/execution/preprocessing', () => ({ preprocessExecution: preprocessExecutionMock, })) @@ -173,7 +151,8 @@ function makeSerializedSnapshot(index: number) { describe('time-pause resume admission', () => { beforeEach(() => { vi.clearAllMocks() - dbSelectMock.mockImplementation((selection: Record) => { + resetDbChainMock() + dbChainMockFns.select.mockImplementation((selection: Record) => { if ('snapshotBytes' in selection) { return { from: vi.fn(() => ({ @@ -219,6 +198,11 @@ describe('time-pause resume admission', () => { executionSnapshotFromJsonMock.mockImplementation((value: string) => JSON.parse(value)) }) + afterAll(() => { + resetDbChainMock() + resetRedisConfigMock() + }) + it('keeps a timed pause unclaimed, records why, and schedules automatic retry', async () => { dueRowsLimitMock.mockResolvedValueOnce([makeDueRow(1)]) preprocessExecutionMock.mockResolvedValueOnce({ @@ -385,14 +369,14 @@ describe('time-pause resume admission', () => { expect(dueRowsLimitMock).toHaveBeenCalledWith(200) expect(legacySizeRowsLimitMock).toHaveBeenCalledWith(200) expect(fallbackRowsLimitMock).toHaveBeenCalledWith(LEGACY_PAUSED_SNAPSHOT_FALLBACK_CHUNK_SIZE) - expect(dbSelectMock).toHaveBeenCalledTimes(3) - expect(dbSelectMock.mock.calls[0]?.[0]).not.toHaveProperty('executionSnapshot') - expect(dbSelectMock.mock.calls[0]?.[0]).toEqual( + expect(dbChainMockFns.select).toHaveBeenCalledTimes(3) + expect(dbChainMockFns.select.mock.calls[0]?.[0]).not.toHaveProperty('executionSnapshot') + expect(dbChainMockFns.select.mock.calls[0]?.[0]).toEqual( expect.objectContaining({ metadata: 'boundedMetadata' }) ) - expect(dbSelectMock.mock.calls[1]?.[0]).toHaveProperty('snapshotBytes') - expect(dbSelectMock.mock.calls[1]?.[0]).not.toHaveProperty('executionSnapshot') - expect(dbSelectMock.mock.calls[2]?.[0]).toHaveProperty('executionSnapshot') + expect(dbChainMockFns.select.mock.calls[1]?.[0]).toHaveProperty('snapshotBytes') + expect(dbChainMockFns.select.mock.calls[1]?.[0]).not.toHaveProperty('executionSnapshot') + expect(dbChainMockFns.select.mock.calls[2]?.[0]).toHaveProperty('executionSnapshot') expect( sqlMock.mock.calls.some(([strings]) => (strings as TemplateStringsArray).join('').includes('octet_length(') @@ -455,9 +439,9 @@ describe('time-pause resume admission', () => { expect(legacySizeRowsLimitMock).toHaveBeenNthCalledWith(1, 200) expect(legacySizeRowsLimitMock).toHaveBeenNthCalledWith(2, 200) expect(fallbackRowsLimitMock).not.toHaveBeenCalled() - expect(dbSelectMock.mock.calls.some(([selection]) => 'executionSnapshot' in selection)).toBe( - false - ) + expect( + dbChainMockFns.select.mock.calls.some(([selection]) => 'executionSnapshot' in selection) + ).toBe(false) expect(executionSnapshotFromJsonMock).not.toHaveBeenCalled() expect(preprocessExecutionMock).not.toHaveBeenCalled() expect(enqueueOrStartResumeMock).not.toHaveBeenCalled() diff --git a/apps/sim/app/api/schedules/[id]/route.test.ts b/apps/sim/app/api/schedules/[id]/route.test.ts index df3407461f1..8522120ad8e 100644 --- a/apps/sim/app/api/schedules/[id]/route.test.ts +++ b/apps/sim/app/api/schedules/[id]/route.test.ts @@ -15,12 +15,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) -vi.mock('drizzle-orm', () => ({ - and: vi.fn(), - eq: vi.fn(), - isNull: vi.fn(), -})) - vi.mock('@sim/audit', () => auditMock) import { PUT } from './route' diff --git a/apps/sim/app/api/schedules/execute/route.test.ts b/apps/sim/app/api/schedules/execute/route.test.ts index bcf0eaf5d3f..4de1d5aaebf 100644 --- a/apps/sim/app/api/schedules/execute/route.test.ts +++ b/apps/sim/app/api/schedules/execute/route.test.ts @@ -3,9 +3,16 @@ * * @vitest-environment node */ -import { dbChainMock, dbChainMockFns, requestUtilsMockFns, resetDbChainMock } from '@sim/testing' +import { + dbChainMock, + dbChainMockFns, + requestUtilsMockFns, + resetDbChainMock, + resetEnvFlagsMock, + setEnvFlags, +} from '@sim/testing' import { type NextRequest, NextResponse } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const orderByLimitMock = vi.fn() @@ -14,7 +21,6 @@ const { mockExecuteScheduleJob, mockExecuteJobInline, mockReleaseScheduleLock, - mockFeatureFlags, mockEnqueue, mockGetJob, mockStartJob, @@ -29,12 +35,6 @@ const { mockExecuteScheduleJob: vi.fn().mockResolvedValue(undefined), mockExecuteJobInline: vi.fn().mockResolvedValue(undefined), mockReleaseScheduleLock: vi.fn().mockResolvedValue(undefined), - mockFeatureFlags: { - isTriggerDevEnabled: false, - isHosted: false, - isProd: false, - isDev: true, - }, mockEnqueue: vi.fn().mockResolvedValue('job-id-1'), mockGetJob: vi.fn().mockResolvedValue(null), mockStartJob: vi.fn().mockResolvedValue(undefined), @@ -70,8 +70,6 @@ vi.mock('@/background/schedule-execution', () => ({ }), })) -vi.mock('@/lib/core/config/env-flags', () => mockFeatureFlags) - vi.mock('@/lib/core/async-jobs', () => ({ getJobQueue: vi.fn().mockResolvedValue({ enqueue: mockEnqueue, @@ -277,6 +275,8 @@ function createMockRequest(): NextRequest { } as NextRequest } +afterAll(resetEnvFlagsMock) + describe('Scheduled Workflow Execution API Route', () => { beforeEach(() => { vi.clearAllMocks() @@ -289,10 +289,7 @@ describe('Scheduled Workflow Execution API Route', () => { dbChainMockFns.orderBy.mockReturnValue({ limit: orderByLimitMock } as never) dbChainMockFns.execute.mockResolvedValue([{ acquired: true }] as never) requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('test-request-id') - mockFeatureFlags.isTriggerDevEnabled = false - mockFeatureFlags.isHosted = false - mockFeatureFlags.isProd = false - mockFeatureFlags.isDev = true + setEnvFlags({ isTriggerDevEnabled: false, isHosted: false, isProd: false, isDev: true }) mockShouldExecuteInline.mockReturnValue(false) mockEnqueue.mockReset() mockEnqueue.mockResolvedValue('job-id-1') @@ -337,7 +334,7 @@ describe('Scheduled Workflow Execution API Route', () => { }) it('should queue schedules to Trigger.dev when enabled', async () => { - mockFeatureFlags.isTriggerDevEnabled = true + setEnvFlags({ isTriggerDevEnabled: true }) dbChainMockFns.limit .mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS) .mockResolvedValueOnce([]) diff --git a/apps/sim/app/api/schedules/route.test.ts b/apps/sim/app/api/schedules/route.test.ts index 88929342615..0d8f5713ec9 100644 --- a/apps/sim/app/api/schedules/route.test.ts +++ b/apps/sim/app/api/schedules/route.test.ts @@ -9,13 +9,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) -vi.mock('drizzle-orm', () => ({ - eq: vi.fn(), - and: vi.fn(), - or: vi.fn(), - isNull: vi.fn(), -})) - import { GET } from '@/app/api/schedules/route' function createRequest(url: string): NextRequest { diff --git a/apps/sim/app/api/speech/token/route.test.ts b/apps/sim/app/api/speech/token/route.test.ts index ca31a3cd609..cf599da66c0 100644 --- a/apps/sim/app/api/speech/token/route.test.ts +++ b/apps/sim/app/api/speech/token/route.test.ts @@ -1,26 +1,30 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + authMockFns, + createMockRequest, + queueTableRows, + resetDbChainMock, + resetEnvMock, + schemaMock, + setEnv, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { - mockGetSession, mockRecordUsage, mockCheckActorUsageLimits, mockVerifyWorkspaceMembership, - mockChatRows, mockResolveBillingAttribution, mockResolveSystemBillingAttribution, mockCheckAttributedUsageLimits, mockToBillingContext, mockCheckAndBillPayerOverageThreshold, } = vi.hoisted(() => ({ - mockGetSession: vi.fn(), mockRecordUsage: vi.fn(), mockCheckActorUsageLimits: vi.fn(), mockVerifyWorkspaceMembership: vi.fn(), - mockChatRows: { value: [] as Array> }, mockResolveBillingAttribution: vi.fn(), mockResolveSystemBillingAttribution: vi.fn(), mockCheckAttributedUsageLimits: vi.fn(), @@ -41,21 +45,6 @@ const SYSTEM_BILLING_ATTRIBUTION = { payerSubscription: null, } -vi.mock('@sim/db', () => ({ - db: { - select: () => { - const chain: Record = {} - chain.from = () => chain - chain.leftJoin = () => chain - chain.where = () => chain - chain.limit = () => Promise.resolve(mockChatRows.value) - return chain - }, - }, -})) - -vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) - vi.mock('@/lib/billing/core/usage-log', () => ({ recordUsage: mockRecordUsage })) vi.mock('@/lib/billing/core/billing-attribution', () => ({ @@ -77,13 +66,6 @@ vi.mock('@/app/api/workflows/utils', () => ({ verifyWorkspaceMembership: mockVerifyWorkspaceMembership, })) -vi.mock('@/lib/core/config/env', () => ({ env: { ELEVENLABS_API_KEY: 'test-key' } })) - -vi.mock('@/lib/core/config/env-flags', () => ({ - isBillingEnabled: false, - getCostMultiplier: () => 1, -})) - vi.mock('@/lib/core/rate-limiter', () => ({ RateLimiter: class { checkRateLimitDirect = vi.fn().mockResolvedValue({ allowed: true }) @@ -94,6 +76,8 @@ vi.mock('@/lib/core/security/deployment', () => ({ validateAuthToken: vi.fn(() = import { POST } from '@/app/api/speech/token/route' +const mockGetSession = authMockFns.mockGetSession + const publicChatRow = { id: 'chat-1', userId: 'owner-1', @@ -105,7 +89,8 @@ const publicChatRow = { beforeEach(() => { vi.clearAllMocks() - mockChatRows.value = [] + resetDbChainMock() + setEnv({ ELEVENLABS_API_KEY: 'test-key' }) mockGetSession.mockResolvedValue({ user: { id: 'member-1' } }) mockRecordUsage.mockResolvedValue(undefined) mockCheckActorUsageLimits.mockResolvedValue({ isExceeded: false }) @@ -135,6 +120,11 @@ beforeEach(() => { }) as unknown as typeof fetch }) +afterAll(() => { + resetDbChainMock() + resetEnvMock() +}) + describe('POST /api/speech/token — usage attribution', () => { it('editor voice: bills the session user and stamps the verified workspace', async () => { const res = await POST(createMockRequest('POST', { workspaceId: 'ws-1' })) @@ -167,7 +157,7 @@ describe('POST /api/speech/token — usage attribution', () => { }) it('deployed chat: uses one atomic system actor and payer snapshot', async () => { - mockChatRows.value = [publicChatRow] + queueTableRows(schemaMock.chat, [publicChatRow]) const res = await POST(createMockRequest('POST', { chatId: 'chat-1' })) @@ -189,7 +179,7 @@ describe('POST /api/speech/token — usage attribution', () => { }) it('deployed chat: uses the chat owner only when no workspace exists', async () => { - mockChatRows.value = [{ ...publicChatRow, workspaceId: null }] + queueTableRows(schemaMock.chat, [{ ...publicChatRow, workspaceId: null }]) const res = await POST(createMockRequest('POST', { chatId: 'chat-1' })) diff --git a/apps/sim/app/api/table/[tableId]/delete-async/route.test.ts b/apps/sim/app/api/table/[tableId]/delete-async/route.test.ts index 5ca3fb8c04d..38b24e06fb0 100644 --- a/apps/sim/app/api/table/[tableId]/delete-async/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/delete-async/route.test.ts @@ -1,9 +1,9 @@ /** * @vitest-environment node */ -import { hybridAuthMockFns } from '@sim/testing' +import { hybridAuthMockFns, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' import { NextRequest, NextResponse } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { TableDefinition } from '@/lib/table' const { @@ -13,7 +13,6 @@ const { mockRunTableDelete, mockTableFilterError, mockTasksTrigger, - flags, } = vi.hoisted(() => ({ mockCheckAccess: vi.fn(), mockMarkTableJobRunning: vi.fn(), @@ -21,7 +20,6 @@ const { mockRunTableDelete: vi.fn(), mockTableFilterError: vi.fn(), mockTasksTrigger: vi.fn(), - flags: { triggerDev: false }, })) vi.mock('@sim/utils/id', () => ({ @@ -33,11 +31,6 @@ vi.mock('@/lib/table/jobs/service', () => ({ releaseJobClaim: mockReleaseJobClaim, })) vi.mock('@/lib/table/delete-runner', () => ({ runTableDelete: mockRunTableDelete })) -vi.mock('@/lib/core/config/env-flags', () => ({ - get isTriggerDevEnabled() { - return flags.triggerDev - }, -})) vi.mock('@/background/table-delete', () => ({ tableDeleteTask: { id: 'table-delete' } })) vi.mock('@/lib/core/async-jobs/region', () => ({ resolveTriggerRegion: vi.fn().mockResolvedValue('us-east-1'), @@ -63,6 +56,8 @@ vi.mock('@/app/api/table/utils', async () => { import { POST } from '@/app/api/table/[tableId]/delete-async/route' +afterAll(resetEnvFlagsMock) + function buildTable(overrides: Partial = {}): TableDefinition { return { id: 'tbl_1', @@ -109,7 +104,7 @@ describe('POST /api/table/[tableId]/delete-async', () => { mockRunTableDelete.mockResolvedValue(undefined) mockTableFilterError.mockReturnValue(null) mockTasksTrigger.mockResolvedValue({ id: 'run_1' }) - flags.triggerDev = false + setEnvFlags({ isTriggerDevEnabled: false }) }) it('claims the job slot and kicks off the delete worker with filter + exclusions', async () => { @@ -185,7 +180,7 @@ describe('POST /api/table/[tableId]/delete-async', () => { }) it('routes through trigger.dev (ISO cutoff, tagged) when the flag is on', async () => { - flags.triggerDev = true + setEnvFlags({ isTriggerDevEnabled: true }) const response = await makeRequest(validBody) expect(response.status).toBe(200) @@ -204,7 +199,7 @@ describe('POST /api/table/[tableId]/delete-async', () => { }) it('releases the job claim when the trigger.dev dispatch fails (no ghost running job)', async () => { - flags.triggerDev = true + setEnvFlags({ isTriggerDevEnabled: true }) mockTasksTrigger.mockRejectedValueOnce(new Error('trigger.dev unreachable')) const response = await makeRequest(validBody) diff --git a/apps/sim/app/api/tools/custom/route.test.ts b/apps/sim/app/api/tools/custom/route.test.ts index 67e1a186e2e..d532688628a 100644 --- a/apps/sim/app/api/tools/custom/route.test.ts +++ b/apps/sim/app/api/tools/custom/route.test.ts @@ -6,42 +6,22 @@ import { authMockFns, createMockRequest, + dbChainMockFns, hybridAuthMockFns, permissionsMock, permissionsMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, workflowAuthzMockFns, workflowsUtilsMock, } from '@sim/testing' import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockSelect, - mockFrom, - mockWhere, - mockOrderBy, - mockInsert, - mockValues, - mockUpdate, - mockSet, - mockDelete, - mockLimit, - mockUpsertCustomTools, -} = vi.hoisted(() => { - return { - mockSelect: vi.fn(), - mockFrom: vi.fn(), - mockWhere: vi.fn(), - mockOrderBy: vi.fn(), - mockInsert: vi.fn(), - mockValues: vi.fn(), - mockUpdate: vi.fn(), - mockSet: vi.fn(), - mockDelete: vi.fn(), - mockLimit: vi.fn(), - mockUpsertCustomTools: vi.fn(), - } -}) +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockUpsertCustomTools } = vi.hoisted(() => ({ + mockUpsertCustomTools: vi.fn(), +})) const mockGetUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions @@ -102,83 +82,8 @@ const sampleTools = [ }, ] -vi.mock('@sim/db', () => ({ - db: { - select: (...args: unknown[]) => mockSelect(...args), - insert: (...args: unknown[]) => mockInsert(...args), - update: (...args: unknown[]) => mockUpdate(...args), - delete: (...args: unknown[]) => mockDelete(...args), - transaction: vi - .fn() - .mockImplementation(async (callback: (tx: Record) => unknown) => { - const txMockSelect = vi.fn().mockReturnValue({ from: mockFrom }) - const txMockInsert = vi.fn().mockReturnValue({ values: mockValues }) - const txMockUpdate = vi.fn().mockReturnValue({ set: mockSet }) - const txMockDelete = vi.fn().mockReturnValue({ where: mockWhere }) - - const txMockOrderBy = vi.fn().mockImplementation(() => { - const queryBuilder = { - limit: mockLimit, - then: (resolve: (value: typeof sampleTools) => void) => { - resolve(sampleTools) - return queryBuilder - }, - catch: (_reject: (error: Error) => void) => queryBuilder, - } - return queryBuilder - }) - - const txMockWhere = vi.fn().mockImplementation(() => { - const queryBuilder = { - orderBy: txMockOrderBy, - limit: mockLimit, - then: (resolve: (value: typeof sampleTools) => void) => { - resolve(sampleTools) - return queryBuilder - }, - catch: (_reject: (error: Error) => void) => queryBuilder, - } - return queryBuilder - }) - - const txMockFrom = vi.fn().mockReturnValue({ where: txMockWhere }) - txMockSelect.mockReturnValue({ from: txMockFrom }) - - return await callback({ - select: txMockSelect, - insert: txMockInsert, - update: txMockUpdate, - delete: txMockDelete, - }) - }), - }, -})) - vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) -vi.mock('drizzle-orm', () => ({ - eq: vi.fn().mockImplementation((field: unknown, value: unknown) => ({ - field, - value, - operator: 'eq', - })), - and: vi.fn().mockImplementation((...conditions: unknown[]) => ({ - operator: 'and', - conditions, - })), - or: vi.fn().mockImplementation((...conditions: unknown[]) => ({ - operator: 'or', - conditions, - })), - isNull: vi.fn().mockImplementation((field: unknown) => ({ field, operator: 'isNull' })), - ne: vi.fn().mockImplementation((field: unknown, value: unknown) => ({ - field, - value, - operator: 'ne', - })), - desc: vi.fn().mockImplementation((field: unknown) => ({ field, operator: 'desc' })), -})) - vi.mock('@/lib/workflows/custom-tools/operations', () => ({ upsertCustomTools: (...args: unknown[]) => mockUpsertCustomTools(...args), })) @@ -192,38 +97,7 @@ describe('Custom Tools API Routes', () => { beforeEach(() => { vi.clearAllMocks() - - mockSelect.mockReturnValue({ from: mockFrom }) - mockFrom.mockReturnValue({ where: mockWhere }) - mockWhere.mockImplementation(() => { - const queryBuilder = { - orderBy: mockOrderBy, - limit: mockLimit, - then: (resolve: (value: typeof sampleTools) => void) => { - resolve(sampleTools) - return queryBuilder - }, - catch: (_reject: (error: Error) => void) => queryBuilder, - } - return queryBuilder - }) - mockOrderBy.mockImplementation(() => { - const queryBuilder = { - limit: mockLimit, - then: (resolve: (value: typeof sampleTools) => void) => { - resolve(sampleTools) - return queryBuilder - }, - catch: (_reject: (error: Error) => void) => queryBuilder, - } - return queryBuilder - }) - mockLimit.mockResolvedValue(sampleTools) - mockInsert.mockReturnValue({ values: mockValues }) - mockValues.mockResolvedValue({ id: 'new-tool-id' }) - mockUpdate.mockReturnValue({ set: mockSet }) - mockSet.mockReturnValue({ where: mockWhere }) - mockDelete.mockReturnValue({ where: mockWhere }) + resetDbChainMock() authMockFns.mockGetSession.mockResolvedValue(mockSession) hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ @@ -240,6 +114,10 @@ describe('Custom Tools API Routes', () => { }) }) + afterAll(() => { + resetDbChainMock() + }) + /** * Test GET endpoint */ @@ -249,9 +127,7 @@ describe('Custom Tools API Routes', () => { 'http://localhost:3000/api/tools/custom?workspaceId=workspace-123' ) - mockWhere.mockReturnValueOnce({ - orderBy: mockOrderBy.mockReturnValueOnce(Promise.resolve(sampleTools)), - }) + queueTableRows(schemaMock.customTools, sampleTools) const response = await GET(req) const data = await response.json() @@ -260,10 +136,10 @@ describe('Custom Tools API Routes', () => { expect(data).toHaveProperty('data') expect(data.data).toEqual(sampleTools) - expect(mockSelect).toHaveBeenCalled() - expect(mockFrom).toHaveBeenCalled() - expect(mockWhere).toHaveBeenCalled() - expect(mockOrderBy).toHaveBeenCalled() + expect(dbChainMockFns.select).toHaveBeenCalled() + expect(dbChainMockFns.from).toHaveBeenCalled() + expect(dbChainMockFns.where).toHaveBeenCalled() + expect(dbChainMockFns.orderBy).toHaveBeenCalled() }) it('should handle unauthorized access', async () => { @@ -286,13 +162,15 @@ describe('Custom Tools API Routes', () => { it('should handle workflowId parameter', async () => { const req = new NextRequest('http://localhost:3000/api/tools/custom?workflowId=workflow-123') + queueTableRows(schemaMock.customTools, sampleTools) + const response = await GET(req) const data = await response.json() expect(response.status).toBe(200) expect(data).toHaveProperty('data') - expect(mockWhere).toHaveBeenCalled() + expect(dbChainMockFns.where).toHaveBeenCalled() }) }) @@ -336,7 +214,7 @@ describe('Custom Tools API Routes', () => { */ describe('DELETE /api/tools/custom', () => { it('should delete a workspace-scoped tool by ID', async () => { - mockLimit.mockResolvedValueOnce([sampleTools[0]]) + queueTableRows(schemaMock.customTools, [sampleTools[0]]) const req = new NextRequest( 'http://localhost:3000/api/tools/custom?id=tool-1&workspaceId=workspace-123' @@ -348,8 +226,8 @@ describe('Custom Tools API Routes', () => { expect(response.status).toBe(200) expect(data).toHaveProperty('success', true) - expect(mockDelete).toHaveBeenCalled() - expect(mockWhere).toHaveBeenCalled() + expect(dbChainMockFns.delete).toHaveBeenCalled() + expect(dbChainMockFns.where).toHaveBeenCalled() }) it('should reject requests missing tool ID', async () => { @@ -363,8 +241,7 @@ describe('Custom Tools API Routes', () => { }) it('should handle tool not found', async () => { - const mockLimitNotFound = vi.fn().mockResolvedValue([]) - mockWhere.mockReturnValueOnce({ limit: mockLimitNotFound }) + queueTableRows(schemaMock.customTools, []) const req = new NextRequest('http://localhost:3000/api/tools/custom?id=non-existent') @@ -383,8 +260,7 @@ describe('Custom Tools API Routes', () => { }) const userScopedTool = { ...sampleTools[0], workspaceId: null, userId: 'user-123' } - const mockLimitUserScoped = vi.fn().mockResolvedValue([userScopedTool]) - mockWhere.mockReturnValueOnce({ limit: mockLimitUserScoped }) + queueTableRows(schemaMock.customTools, [userScopedTool]) const req = new NextRequest('http://localhost:3000/api/tools/custom?id=tool-1') diff --git a/apps/sim/app/api/tools/onepassword/utils.test.ts b/apps/sim/app/api/tools/onepassword/utils.test.ts index 8d9e9a76672..160bbe69b7f 100644 --- a/apps/sim/app/api/tools/onepassword/utils.test.ts +++ b/apps/sim/app/api/tools/onepassword/utils.test.ts @@ -1,17 +1,11 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockDnsLookup, hostedFlag } = vi.hoisted(() => ({ +const { mockDnsLookup } = vi.hoisted(() => ({ mockDnsLookup: vi.fn(), - hostedFlag: { value: false }, -})) - -vi.mock('@/lib/core/config/env-flags', () => ({ - get isHosted() { - return hostedFlag.value - }, })) vi.mock('dns/promises', () => ({ @@ -20,10 +14,12 @@ vi.mock('dns/promises', () => ({ import { validateConnectServerUrl } from '@/app/api/tools/onepassword/utils' +afterAll(resetEnvFlagsMock) + describe('validateConnectServerUrl', () => { beforeEach(() => { vi.clearAllMocks() - hostedFlag.value = false + setEnvFlags({ isHosted: false }) }) it('rejects a non-URL string', async () => { @@ -32,7 +28,7 @@ describe('validateConnectServerUrl', () => { describe('hosted deployment', () => { beforeEach(() => { - hostedFlag.value = true + setEnvFlags({ isHosted: true }) }) it.each([ @@ -87,7 +83,7 @@ describe('validateConnectServerUrl', () => { describe('self-hosted deployment', () => { beforeEach(() => { - hostedFlag.value = false + setEnvFlags({ isHosted: false }) }) it.each([ diff --git a/apps/sim/app/api/usage/route.test.ts b/apps/sim/app/api/usage/route.test.ts index ab03f1a5982..8c922f6a170 100644 --- a/apps/sim/app/api/usage/route.test.ts +++ b/apps/sim/app/api/usage/route.test.ts @@ -1,19 +1,12 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' +import { authMockFns, createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetOrganizationBillingData, mockGetSession, mockIsOrganizationOwnerOrAdmin } = - vi.hoisted(() => ({ - mockGetOrganizationBillingData: vi.fn(), - mockGetSession: vi.fn(), - mockIsOrganizationOwnerOrAdmin: vi.fn(), - })) - -vi.mock('@/lib/auth', () => ({ - auth: { api: { getSession: vi.fn() } }, - getSession: mockGetSession, +const { mockGetOrganizationBillingData, mockIsOrganizationOwnerOrAdmin } = vi.hoisted(() => ({ + mockGetOrganizationBillingData: vi.fn(), + mockIsOrganizationOwnerOrAdmin: vi.fn(), })) vi.mock('@/lib/billing', () => ({ @@ -28,6 +21,8 @@ vi.mock('@/lib/billing/core/organization', () => ({ import { GET } from '@/app/api/usage/route' +const mockGetSession = authMockFns.mockGetSession + describe('GET /api/usage organization context', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/app/api/users/me/subscription/[id]/transfer/route.test.ts b/apps/sim/app/api/users/me/subscription/[id]/transfer/route.test.ts index 4743b35041a..b2b44642e0a 100644 --- a/apps/sim/app/api/users/me/subscription/[id]/transfer/route.test.ts +++ b/apps/sim/app/api/users/me/subscription/[id]/transfer/route.test.ts @@ -2,11 +2,9 @@ * @vitest-environment node */ import { - authMock, authMockFns, createMockRequest, createSession, - dbChainMock, dbChainMockFns, resetDbChainMock, } from '@sim/testing' @@ -18,9 +16,6 @@ const { mockAcquireOrganizationMutationLock, mockAssertNoUnresolvedEnterpriseIss mockAssertNoUnresolvedEnterpriseIssuance: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) -vi.mock('@/lib/auth', () => authMock) - vi.mock('@/lib/billing/enterprise-outbox', () => { class EnterpriseIssuanceInProgressError extends Error {} return { diff --git a/apps/sim/app/api/v1/audit-logs/[id]/route.test.ts b/apps/sim/app/api/v1/audit-logs/[id]/route.test.ts index c44f99f0d25..0424663f9c1 100644 --- a/apps/sim/app/api/v1/audit-logs/[id]/route.test.ts +++ b/apps/sim/app/api/v1/audit-logs/[id]/route.test.ts @@ -4,7 +4,7 @@ * Tests for GET /api/v1/audit-logs/[id] — verifies the lookup is constrained * by the organization scope and 404s for rows outside it. */ -import { createMockRequest, dbChainMock, dbChainMockFns } from '@sim/testing' +import { createMockRequest, dbChainMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -19,8 +19,6 @@ const { mockGetOrgWorkspaceIds: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/app/api/v1/middleware', () => ({ checkRateLimit: mockCheckRateLimit, createRateLimitResponse: vi.fn(), diff --git a/apps/sim/app/api/v1/audit-logs/auth.test.ts b/apps/sim/app/api/v1/audit-logs/auth.test.ts index 119c7e9c643..a16dee2b0c4 100644 --- a/apps/sim/app/api/v1/audit-logs/auth.test.ts +++ b/apps/sim/app/api/v1/audit-logs/auth.test.ts @@ -1,44 +1,11 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockIsOrganizationBillingBlocked, - mockMemberWhere, - mockMembersWhere, - mockSelect, - mockSubscriptionWhere, -} = vi.hoisted(() => ({ +const { mockIsOrganizationBillingBlocked } = vi.hoisted(() => ({ mockIsOrganizationBillingBlocked: vi.fn(), - mockMemberWhere: vi.fn(), - mockMembersWhere: vi.fn(), - mockSelect: vi.fn(), - mockSubscriptionWhere: vi.fn(), -})) - -vi.mock('@sim/db', () => ({ - db: { select: mockSelect }, -})) - -vi.mock('@sim/db/schema', () => ({ - member: { - organizationId: 'member.organizationId', - role: 'member.role', - userId: 'member.userId', - }, - subscription: { - id: 'subscription.id', - plan: 'subscription.plan', - referenceId: 'subscription.referenceId', - status: 'subscription.status', - }, -})) - -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })), - eq: vi.fn((left: unknown, right: unknown) => ({ type: 'eq', left, right })), - inArray: vi.fn((left: unknown, right: unknown) => ({ type: 'inArray', left, right })), })) vi.mock('@/lib/billing/core/access', () => ({ @@ -50,18 +17,15 @@ import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' describe('enterprise audit access', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() mockIsOrganizationBillingBlocked.mockResolvedValue(false) - mockMemberWhere.mockReturnValue({ - limit: vi.fn().mockResolvedValue([{ organizationId: 'organization-route', role: 'admin' }]), - }) - mockSubscriptionWhere.mockReturnValue({ - limit: vi.fn().mockResolvedValue([{ id: 'subscription-1' }]), - }) - mockMembersWhere.mockResolvedValue([{ userId: 'viewer' }, { userId: 'member-2' }]) - mockSelect - .mockReturnValueOnce({ from: () => ({ where: mockMemberWhere }) }) - .mockReturnValueOnce({ from: () => ({ where: mockSubscriptionWhere }) }) - .mockReturnValueOnce({ from: () => ({ where: mockMembersWhere }) }) + queueTableRows(schemaMock.member, [{ organizationId: 'organization-route', role: 'admin' }]) + queueTableRows(schemaMock.subscription, [{ id: 'subscription-1' }]) + queueTableRows(schemaMock.member, [{ userId: 'viewer' }, { userId: 'member-2' }]) + }) + + afterAll(() => { + resetDbChainMock() }) it('authorizes and bills against the organization named by the route', async () => { @@ -72,11 +36,11 @@ describe('enterprise audit access', () => { orgMemberIds: ['viewer', 'member-2'], }, }) - expect(mockMemberWhere).toHaveBeenCalledWith({ + expect(dbChainMockFns.where).toHaveBeenNthCalledWith(1, { type: 'and', conditions: [ - { type: 'eq', left: 'member.userId', right: 'viewer' }, - { type: 'eq', left: 'member.organizationId', right: 'organization-route' }, + { type: 'eq', left: schemaMock.member.userId, right: 'viewer' }, + { type: 'eq', left: schemaMock.member.organizationId, right: 'organization-route' }, ], }) expect(mockIsOrganizationBillingBlocked).toHaveBeenCalledWith('organization-route') diff --git a/apps/sim/app/api/v1/audit-logs/query.test.ts b/apps/sim/app/api/v1/audit-logs/query.test.ts index c4a4aacecf7..72740ca537f 100644 --- a/apps/sim/app/api/v1/audit-logs/query.test.ts +++ b/apps/sim/app/api/v1/audit-logs/query.test.ts @@ -5,11 +5,8 @@ * mock returns structured operator objects, so these tests assert directly on * the predicate tree. */ -import { dbChainMock, dbChainMockFns } from '@sim/testing' +import { dbChainMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('@sim/db', () => dbChainMock) - import { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/app/api/v1/audit-logs/query' const ORG_ID = 'org-1' diff --git a/apps/sim/app/api/webhooks/poll/[provider]/route.test.ts b/apps/sim/app/api/webhooks/poll/[provider]/route.test.ts index 859eba6b73d..432e38bf308 100644 --- a/apps/sim/app/api/webhooks/poll/[provider]/route.test.ts +++ b/apps/sim/app/api/webhooks/poll/[provider]/route.test.ts @@ -3,7 +3,7 @@ * * @vitest-environment node */ -import { createMockRequest, redisConfigMock, redisConfigMockFns } from '@sim/testing' +import { createMockRequest, redisConfigMockFns } from '@sim/testing' import { sleep } from '@sim/utils/helpers' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -16,8 +16,6 @@ vi.mock('@/lib/auth/internal', () => ({ verifyCronAuth: mockVerifyCronAuth, })) -vi.mock('@/lib/core/config/redis', () => redisConfigMock) - vi.mock('@/lib/webhooks/polling', () => ({ pollProvider: mockPollProvider, VALID_POLLING_PROVIDERS: new Set(['gmail', 'outlook', 'rss']), diff --git a/apps/sim/app/api/webhooks/slack/route.test.ts b/apps/sim/app/api/webhooks/slack/route.test.ts index ac20c7927cf..bbb2b0baf6b 100644 --- a/apps/sim/app/api/webhooks/slack/route.test.ts +++ b/apps/sim/app/api/webhooks/slack/route.test.ts @@ -1,7 +1,8 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resetEnvMock, setEnv } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockParseWebhookBody, mockFindWebhooksByRoutingKey, mockDispatchResolvedWebhookTarget } = vi.hoisted(() => ({ @@ -15,10 +16,6 @@ vi.mock('@/lib/core/admission/gate', () => ({ admissionRejectedResponse: () => new Response(null, { status: 503 }), })) -vi.mock('@/lib/core/config/env', () => ({ - env: { SLACK_SIGNING_SECRET: 'test-secret' }, -})) - vi.mock('@/lib/webhooks/processor', () => ({ parseWebhookBody: mockParseWebhookBody, findWebhooksByRoutingKey: mockFindWebhooksByRoutingKey, @@ -56,8 +53,13 @@ const messageBody = { } describe('Slack app webhook route', () => { + afterAll(() => { + resetEnvMock() + }) + beforeEach(() => { vi.clearAllMocks() + setEnv({ SLACK_SIGNING_SECRET: 'test-secret' }) mockFindWebhooksByRoutingKey.mockResolvedValue([webhook('wh1')]) mockDispatchResolvedWebhookTarget.mockResolvedValue({ outcome: 'queued', diff --git a/apps/sim/app/api/webhooks/tiktok/route.test.ts b/apps/sim/app/api/webhooks/tiktok/route.test.ts index 6ad1f0676eb..1bd8d805627 100644 --- a/apps/sim/app/api/webhooks/tiktok/route.test.ts +++ b/apps/sim/app/api/webhooks/tiktok/route.test.ts @@ -3,8 +3,9 @@ */ import crypto from 'node:crypto' +import { requestUtilsMockFns, resetEnvMock, setEnv } from '@sim/testing' import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockEnqueueTikTokWebhookIngress, mockRelease } = vi.hoisted(() => ({ mockEnqueueTikTokWebhookIngress: vi.fn(), @@ -20,17 +21,6 @@ vi.mock('@/lib/core/admission/gate', () => ({ tryAdmit: vi.fn(() => ({ release: mockRelease })), })) -vi.mock('@/lib/core/config/env', () => ({ - env: { - TIKTOK_CLIENT_ID: 'client-key', - TIKTOK_CLIENT_SECRET: 'client-secret', - }, -})) - -vi.mock('@/lib/core/utils/request', () => ({ - generateRequestId: vi.fn(() => 'request-1'), -})) - vi.mock('@/lib/core/utils/with-route-handler', () => ({ withRouteHandler: (handler: (request: NextRequest) => Promise) => (request: NextRequest) => @@ -66,9 +56,16 @@ function signedRequest(overrides?: { clientKey?: string }): NextRequest { describe('TikTok webhook ingress route', () => { beforeEach(() => { vi.clearAllMocks() + setEnv({ TIKTOK_CLIENT_ID: 'client-key', TIKTOK_CLIENT_SECRET: 'client-secret' }) + requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('request-1') mockEnqueueTikTokWebhookIngress.mockResolvedValue('ingress-job-1') }) + afterAll(() => { + resetEnvMock() + requestUtilsMockFns.mockGenerateRequestId.mockReset() + }) + it('returns 200 only after the verified delivery is accepted by the job queue', async () => { const response = await POST(signedRequest()) diff --git a/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts b/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts index 188a5a47580..d94b3abcc2a 100644 --- a/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts @@ -4,7 +4,6 @@ * @vitest-environment node */ import { - dbChainMock, dbChainMockFns, hybridAuthMockFns, resetDbChainMock, @@ -14,13 +13,6 @@ import { import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@sim/db', () => dbChainMock) -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...args: unknown[]) => ({ type: 'and', args })), - eq: vi.fn(), - isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })), -})) - vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) import { GET } from '@/app/api/workflows/[id]/chat/status/route' diff --git a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts index ce55f06ee23..771e7cda706 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts @@ -4,7 +4,6 @@ import { createMockRequest, - dbChainMock, dbChainMockFns, executionPreprocessingMock, executionPreprocessingMockFns, @@ -12,6 +11,8 @@ import { loggingSessionMock, requestUtilsMockFns, resetDbChainMock, + resetEnvMock, + setEnv, workflowAuthzMockFns, workflowsPersistenceUtilsMock, workflowsPersistenceUtilsMockFns, @@ -19,7 +20,7 @@ import { workflowsUtilsMockFns, } from '@sim/testing' import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import { AsyncJobEnqueueError } from '@/lib/core/async-jobs/types' const { @@ -55,8 +56,6 @@ const { mockValidatePublicApiAllowed: vi.fn(), })) -vi.mock('@sim/db', () => dbChainMock) - vi.mock('@/lib/billing/core/billing-attribution', () => ({ assertBillingAttributionSnapshot: mockAssertBillingAttributionSnapshot, requireBillingAttributionHeader: mockRequireBillingAttributionHeader, @@ -121,11 +120,6 @@ vi.mock('@/lib/core/async-jobs', () => ({ shouldExecuteInline: vi.fn().mockReturnValue(false), })) -vi.mock('@/lib/core/utils/urls', () => ({ - getBaseUrl: vi.fn().mockReturnValue('http://localhost:3000'), - getOllamaUrl: vi.fn().mockReturnValue('http://localhost:11434'), -})) - vi.mock('@/lib/execution/call-chain', () => ({ SIM_VIA_HEADER: 'x-sim-via', parseCallChain: vi.fn().mockReturnValue([]), @@ -280,9 +274,14 @@ function createCallerExecutionRequest( } describe('workflow execute async route', () => { + afterAll(() => { + resetEnvMock() + }) + beforeEach(() => { vi.clearAllMocks() resetDbChainMock() + setEnv({ NEXT_PUBLIC_APP_URL: 'http://localhost:3000' }) mockGenerateId.mockReset().mockReturnValue('execution-123') mockClaimExecutionId.mockImplementation(async (executionId: string) => ({ key: `workflow-execution-id:${executionId}`, diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.test.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.test.ts index 27529007563..5ab5775652b 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.test.ts @@ -1,29 +1,17 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' +import { authMockFns, createMockRequest, workflowAuthzMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ExecutionEventEntry } from '@/lib/execution/event-buffer' -const { - mockAuthorizeWorkflowByWorkspacePermission, - mockGetSession, - mockReadExecutionEventsState, - mockReadExecutionMetaState, -} = vi.hoisted(() => ({ - mockAuthorizeWorkflowByWorkspacePermission: vi.fn(), - mockGetSession: vi.fn(), +const { mockReadExecutionEventsState, mockReadExecutionMetaState } = vi.hoisted(() => ({ mockReadExecutionEventsState: vi.fn(), mockReadExecutionMetaState: vi.fn(), })) -vi.mock('@/lib/auth', () => ({ - getSession: mockGetSession, -})) - -vi.mock('@sim/platform-authz/workflow', () => ({ - authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission, -})) +const mockAuthorizeWorkflowByWorkspacePermission = + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission vi.mock('@/lib/execution/event-buffer', () => ({ readExecutionEventsState: mockReadExecutionEventsState, @@ -32,6 +20,8 @@ vi.mock('@/lib/execution/event-buffer', () => ({ import { GET } from './route' +const mockGetSession = authMockFns.mockGetSession + function completedEntry(eventId: number): ExecutionEventEntry { return { eventId, diff --git a/apps/sim/app/api/workflows/[id]/log/route.test.ts b/apps/sim/app/api/workflows/[id]/log/route.test.ts index b6d0169c1fe..9b6c35578d5 100644 --- a/apps/sim/app/api/workflows/[id]/log/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/log/route.test.ts @@ -1,12 +1,11 @@ /** * @vitest-environment node */ -import { authMockFns, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { authMockFns, dbChainMockFns, resetDbChainMock } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' // Override global db mock with the configurable chain mock -vi.mock('@sim/db', () => dbChainMock) const { mockValidateWorkflowAccess, diff --git a/apps/sim/app/api/workflows/[id]/references/route.test.ts b/apps/sim/app/api/workflows/[id]/references/route.test.ts new file mode 100644 index 00000000000..785a1ec2893 --- /dev/null +++ b/apps/sim/app/api/workflows/[id]/references/route.test.ts @@ -0,0 +1,88 @@ +/** + * @vitest-environment node + */ +import { authMockFns, createMockRequest, workflowAuthzMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetWorkflowReferences } = vi.hoisted(() => ({ + mockGetWorkflowReferences: vi.fn(), +})) + +const mockAuthorizeWorkflow = workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission + +vi.mock('@/lib/workflows/references/operations', () => ({ + getWorkflowReferences: mockGetWorkflowReferences, +})) + +import { GET } from '@/app/api/workflows/[id]/references/route' + +const mockGetSession = authMockFns.mockGetSession + +const REFERENCES = { + callers: [{ id: 'b', name: 'B', cycle: false, children: [] }], + callees: [], +} + +function callRoute(id = 'wf-1') { + const url = `http://localhost:3000/api/workflows/${id}/references` + return GET(createMockRequest('GET', undefined, {}, url), { params: Promise.resolve({ id }) }) +} + +describe('GET /api/workflows/[id]/references', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockAuthorizeWorkflow.mockResolvedValue({ + allowed: true, + status: 200, + workflow: { id: 'wf-1', workspaceId: 'ws-1' }, + workspacePermission: 'read', + }) + mockGetWorkflowReferences.mockResolvedValue(REFERENCES) + }) + + it('returns 401 without a session', async () => { + mockGetSession.mockResolvedValue(null) + const response = await callRoute() + expect(response.status).toBe(401) + expect(mockGetWorkflowReferences).not.toHaveBeenCalled() + }) + + it('returns 404 when the workflow does not exist', async () => { + mockAuthorizeWorkflow.mockResolvedValue({ + allowed: false, + status: 404, + message: 'Workflow not found', + workflow: null, + workspacePermission: null, + }) + const response = await callRoute() + expect(response.status).toBe(404) + expect(mockGetWorkflowReferences).not.toHaveBeenCalled() + }) + + it('returns 403 when the user cannot read the workflow', async () => { + mockAuthorizeWorkflow.mockResolvedValue({ + allowed: false, + status: 403, + message: 'Unauthorized: Access denied to read this workflow', + workflow: { id: 'wf-1', workspaceId: 'ws-1' }, + workspacePermission: null, + }) + const response = await callRoute() + expect(response.status).toBe(403) + expect(mockGetWorkflowReferences).not.toHaveBeenCalled() + }) + + it('returns the reference trees scoped to the workflow workspace', async () => { + const response = await callRoute() + expect(response.status).toBe(200) + expect(await response.json()).toEqual(REFERENCES) + expect(mockAuthorizeWorkflow).toHaveBeenCalledWith({ + workflowId: 'wf-1', + userId: 'user-1', + action: 'read', + }) + expect(mockGetWorkflowReferences).toHaveBeenCalledWith('ws-1', 'wf-1') + }) +}) diff --git a/apps/sim/app/api/workflows/[id]/references/route.ts b/apps/sim/app/api/workflows/[id]/references/route.ts new file mode 100644 index 00000000000..c2e5505e391 --- /dev/null +++ b/apps/sim/app/api/workflows/[id]/references/route.ts @@ -0,0 +1,37 @@ +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { getWorkflowReferencesContract } from '@/lib/api/contracts/workflow-references' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getWorkflowReferences } from '@/lib/workflows/references/operations' + +type RouteContext = { params: Promise<{ id: string }> } + +export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(getWorkflowReferencesContract, request, context) + if (!parsed.success) return parsed.response + + const { id } = parsed.data.params + + const auth = await authorizeWorkflowByWorkspacePermission({ + workflowId: id, + userId: session.user.id, + action: 'read', + }) + if (!auth.allowed || !auth.workflow?.workspaceId) { + return NextResponse.json( + { error: auth.message ?? 'Access denied' }, + { status: auth.allowed ? 403 : auth.status } + ) + } + + const references = await getWorkflowReferences(auth.workflow.workspaceId, id) + return NextResponse.json(references) +}) diff --git a/apps/sim/app/api/workflows/[id]/route.test.ts b/apps/sim/app/api/workflows/[id]/route.test.ts index 2315712fdb5..9b9be85824c 100644 --- a/apps/sim/app/api/workflows/[id]/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/route.test.ts @@ -7,8 +7,9 @@ import { auditMock, - envMock, + dbChainMockFns, hybridAuthMockFns, + resetDbChainMock, telemetryMock, workflowAuthzMockFns, workflowsOrchestrationMock, @@ -19,7 +20,7 @@ import { workflowsUtilsMockFns, } from '@sim/testing' import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import { getWorkflowResponseDataSchema } from '@/lib/api/contracts/workflows' const mockLoadWorkflowFromNormalizedTables = @@ -30,12 +31,6 @@ const mockAuthorizeWorkflowByWorkspacePermission = const mockPerformDeleteWorkflow = workflowsOrchestrationMockFns.mockPerformDeleteWorkflow const mockPerformUpdateWorkflow = workflowsOrchestrationMockFns.mockPerformUpdateWorkflow -const { mockDbUpdate, mockDbSelect, mockDbTransaction } = vi.hoisted(() => ({ - mockDbUpdate: vi.fn(), - mockDbSelect: vi.fn(), - mockDbTransaction: vi.fn(), -})) - /** * Helper to set mock auth state consistently across getSession and hybrid auth. */ @@ -55,8 +50,6 @@ function mockGetSession(session: { user: { id: string } } | null) { } } -vi.mock('@/lib/core/config/env', () => envMock) - vi.mock('@/lib/core/telemetry', () => telemetryMock) vi.mock('@sim/audit', () => auditMock) @@ -67,20 +60,16 @@ vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock) -vi.mock('@sim/db', () => ({ - db: { - update: () => mockDbUpdate(), - select: () => mockDbSelect(), - transaction: mockDbTransaction, - }, - workflow: {}, -})) - import { DELETE, GET, PUT } from './route' describe('Workflow By ID API Route', () => { + afterAll(() => { + resetDbChainMock() + }) + beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() vi.stubGlobal('crypto', { randomUUID: vi.fn().mockReturnValue('mock-request-id-12345678'), @@ -103,18 +92,6 @@ describe('Workflow By ID API Route', () => { archivedAt: null, }, })) - mockDbTransaction.mockImplementation(async (callback) => - callback({ - execute: vi.fn().mockResolvedValue(undefined), - select: vi.fn().mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([]), - }), - }), - }), - }) - ) }) describe('GET /api/workflows/[id]', () => { @@ -515,16 +492,6 @@ describe('Workflow By ID API Route', () => { }) describe('PUT /api/workflows/[id]', () => { - function mockDuplicateCheck(results: Array<{ id: string }> = []) { - mockDbSelect.mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue(results), - }), - }), - }) - } - it('should allow user with write permission to update workflow', async () => { const mockWorkflow = { id: 'workflow-123', @@ -534,8 +501,6 @@ describe('Workflow By ID API Route', () => { } const updateData = { name: 'Updated Workflow' } - const updatedWorkflow = { ...mockWorkflow, ...updateData, updatedAt: new Date() } - mockGetSession({ user: { id: 'user-123' } }) mockGetWorkflowById.mockResolvedValue(mockWorkflow) @@ -546,16 +511,6 @@ describe('Workflow By ID API Route', () => { workspacePermission: 'write', }) - mockDuplicateCheck([]) - - mockDbUpdate.mockReturnValue({ - set: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - returning: vi.fn().mockResolvedValue([updatedWorkflow]), - }), - }), - }) - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { method: 'PUT', body: JSON.stringify(updateData), @@ -578,8 +533,6 @@ describe('Workflow By ID API Route', () => { } const updateData = { name: 'Updated Workflow' } - const updatedWorkflow = { ...mockWorkflow, ...updateData, updatedAt: new Date() } - mockGetSession({ user: { id: 'user-123' } }) mockGetWorkflowById.mockResolvedValue(mockWorkflow) @@ -590,16 +543,6 @@ describe('Workflow By ID API Route', () => { workspacePermission: 'write', }) - mockDuplicateCheck([]) - - mockDbUpdate.mockReturnValue({ - set: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - returning: vi.fn().mockResolvedValue([updatedWorkflow]), - }), - }), - }) - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { method: 'PUT', body: JSON.stringify(updateData), @@ -761,8 +704,6 @@ describe('Workflow By ID API Route', () => { workspaceId: 'workspace-456', } - const updatedWorkflow = { ...mockWorkflow, name: 'Unique Name', updatedAt: new Date() } - mockGetSession({ user: { id: 'user-123' } }) mockGetWorkflowById.mockResolvedValue(mockWorkflow) mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ @@ -772,16 +713,6 @@ describe('Workflow By ID API Route', () => { workspacePermission: 'write', }) - mockDuplicateCheck([]) - - mockDbUpdate.mockReturnValue({ - set: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - returning: vi.fn().mockResolvedValue([updatedWorkflow]), - }), - }), - }) - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { method: 'PUT', body: JSON.stringify({ name: 'Unique Name' }), @@ -804,8 +735,6 @@ describe('Workflow By ID API Route', () => { workspaceId: 'workspace-456', } - const updatedWorkflow = { ...mockWorkflow, folderId: 'folder-2', updatedAt: new Date() } - mockGetSession({ user: { id: 'user-123' } }) mockGetWorkflowById.mockResolvedValue(mockWorkflow) mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ @@ -815,17 +744,6 @@ describe('Workflow By ID API Route', () => { workspacePermission: 'write', }) - // No duplicate in target folder - mockDuplicateCheck([]) - - mockDbUpdate.mockReturnValue({ - set: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - returning: vi.fn().mockResolvedValue([updatedWorkflow]), - }), - }), - }) - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { method: 'PUT', body: JSON.stringify({ folderId: 'folder-2' }), @@ -883,12 +801,6 @@ describe('Workflow By ID API Route', () => { workspaceId: 'workspace-456', } - const updatedWorkflow = { - ...mockWorkflow, - description: 'Updated description', - updatedAt: new Date(), - } - mockGetSession({ user: { id: 'user-123' } }) mockGetWorkflowById.mockResolvedValue(mockWorkflow) mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ @@ -898,14 +810,6 @@ describe('Workflow By ID API Route', () => { workspacePermission: 'write', }) - mockDbUpdate.mockReturnValue({ - set: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - returning: vi.fn().mockResolvedValue([updatedWorkflow]), - }), - }), - }) - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { method: 'PUT', body: JSON.stringify({ description: 'Updated description' }), @@ -915,8 +819,7 @@ describe('Workflow By ID API Route', () => { const response = await PUT(req, { params }) expect(response.status).toBe(200) - // db.select should NOT have been called since no name/folder change - expect(mockDbSelect).not.toHaveBeenCalled() + expect(dbChainMockFns.select).not.toHaveBeenCalled() }) it('should deny forkSyncExcluded update for non-admin users', async () => { diff --git a/apps/sim/app/api/workflows/middleware.test.ts b/apps/sim/app/api/workflows/middleware.test.ts index 202326d2c15..e66f552df89 100644 --- a/apps/sim/app/api/workflows/middleware.test.ts +++ b/apps/sim/app/api/workflows/middleware.test.ts @@ -7,7 +7,6 @@ import { hybridAuthMockFns, - workflowAuthzMock, workflowAuthzMockFns, workflowsUtilsMock, workflowsUtilsMockFns, @@ -16,7 +15,6 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) -vi.mock('@sim/platform-authz/workflow', () => workflowAuthzMock) vi.mock('@/lib/api-key/service', () => ({ authenticateApiKeyFromHeader: vi.fn(), updateApiKeyLastUsed: vi.fn(), diff --git a/apps/sim/app/api/workflows/route.test.ts b/apps/sim/app/api/workflows/route.test.ts index 261b8bf4b5d..106b6bf8faf 100644 --- a/apps/sim/app/api/workflows/route.test.ts +++ b/apps/sim/app/api/workflows/route.test.ts @@ -4,44 +4,26 @@ import { auditMock, createMockRequest, + dbChainMockFns, hybridAuthMockFns, permissionsMock, permissionsMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, workflowAuthzMockFns, workflowsApiUtilsMock, workflowsPersistenceUtilsMock, workflowsPersistenceUtilsMockFns, } from '@sim/testing' -import { drizzleOrmMock } from '@sim/testing/mocks' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockWorkflowCreated, mockDbSelect, mockDbInsert } = vi.hoisted(() => ({ +const { mockWorkflowCreated } = vi.hoisted(() => ({ mockWorkflowCreated: vi.fn(), - mockDbSelect: vi.fn(), - mockDbInsert: vi.fn(), })) const mockGetUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions -vi.mock('drizzle-orm', () => ({ - ...drizzleOrmMock, - min: vi.fn((field) => ({ type: 'min', field })), -})) - -vi.mock('@sim/db', () => ({ - db: { - select: (...args: unknown[]) => mockDbSelect(...args), - insert: (...args: unknown[]) => mockDbInsert(...args), - transaction: vi.fn(async (fn: (tx: Record) => Promise) => { - const tx = { - select: (...args: unknown[]) => mockDbSelect(...args), - insert: (...args: unknown[]) => mockDbInsert(...args), - } - await fn(tx) - }), - }, -})) - vi.mock('@sim/audit', () => auditMock) vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) @@ -67,8 +49,13 @@ vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock import { POST } from '@/app/api/workflows/route' describe('Workflows API Route - POST ordering', () => { + afterAll(() => { + resetDbChainMock() + }) + beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() vi.stubGlobal('crypto', { randomUUID: vi.fn().mockReturnValue('workflow-new-id'), @@ -102,33 +89,13 @@ describe('Workflows API Route - POST ordering', () => { const response = await POST(req) expect(response.status).toBe(423) - expect(mockDbInsert).not.toHaveBeenCalled() + expect(dbChainMockFns.insert).not.toHaveBeenCalled() }) it('uses top insertion against mixed siblings (folders + workflows)', async () => { - const minResultsQueue: Array> = [ - [], - [{ minOrder: 5 }], - [{ minOrder: 2 }], - ] - - mockDbSelect.mockImplementation(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockImplementation(() => ({ - limit: vi.fn().mockImplementation(() => Promise.resolve(minResultsQueue.shift() ?? [])), - then: (onFulfilled: (value: Array<{ minOrder: number }>) => unknown) => - Promise.resolve(minResultsQueue.shift() ?? []).then(onFulfilled), - })), - }), - })) - - let insertedValues: Record | null = null - mockDbInsert.mockReturnValue({ - values: vi.fn().mockImplementation((values: Record) => { - insertedValues = values - return Promise.resolve(undefined) - }), - }) + queueTableRows(schemaMock.workflow, []) + queueTableRows(schemaMock.workflow, [{ minOrder: 5 }]) + queueTableRows(schemaMock.workflowFolder, [{ minOrder: 2 }]) const req = createMockRequest('POST', { name: 'New Workflow', @@ -141,31 +108,10 @@ describe('Workflows API Route - POST ordering', () => { const data = await response.json() expect(response.status).toBe(200) expect(data.sortOrder).toBe(1) - expect(insertedValues).not.toBeNull() - expect(insertedValues?.sortOrder).toBe(1) + expect(dbChainMockFns.values).toHaveBeenCalledWith(expect.objectContaining({ sortOrder: 1 })) }) it('defaults to sortOrder 0 when there are no siblings', async () => { - const minResultsQueue: Array> = [[], [], []] - - mockDbSelect.mockImplementation(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockImplementation(() => ({ - limit: vi.fn().mockImplementation(() => Promise.resolve(minResultsQueue.shift() ?? [])), - then: (onFulfilled: (value: Array<{ minOrder: number }>) => unknown) => - Promise.resolve(minResultsQueue.shift() ?? []).then(onFulfilled), - })), - }), - })) - - let insertedValues: Record | null = null - mockDbInsert.mockReturnValue({ - values: vi.fn().mockImplementation((values: Record) => { - insertedValues = values - return Promise.resolve(undefined) - }), - }) - const req = createMockRequest('POST', { name: 'New Workflow', description: 'desc', @@ -177,6 +123,6 @@ describe('Workflows API Route - POST ordering', () => { const data = await response.json() expect(response.status).toBe(200) expect(data.sortOrder).toBe(0) - expect(insertedValues?.sortOrder).toBe(0) + expect(dbChainMockFns.values).toHaveBeenCalledWith(expect.objectContaining({ sortOrder: 0 })) }) }) diff --git a/apps/sim/app/api/workspace-events/poll/route.test.ts b/apps/sim/app/api/workspace-events/poll/route.test.ts index dee80482bc8..96d52e8b3a7 100644 --- a/apps/sim/app/api/workspace-events/poll/route.test.ts +++ b/apps/sim/app/api/workspace-events/poll/route.test.ts @@ -3,7 +3,7 @@ * * @vitest-environment node */ -import { createMockRequest, redisConfigMock, redisConfigMockFns } from '@sim/testing' +import { createMockRequest, redisConfigMockFns } from '@sim/testing' import { sleep } from '@sim/utils/helpers' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -18,8 +18,6 @@ vi.mock('@/lib/auth/internal', () => ({ verifyCronAuth: mockVerifyCronAuth, })) -vi.mock('@/lib/core/config/redis', () => redisConfigMock) - vi.mock('@/lib/workspace-events/no-activity', () => ({ pollNoActivityEvents: mockPollNoActivityEvents, })) diff --git a/apps/sim/app/api/workspaces/[id]/api-keys/route.test.ts b/apps/sim/app/api/workspaces/[id]/api-keys/route.test.ts index 67b354a5268..39eb2394e0b 100644 --- a/apps/sim/app/api/workspaces/[id]/api-keys/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/api-keys/route.test.ts @@ -1,34 +1,21 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + authMockFns, + createMockRequest, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockGetApiKeyDisplayFormat, - mockGetSession, - mockGetUserEntityPermissions, - mockGetWorkspaceById, - mockOrderBy, -} = vi.hoisted(() => ({ - mockGetApiKeyDisplayFormat: vi.fn(), - mockGetSession: vi.fn(), - mockGetUserEntityPermissions: vi.fn(), - mockGetWorkspaceById: vi.fn(), - mockOrderBy: vi.fn(), -})) - -vi.mock('@sim/db', () => ({ - db: { - select: vi.fn(() => ({ - from: vi.fn(() => ({ - where: vi.fn(() => ({ - orderBy: mockOrderBy, - })), - })), - })), - }, -})) +const { mockGetApiKeyDisplayFormat, mockGetUserEntityPermissions, mockGetWorkspaceById } = + vi.hoisted(() => ({ + mockGetApiKeyDisplayFormat: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockGetWorkspaceById: vi.fn(), + })) vi.mock('@/lib/api-key/auth', () => ({ getApiKeyDisplayFormat: mockGetApiKeyDisplayFormat, @@ -38,11 +25,6 @@ vi.mock('@/lib/api-key/orchestration', () => ({ performCreateWorkspaceApiKey: vi.fn(), })) -vi.mock('@/lib/auth', () => ({ - auth: { api: { getSession: vi.fn() } }, - getSession: mockGetSession, -})) - vi.mock('@/lib/workspaces/permissions/utils', () => ({ getUserEntityPermissions: mockGetUserEntityPermissions, getWorkspaceById: mockGetWorkspaceById, @@ -50,14 +32,17 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ import { GET } from '@/app/api/workspaces/[id]/api-keys/route' +const mockGetSession = authMockFns.mockGetSession + describe('GET /api/workspaces/[id]/api-keys', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() mockGetSession.mockResolvedValue({ user: { id: 'reader-1' } }) mockGetWorkspaceById.mockResolvedValue({ id: 'workspace-1' }) mockGetUserEntityPermissions.mockResolvedValue('read') mockGetApiKeyDisplayFormat.mockResolvedValue('sim_••••legacy') - mockOrderBy.mockResolvedValue([ + queueTableRows(schemaMock.apiKey, [ { id: 'key-1', name: 'Legacy key', @@ -70,6 +55,10 @@ describe('GET /api/workspaces/[id]/api-keys', () => { ]) }) + afterAll(() => { + resetDbChainMock() + }) + it('returns metadata without exposing the stored key value', async () => { const response = await GET(createMockRequest('GET'), { params: Promise.resolve({ id: 'workspace-1' }), diff --git a/apps/sim/app/api/workspaces/[id]/byok-keys/route.test.ts b/apps/sim/app/api/workspaces/[id]/byok-keys/route.test.ts index 186839287d7..634d5e19137 100644 --- a/apps/sim/app/api/workspaces/[id]/byok-keys/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/byok-keys/route.test.ts @@ -1,70 +1,27 @@ /** * @vitest-environment node */ -import { auditMock, createMockRequest } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockDbState, - mockGetSession, - mockGetUserEntityPermissions, - mockGetWorkspaceById, - mockEncryptSecret, - mockDecryptSecret, - mockUpdateSet, - mockInsertValues, - mockDeleteWhere, -} = vi.hoisted(() => { - const state = { - selectResults: [] as unknown[][], - insertReturning: [] as unknown[], - deleteReturning: [] as unknown[], - } - return { - mockDbState: state, - mockGetSession: vi.fn(), +import { + auditMock, + authMockFns, + createMockRequest, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetUserEntityPermissions, mockGetWorkspaceById, mockEncryptSecret, mockDecryptSecret } = + vi.hoisted(() => ({ mockGetUserEntityPermissions: vi.fn(), mockGetWorkspaceById: vi.fn(), mockEncryptSecret: vi.fn(), mockDecryptSecret: vi.fn(), - mockUpdateSet: vi.fn(() => ({ where: vi.fn(() => Promise.resolve()) })), - mockInsertValues: vi.fn(() => ({ - returning: vi.fn(() => Promise.resolve(state.insertReturning)), - })), - mockDeleteWhere: vi.fn(() => ({ - returning: vi.fn(() => Promise.resolve(state.deleteReturning)), - })), - } -}) - -vi.mock('@sim/db', () => { - const dbMock: Record = { - select: vi.fn(() => { - const chain: Record = {} - chain.from = vi.fn().mockReturnValue(chain) - chain.where = vi.fn().mockImplementation(() => { - const result: any = Promise.resolve(mockDbState.selectResults.shift() ?? []) - result.limit = vi.fn(() => result) - result.orderBy = vi.fn(() => result) - return result - }) - return chain - }), - update: vi.fn(() => ({ set: mockUpdateSet })), - insert: vi.fn(() => ({ values: mockInsertValues })), - delete: vi.fn(() => ({ where: mockDeleteWhere })), - execute: vi.fn(() => Promise.resolve([])), - } - dbMock.transaction = vi.fn(async (callback: (tx: unknown) => unknown) => callback(dbMock)) - return { db: dbMock } -}) + })) vi.mock('@sim/audit', () => auditMock) -vi.mock('@/lib/auth', () => ({ - getSession: mockGetSession, -})) - vi.mock('@/lib/core/security/encryption', () => ({ encryptSecret: mockEncryptSecret, decryptSecret: mockDecryptSecret, @@ -81,6 +38,8 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ import { DELETE, GET, POST } from '@/app/api/workspaces/[id]/byok-keys/route' +const mockGetSession = authMockFns.mockGetSession + const WORKSPACE_ID = 'workspace-1' const routeContext = { params: Promise.resolve({ id: WORKSPACE_ID }) } @@ -97,9 +56,7 @@ const storedKeyRow = (id: string, name: string | null = null) => ({ describe('workspace BYOK keys route', () => { beforeEach(() => { vi.clearAllMocks() - mockDbState.selectResults = [] - mockDbState.insertReturning = [] - mockDbState.deleteReturning = [] + resetDbChainMock() mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) mockGetUserEntityPermissions.mockResolvedValue('admin') @@ -110,9 +67,16 @@ describe('workspace BYOK keys route', () => { })) }) + afterAll(() => { + resetDbChainMock() + }) + describe('GET', () => { it('lists every stored key with name and masked value', async () => { - mockDbState.selectResults = [[storedKeyRow('key-1', 'Production'), storedKeyRow('key-2')]] + queueTableRows(schemaMock.workspaceBYOKKeys, [ + storedKeyRow('key-1', 'Production'), + storedKeyRow('key-2'), + ]) const res = await GET(createMockRequest('GET'), routeContext) @@ -143,14 +107,14 @@ describe('workspace BYOK keys route', () => { ) expect(res.status).toBe(403) - expect(mockInsertValues).not.toHaveBeenCalled() + expect(dbChainMockFns.values).not.toHaveBeenCalled() }) it('adds a new key even when the provider already has keys', async () => { - mockDbState.selectResults = [[{ keyCount: 2 }]] - mockDbState.insertReturning = [ + queueTableRows(schemaMock.workspaceBYOKKeys, [{ keyCount: 2 }]) + dbChainMockFns.returning.mockResolvedValueOnce([ { id: 'key-3', providerId: 'openai', name: 'Backup', createdAt: new Date() }, - ] + ]) const res = await POST( createMockRequest('POST', { providerId: 'openai', apiKey: 'sk-new-key', name: 'Backup' }), @@ -161,7 +125,7 @@ describe('workspace BYOK keys route', () => { const body = await res.json() expect(body.success).toBe(true) expect(body.key).toMatchObject({ id: 'key-3', name: 'Backup' }) - expect(mockInsertValues).toHaveBeenCalledWith( + expect(dbChainMockFns.values).toHaveBeenCalledWith( expect.objectContaining({ workspaceId: WORKSPACE_ID, providerId: 'openai', @@ -172,10 +136,10 @@ describe('workspace BYOK keys route', () => { }) it('stores a null name when none is provided', async () => { - mockDbState.selectResults = [[{ keyCount: 0 }]] - mockDbState.insertReturning = [ + queueTableRows(schemaMock.workspaceBYOKKeys, [{ keyCount: 0 }]) + dbChainMockFns.returning.mockResolvedValueOnce([ { id: 'key-1', providerId: 'openai', name: null, createdAt: new Date() }, - ] + ]) const res = await POST( createMockRequest('POST', { providerId: 'openai', apiKey: 'sk-new-key' }), @@ -183,11 +147,11 @@ describe('workspace BYOK keys route', () => { ) expect(res.status).toBe(200) - expect(mockInsertValues).toHaveBeenCalledWith(expect.objectContaining({ name: null })) + expect(dbChainMockFns.values).toHaveBeenCalledWith(expect.objectContaining({ name: null })) }) it('rejects adding a key beyond the per-provider cap', async () => { - mockDbState.selectResults = [[{ keyCount: 10 }]] + queueTableRows(schemaMock.workspaceBYOKKeys, [{ keyCount: 10 }]) const res = await POST( createMockRequest('POST', { providerId: 'openai', apiKey: 'sk-new-key' }), @@ -197,12 +161,12 @@ describe('workspace BYOK keys route', () => { expect(res.status).toBe(400) const body = await res.json() expect(body.error).toContain('at most 10 keys') - expect(mockInsertValues).not.toHaveBeenCalled() + expect(dbChainMockFns.values).not.toHaveBeenCalled() expect(mockEncryptSecret).not.toHaveBeenCalled() }) it('updates the targeted key in place when keyId is provided', async () => { - mockDbState.selectResults = [[{ id: 'key-2', name: 'Old name' }]] + queueTableRows(schemaMock.workspaceBYOKKeys, [{ id: 'key-2', name: 'Old name' }]) const res = await POST( createMockRequest('POST', { providerId: 'openai', apiKey: 'sk-rotated', keyId: 'key-2' }), @@ -212,14 +176,14 @@ describe('workspace BYOK keys route', () => { expect(res.status).toBe(200) const body = await res.json() expect(body.key).toMatchObject({ id: 'key-2', name: 'Old name' }) - expect(mockUpdateSet).toHaveBeenCalledWith( + expect(dbChainMockFns.set).toHaveBeenCalledWith( expect.objectContaining({ encryptedApiKey: 'encrypted-value', name: 'Old name' }) ) - expect(mockInsertValues).not.toHaveBeenCalled() + expect(dbChainMockFns.values).not.toHaveBeenCalled() }) it('clears the name when updating with an empty name', async () => { - mockDbState.selectResults = [[{ id: 'key-2', name: 'Old name' }]] + queueTableRows(schemaMock.workspaceBYOKKeys, [{ id: 'key-2', name: 'Old name' }]) const res = await POST( createMockRequest('POST', { @@ -232,11 +196,11 @@ describe('workspace BYOK keys route', () => { ) expect(res.status).toBe(200) - expect(mockUpdateSet).toHaveBeenCalledWith(expect.objectContaining({ name: null })) + expect(dbChainMockFns.set).toHaveBeenCalledWith(expect.objectContaining({ name: null })) }) it('returns 404 when the keyId does not exist in the workspace', async () => { - mockDbState.selectResults = [[]] + queueTableRows(schemaMock.workspaceBYOKKeys, []) const res = await POST( createMockRequest('POST', { providerId: 'openai', apiKey: 'sk-rotated', keyId: 'missing' }), @@ -244,7 +208,7 @@ describe('workspace BYOK keys route', () => { ) expect(res.status).toBe(404) - expect(mockUpdateSet).not.toHaveBeenCalled() + expect(dbChainMockFns.set).not.toHaveBeenCalled() expect(mockEncryptSecret).not.toHaveBeenCalled() }) @@ -260,7 +224,7 @@ describe('workspace BYOK keys route', () => { describe('DELETE', () => { it('deletes a single key when keyId is provided', async () => { - mockDbState.deleteReturning = [{ id: 'key-2' }] + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'key-2' }]) const res = await DELETE( createMockRequest('DELETE', { providerId: 'openai', keyId: 'key-2' }), @@ -272,7 +236,7 @@ describe('workspace BYOK keys route', () => { }) it('returns 404 when keyId is provided but no key matches', async () => { - mockDbState.deleteReturning = [] + dbChainMockFns.returning.mockResolvedValueOnce([]) const res = await DELETE( createMockRequest('DELETE', { providerId: 'openai', keyId: 'missing' }), @@ -283,7 +247,7 @@ describe('workspace BYOK keys route', () => { }) it('deletes all provider keys when keyId is omitted', async () => { - mockDbState.deleteReturning = [{ id: 'key-1' }, { id: 'key-2' }] + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'key-1' }, { id: 'key-2' }]) const res = await DELETE(createMockRequest('DELETE', { providerId: 'openai' }), routeContext) @@ -292,7 +256,7 @@ describe('workspace BYOK keys route', () => { }) it('succeeds when keyId is omitted and the provider has no keys', async () => { - mockDbState.deleteReturning = [] + dbChainMockFns.returning.mockResolvedValueOnce([]) const res = await DELETE(createMockRequest('DELETE', { providerId: 'openai' }), routeContext) @@ -306,7 +270,7 @@ describe('workspace BYOK keys route', () => { const res = await DELETE(createMockRequest('DELETE', { providerId: 'openai' }), routeContext) expect(res.status).toBe(403) - expect(mockDeleteWhere).not.toHaveBeenCalled() + expect(dbChainMockFns.delete).not.toHaveBeenCalled() }) }) }) diff --git a/apps/sim/app/api/workspaces/[id]/credit-availability/route.test.ts b/apps/sim/app/api/workspaces/[id]/credit-availability/route.test.ts index b4d90966f77..637b4e5c0fa 100644 --- a/apps/sim/app/api/workspaces/[id]/credit-availability/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/credit-availability/route.test.ts @@ -1,20 +1,15 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' +import { authMockFns, createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetSession, mockGetWorkspaceCreditAvailability, mockGetWorkspaceHostContextForViewer } = - vi.hoisted(() => ({ - mockGetSession: vi.fn(), +const { mockGetWorkspaceCreditAvailability, mockGetWorkspaceHostContextForViewer } = vi.hoisted( + () => ({ mockGetWorkspaceCreditAvailability: vi.fn(), mockGetWorkspaceHostContextForViewer: vi.fn(), - })) - -vi.mock('@/lib/auth', () => ({ - auth: { api: { getSession: vi.fn() } }, - getSession: mockGetSession, -})) + }) +) vi.mock('@/lib/billing/core/workspace-usage-gate', () => ({ getWorkspaceCreditAvailability: mockGetWorkspaceCreditAvailability, @@ -26,6 +21,8 @@ vi.mock('@/lib/workspaces/host-context', () => ({ import { GET } from '@/app/api/workspaces/[id]/credit-availability/route' +const mockGetSession = authMockFns.mockGetSession + const HOST_CONTEXT = { workspace: { id: 'workspace-b', diff --git a/apps/sim/app/api/workspaces/[id]/environment/route.test.ts b/apps/sim/app/api/workspaces/[id]/environment/route.test.ts index 6ad61921591..759abd0b1e5 100644 --- a/apps/sim/app/api/workspaces/[id]/environment/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/environment/route.test.ts @@ -1,37 +1,22 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' +import { authMockFns, createMockRequest, environmentUtilsMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockGetSession, - mockGetWorkspaceById, - mockGetUserEntityPermissions, - mockGetPersonalAndWorkspaceEnv, - mockGetWorkspaceEnvKeyAdminAccess, -} = vi.hoisted(() => ({ - mockGetSession: vi.fn(), - mockGetWorkspaceById: vi.fn(), - mockGetUserEntityPermissions: vi.fn(), - mockGetPersonalAndWorkspaceEnv: vi.fn(), - mockGetWorkspaceEnvKeyAdminAccess: vi.fn(), -})) - -vi.mock('@/lib/auth', () => ({ - auth: { api: { getSession: vi.fn() } }, - getSession: mockGetSession, -})) +const { mockGetWorkspaceById, mockGetUserEntityPermissions, mockGetWorkspaceEnvKeyAdminAccess } = + vi.hoisted(() => ({ + mockGetWorkspaceById: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), + mockGetWorkspaceEnvKeyAdminAccess: vi.fn(), + })) vi.mock('@/lib/workspaces/permissions/utils', () => ({ getWorkspaceById: mockGetWorkspaceById, getUserEntityPermissions: mockGetUserEntityPermissions, })) -vi.mock('@/lib/environment/utils', () => ({ - getPersonalAndWorkspaceEnv: mockGetPersonalAndWorkspaceEnv, - invalidateEffectiveDecryptedEnvCache: vi.fn(), -})) +const mockGetPersonalAndWorkspaceEnv = environmentUtilsMockFns.mockGetPersonalAndWorkspaceEnv vi.mock('@/lib/credentials/environment', () => ({ getWorkspaceEnvKeyAdminAccess: mockGetWorkspaceEnvKeyAdminAccess, @@ -41,6 +26,8 @@ vi.mock('@/lib/credentials/environment', () => ({ import { GET } from '@/app/api/workspaces/[id]/environment/route' +const mockGetSession = authMockFns.mockGetSession + const WORKSPACE_ID = 'ws-1' function buildParams() { diff --git a/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts index 3bb2a8a06ba..c57ac919e38 100644 --- a/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts @@ -1,20 +1,16 @@ /** * @vitest-environment node */ +import { authMockFns } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetSession, mockGetPerms, mockResolveImage, mockDownloadFile } = vi.hoisted(() => ({ - mockGetSession: vi.fn(), +const { mockGetPerms, mockResolveImage, mockDownloadFile } = vi.hoisted(() => ({ mockGetPerms: vi.fn(), mockResolveImage: vi.fn(), mockDownloadFile: vi.fn(), })) -vi.mock('@/lib/auth', () => ({ - auth: { api: { getSession: vi.fn() } }, - getSession: mockGetSession, -})) vi.mock('@/lib/workspaces/permissions/utils', () => ({ getUserEntityPermissions: mockGetPerms })) vi.mock('@/lib/uploads/server/inline-image', () => ({ resolveWorkspaceInlineImage: mockResolveImage, @@ -23,6 +19,8 @@ vi.mock('@/lib/uploads/core/storage-service', () => ({ downloadFile: mockDownloa import { GET } from '@/app/api/workspaces/[id]/files/inline/route' +const mockGetSession = authMockFns.mockGetSession + const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00]) const params = { params: Promise.resolve({ id: 'ws-1' }) } const req = (q: string) => new NextRequest(`http://localhost/api/workspaces/ws-1/files/inline?${q}`) diff --git a/apps/sim/app/api/workspaces/[id]/fork/excluded-workflows/route.test.ts b/apps/sim/app/api/workspaces/[id]/fork/excluded-workflows/route.test.ts index f882cb9bf54..f31acbbc923 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/excluded-workflows/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/excluded-workflows/route.test.ts @@ -1,20 +1,19 @@ /** * @vitest-environment node */ -import { auditMock, auditMockFns, createMockRequest } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockGetSession, mockAssertWorkspaceAdminAccess, mockDbUpdate, mockCaptureServerEvent } = - vi.hoisted(() => ({ - mockGetSession: vi.fn(), - mockAssertWorkspaceAdminAccess: vi.fn(), - mockDbUpdate: vi.fn(), - mockCaptureServerEvent: vi.fn(), - })) - -vi.mock('@/lib/auth', () => ({ - auth: { api: { getSession: vi.fn() } }, - getSession: mockGetSession, +import { + auditMock, + auditMockFns, + authMockFns, + createMockRequest, + dbChainMockFns, + resetDbChainMock, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockAssertWorkspaceAdminAccess, mockCaptureServerEvent } = vi.hoisted(() => ({ + mockAssertWorkspaceAdminAccess: vi.fn(), + mockCaptureServerEvent: vi.fn(), })) vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => ({ @@ -27,34 +26,31 @@ vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCaptureServerEvent, })) -vi.mock('@sim/db', () => ({ - db: { update: () => mockDbUpdate() }, -})) - import { PUT } from '@/app/api/workspaces/[id]/fork/excluded-workflows/route' +const mockGetSession = authMockFns.mockGetSession + const WORKSPACE_ID = 'workspace-1' const ADMIN_ID = 'user-1' const routeContext = { params: Promise.resolve({ id: WORKSPACE_ID }) } function mockUpdateReturning(rows: Array<{ id: string; name: string }>) { - mockDbUpdate.mockReturnValue({ - set: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - returning: vi.fn().mockResolvedValue(rows), - }), - }), - }) + dbChainMockFns.returning.mockResolvedValue(rows) } describe('fork excluded-workflows route', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() mockGetSession.mockResolvedValue({ user: { id: ADMIN_ID } }) mockAssertWorkspaceAdminAccess.mockResolvedValue({ id: WORKSPACE_ID, name: 'My Workspace' }) mockUpdateReturning([]) }) + afterAll(() => { + resetDbChainMock() + }) + it('returns 401 when there is no session', async () => { mockGetSession.mockResolvedValue(null) @@ -74,7 +70,7 @@ describe('fork excluded-workflows route', () => { ) expect(res.status).toBe(400) - expect(mockDbUpdate).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() }) it('requires workspace admin (and the fork entitlement gate) before writing', async () => { diff --git a/apps/sim/app/api/workspaces/[id]/fork/lineage/route.test.ts b/apps/sim/app/api/workspaces/[id]/fork/lineage/route.test.ts index 1970a50b9e4..c3c265ba4c6 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/lineage/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/lineage/route.test.ts @@ -1,18 +1,16 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' +import { authMockFns, createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { - mockGetSession, mockAssertWorkspaceAdminAccess, mockGetForkParent, mockGetForkChildren, mockGetUndoableRunForTarget, mockGetEffectiveWorkspacePermission, } = vi.hoisted(() => ({ - mockGetSession: vi.fn(), mockAssertWorkspaceAdminAccess: vi.fn(), mockGetForkParent: vi.fn(), mockGetForkChildren: vi.fn(), @@ -20,11 +18,6 @@ const { mockGetEffectiveWorkspacePermission: vi.fn(), })) -vi.mock('@/lib/auth', () => ({ - auth: { api: { getSession: vi.fn() } }, - getSession: mockGetSession, -})) - vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => ({ assertWorkspaceAdminAccess: mockAssertWorkspaceAdminAccess, })) @@ -44,6 +37,8 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ import { GET } from '@/app/api/workspaces/[id]/fork/lineage/route' +const mockGetSession = authMockFns.mockGetSession + const WORKSPACE_ID = 'workspace-1' const VIEWER_ID = 'user-1' const routeContext = { params: Promise.resolve({ id: WORKSPACE_ID }) } diff --git a/apps/sim/app/api/workspaces/[id]/host-context/route.test.ts b/apps/sim/app/api/workspaces/[id]/host-context/route.test.ts index 15a573533e8..b893936cd63 100644 --- a/apps/sim/app/api/workspaces/[id]/host-context/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/host-context/route.test.ts @@ -1,25 +1,21 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' +import { authMockFns, createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetSession, mockGetWorkspaceHostContextForViewer } = vi.hoisted(() => ({ - mockGetSession: vi.fn(), +const { mockGetWorkspaceHostContextForViewer } = vi.hoisted(() => ({ mockGetWorkspaceHostContextForViewer: vi.fn(), })) -vi.mock('@/lib/auth', () => ({ - auth: { api: { getSession: vi.fn() } }, - getSession: mockGetSession, -})) - vi.mock('@/lib/workspaces/host-context', () => ({ getWorkspaceHostContextForViewer: mockGetWorkspaceHostContextForViewer, })) import { GET } from '@/app/api/workspaces/[id]/host-context/route' +const mockGetSession = authMockFns.mockGetSession + const HOST_CONTEXT = { workspace: { id: 'workspace-1', diff --git a/apps/sim/app/api/workspaces/[id]/usage-gate/route.test.ts b/apps/sim/app/api/workspaces/[id]/usage-gate/route.test.ts index a95c44df843..1416eb53e61 100644 --- a/apps/sim/app/api/workspaces/[id]/usage-gate/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/usage-gate/route.test.ts @@ -1,19 +1,12 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' +import { authMockFns, createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCheckWorkspaceUsageGate, mockGetSession, mockGetWorkspaceHostContextForViewer } = - vi.hoisted(() => ({ - mockCheckWorkspaceUsageGate: vi.fn(), - mockGetSession: vi.fn(), - mockGetWorkspaceHostContextForViewer: vi.fn(), - })) - -vi.mock('@/lib/auth', () => ({ - auth: { api: { getSession: vi.fn() } }, - getSession: mockGetSession, +const { mockCheckWorkspaceUsageGate, mockGetWorkspaceHostContextForViewer } = vi.hoisted(() => ({ + mockCheckWorkspaceUsageGate: vi.fn(), + mockGetWorkspaceHostContextForViewer: vi.fn(), })) vi.mock('@/lib/billing/core/workspace-usage-gate', () => ({ @@ -26,6 +19,8 @@ vi.mock('@/lib/workspaces/host-context', () => ({ import { GET } from '@/app/api/workspaces/[id]/usage-gate/route' +const mockGetSession = authMockFns.mockGetSession + const HOST_CONTEXT = { workspace: { id: 'workspace-b', diff --git a/apps/sim/app/api/workspaces/invitations/route.test.ts b/apps/sim/app/api/workspaces/invitations/route.test.ts index ddfe8a59213..2b600626204 100644 --- a/apps/sim/app/api/workspaces/invitations/route.test.ts +++ b/apps/sim/app/api/workspaces/invitations/route.test.ts @@ -3,15 +3,16 @@ */ import { auditMock, - authMock, authMockFns, createMockRequest, permissionsMock, permissionsMockFns, posthogServerMock, + queueTableRows, + resetDbChainMock, schemaMock, } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetWorkspaceInvitePolicy, @@ -22,7 +23,6 @@ const { mockSendInvitationEmail, mockCancelPendingInvitation, mockFindPendingGrantForWorkspaceEmail, - mockDbResults, } = vi.hoisted(() => ({ mockGetWorkspaceInvitePolicy: vi.fn(), mockValidateInvitationsAllowed: vi.fn().mockResolvedValue(undefined), @@ -32,32 +32,8 @@ const { mockSendInvitationEmail: vi.fn(), mockCancelPendingInvitation: vi.fn(), mockFindPendingGrantForWorkspaceEmail: vi.fn(), - mockDbResults: { value: [] as any[] }, })) -vi.mock('@sim/db', () => ({ - db: { - select: vi.fn().mockImplementation(() => { - const chain: any = {} - chain.from = vi.fn().mockReturnValue(chain) - chain.innerJoin = vi.fn().mockReturnValue(chain) - chain.where = vi.fn().mockReturnValue(chain) - chain.limit = vi - .fn() - .mockImplementation(() => Promise.resolve(mockDbResults.value.shift() || [])) - chain.then = vi.fn().mockImplementation((callback: (rows: any[]) => unknown) => { - const result = mockDbResults.value.shift() || [] - return Promise.resolve(callback ? callback(result) : result) - }) - return chain - }), - }, -})) - -vi.mock('@sim/db/schema', () => schemaMock) - -vi.mock('@/lib/auth', () => authMock) - vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) vi.mock('@/lib/workspaces/policy', () => ({ @@ -112,7 +88,7 @@ import { POST } from '@/app/api/workspaces/invitations/batch/route' describe('POST /api/workspaces/invitations/batch', () => { beforeEach(() => { vi.clearAllMocks() - mockDbResults.value = [] + resetDbChainMock() mockGetSession.mockResolvedValue({ user: { id: 'user-1', email: 'owner@test.com', name: 'Owner User' }, }) @@ -149,6 +125,10 @@ describe('POST /api/workspaces/invitations/batch', () => { mockFindPendingGrantForWorkspaceEmail.mockResolvedValue(null) }) + afterAll(() => { + resetDbChainMock() + }) + it('blocks invites for personal workspaces with an upgrade prompt', async () => { mockGetWorkspaceWithOwner.mockResolvedValueOnce({ id: 'workspace-1', @@ -165,7 +145,6 @@ describe('POST /api/workspaces/invitations/batch', () => { organizationId: null, upgradeRequired: true, }) - mockDbResults.value = [] const request = createMockRequest('POST', { workspaceId: 'workspace-1', @@ -196,7 +175,6 @@ describe('POST /api/workspaces/invitations/batch', () => { organizationId: null, upgradeRequired: true, }) - mockDbResults.value = [] const request = createMockRequest('POST', { workspaceId: 'workspace-1', @@ -234,7 +212,6 @@ describe('POST /api/workspaces/invitations/batch', () => { maxSeats: 5, availableSeats: 0, }) - mockDbResults.value = [[]] const request = createMockRequest('POST', { workspaceId: 'workspace-1', @@ -277,7 +254,7 @@ describe('POST /api/workspaces/invitations/batch', () => { role: 'member', memberId: 'member-1', }) - mockDbResults.value = [[{ id: 'existing-user', email: 'new@example.com' }]] + queueTableRows(schemaMock.user, [{ id: 'existing-user', email: 'new@example.com' }]) const request = createMockRequest('POST', { workspaceId: 'workspace-1', @@ -311,7 +288,6 @@ describe('POST /api/workspaces/invitations/batch', () => { workspaceMode: 'grandfathered_shared', billedAccountUserId: 'user-1', }) - mockDbResults.value = [[]] const request = createMockRequest('POST', { workspaceId: 'workspace-1', @@ -336,7 +312,6 @@ describe('POST /api/workspaces/invitations/batch', () => { }) it('creates multiple workspace invitations in one batch request', async () => { - mockDbResults.value = [[], []] mockCreatePendingInvitation .mockResolvedValueOnce({ invitationId: 'inv-1', @@ -382,7 +357,6 @@ describe('POST /api/workspaces/invitations/batch', () => { success: false, error: 'mailer unavailable', }) - mockDbResults.value = [[]] const request = createMockRequest('POST', { workspaceId: 'workspace-1', diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.test.tsx index e91226d8757..b3e08f18da1 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.test.tsx @@ -18,9 +18,6 @@ vi.mock('next/navigation', () => ({ })) // Override the global `getAllBlocks: () => ({})` stub — `getIconColorMap` iterates it as an array. -vi.mock('@/blocks/registry', () => ({ - getAllBlocks: () => [], -})) const { MentionChipView } = await import('./mention-chip') diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.test.tsx index 6fdcac86f98..6e84fab43ac 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.test.tsx @@ -2,8 +2,9 @@ * @vitest-environment jsdom */ import { act } from 'react' +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockHostContext, mockPush, mockSession, mockUseWorkspaceCreditAvailability } = vi.hoisted( () => ({ @@ -55,10 +56,6 @@ vi.mock('@/lib/auth/auth-client', () => ({ useSession: () => ({ data: mockSession.current }), })) -vi.mock('@/lib/core/config/env-flags', () => ({ - isBillingEnabled: true, -})) - vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({ useWorkspaceHostContext: () => mockHostContext.current, })) @@ -76,6 +73,12 @@ import { CreditsChip } from '@/app/workspace/[workspaceId]/home/components/credi let container: HTMLDivElement let root: Root +beforeAll(() => { + setEnvFlags({ isBillingEnabled: true }) +}) + +afterAll(resetEnvFlagsMock) + describe('CreditsChip', () => { beforeEach(() => { container = document.createElement('div') diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts index 638e3c3a747..09444ecccbf 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts @@ -1,7 +1,19 @@ /** * @vitest-environment node */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' + +/** + * `@/lib/auth/auth-client` builds a Better Auth client at module scope, which + * throws when NEXT_PUBLIC_APP_URL is absent from the environment (and under + * `isolate: false` an earlier file may have imported the graph in a polluted + * env). These tests only exercise pure parsing/model helpers, so stub the + * client module out entirely. + */ +vi.mock('@/lib/auth/auth-client', () => ({ + useSession: vi.fn(() => ({ data: null, isPending: false })), +})) + import { parseQuestionTagBody, parseSpecialTags, @@ -155,3 +167,87 @@ describe('parseSpecialTags with ', () => { ]) }) }) + +describe('service_account credential tag', () => { + it('parses a service_account tag into a credential segment', () => { + const body = JSON.stringify({ type: 'service_account', provider: 'slack' }) + const { segments } = parseSpecialTags(`Set this up: ${body}`, false) + + const credential = segments.find((segment) => segment.type === 'credential') + expect(credential).toBeDefined() + expect(credential).toMatchObject({ + type: 'credential', + data: { type: 'service_account', provider: 'slack' }, + }) + }) + + it('carries no value — the secret is typed into Sim’s own form, never the transcript', () => { + const body = JSON.stringify({ type: 'service_account', provider: 'google-sheets' }) + const { segments } = parseSpecialTags(`${body}`, false) + + const credential = segments.find((segment) => segment.type === 'credential') + expect((credential as { data: { value?: string } }).data.value).toBeUndefined() + }) + + it('suppresses the tag while it is still streaming', () => { + // A half-streamed tag must not flash raw JSON into the message body. + const { segments, hasPendingTag } = parseSpecialTags( + 'Set this up: {"type": "service_a', + true + ) + expect(hasPendingTag).toBe(true) + expect(segments.some((segment) => segment.type === 'credential')).toBe(false) + const text = segments + .filter((segment): segment is { type: 'text'; content: string } => segment.type === 'text') + .map((segment) => segment.content) + .join('') + expect(text).not.toContain('service_a') + }) +}) + +describe('service_account tag validation', () => { + it('rejects a provider-less tag, which would render an unresolvable control', () => { + const { segments } = parseSpecialTags( + `${JSON.stringify({ type: 'service_account' })}`, + false + ) + expect(segments.some((segment) => segment.type === 'credential')).toBe(false) + }) + + it('rejects a blank provider', () => { + const { segments } = parseSpecialTags( + `${JSON.stringify({ type: 'service_account', provider: ' ' })}`, + false + ) + expect(segments.some((segment) => segment.type === 'credential')).toBe(false) + }) + + it('accepts an optional credentialId for reconnect and carries it through', () => { + const body = JSON.stringify({ + type: 'service_account', + provider: 'notion', + credentialId: 'cred_abc123', + }) + const { segments } = parseSpecialTags(`${body}`, false) + const credential = segments.find((segment) => segment.type === 'credential') + expect(credential).toMatchObject({ + type: 'credential', + data: { type: 'service_account', provider: 'notion', credentialId: 'cred_abc123' }, + }) + }) + + it('rejects a non-string credentialId', () => { + const body = JSON.stringify({ type: 'service_account', provider: 'notion', credentialId: 42 }) + const { segments } = parseSpecialTags(`${body}`, false) + expect(segments.some((segment) => segment.type === 'credential')).toBe(false) + }) + + it.each(['', ' '])( + 'rejects a blank credentialId (%j) so reconnect cannot target a missing credential', + (credentialId) => { + const body = JSON.stringify({ type: 'service_account', provider: 'notion', credentialId }) + const { segments } = parseSpecialTags(`${body}`, false) + expect(segments.some((segment) => segment.type === 'credential')).toBe(false) + } + ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index f44aab8407f..60227897d94 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -1,6 +1,6 @@ 'use client' -import { createElement, useMemo, useState } from 'react' +import { createElement, lazy, Suspense, useMemo, useState } from 'react' import { ArrowRight, Button, @@ -19,6 +19,10 @@ import { useSession } from '@/lib/auth/auth-client' import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions' import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' import { isSafeHttpUrl } from '@/lib/core/utils/urls' +import { + resolveOAuthServiceForSlug, + resolveServiceAccountIntegration, +} from '@/lib/integrations/oauth-service' import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth' import { getServiceConfigByProviderId } from '@/lib/oauth/utils' import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon' @@ -27,6 +31,10 @@ import type { ChatMessageContext, MothershipResource, } from '@/app/workspace/[workspaceId]/home/types' +// Deep import, not the barrel: the barrel also re-exports +// ConnectServiceAccountModal, and that edge would pull the modal into this +// chunk and defeat the lazy() split below. +import { useServiceAccountConnectTarget } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/use-service-account-connect' import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useWorkspaceCredential } from '@/hooks/queries/credentials' @@ -62,6 +70,17 @@ export interface UsageUpgradeTagData { message: string } +/** + * Kept out of the chat's initial chunk — it pulls in three provider-specific + * setup forms and is only mounted once a message actually offers a service + * account. + */ +const ConnectServiceAccountModal = lazy(() => + import( + '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal' + ).then((m) => ({ default: m.ConnectServiceAccountModal })) +) + export const CREDENTIAL_TAG_TYPES = [ 'env_key', 'oauth_key', @@ -69,6 +88,7 @@ export const CREDENTIAL_TAG_TYPES = [ 'credential_id', 'link', 'secret_input', + 'service_account', ] as const export type CredentialTagType = (typeof CREDENTIAL_TAG_TYPES)[number] @@ -88,6 +108,11 @@ export interface CredentialTagData { name?: string /** Where a secret_input value is persisted. Defaults to "workspace". */ scope?: SecretInputScope + /** + * Existing credential to reconnect in place (service_account only). Present = + * rotate the secret on this credential; absent = create a new one. + */ + credentialId?: string } export interface MothershipErrorTagData { @@ -225,6 +250,20 @@ function isCredentialTagData(value: unknown): value is CredentialTagData { } return typeof value.name === 'string' && value.name.trim().length > 0 } + // A service_account tag is a control, not a value: it names the provider + // whose setup form to open, and the user types the secret into that form — + // so it never carries a `value`, but it is useless without a provider. An + // optional `credentialId` reconnects an existing service account in place; + // reject a blank one, since the renderer treats a truthy id as "reconnect" + // and would try to rotate a non-existent credential. + if (value.type === 'service_account') { + if (value.credentialId !== undefined) { + if (typeof value.credentialId !== 'string' || value.credentialId.trim().length === 0) { + return false + } + } + return typeof value.provider === 'string' && value.provider.trim().length > 0 + } // A sim_key chip is platform-filled: the model only marks where the workspace // API key belongs (it never holds the value) and Sim injects it from the tool // result, so the tag is valid with or without a `value`. Every other rendered @@ -870,6 +909,74 @@ function SecretInputDisplay({ data }: { data: CredentialTagData }) { ) } +/** + * Inline "set up a service account" control rendered for + * `{"type":"service_account","provider":"slack"}`. + * + * Opens `ConnectServiceAccountModal` over the chat rather than linking out to + * the integrations page — the user stays in the conversation that asked for + * the credential, and comes back to it with the credential in hand. + */ +function ServiceAccountConnectDisplay({ data }: { data: CredentialTagData }) { + const { workspaceId } = useParams<{ workspaceId: string }>() + const { canEdit } = useUserPermissionsContext() + const [open, setOpen] = useState(false) + + const match = useMemo( + () => (data.provider ? resolveServiceAccountIntegration(data.provider) : null), + [data.provider] + ) + const service = useMemo(() => (match ? resolveOAuthServiceForSlug(match.slug) : null), [match]) + const target = useServiceAccountConnectTarget({ + serviceAccountProviderId: match?.serviceAccountProviderId, + serviceName: match?.serviceName, + serviceIcon: service?.serviceIcon, + }) + + // A credentialId reconnects (rotates the secret on) that existing service + // account in place rather than creating a new one — the modal keeps its id. + const reconnectCredentialId = data.credentialId + const { data: reconnectCredential } = useWorkspaceCredential(reconnectCredentialId) + + // Creating a credential mutates the workspace — hide it from read-only + // members, and honour the provider's own preview gate (custom Slack bots + // ride the slack_v2 flag) so chat can't surface what the integrations page + // deliberately hides. + if (!target || target.hidden || !canEdit || !workspaceId) return null + + const label = reconnectCredentialId + ? `Reconnect ${reconnectCredential?.displayName ?? target.serviceName}` + : `${target.label} for ${target.serviceName}` + + return ( + <> + + {open && ( + + + + )} + + ) +} + function CredentialLinkDisplay({ data }: { data: CredentialTagData }) { const { canEdit } = useUserPermissionsContext() @@ -921,6 +1028,10 @@ function CredentialDisplay({ data }: { data: CredentialTagData }) { return } + if (data.type === 'service_account') { + return + } + if (data.type === 'sim_key') { // SecretReveal masks itself when there's no value, so a value-less tag (the // model's placeholder / persisted form) renders masked and a Sim-filled tag diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts index 942ca3c5afe..60c7a5fbce9 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts @@ -1,7 +1,19 @@ /** * @vitest-environment node */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' + +/** + * `@/lib/auth/auth-client` builds a Better Auth client at module scope, which + * throws when NEXT_PUBLIC_APP_URL is absent from the environment (and under + * `isolate: false` an earlier file may have imported the graph in a polluted + * env). These tests only exercise pure parsing/model helpers, so stub the + * client module out entirely. + */ +vi.mock('@/lib/auth/auth-client', () => ({ + useSession: vi.fn(() => ({ data: null, isPending: false })), +})) + import { TOOL_CATALOG, type ToolCatalogEntry } from '@/lib/copilot/generated/tool-catalog-v1' import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' import { getHiddenToolNames } from '@/lib/copilot/tools/client/hidden-tools' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index 2abcec9fad5..21fb1a5a20d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -30,6 +30,14 @@ import { deriveMessagePhase, isToolDone, type MessagePhase } from './utils' const FILE_SUBAGENT_ID = 'file' /** Quiet period before the shimmer takes the slot back from streamed output. */ const STREAM_IDLE_DELAY_MS = 1_500 +/** + * The vertical extent (10px gap + 36px row) shared by the shimmer slot and the + * actions row that replaces it at settle. The swap is only jump-free because + * these are equal; changing one side without the other reintroduces a scroll + * clamp at end of turn. (A stopped turn's stacked rows are exempt — their + * extra height is glided-in growth, not a swap.) + */ +const TAIL_REGION_CLASSES = 'mt-[10px] flex h-[36px] items-center' interface TextSegment { type: 'text' @@ -853,8 +861,16 @@ function MessageContentInner({ }, [visibleStreamActivityKey, isStreaming]) const lastSegment = segments[segments.length - 1] - const hasTrailingTextSegment = lastSegment?.type === 'text' - const isRevealing = hasTrailingTextSegment && trailingRevealing + // The reveal tail is the last TEXT segment — a stopped block appends AFTER + // the text that is still visibly draining, and treating the turn as settled + // the moment it lands tears down the scroll machinery mid-reveal. + const revealTailIndex = + lastSegment?.type === 'stopped' && segments[segments.length - 2]?.type === 'text' + ? segments.length - 2 + : lastSegment?.type === 'text' + ? segments.length - 1 + : -1 + const isRevealing = revealTailIndex >= 0 && trailingRevealing const phase = deriveMessagePhase({ isStreaming, isRevealing }) const onPhaseChangeRef = useRef(onPhaseChange) @@ -905,13 +921,13 @@ function MessageContentInner({ onQuestionDismiss={onQuestionDismiss} onWorkspaceResourceSelect={onWorkspaceResourceSelect} onRevealStateChange={ - i === segments.length - 1 ? handleTrailingRevealChange : undefined + i === revealTailIndex ? handleTrailingRevealChange : undefined } onStreamActivityChange={ - i === segments.length - 1 ? handleTrailingStreamActivityChange : undefined + i === revealTailIndex ? handleTrailingStreamActivityChange : undefined } onPendingTagChange={ - i === segments.length - 1 ? handleTrailingPendingTagChange : undefined + i === revealTailIndex ? handleTrailingPendingTagChange : undefined } /> ) @@ -943,13 +959,12 @@ function MessageContentInner({ ) + // The stopped row renders in the tail region below, in the + // shimmer's place — a stop while the shimmer is visible must read + // as an in-place replacement, not the shimmer vanishing from the + // tail while a row mounts up here. case 'stopped': - return ( -
- - Stopped by user -
- ) + return null } })} @@ -957,8 +972,8 @@ function MessageContentInner({ // Fixed-height placeholder for the NEXT piece of output: the shimmer // and arriving output trade places via opacity only, so mid-turn swaps // can't move layout. A sibling of the space-y stack (not a child), so - // it carries no stray sibling margin — pt-[10px] is its own gap. -
+ // it carries no stray sibling margin. +
+ ) : // The settled tail takes the slot's place in the SAME render and at the + // SAME extent (TAIL_REGION_CLASSES), so the swap is height-neutral by + // construction — no reflow for the pinned scroller to absorb. A stopped + // turn instead stacks compact natural rows (10px gaps, no 36px boxes): + // its extra height is glided-in growth either way, so only the + // shimmer-swap occupant needs the fixed extent. + lastSegment?.type === 'stopped' ? ( + <> +
+ + Stopped by user +
+ {actions &&
{actions}
} + ) : ( - // The actions row takes the slot's place in the SAME render — a single - // ~10px reflow instead of a collapse the buttons would ride upward or a - // late mount the chase would visibly scroll to. - actions &&
{actions}
+ actions &&
{actions}
)}
) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index db903e8398a..dcac9da0b56 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -12,6 +12,7 @@ import { } from 'react' import { cn } from '@sim/emcn' import { defaultRangeExtractor, type Range, useVirtualizer } from '@tanstack/react-virtual' +import { SMOOTH_CHASE_RATE } from '@/lib/core/utils/smooth-bottom-chase' import { MessageActions } from '@/app/workspace/[workspaceId]/components' import { ChatMessageAttachments } from '@/app/workspace/[workspaceId]/home/components/chat-message-attachments' import { ChatSurfaceProvider } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context' @@ -109,7 +110,7 @@ const UNSCROLLED = Symbol('unscrolled') const LAYOUT_STYLES = { 'mothership-view': { scrollContainer: - 'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-2 [scrollbar-gutter:stable_both-edges]', + 'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-2 [overflow-anchor:none] [scrollbar-gutter:stable_both-edges]', sizer: 'relative mx-auto w-full max-w-[48rem]', rowGap: 'pb-6', userRow: 'flex flex-col items-end gap-[6px] pt-3', @@ -120,7 +121,8 @@ const LAYOUT_STYLES = { footerInner: 'mx-auto max-w-[48rem]', }, 'copilot-view': { - scrollContainer: 'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 pt-2 pb-4', + scrollContainer: + 'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 pt-2 pb-4 [overflow-anchor:none]', sizer: 'relative w-full', rowGap: 'pb-4', userRow: 'flex flex-col items-end gap-[6px] pt-2', @@ -302,6 +304,9 @@ export function MothershipChat({ const { ref: autoScrollRef } = useAutoScroll(isStreamActive || lastRowAnimating) const sizerRef = useRef(null) const scrollerPaddingRef = useRef<{ top: number; bottom: number } | null>(null) + const sizerFloorAppliedRef = useRef(0) + const floorDrainRafRef = useRef(0) + useEffect(() => () => cancelAnimationFrame(floorDrainRafRef.current), []) /** * Sizer floor while streaming: `scrollHeight` must never dip below the @@ -318,6 +323,15 @@ export function MothershipChat({ * Active on the same signal as auto-scroll: the reveal keeps re-parsing * markdown (and shrinking) after the network stream closes, so the floor * must hold through `lastRowAnimating` too. + * + * Release is DRAINED, not cliffed: while active the floor forbids + * scrollHeight from dropping, so permanent shrinks over the turn accrue as + * phantom space (debt). Clearing min-height in one commit released that + * whole debt as a single clamp — the end-of-turn downward jump. Instead the + * floor glides down to the natural size at the chase's rate; the browser's + * clamp follows a few px per frame, which reads as the same eased settle as + * the rest of the stream. Instant-clears when the debt is sub-pixel or the + * user isn't pinned (shrinking below-viewport space is invisible then). */ const floorActive = isStreamActive || lastRowAnimating useLayoutEffect(() => { @@ -325,9 +339,41 @@ export function MothershipChat({ const el = scrollElementRef.current if (!sizer || !el) return if (!floorActive) { - sizer.style.minHeight = '' + if (sizerFloorAppliedRef.current === 0) return + // A drain already in flight keeps its own rAF cadence — settle-burst + // commits re-enter this branch and must not add extra steps in layout, + // which would accelerate the release past the eased rate. + if (floorDrainRafRef.current !== 0) return + scrollerPaddingRef.current = null + const drain = () => { + const target = virtualizer.getTotalSize() + const current = sizerFloorAppliedRef.current + if (current === 0) { + floorDrainRafRef.current = 0 + return + } + // Instant-clear only when the whole remaining debt sits BELOW the + // viewport (debt ≤ distance-from-bottom) — then the shrink is + // invisible. A merely-unpinned viewport with debt larger than its + // slack would still clamp, so it keeps the eased drain instead. + const distance = el.scrollHeight - el.scrollTop - el.clientHeight + const debt = current - target + if (debt <= 1 || debt <= distance) { + sizerFloorAppliedRef.current = 0 + floorDrainRafRef.current = 0 + sizer.style.minHeight = '' + return + } + const next = Math.floor(current - Math.max(1, debt * SMOOTH_CHASE_RATE)) + sizerFloorAppliedRef.current = next + sizer.style.minHeight = `${next}px` + floorDrainRafRef.current = requestAnimationFrame(drain) + } + floorDrainRafRef.current = requestAnimationFrame(drain) return } + cancelAnimationFrame(floorDrainRafRef.current) + floorDrainRafRef.current = 0 if (!scrollerPaddingRef.current) { const style = getComputedStyle(el) scrollerPaddingRef.current = { @@ -336,7 +382,21 @@ export function MothershipChat({ } } const padding = scrollerPaddingRef.current - const floor = Math.max(0, el.scrollTop + el.clientHeight - padding.top - padding.bottom) + // Math.floor, not the raw float: a fractional min-height can round + // scrollHeight 1px ABOVE the scrolled-to extent, and that phantom 1px gap + // re-derives 1px higher after every chase step — a visible 1px/frame + // upward creep whenever the floor is what's holding scrollHeight. + const floor = Math.max( + 0, + Math.floor(el.scrollTop + el.clientHeight - padding.top - padding.bottom) + ) + // Dead-band: the floor feeds back into its own inputs (a floored value can + // land a fraction BELOW the extent, the browser clamps scrollTop, and the + // next commit re-derives from the clamped position — a visible ~1px×N + // downward cascade on fractional-scrollTop displays). Sub-pixel deltas are + // rounding noise from that loop, never real growth; only apply real moves. + if (Math.abs(floor - sizerFloorAppliedRef.current) <= 1) return + sizerFloorAppliedRef.current = floor sizer.style.minHeight = `${floor}px` }) const setScrollElement = useCallback( diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx index 2d7abb20569..b11854646f4 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx @@ -6,25 +6,23 @@ import { ArrowLeft, ArrowRight, Plus } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/navigation' import { useQueryState } from 'nuqs' -import { getClientCredentialAccountDescriptor } from '@/lib/credentials/client-credential-accounts/descriptors' -import { getTokenServiceAccountDescriptor } from '@/lib/credentials/token-service-accounts/descriptors' import { blockTypeToIconMap, type Integration, resolveOAuthServiceForIntegration, } from '@/lib/integrations' import { getServiceConfigByProviderId } from '@/lib/oauth' -import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal' import { IntegrationSkillsSection } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section' import { connectParam } from '@/app/workspace/[workspaceId]/integrations/[block]/search-params' -import { ConnectServiceAccountModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal' +import { + ConnectServiceAccountModal, + useServiceAccountConnectTarget, +} from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal' import { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/components/integration-section' import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' import { CONNECT_MODE } from '@/app/workspace/[workspaceId]/integrations/connect-route' import { useScrollRestoration } from '@/app/workspace/[workspaceId]/integrations/hooks/use-scroll-restoration' -import { getBlock } from '@/blocks' -import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay' import { getTileIconColorClass } from '@/blocks/icon-color' import { storeCuratedPrompt } from '@/blocks/integration-matcher' import { @@ -32,7 +30,6 @@ import { getTemplatesForBlock, type ScopedBlockTemplate, } from '@/blocks/registry' -import { isHiddenUnder, overlayVisibility } from '@/blocks/visibility/context' import { useWorkspaceCredentials } from '@/hooks/queries/credentials' import { useOAuthReturnRouter } from '@/hooks/use-oauth-return' @@ -78,29 +75,13 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration ) }, [credentials, oauthService]) const [serviceAccountOpen, setServiceAccountOpen] = useState(false) - const isSlackBot = oauthService?.serviceAccountProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID - const blockOverlayVersion = useCustomBlockOverlayVersion() - // Custom Slack bots ride the slack_v2 preview flag: the setup surface stays - // hidden until that block is revealed for this viewer. - const slackBotPreviewHidden = useMemo(() => { - if (!isSlackBot) return false - const v2 = getBlock('slack_v2') - return !v2 || isHiddenUnder(overlayVisibility(), v2) - }, [isSlackBot, blockOverlayVersion]) - const hasServiceAccount = - Boolean(oauthService?.serviceAccountProviderId) && !slackBotPreviewHidden - // Vendor-accurate connect label: token-paste and client-credential - // providers use their own noun ("Add API key", "Add server-to-server app"); - // only true service-account providers (Google, Atlassian) say - // "Add service account". - const nounDescriptor = - getTokenServiceAccountDescriptor(oauthService?.serviceAccountProviderId) ?? - getClientCredentialAccountDescriptor(oauthService?.serviceAccountProviderId) - const serviceAccountConnectLabel = isSlackBot - ? 'Set up a custom bot' - : nounDescriptor - ? `Add ${nounDescriptor.connectNoun}` - : 'Add service account' + const serviceAccountTarget = useServiceAccountConnectTarget({ + serviceAccountProviderId: oauthService?.serviceAccountProviderId, + serviceName: oauthService?.serviceName, + serviceIcon: oauthService?.serviceIcon, + }) + const hasServiceAccount = Boolean(serviceAccountTarget) && !serviceAccountTarget?.hidden + const serviceAccountConnectLabel = serviceAccountTarget?.label ?? 'Add service account' const hasHandledConnectQueryRef = useRef(false) useEffect(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/index.ts b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/index.ts index e09a3c81a74..f9c7989e1e9 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/index.ts @@ -2,3 +2,7 @@ export { ConnectServiceAccountModal, type ServiceAccountProviderId, } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal' +export { + type ServiceAccountConnectTarget, + useServiceAccountConnectTarget, +} from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/use-service-account-connect' diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/use-service-account-connect.ts b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/use-service-account-connect.ts new file mode 100644 index 00000000000..fdeda1993b1 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/use-service-account-connect.ts @@ -0,0 +1,76 @@ +'use client' + +import { type ComponentType, useMemo } from 'react' +import { + getServiceAccountConnectNoun, + getServiceAccountGatingBlockType, +} from '@/lib/credentials/service-account-provider-ids' +import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' +import type { ServiceAccountProviderId } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal' +import { getBlock } from '@/blocks' +import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay' +import { isHiddenUnder, overlayVisibility } from '@/blocks/visibility/context' + +/** + * Everything a caller needs to render a service-account connect control: + * whether to show it at all, what to call it, and the props the modal takes. + */ +export interface ServiceAccountConnectTarget { + serviceAccountProviderId: ServiceAccountProviderId + serviceName: string + serviceIcon: ComponentType<{ className?: string }> + /** + * Vendor-accurate control label — token-paste and client-credential + * providers use their own noun ("Add API key", "Add server-to-server app"); + * only true service-account providers say "Add service account". + */ + label: string + /** + * True when the provider's setup surface must stay hidden for this viewer. + * Custom Slack bots ride the `slack_v2` preview flag, so any surface that + * offers one — the integrations page or the chat — has to honour it or the + * flag is trivially bypassed. + */ + hidden: boolean +} + +interface UseServiceAccountConnectTargetArgs { + serviceAccountProviderId: ServiceAccountProviderId | undefined + serviceName: string | undefined + serviceIcon: ComponentType<{ className?: string }> | undefined +} + +/** + * Derives the connect-control label and preview gating for a service-account + * provider. Shared by the integrations detail page and the chat's inline + * connect button so the two can't drift on either the wording or the gate. + */ +export function useServiceAccountConnectTarget({ + serviceAccountProviderId, + serviceName, + serviceIcon, +}: UseServiceAccountConnectTargetArgs): ServiceAccountConnectTarget | null { + const blockOverlayVersion = useCustomBlockOverlayVersion() + + const isSlackBot = serviceAccountProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID + + const hidden = useMemo(() => { + const gatingBlockType = serviceAccountProviderId + ? getServiceAccountGatingBlockType(serviceAccountProviderId) + : null + if (!gatingBlockType) return false + const gatingBlock = getBlock(gatingBlockType) + return !gatingBlock || isHiddenUnder(overlayVisibility(), gatingBlock) + // blockOverlayVersion is read to re-evaluate when the overlay changes. + }, [serviceAccountProviderId, blockOverlayVersion]) + + return useMemo(() => { + if (!serviceAccountProviderId || !serviceName || !serviceIcon) return null + + const label = isSlackBot + ? 'Set up a custom bot' + : `Add ${getServiceAccountConnectNoun(serviceAccountProviderId)}` + + return { serviceAccountProviderId, serviceName, serviceIcon, label, hidden } + }, [serviceAccountProviderId, serviceName, serviceIcon, isSlackBot, hidden]) +} diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/search-params.ts b/apps/sim/app/workspace/[workspaceId]/integrations/search-params.ts index f03533261f6..a5775bde969 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/integrations/search-params.ts @@ -15,8 +15,8 @@ export const CONNECTED_LABEL = 'Connected' * pseudo-categories and are derived from the data set, so a plain string is * used; the `All` default clears from the URL. * - `search` is the integration search term. The input is controlled directly by - * the nuqs value; only its URL write is debounced via `limitUrlUpdates` - * (`debounce`) on the setter — never written on every keystroke. + * the nuqs value; only its URL write is debounced via + * `useDebouncedSearchSetter` — never written on every keystroke. */ export const integrationsParsers = { category: parseAsString.withDefault(ALL_CATEGORY), diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx index 696c3fd82c2..f34ed657dbc 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -137,7 +137,12 @@ export function Document({ const { workspaceId } = useParams() const router = useRouter() const [ - { page: currentPageFromURL, chunk: chunkFromURL, search: searchQuery }, + { + page: currentPageFromURL, + chunk: chunkFromURL, + search: searchQuery, + enabled: enabledFilterParam, + }, setDocumentParams, ] = useQueryStates(documentParsers, documentUrlKeys) const userPermissions = useUserPermissionsContext() @@ -160,16 +165,31 @@ export function Document({ ) /** Raw URL value drives the input; the chunk search query always sees it trimmed. */ const debouncedSearchQuery = useDebounce(searchQuery, CHUNK_SEARCH_DEBOUNCE_MS).trim() - const [enabledFilter, setEnabledFilter] = useState([]) const { activeSort, onSort: onSortColumn, onClear: onClearSort, } = useUrlSort(documentChunkSortParams, documentUrlKeys) - const enabledFilterParam = useMemo( - () => (enabledFilter.length === 1 ? (enabledFilter[0] as 'enabled' | 'disabled') : 'all'), - [enabledFilter] + /** Multi-select UI view of the scalar `enabled` param (`all` ↔ nothing selected). */ + const enabledFilter = useMemo( + () => (enabledFilterParam === 'all' ? [] : [enabledFilterParam]), + [enabledFilterParam] + ) + + /** + * Collapses the dropdown's multi-select values to the scalar param (one value + * filters; none or both mean `all`) and resets `page` in the same write so a + * filter change always lands on the first page. + */ + const setEnabledFilter = useCallback( + (values: string[]) => { + void setDocumentParams({ + enabled: values.length === 1 ? (values[0] as 'enabled' | 'disabled') : null, + page: null, + }) + }, + [setDocumentParams] ) const { @@ -619,8 +639,7 @@ export function Document({ const enabledDisplayLabel = useMemo(() => { if (enabledFilter.length === 0) return 'All' - if (enabledFilter.length === 1) return enabledFilter[0] === 'enabled' ? 'Enabled' : 'Disabled' - return `${enabledFilter.length} selected` + return enabledFilter[0] === 'enabled' ? 'Enabled' : 'Disabled' }, [enabledFilter]) const filterContent = useMemo( @@ -638,7 +657,6 @@ export function Document({ onMultiSelectChange={(values) => { setEnabledFilter(values) setSelectedChunks(new Set()) - void goToPage(1) }} overlayContent={ {enabledDisplayLabel} @@ -654,7 +672,6 @@ export function Document({ onClick={() => { setEnabledFilter([]) setSelectedChunks(new Set()) - void goToPage(1) }} className='flex h-[32px] w-full items-center justify-center rounded-md text-[var(--text-secondary)] text-caption transition-colors hover-hover:bg-[var(--surface-active)]' > @@ -663,7 +680,7 @@ export function Document({ )} ), - [enabledFilter, enabledDisplayLabel, goToPage] + [enabledFilter, enabledDisplayLabel, setEnabledFilter] ) const filterTags: FilterTag[] = useMemo( @@ -671,12 +688,11 @@ export function Document({ enabledFilter.map((value) => ({ label: `Status: ${value === 'enabled' ? 'Enabled' : 'Disabled'}`, onRemove: () => { - setEnabledFilter((prev) => prev.filter((v) => v !== value)) + setEnabledFilter(enabledFilter.filter((v) => v !== value)) setSelectedChunks(new Set()) - void goToPage(1) }, })), - [enabledFilter, goToPage] + [enabledFilter, setEnabledFilter] ) const handleChunkClick = useCallback( diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/search-params.ts index 072e08d66ef..2ac0878d3a7 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/search-params.ts @@ -1,6 +1,9 @@ -import { parseAsInteger, parseAsString } from 'nuqs/server' +import { parseAsInteger, parseAsString, parseAsStringLiteral } from 'nuqs/server' import { createSortParams } from '@/lib/url-state' +/** Chunk `enabled` filter buckets, matching the status filter dropdown. */ +const ENABLED_FILTERS = ['all', 'enabled', 'disabled'] as const + /** Sortable chunk columns, matching the `Resource.Options` sort menu ids. */ export const CHUNK_SORT_COLUMNS = ['index', 'tokens', 'status'] as const @@ -24,11 +27,14 @@ export const documentChunkSortParams = createSortParams(CHUNK_SORT_COLUMNS) * - `search` is the chunk content search. The input is controlled directly by * the instant nuqs value; only its URL write is debounced via * `useDebouncedSearchSetter` — never written on every keystroke. + * - `enabled` filters chunks by enabled status (`all` clears from the URL), + * mirroring the same filter on the knowledge base document list. */ export const documentParsers = { page: parseAsInteger.withDefault(1), chunk: parseAsString, search: parseAsString.withDefault(''), + enabled: parseAsStringLiteral(ENABLED_FILTERS).withDefault('all'), } as const /** diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/search-params.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/search-params.ts index 8a10c0796d0..e27c2e3c8b3 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/search-params.ts @@ -27,8 +27,8 @@ export const knowledgeSortParams = createSortParams(KNOWLEDGE_SORT_COLUMNS, { * * - `search` is the knowledge base name/description filter. The input is * controlled directly by the instant nuqs value; only its URL write is - * debounced via `limitUrlUpdates` (`debounce`) on the setter — never written - * on every keystroke. + * debounced via `useDebouncedSearchSetter` — never written on every + * keystroke. * - `connector` filters by connector presence; `content` filters by document * presence; `owner` filters by creator id. All are multi-select arrays. * diff --git a/apps/sim/app/workspace/[workspaceId]/layout.test.tsx b/apps/sim/app/workspace/[workspaceId]/layout.test.tsx index b8f0399d751..10264ea9276 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.test.tsx @@ -1,20 +1,20 @@ /** * @vitest-environment node */ + import type { ReactNode } from 'react' +import { authMockFns } from '@sim/testing' import { renderToStaticMarkup } from 'react-dom/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockBrandingProvider, mockGetOrgWhitelabelSettings, - mockGetSession, mockPrefetchWorkspaceHostContext, mockPrefetchWorkspaceSidebar, } = vi.hoisted(() => ({ mockBrandingProvider: vi.fn(({ children }: { children: ReactNode }) => children), mockGetOrgWhitelabelSettings: vi.fn(), - mockGetSession: vi.fn(), mockPrefetchWorkspaceHostContext: vi.fn(), mockPrefetchWorkspaceSidebar: vi.fn(), })) @@ -36,10 +36,6 @@ vi.mock('next/navigation', () => ({ redirect: vi.fn(), })) -vi.mock('@/lib/auth', () => ({ - getSession: mockGetSession, -})) - vi.mock('@/app/_shell/providers/get-query-client', () => ({ getQueryClient: () => ({ setQueryData: vi.fn() }), })) @@ -104,6 +100,8 @@ vi.mock('@/app/workspace/[workspaceId]/providers/workspace-scope-sync', () => ({ import WorkspaceLayout from '@/app/workspace/[workspaceId]/layout' +const mockGetSession = authMockFns.mockGetSession + const HOST_CONTEXT = { workspace: { id: 'workspace-b', diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 9274a26e23a..269993d45bd 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -302,7 +302,9 @@ export default function Logs() { (query: { state: { data?: WorkflowLogDetail } }) => { if (!isLive) return false const status = query.state.data?.status - return status === 'running' || status === 'pending' ? ACTIVE_RUN_DETAIL_REFRESH_MS : false + return status === 'running' || status === 'pending' || status === 'redacting' + ? ACTIVE_RUN_DETAIL_REFRESH_MS + : false }, [isLive] ) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/search-params.ts b/apps/sim/app/workspace/[workspaceId]/logs/search-params.ts index d2171915c21..8640ba2449f 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/search-params.ts @@ -41,12 +41,13 @@ const TOKEN_TO_TIME_RANGE: Record = Object.fromEntries( ) as Record /** - * Parser for the `timeRange` param. Serializes labels to kebab tokens and - * tolerantly maps unknown tokens back to the default ("All time"). + * Parser for the `timeRange` param. Serializes labels to kebab tokens. Unknown + * tokens parse to `null` so each consuming surface's `.withDefault(...)` decides + * the fallback (logs: "All time"; audit-logs: "Past 30 days"). */ export const parseAsTimeRange = createParser({ parse(value) { - return TOKEN_TO_TIME_RANGE[value] ?? DEFAULT_TIME_RANGE + return TOKEN_TO_TIME_RANGE[value] ?? null }, serialize(value) { return TIME_RANGE_TO_TOKEN[value] ?? 'all-time' @@ -76,6 +77,21 @@ export const parseAsLogLevel = createParser({ }, }) +/** + * Parser for free-form date/datetime strings (`startDate`/`endDate`). Rejects + * unparseable values at the URL boundary — an invalid date string reaching + * `new Date(...).toISOString()` throws, so a malformed deep link must parse to + * `null` (treated as a missing bound) instead of crashing the consumer. + */ +export const parseAsDateString = createParser({ + parse(value) { + return Number.isNaN(Date.parse(value)) ? null : value + }, + serialize(value) { + return value + }, +}) + const CORE_TRIGGER_SET = new Set(CORE_TRIGGER_TYPES) /** @@ -104,8 +120,12 @@ export const parseAsTriggers = createParser({ */ export const logFilterParsers = { timeRange: parseAsTimeRange.withDefault(DEFAULT_TIME_RANGE), - startDate: parseAsString, - endDate: parseAsString, + /** + * Deliberately nullable: only populated when timeRange is "Custom range"; + * every preset range derives its window from the label instead. + */ + startDate: parseAsDateString, + endDate: parseAsDateString, level: parseAsLogLevel.withDefault('all'), workflowIds: parseAsArrayOf(parseAsString).withDefault([]), folderIds: parseAsArrayOf(parseAsString).withDefault([]), diff --git a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts index 6a28af7d077..dc1fecf2ab3 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts @@ -18,7 +18,14 @@ export const LOG_COLUMNS = { export const DELETED_WORKFLOW_LABEL = 'Deleted Workflow' -export type LogStatus = 'error' | 'pending' | 'running' | 'info' | 'cancelled' | 'cancelling' +export type LogStatus = + | 'error' + | 'pending' + | 'running' + | 'redacting' + | 'info' + | 'cancelled' + | 'cancelling' /** * Maps raw status string to LogStatus for display. @@ -29,6 +36,8 @@ export function getDisplayStatus(status: string | null | undefined): LogStatus { switch (status) { case 'running': return 'running' + case 'redacting': + return 'redacting' case 'pending': return 'pending' case 'cancelling': @@ -55,6 +64,7 @@ export const STATUS_CONFIG: Record< error: { variant: 'red', label: 'Error', color: 'var(--text-error)', filterable: true }, pending: { variant: 'amber', label: 'Pending', color: '#f59e0b', filterable: true }, running: { variant: 'amber', label: 'Running', color: '#f59e0b', filterable: true }, + redacting: { variant: 'amber', label: 'Redacting', color: '#f59e0b', filterable: false }, cancelling: { variant: 'amber', label: 'Cancelling...', color: '#f59e0b', filterable: false }, cancelled: { variant: 'orange', label: 'Cancelled', color: '#f97316', filterable: true }, info: { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts index bdf4eb6350c..85b4a56b913 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts @@ -49,6 +49,53 @@ export const forkViewUrlKeys = { clearOnDefault: true, } as const +/** + * `server-tab` is the active tab (Details / Workflows) inside the deep-linked + * workflow MCP server detail view, so a shared `mcpServerId` link can land on + * either tab. + */ +export const serverTabParam = { + key: 'server-tab', + parser: parseAsStringLiteral(['details', 'workflows'] as const).withDefault('details'), +} as const + +/** Tab view-state: clean URLs, no back-stack churn. */ +export const serverTabUrlKeys = { + history: 'replace', + clearOnDefault: true, +} as const + +/** + * `group-id` deep-links the Access Control settings tab to a specific + * permission group's detail sub-view (mirrors `mcpServerId` on the MCP tab). + */ +export const groupIdParam = { + key: 'group-id', + parser: parseAsString, +} as const + +/** Opening a group's detail is a destination → push to history; clear on close. */ +export const groupIdUrlKeys = { + history: 'push', + clearOnDefault: true, +} as const + +/** + * `custom-block-id` deep-links the Custom Blocks settings tab to a specific + * block's detail sub-view. The "create new" flow stays in local state — only + * existing entities are deep-linkable. + */ +export const customBlockIdParam = { + key: 'custom-block-id', + parser: parseAsString, +} as const + +/** Opening a block's detail is a destination → push to history; clear on close. */ +export const customBlockIdUrlKeys = { + history: 'push', + clearOnDefault: true, +} as const + /** * `fork-direction` is the sync direction (push/pull) on the parent fork's detail * page — shareable view state, so a copied link opens the same side of the sync. diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index 1f6fe23b076..cf2bf5caf18 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -81,6 +81,11 @@ const AuditLogs = dynamic(() => import('@/ee/audit-logs/components/audit-logs').then((m) => m.AuditLogs) ) const SSO = dynamic(() => import('@/ee/sso/components/sso-settings').then((m) => m.SSO)) +const SessionPolicySettings = dynamic(() => + import('@/ee/session-policy/components/session-policy-settings').then( + (m) => m.SessionPolicySettings + ) +) const DataRetentionSettings = dynamic(() => import('@/ee/data-retention/components/data-retention-settings').then( (m) => m.DataRetentionSettings @@ -158,6 +163,9 @@ export function SettingsPage({ section }: SettingsPageProps) { /> )} {effectiveSection === 'sso' && organizationId && } + {effectiveSection === 'sessions' && organizationId && ( + + )} {effectiveSection === 'data-retention' && organizationId && ( )} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx index bc1c0b2d394..ba957ffde7e 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx @@ -66,6 +66,13 @@ interface BYOKKeyManagerBaseProps { description?: string /** Show the provider search box (hidden when there are only a couple). */ showSearch?: boolean + /** + * Controlled search value + setter. The BYOK settings page passes the shared + * `?search=` binding (`useSettingsSearch`) so the search is deep-linkable; + * modal/embedded consumers omit both and keep local state. + */ + searchTerm?: string + onSearchTermChange?: (value: string) => void } /** One key per provider; saving replaces the stored key. */ @@ -138,7 +145,9 @@ export function BYOKKeyManager(props: BYOKKeyManagerProps) { showSearch = true, } = props - const [searchTerm, setSearchTerm] = useState('') + const [localSearchTerm, setLocalSearchTerm] = useState('') + const searchTerm = props.searchTerm ?? localSearchTerm + const setSearchTerm = props.onSearchTermChange ?? setLocalSearchTerm const [editing, setEditing] = useState(null) const [apiKeyInput, setApiKeyInput] = useState('') const [nameInput, setNameInput] = useState('') diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx index 5798db7b329..ff34151ca34 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok.tsx @@ -48,6 +48,7 @@ import { type BYOKProviderSection, } from '@/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { useBYOKKeys, useDeleteBYOKKey, useUpsertBYOKKey } from '@/hooks/queries/byok-keys' import type { BYOKProviderId } from '@/tools/types' @@ -354,6 +355,7 @@ export function BYOK() { const workspaceId = (params?.workspaceId as string) || '' const workspacePermissions = useUserPermissionsContext() const canManage = canMutateWorkspaceSettingsSection('byok', workspacePermissions) + const [searchTerm, setSearchTerm] = useSettingsSearch() const { data, isLoading } = useBYOKKeys(workspaceId) const upsertKey = useUpsertBYOKKey() @@ -381,6 +383,8 @@ export function BYOK() { isSaving={upsertKey.isPending} isDeleting={deleteKey.isPending} readOnly={!canManage} + searchTerm={searchTerm} + onSearchTermChange={setSearchTerm} onSaveKey={async ({ providerId, apiKey, keyId, name }) => { await upsertKey.mutateAsync({ workspaceId, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/search-params.ts index f21a66f2d11..d1aa319c771 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/search-params.ts @@ -18,7 +18,7 @@ export type InboxStatusFilter = (typeof INBOX_STATUS_FILTERS)[number] * - `status` is the active status filter (feeds the tasks query key). * - `search` is the subject/sender/body name filter. The input is controlled * directly by the nuqs value; only its URL write is debounced via - * `limitUrlUpdates` (`debounce`) on the setter — never written per keystroke. + * `useDebouncedSearchSetter` — never written per keystroke. */ export const inboxTaskParsers = { status: parseAsStringLiteral(INBOX_STATUS_FILTERS).withDefault('all'), diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/search-params.ts index 8340051100e..1884a1f22b0 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/search-params.ts @@ -2,9 +2,10 @@ import { parseAsString } from 'nuqs/server' /** * Shared URL query-param definition for the settings list search boxes - * (teammates, api-keys, copilot, custom-tools, mcp, secrets, - * workflow-mcp-servers). Settings sections never co-render, so they all share - * the `search` key without collisions. + * (teammates, api-keys, byok, copilot, custom-tools, mcp, secrets, + * workflow-mcp-servers, and the ee sections: audit-logs, access-control, + * custom-blocks, data-drains, forks). Settings sections never co-render, so + * they all share the `search` key without collisions. * * Consume via `useSettingsSearch` (`settings/components/use-settings-search`), * which owns the debounced-write wiring — the input is controlled directly by diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/use-settings-search.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/use-settings-search.ts index 2da76cad0ee..c5749effaa4 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/use-settings-search.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/use-settings-search.ts @@ -9,7 +9,9 @@ import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' /** * The shared `?search=` binding for settings list search boxes (teammates, - * api-keys, copilot, custom-tools, mcp, secrets, workflow-mcp-servers). + * api-keys, byok, copilot, custom-tools, mcp, secrets, workflow-mcp-servers, + * and the ee sections: audit-logs, access-control, custom-blocks, data-drains, + * forks). * Composes `useDebouncedSearchSetter`, so it carries the canonical semantics: * the value updates instantly (drives the controlled input and the in-memory * filter), non-empty URL writes are debounced, and clearing (or a diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx index b5c46b36462..ebbfa9883cd 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx @@ -32,6 +32,8 @@ import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/provide import { mcpServerIdParam, mcpServerIdUrlKeys, + serverTabParam, + serverTabUrlKeys, } from '@/app/workspace/[workspaceId]/settings/[section]/search-params' import { CreateApiKeyModal } from '@/app/workspace/[workspaceId]/settings/components/api-keys/components' import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' @@ -108,7 +110,10 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe const [editServerName, setEditServerName] = useState('') const [editServerDescription, setEditServerDescription] = useState('') const [editServerIsPublic, setEditServerIsPublic] = useState(false) - const [activeServerTab, setActiveServerTab] = useState<'workflows' | 'details'>('details') + const [activeServerTab, setActiveServerTab] = useQueryState(serverTabParam.key, { + ...serverTabParam.parser, + ...serverTabUrlKeys, + }) const [selectedWorkflowId, setSelectedWorkflowId] = useState(null) @@ -401,7 +406,7 @@ function ServerDetailView({ canManage, workspaceId, serverId, onBack }: ServerDe { value: 'workflows', label: 'Workflows' }, ]} value={activeServerTab} - onChange={(value) => setActiveServerTab(value as 'workflows' | 'details')} + onChange={(value) => void setActiveServerTab(value as 'workflows' | 'details')} />
@@ -892,6 +897,11 @@ export function WorkflowMcpServers() { }) const [serverToDelete, setServerToDelete] = useState(null) const [deletingServers, setDeletingServers] = useState>(() => new Set()) + /** Cleared alongside the server id on close so the tab never lingers on the list URL. */ + const [, setServerTab] = useQueryState(serverTabParam.key, { + ...serverTabParam.parser, + ...serverTabUrlKeys, + }) const filteredServers = useMemo(() => { if (!searchTerm.trim()) return servers @@ -948,7 +958,10 @@ export function WorkflowMcpServers() { canManage={canAdmin} workspaceId={workspaceId} serverId={selectedServerId} - onBack={() => setSelectedServerId(null, { history: 'replace' })} + onBack={() => { + void setServerTab(null, { history: 'replace' }) + void setSelectedServerId(null, { history: 'replace' }) + }} /> ) } @@ -1011,7 +1024,14 @@ export function WorkflowMcpServers() { setSelectedServerId(server.id) }, + { + label: 'Details', + onSelect: () => { + // A lingering ?server-tab= (dead deep link) must not re-target the next open — reset it in the same batched push. + void setServerTab(null) + void setSelectedServerId(server.id) + }, + }, ...(canAdmin ? [ { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts index 789cf923fd9..fc2e914762b 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts @@ -39,6 +39,7 @@ describe('unified settings navigation', () => { { id: 'inbox', label: 'Sim mailer', section: 'system' }, { id: 'recently-deleted', label: 'Recently deleted', section: 'system' }, { id: 'sso', label: 'Single sign-on', section: 'enterprise' }, + { id: 'sessions', label: 'Session policies', section: 'enterprise' }, { id: 'data-retention', label: 'Data retention', section: 'enterprise' }, { id: 'data-drains', label: 'Data drains', section: 'enterprise' }, { id: 'whitelabeling', label: 'Whitelabeling', section: 'enterprise' }, diff --git a/apps/sim/app/workspace/[workspaceId]/skills/search-params.ts b/apps/sim/app/workspace/[workspaceId]/skills/search-params.ts index ae63f33ebc4..554005fc76d 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/skills/search-params.ts @@ -23,8 +23,7 @@ export const skillIdUrlKeys = { /** * `search` filters the skills list by name/description. The input is controlled * directly by the instant nuqs value; only its URL write is debounced via - * `limitUrlUpdates` (`debounce`) on the setter — never written on every - * keystroke. + * `useDebouncedSearchSetter` — never written on every keystroke. */ export const skillSearchParam = { key: 'search', diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx index 8611bf7ac72..6674f8f6d6f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx @@ -17,6 +17,7 @@ import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/conn import { ConnectServiceAccountModal, type ServiceAccountProviderId, + useServiceAccountConnectTarget, } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight' @@ -130,6 +131,20 @@ export function CredentialSelector({ [credentialKind, isMergedKinds, serviceId] ) + // Canonical resolver for the service-account connect control: the vendor- + // accurate label and — critically — the per-viewer preview gate (a custom + // Slack bot rides `slack_v2`). Shared with the integrations page and chat so + // the gate can't be bypassed here. When `hidden`, the setup action is + // suppressed; existing service-account credentials stay selectable. + const serviceAccountTarget = useServiceAccountConnectTarget({ + serviceAccountProviderId: serviceAccountService?.serviceAccountProviderId as + | ServiceAccountProviderId + | undefined, + serviceName: serviceAccountService?.name, + serviceIcon: serviceAccountService?.icon, + }) + const serviceAccountConnectHidden = Boolean(serviceAccountTarget?.hidden) + const selectedCredential = useMemo( () => credentials.find((cred) => cred.id === selectedId), [credentials, selectedId] @@ -249,19 +264,23 @@ export function CredentialSelector({ iconElement: getProviderIcon((cred.provider ?? provider) as OAuthProvider), })) - options.push({ - label: - credentialKind === 'service-account' - ? (subBlock.credentialLabels?.serviceAccountConnect ?? - (credentials.length > 0 - ? `Add another ${getProviderName(provider)} key` - : `Add ${getProviderName(provider)} key`)) - : credentials.length > 0 - ? `Connect another ${getProviderName(provider)} account` - : `Connect ${getProviderName(provider)} account`, - value: '__connect_account__', - iconElement: , - }) + // Suppress the setup action when the service-account flow is preview-gated + // for this viewer (a custom Slack bot needs slack_v2) — existing accounts + // above stay selectable. + if (credentialKind !== 'service-account' || !serviceAccountConnectHidden) { + options.push({ + label: + credentialKind === 'service-account' + ? (subBlock.credentialLabels?.serviceAccountConnect ?? + serviceAccountTarget?.label ?? + `Add ${getProviderName(provider)} key`) + : credentials.length > 0 + ? `Connect another ${getProviderName(provider)} account` + : `Connect ${getProviderName(provider)} account`, + value: '__connect_account__', + iconElement: , + }) + } return options }, [ @@ -271,6 +290,8 @@ export function CredentialSelector({ credentials, credentialKind, subBlock.credentialLabels, + serviceAccountConnectHidden, + serviceAccountTarget, provider, getProviderIcon, getProviderName, @@ -302,11 +323,19 @@ export function CredentialSelector({ section: labels?.serviceAccountGroup ?? 'Service accounts', items: [ ...credentials.filter((c) => c.type === 'service_account').map(toOption), - { - label: labels?.serviceAccountConnect ?? `Add ${getProviderName(provider)} key`, - value: '__connect_service_account__', - iconElement: , - }, + // Drop the setup action when the flow is preview-gated for this viewer. + ...(serviceAccountConnectHidden + ? [] + : [ + { + label: + labels?.serviceAccountConnect ?? + serviceAccountTarget?.label ?? + `Add ${getProviderName(provider)} key`, + value: '__connect_service_account__', + iconElement: , + }, + ]), ], }, ] @@ -314,6 +343,8 @@ export function CredentialSelector({ isMergedKinds, subBlock.credentialLabels, credentials, + serviceAccountConnectHidden, + serviceAccountTarget, provider, getProviderIcon, getProviderName, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index a4a937a5e27..3cfcb3fe3e8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -79,7 +79,6 @@ import { useCreateMcpServer, useForceRefreshMcpTools, useMcpServers, - useMcpToolsEvents, useStoredMcpTools, } from '@/hooks/queries/mcp' import { useWorkflowState, useWorkflows } from '@/hooks/queries/workflows' @@ -570,7 +569,6 @@ export const ToolInput = memo(function ToolInput({ const { data: mcpServers = [], isLoading: mcpServersLoading } = useMcpServers(workspaceId) const { data: storedMcpTools = [] } = useStoredMcpTools(workspaceId) const forceRefreshMcpTools = useForceRefreshMcpTools().mutate - useMcpToolsEvents(workspaceId) const { navigateToSettings } = useSettingsNavigation() const createMcpServer = useCreateMcpServer() const { startOauthForServer } = useMcpOauthPopup({ workspaceId }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx index 13e4dc6ab16..bd35b5b2614 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx @@ -22,6 +22,7 @@ import { SquareArrowUpRight, Trash, Unlock, + Workflow, } from '@sim/emcn/icons' import { Pin, PinOff } from 'lucide-react' @@ -31,6 +32,7 @@ interface ContextMenuProps { menuRef: React.RefObject onClose: () => void onOpenInNewTab?: () => void + onFindReferences?: () => void onMarkAsRead?: () => void onMarkAsUnread?: () => void onTogglePin?: () => void @@ -53,6 +55,7 @@ interface ContextMenuProps { onExport?: () => void onDelete: () => void showOpenInNewTab?: boolean + showFindReferences?: boolean showMarkAsRead?: boolean showMarkAsUnread?: boolean showPin?: boolean @@ -93,6 +96,7 @@ export function ContextMenu({ menuRef, onClose, onOpenInNewTab, + onFindReferences, onMarkAsRead, onMarkAsUnread, onTogglePin, @@ -104,6 +108,7 @@ export function ContextMenu({ onExport, onDelete, showOpenInNewTab = false, + showFindReferences = false, showMarkAsRead = false, showMarkAsUnread = false, showPin = false, @@ -133,7 +138,8 @@ export function ContextMenu({ showUploadLogo = false, disableUploadLogo = false, }: ContextMenuProps) { - const hasNavigationSection = showOpenInNewTab && onOpenInNewTab + const hasNavigationSection = + (showOpenInNewTab && onOpenInNewTab) || (showFindReferences && onFindReferences) const hasStatusSection = (showMarkAsRead && onMarkAsRead) || (showMarkAsUnread && onMarkAsUnread) || @@ -195,6 +201,17 @@ export function ContextMenu({ Open in new tab )} + {showFindReferences && onFindReferences && ( + { + onFindReferences() + onClose() + }} + > + + Show references + + )} {hasNavigationSection && (hasStatusSection || hasEditSection || hasCopySection) && ( )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/references-modal/components/reference-tree/reference-tree.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/references-modal/components/reference-tree/reference-tree.tsx new file mode 100644 index 00000000000..ae4def07afa --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/references-modal/components/reference-tree/reference-tree.tsx @@ -0,0 +1,68 @@ +'use client' + +import { Workflow } from '@sim/emcn/icons' +import type { ReferenceNode } from '@/lib/api/contracts/workflow-references' + +const CONFIG = { + /** Horizontal indent added per tree level, in pixels. */ + INDENT_PER_LEVEL: 16, + /** Base row padding: lands depth-0 content on the modal's px-4 text gutter. */ + BASE_INDENT: 8, +} as const + +interface ReferenceTreeProps { + nodes: ReferenceNode[] + /** Invoked with a workflow id when a row is activated. */ + onNavigate: (workflowId: string) => void +} + +interface ReferenceTreeItemProps { + node: ReferenceNode + depth: number + onNavigate: (workflowId: string) => void +} + +function ReferenceTreeItem({ node, depth, onNavigate }: ReferenceTreeItemProps) { + return ( +
+ + {node.children.length > 0 && ( +
+ {node.children.map((child) => ( + + ))} +
+ )} +
+ ) +} + +/** + * Read-only recursive tree of workflow references. Each row navigates to its + * workflow on click; cyclic leaves are marked and render no children. + */ +export function ReferenceTree({ nodes, onNavigate }: ReferenceTreeProps) { + return ( +
+ {nodes.map((node) => ( + + ))} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/references-modal/references-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/references-modal/references-modal.tsx new file mode 100644 index 00000000000..e507111c956 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/references-modal/references-modal.tsx @@ -0,0 +1,74 @@ +'use client' + +import { useState } from 'react' +import { ChipModal, ChipModalBody, ChipModalHeader, ChipModalTabs } from '@sim/emcn' +import { useRouter } from 'next/navigation' +import { ReferenceTree } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/references-modal/components/reference-tree/reference-tree' +import { useWorkflowReferences } from '@/hooks/queries/workflow-references' + +type ReferencesTab = 'callers' | 'callees' + +const TABS = [ + { value: 'callers', label: 'Used by' }, + { value: 'callees', label: 'Uses' }, +] as const + +const EMPTY_MESSAGE: Record = { + callers: 'No workflows call this workflow.', + callees: "This workflow doesn't call any other workflows.", +} + +interface ReferencesModalProps { + onClose: () => void + workspaceId: string + workflowId: string + workflowName: string +} + +/** + * IDE-style reference viewer for a workflow. "Used by" lists the workflows that + * call it (inbound); "Uses" lists the workflows it calls (outbound). Both are + * recursive trees whose rows navigate to the referenced workflow. Mounted on + * demand by the owning row, so state initializes fresh per open. + */ +export function ReferencesModal({ + onClose, + workspaceId, + workflowId, + workflowName, +}: ReferencesModalProps) { + const router = useRouter() + const [activeTab, setActiveTab] = useState('callers') + + const { data, isPending, isError } = useWorkflowReferences(workflowId) + + const handleNavigate = (targetId: string) => { + router.push(`/workspace/${workspaceId}/w/${targetId}`) + onClose() + } + + const nodes = data?.[activeTab] ?? [] + + return ( + !next && onClose()} srTitle='References'> + References — {workflowName} + + setActiveTab(value as ReferencesTab)} + aria-label='Reference direction' + /> + {isPending ? ( +

Loading references…

+ ) : isError ? ( +

Failed to load references.

+ ) : nodes.length === 0 ? ( +

{EMPTY_MESSAGE[activeTab]}

+ ) : ( + + )} +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx index fcc7d8a28b8..372d093b014 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx @@ -10,6 +10,7 @@ import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu' import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal' +import { ReferencesModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/references-modal/references-modal' import { Avatars } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/avatars/avatars' import { useContextMenu, @@ -80,6 +81,7 @@ export const WorkflowItem = memo(function WorkflowItem({ const { canDeleteWorkflows, canDeleteFolder } = useCanDelete({ workspaceId }) const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false) + const [isReferencesOpen, setIsReferencesOpen] = useState(false) const [deleteItemType, setDeleteItemType] = useState<'workflow' | 'mixed'>('workflow') const [deleteModalNames, setDeleteModalNames] = useState('') const [canDeleteSelection, setCanDeleteSelection] = useState(true) @@ -398,6 +400,10 @@ export const WorkflowItem = memo(function WorkflowItem({ [shouldPreventClickRef, workflow.id, onWorkflowClick, isEditing] ) + const handleFindReferences = useCallback(() => { + setIsReferencesOpen(true) + }, []) + return ( <> + + {isReferencesOpen && ( + setIsReferencesOpen(false)} + workspaceId={workspaceId} + workflowId={workflow.id} + workflowName={workflow.name} + /> + )} ) }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.test.tsx index cbf1d3c3cd0..c1f8bf34dec 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.test.tsx @@ -2,8 +2,9 @@ * @vitest-environment jsdom */ import { act, type ReactNode } from 'react' +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const { hostContext, mockUseOrganizationBilling } = vi.hoisted(() => ({ hostContext: { @@ -32,10 +33,6 @@ vi.mock('@/lib/auth/auth-client', () => ({ useSession: () => ({ data: { user: { email: 'viewer@example.com' } } }), })) -vi.mock('@/lib/core/config/env-flags', () => ({ - isBillingEnabled: true, -})) - vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({ useWorkspaceHostContext: () => hostContext.current, })) @@ -66,6 +63,12 @@ import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/ let container: HTMLDivElement let root: Root +beforeAll(() => { + setEnvFlags({ isBillingEnabled: true }) +}) + +afterAll(resetEnvFlagsMock) + describe('InviteModal organization billing isolation', () => { beforeEach(() => { container = document.createElement('div') diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index 66a04dfd0d4..e7a22926be7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -39,6 +39,7 @@ import { MoreHorizontal, Pin } from 'lucide-react' import Link from 'next/link' import { useParams, usePathname, useRouter } from 'next/navigation' import { usePostHog } from 'posthog-js/react' +import { SlackIcon } from '@/components/icons' import { useSession } from '@/lib/auth/auth-client' import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types' import { isMacPlatform } from '@/lib/core/utils/platform' @@ -115,6 +116,9 @@ import { useSidebarStore } from '@/stores/sidebar/store' const logger = createLogger('Sidebar') +const SLACK_COMMUNITY_URL = + 'https://join.slack.com/t/sim-ott9864/shared_invite/zt-43lp8tc5v-0qrrqHGBKUsvQlpoouH~TA' + export function SidebarTooltip({ children, label, @@ -1170,6 +1174,11 @@ export const Sidebar = memo(function Sidebar({ isCollapsed }: SidebarProps) { captureEvent(posthog, 'docs_opened', { source: 'help_menu' }) }, [posthog]) + const handleOpenSlackCommunity = useCallback(() => { + window.open(SLACK_COMMUNITY_URL, '_blank', 'noopener,noreferrer') + captureEvent(posthog, 'slack_community_opened', { source: 'help_menu' }) + }, [posthog]) + const handleChatRenameBlur = useCallback( () => void chatFlyoutRename.saveRename(), [chatFlyoutRename.saveRename] @@ -1670,6 +1679,10 @@ export const Sidebar = memo(function Sidebar({ isCollapsed }: SidebarProps) { Docs + + + Slack Community + Report an issue diff --git a/apps/sim/background/async-preprocessing-correlation.test.ts b/apps/sim/background/async-preprocessing-correlation.test.ts index 97b9e7900c8..e86d3bd66de 100644 --- a/apps/sim/background/async-preprocessing-correlation.test.ts +++ b/apps/sim/background/async-preprocessing-correlation.test.ts @@ -35,13 +35,6 @@ vi.mock('@sim/db', () => ({ workflowSchedule: {}, })) -vi.mock('drizzle-orm', () => ({ - eq: vi.fn(), - and: vi.fn(), - isNull: vi.fn(), - sql: Object.assign(vi.fn(), { raw: vi.fn() }), -})) - vi.mock('@/lib/execution/preprocessing', () => executionPreprocessingMock) vi.mock('@/lib/logs/execution/logging-session', () => loggingSessionMock) diff --git a/apps/sim/background/cleanup-logs.test.ts b/apps/sim/background/cleanup-logs.test.ts index 6999171e630..9ee5306a39b 100644 --- a/apps/sim/background/cleanup-logs.test.ts +++ b/apps/sim/background/cleanup-logs.test.ts @@ -2,7 +2,9 @@ * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { dbChainMockFns, resetDbChainMock, schemaMock } from '@sim/testing' +import { drizzleOrmMock } from '@sim/testing/mocks' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' interface CleanupRow { id: string @@ -18,139 +20,33 @@ interface CapturedBatchDeleteOptions { } const { - mockAnd, mockBatchDeleteByWorkspaceAndTimestamp, mockChunkedBatchDelete, mockDeleteFileMetadata, mockDeleteFiles, - mockEq, - mockExecute, - mockFrom, - mockInArray, - mockIsNull, - mockLeftJoin, - mockLimit, - mockLt, mockMarkLargeValuesDeleted, - mockNotInArray, - mockOr, - mockOrderBy, mockPruneLargeValueMetadata, - mockSelect, mockTask, - mockWhere, -} = vi.hoisted(() => { - const mockLimit = vi.fn(async () => []) - const mockOrderBy = vi.fn(() => ({ limit: mockLimit })) - const mockWhere = vi.fn(() => ({ limit: mockLimit, orderBy: mockOrderBy })) - const mockLeftJoin = vi.fn(() => ({ where: mockWhere })) - const mockFrom = vi.fn(() => ({ leftJoin: mockLeftJoin, where: mockWhere })) - const mockSelect = vi.fn(() => ({ from: mockFrom })) - - return { - mockAnd: vi.fn((...args: unknown[]) => ({ op: 'and', args })), - mockBatchDeleteByWorkspaceAndTimestamp: vi.fn(async () => ({ - table: 'job', - deleted: 0, - failed: 0, - })), - mockChunkedBatchDelete: vi.fn(), - mockDeleteFileMetadata: vi.fn(async () => true), - mockDeleteFiles: vi.fn(async () => ({ deleted: 2, failed: [] })), - mockEq: vi.fn((...args: unknown[]) => ({ op: 'eq', args })), - mockExecute: vi.fn(), - mockFrom, - mockInArray: vi.fn((...args: unknown[]) => ({ op: 'inArray', args })), - mockIsNull: vi.fn((...args: unknown[]) => ({ op: 'isNull', args })), - mockLeftJoin, - mockLimit, - mockLt: vi.fn((...args: unknown[]) => ({ op: 'lt', args })), - mockMarkLargeValuesDeleted: vi.fn(async () => undefined), - mockNotInArray: vi.fn((...args: unknown[]) => ({ op: 'notInArray', args })), - mockOr: vi.fn((...args: unknown[]) => ({ op: 'or', args })), - mockOrderBy, - mockPruneLargeValueMetadata: vi.fn(async () => ({ - referencesDeleted: 0, - dependenciesDeleted: 0, - tombstonesDeleted: 0, - })), - mockSelect, - mockTask: vi.fn((config: unknown) => config), - mockWhere, - } -}) - -vi.mock('@sim/db', () => ({ - db: { - execute: mockExecute, - select: mockSelect, - }, -})) - -vi.mock('@sim/db/schema', () => ({ - executionLargeValueDependencies: { - childKey: 'executionLargeValueDependencies.childKey', - parentKey: 'executionLargeValueDependencies.parentKey', - workspaceId: 'executionLargeValueDependencies.workspaceId', - }, - executionLargeValueReferences: { - executionId: 'executionLargeValueReferences.executionId', - key: 'executionLargeValueReferences.key', - source: 'executionLargeValueReferences.source', - }, - executionLargeValues: { - createdAt: 'executionLargeValues.createdAt', - deletedAt: 'executionLargeValues.deletedAt', - key: 'executionLargeValues.key', - workspaceId: 'executionLargeValues.workspaceId', - }, - jobExecutionLogs: { - startedAt: 'jobExecutionLogs.startedAt', - workspaceId: 'jobExecutionLogs.workspaceId', - }, - pausedExecutions: { - executionId: 'pausedExecutions.executionId', - status: 'pausedExecutions.status', - }, - workspaceFiles: { - context: 'workspaceFiles.context', - deletedAt: 'workspaceFiles.deletedAt', - key: 'workspaceFiles.key', - uploadedAt: 'workspaceFiles.uploadedAt', - workspaceId: 'workspaceFiles.workspaceId', - }, - workflowExecutionLogs: { - executionData: 'workflowExecutionLogs.executionData', - executionId: 'workflowExecutionLogs.executionId', - files: 'workflowExecutionLogs.files', - id: 'workflowExecutionLogs.id', - startedAt: 'workflowExecutionLogs.startedAt', - workspaceId: 'workflowExecutionLogs.workspaceId', - }, -})) - -vi.mock('@sim/logger', () => ({ - createLogger: vi.fn(() => ({ - error: vi.fn(), - info: vi.fn(), - warn: vi.fn(), +} = vi.hoisted(() => ({ + mockBatchDeleteByWorkspaceAndTimestamp: vi.fn(async () => ({ + table: 'job', + deleted: 0, + failed: 0, })), + mockChunkedBatchDelete: vi.fn(), + mockDeleteFileMetadata: vi.fn(async () => true), + mockDeleteFiles: vi.fn(async () => ({ deleted: 2, failed: [] })), + mockMarkLargeValuesDeleted: vi.fn(async () => undefined), + mockPruneLargeValueMetadata: vi.fn(async () => ({ + referencesDeleted: 0, + dependenciesDeleted: 0, + tombstonesDeleted: 0, + })), + mockTask: vi.fn((config: unknown) => config), })) vi.mock('@trigger.dev/sdk', () => ({ task: mockTask })) -vi.mock('drizzle-orm', () => ({ - and: mockAnd, - asc: vi.fn((column: unknown) => ({ op: 'asc', column })), - eq: mockEq, - inArray: mockInArray, - isNull: mockIsNull, - lt: mockLt, - notInArray: mockNotInArray, - or: mockOr, - sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values })), -})) - vi.mock('@/lib/cleanup/batch-delete', () => ({ batchDeleteByWorkspaceAndTimestamp: mockBatchDeleteByWorkspaceAndTimestamp, chunkArray: (items: string[], size: number) => { @@ -190,8 +86,13 @@ vi.mock('@/lib/uploads/server/metadata', () => ({ import { cleanupLogsTask, runCleanupLogs } from '@/background/cleanup-logs' describe('cleanup logs worker', () => { + afterAll(() => { + resetDbChainMock() + }) + beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() mockChunkedBatchDelete.mockImplementation(async (options: CapturedBatchDeleteOptions) => { await options.selectChunk(['workspace-1'], 500) await options.onBatch?.([ @@ -223,11 +124,11 @@ describe('cleanup logs worker', () => { totalRowLimit: 25_000, }) ) - expect(mockSelect).toHaveBeenCalledWith({ - id: 'workflowExecutionLogs.id', - files: 'workflowExecutionLogs.files', + expect(dbChainMockFns.select).toHaveBeenCalledWith({ + id: schemaMock.workflowExecutionLogs.id, + files: schemaMock.workflowExecutionLogs.files, }) - expect(mockExecute).not.toHaveBeenCalled() + expect(dbChainMockFns.execute).not.toHaveBeenCalled() expect(mockDeleteFiles).toHaveBeenCalledWith( ['execution-file-a', 'execution-file-b'], 'execution' @@ -242,7 +143,7 @@ describe('cleanup logs worker', () => { it('does not count large values as deleted when deleted_at marking fails', async () => { const largeValueKey = 'execution/workspace-1/workflow-1/execution-1/large-value-lv_abcdefghijkl.json' - mockLimit.mockResolvedValueOnce([]).mockResolvedValueOnce([{ key: largeValueKey }]) + dbChainMockFns.limit.mockResolvedValueOnce([]).mockResolvedValueOnce([{ key: largeValueKey }]) mockDeleteFiles .mockResolvedValueOnce({ deleted: 2, failed: [] }) .mockResolvedValueOnce({ deleted: 1, failed: [] }) @@ -255,14 +156,14 @@ describe('cleanup logs worker', () => { workspaceIds: ['workspace-1'], }) - expect(mockMarkLargeValuesDeleted).toHaveBeenCalledWith([largeValueKey]) + expect(mockMarkLargeValuesDeleted).toHaveBeenCalledWith([largeValueKey], expect.anything()) expect(mockDeleteFileMetadata).toHaveBeenCalledTimes(2) }) it('cleans legacy large values from file metadata without selecting execution_data', async () => { const legacyKey = 'execution/workspace-1/workflow-1/execution-1/large-value-lv_abcdefghijkl.json' - mockLimit + dbChainMockFns.limit .mockResolvedValueOnce([]) .mockResolvedValueOnce([]) .mockResolvedValueOnce([{ key: legacyKey }]) @@ -277,14 +178,14 @@ describe('cleanup logs worker', () => { workspaceIds: ['workspace-1'], }) - expect(mockSelect).toHaveBeenCalledWith({ - id: 'workflowExecutionLogs.id', - files: 'workflowExecutionLogs.files', + expect(dbChainMockFns.select).toHaveBeenCalledWith({ + id: schemaMock.workflowExecutionLogs.id, + files: schemaMock.workflowExecutionLogs.files, }) - expect(mockSelect).not.toHaveBeenCalledWith( + expect(dbChainMockFns.select).not.toHaveBeenCalledWith( expect.objectContaining({ executionData: expect.anything() }) ) - const legacyWhereArgs = mockAnd.mock.calls + const legacyWhereArgs = drizzleOrmMock.and.mock.calls .flat() .filter((arg): arg is { strings: string[] } => { return ( diff --git a/apps/sim/background/cleanup-logs.ts b/apps/sim/background/cleanup-logs.ts index bd451aa1e09..343e388ed2b 100644 --- a/apps/sim/background/cleanup-logs.ts +++ b/apps/sim/background/cleanup-logs.ts @@ -1,4 +1,4 @@ -import { db } from '@sim/db' +import { dbFor } from '@sim/db' import { executionLargeValueDependencies, executionLargeValueReferences, @@ -30,6 +30,9 @@ import { deleteFileMetadata } from '@/lib/uploads/server/metadata' const logger = createLogger('CleanupLogs') +/** All cleanup queries run on the dedicated cleanup pool. */ +const cleanupDb = dbFor('cleanup') + interface FileDeleteStats { filesTotal: number filesDeleted: number @@ -107,7 +110,7 @@ async function deleteLargeValueKeys(keys: string[]): Promise<{ deleted: number; if (deletedKeys.length > 0) { try { - await markLargeValuesDeleted(deletedKeys) + await markLargeValuesDeleted(deletedKeys, cleanupDb) } catch (error) { logger.error('Failed to mark large execution values as deleted:', { error }) return { deleted: 0, failed: result.failed.length + deletedKeys.length } @@ -153,7 +156,7 @@ async function cleanupLargeExecutionValues( LARGE_VALUE_CLEANUP_BATCH_SIZE, LARGE_VALUE_CLEANUP_TOTAL_KEY_LIMIT - attempted ) - const rows = await db + const rows = await cleanupDb .select({ key: executionLargeValues.key }) .from(executionLargeValues) .where( @@ -219,7 +222,7 @@ async function cleanupLegacyLargeExecutionValues( LARGE_VALUE_CLEANUP_BATCH_SIZE, LARGE_VALUE_CLEANUP_TOTAL_KEY_LIMIT - attempted ) - const rows = await db + const rows = await cleanupDb .select({ key: workspaceFiles.key }) .from(workspaceFiles) .where( @@ -353,7 +356,11 @@ async function cleanupLargeValueMetadata(workspaceIds: string[], label: string): const tombstonesDeletedBefore = new Date( Date.now() - LARGE_VALUE_TOMBSTONE_RETENTION_HOURS * 60 * 60 * 1000 ) - const result = await pruneLargeValueMetadata({ workspaceIds, tombstonesDeletedBefore }) + const result = await pruneLargeValueMetadata({ + workspaceIds, + tombstonesDeletedBefore, + dbClient: cleanupDb, + }) logger.info( `[${label}/execution_large_value_metadata] Pruned ${result.referencesDeleted} stale references, ${result.dependenciesDeleted} dependencies, ${result.tombstonesDeleted} tombstones` ) @@ -377,8 +384,9 @@ async function cleanupWorkflowExecutionLogs( tableDef: workflowExecutionLogs, workspaceIds, tableName: `${label}/workflow_execution_logs`, + dbClient: cleanupDb, selectChunk: (chunkIds, limit) => - db + cleanupDb .select({ id: workflowExecutionLogs.id, files: workflowExecutionLogs.files, @@ -465,6 +473,7 @@ export async function runCleanupLogs(payload: CleanupJobPayload): Promise workspaceIds, retentionDate, tableName: `${label}/job_execution_logs`, + dbClient: cleanupDb, }) if (runGlobalHousekeeping && plan === 'free') { diff --git a/apps/sim/background/cleanup-soft-deletes.test.ts b/apps/sim/background/cleanup-soft-deletes.test.ts index c104d9b7351..a52108e5d36 100644 --- a/apps/sim/background/cleanup-soft-deletes.test.ts +++ b/apps/sim/background/cleanup-soft-deletes.test.ts @@ -2,113 +2,35 @@ * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockBatchDeleteByWorkspaceAndTimestamp, mockChunkedBatchDelete, mockDecrementStorageUsageForBillingContextInTx, - mockDelete, - mockDeleteReturning, - mockDeleteWhere, mockDeleteFileMetadata, mockDeleteFiles, mockDeleteRowsById, mockHardDeleteDocuments, mockIsUsingCloudStorage, mockKnowledgeBaseContainerDelete, - mockLimit, - mockOrderBy, mockPrepareChatCleanup, mockResolveStorageBillingContext, - mockSelect, mockSelectRowsByIdChunks, - mockTask, - mockTransaction, - mockWhere, -} = vi.hoisted(() => { - const mockLimit = vi.fn(async () => [] as Array<{ key: string }>) - const mockOrderBy = vi.fn(() => ({ limit: mockLimit })) - const mockWhere = vi.fn(() => ({ orderBy: mockOrderBy, limit: mockLimit })) - const mockFrom = vi.fn(() => ({ - where: mockWhere, - leftJoin: vi.fn(() => ({ where: mockWhere })), - })) - const mockSelect = vi.fn(() => ({ from: mockFrom })) - const mockDeleteReturning = vi.fn(async () => [] as Array<{ id: string; size?: number }>) - const mockDeleteWhere = vi.fn(() => ({ returning: mockDeleteReturning })) - const mockDelete = vi.fn(() => ({ where: mockDeleteWhere })) - const mockKnowledgeBaseContainerDelete = vi.fn() - const mockChunkedBatchDelete = vi.fn(async () => ({ deleted: 0, failed: 0 })) - - return { - mockBatchDeleteByWorkspaceAndTimestamp: vi.fn(async () => ({ deleted: 0, failed: 0 })), - mockChunkedBatchDelete, - mockDecrementStorageUsageForBillingContextInTx: vi.fn(async () => undefined), - mockDelete, - mockDeleteReturning, - mockDeleteWhere, - mockDeleteFileMetadata: vi.fn(async () => true), - mockDeleteFiles: vi.fn(async () => ({ deleted: 0, failed: [] as Array<{ key: string }> })), - mockDeleteRowsById: vi.fn(async () => ({ deleted: 0, failed: 0 })), - mockHardDeleteDocuments: vi.fn(async (ids: string[]) => ids.length), - mockIsUsingCloudStorage: vi.fn(() => true), - mockKnowledgeBaseContainerDelete, - mockLimit, - mockOrderBy, - mockPrepareChatCleanup: vi.fn(async () => ({ execute: vi.fn(async () => undefined) })), - mockResolveStorageBillingContext: vi.fn(), - mockSelect, - mockSelectRowsByIdChunks: vi.fn(async () => [] as unknown[]), - mockTask: vi.fn((config: unknown) => config), - mockTransaction: vi.fn(), - mockWhere, - } -}) - -vi.mock('@sim/db', () => ({ - db: { - delete: mockDelete, - select: mockSelect, - transaction: mockTransaction, - }, -})) - -vi.mock('@sim/db/schema', () => { - const table = (cols: string[]) => - Object.fromEntries(cols.map((c) => [c, `col.${c}`])) as Record - const wsFileCols = ['id', 'key', 'context', 'size', 'workspaceId', 'deletedAt', 'uploadedAt'] - const softCols = ['id', 'archivedAt', 'deletedAt', 'workspaceId'] - return { - copilotChats: table(['id', 'workflowId']), - document: table(['id', 'storageKey', 'knowledgeBaseId']), - knowledgeBase: table(softCols), - mcpServers: table(softCols), - memory: table(softCols), - userTableDefinitions: table(softCols), - workflow: table(softCols), - workflowFolder: table(softCols), - workflowMcpServer: table(softCols), - workspaceFile: table(wsFileCols), - workspaceFiles: table(wsFileCols), - } -}) - -vi.mock('@sim/logger', () => ({ - createLogger: vi.fn(() => ({ error: vi.fn(), info: vi.fn(), warn: vi.fn() })), -})) - -vi.mock('@trigger.dev/sdk', () => ({ task: mockTask })) - -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...args: unknown[]) => ({ op: 'and', args })), - asc: vi.fn((column: unknown) => ({ op: 'asc', column })), - eq: vi.fn((...args: unknown[]) => ({ op: 'eq', args })), - inArray: vi.fn((...args: unknown[]) => ({ op: 'inArray', args })), - isNotNull: vi.fn((...args: unknown[]) => ({ op: 'isNotNull', args })), - isNull: vi.fn((...args: unknown[]) => ({ op: 'isNull', args })), - lt: vi.fn((...args: unknown[]) => ({ op: 'lt', args })), - sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values })), +} = vi.hoisted(() => ({ + mockBatchDeleteByWorkspaceAndTimestamp: vi.fn(async () => ({ deleted: 0, failed: 0 })), + mockChunkedBatchDelete: vi.fn(async () => ({ deleted: 0, failed: 0 })), + mockDecrementStorageUsageForBillingContextInTx: vi.fn(async () => undefined), + mockDeleteFileMetadata: vi.fn(async () => true), + mockDeleteFiles: vi.fn(async () => ({ deleted: 0, failed: [] as Array<{ key: string }> })), + mockDeleteRowsById: vi.fn(async () => ({ deleted: 0, failed: 0 })), + mockHardDeleteDocuments: vi.fn(async (ids: string[]) => ids.length), + mockIsUsingCloudStorage: vi.fn(() => true), + mockKnowledgeBaseContainerDelete: vi.fn(), + mockPrepareChatCleanup: vi.fn(async () => ({ execute: vi.fn(async () => undefined) })), + mockResolveStorageBillingContext: vi.fn(), + mockSelectRowsByIdChunks: vi.fn(async () => [] as unknown[]), })) vi.mock('@/lib/cleanup/batch-delete', () => ({ @@ -154,14 +76,17 @@ const basePayload = { } describe('cleanup soft deletes', () => { + afterAll(() => { + resetDbChainMock() + }) + beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() mockIsUsingCloudStorage.mockReturnValue(true) - mockLimit.mockReset().mockResolvedValue([]) mockSelectRowsByIdChunks.mockReset().mockResolvedValue([]) mockDeleteFiles.mockReset().mockResolvedValue({ deleted: 0, failed: [] }) mockChunkedBatchDelete.mockReset().mockResolvedValue({ deleted: 0, failed: 0 }) - mockDeleteReturning.mockReset().mockResolvedValue([]) mockResolveStorageBillingContext.mockResolvedValue({ workspaceId: 'ws-1', billedAccountUserId: 'user-1', @@ -169,11 +94,6 @@ describe('cleanup soft deletes', () => { plan: 'free', customStorageLimitGB: null, }) - mockTransaction - .mockReset() - .mockImplementation(async (callback: (tx: { delete: typeof mockDelete }) => unknown) => - callback({ delete: mockDelete }) - ) }) it('keeps metadata rows whose object deletion failed', async () => { @@ -196,8 +116,8 @@ describe('cleanup soft deletes', () => { await runCleanupSoftDeletes(basePayload) - expect(mockTransaction).not.toHaveBeenCalled() - expect(mockDelete).not.toHaveBeenCalled() + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() + expect(dbChainMockFns.delete).not.toHaveBeenCalled() expect( mockDeleteRowsById.mock.calls.some(([, , ids]) => (ids as string[]).includes('file-failed')) ).toBe(false) @@ -224,18 +144,18 @@ describe('cleanup soft deletes', () => { }, ]) mockDeleteFiles.mockResolvedValueOnce({ deleted: 2, failed: [] }) - mockDeleteReturning.mockResolvedValueOnce([{ id: 'file-deleted', size: 7 }]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'file-deleted', size: 7 }]) await runCleanupSoftDeletes(basePayload) expect(mockResolveStorageBillingContext).toHaveBeenCalledOnce() expect(mockDecrementStorageUsageForBillingContextInTx).toHaveBeenCalledWith( - expect.objectContaining({ delete: mockDelete }), + dbChainMock.db, expect.objectContaining({ workspaceId: 'ws-1' }), 7 ) expect(mockDeleteFiles.mock.invocationCallOrder[0]).toBeLessThan( - mockTransaction.mock.invocationCallOrder[0] + dbChainMockFns.transaction.mock.invocationCallOrder[0] ) }) @@ -253,12 +173,12 @@ describe('cleanup soft deletes', () => { }, ]) mockDeleteFiles.mockResolvedValueOnce({ deleted: 1, failed: [] }) - mockDeleteReturning.mockResolvedValueOnce([{ id: 'chat-file' }]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'chat-file' }]) await runCleanupSoftDeletes(basePayload) expect(mockDeleteFiles).toHaveBeenCalledWith(['mothership/chat-file'], 'mothership') - expect(mockDelete).toHaveBeenCalled() + expect(dbChainMockFns.delete).toHaveBeenCalled() expect(mockResolveStorageBillingContext).not.toHaveBeenCalled() expect(mockDecrementStorageUsageForBillingContextInTx).not.toHaveBeenCalled() }) @@ -275,7 +195,7 @@ describe('cleanup soft deletes', () => { return { deleted: 1, failed: 0 } } ) - mockLimit + dbChainMockFns.limit .mockResolvedValueOnce([{ id: 'doc-1' }]) .mockResolvedValueOnce([]) .mockResolvedValueOnce([]) @@ -289,7 +209,7 @@ describe('cleanup soft deletes', () => { }) it('soft-deletes abandoned KB bindings and removes their storage objects', async () => { - mockLimit + dbChainMockFns.limit .mockResolvedValueOnce([{ key: 'kb/orphan-1' }, { key: 'kb/orphan-2' }]) .mockResolvedValueOnce([]) @@ -302,7 +222,7 @@ describe('cleanup soft deletes', () => { }) it('keeps an orphan KB binding when its object deletion fails', async () => { - mockLimit.mockResolvedValueOnce([{ key: 'kb/orphan-retry' }]) + dbChainMockFns.limit.mockResolvedValueOnce([{ key: 'kb/orphan-retry' }]) mockDeleteFiles.mockResolvedValueOnce({ deleted: 0, failed: [{ key: 'kb/orphan-retry', error: 'storage unavailable' }], @@ -315,7 +235,7 @@ describe('cleanup soft deletes', () => { it('still removes bindings but skips object deletion without cloud storage', async () => { mockIsUsingCloudStorage.mockReturnValue(false) - mockLimit.mockResolvedValueOnce([{ key: 'kb/orphan-1' }]).mockResolvedValueOnce([]) + dbChainMockFns.limit.mockResolvedValueOnce([{ key: 'kb/orphan-1' }]).mockResolvedValueOnce([]) await runCleanupSoftDeletes(basePayload) @@ -324,7 +244,7 @@ describe('cleanup soft deletes', () => { }) it('stops the batch loop when binding deletion makes no progress', async () => { - mockLimit.mockResolvedValue([{ key: 'kb/stuck' }]) + dbChainMockFns.limit.mockResolvedValue([{ key: 'kb/stuck' }]) mockDeleteFileMetadata.mockRejectedValue(new Error('db down')) await runCleanupSoftDeletes(basePayload) @@ -336,7 +256,7 @@ describe('cleanup soft deletes', () => { it('does not run the sweep when there are no workspaces', async () => { await runCleanupSoftDeletes({ ...basePayload, workspaceIds: [] }) - expect(mockSelect).not.toHaveBeenCalled() + expect(dbChainMockFns.select).not.toHaveBeenCalled() expect(mockDeleteFiles).not.toHaveBeenCalled() expect(mockDeleteFileMetadata).not.toHaveBeenCalled() }) diff --git a/apps/sim/background/cleanup-soft-deletes.ts b/apps/sim/background/cleanup-soft-deletes.ts index 75e9c42ae20..7db8b2cc8f0 100644 --- a/apps/sim/background/cleanup-soft-deletes.ts +++ b/apps/sim/background/cleanup-soft-deletes.ts @@ -1,4 +1,4 @@ -import { db } from '@sim/db' +import { db, dbFor } from '@sim/db' import { copilotChats, document, @@ -36,6 +36,13 @@ import { deleteFileMetadata } from '@/lib/uploads/server/metadata' const logger = createLogger('CleanupSoftDeletes') +/** + * Cleanup queries run on the dedicated cleanup pool. The one exception is the + * billable-file transaction below, which couples row deletion with a storage + * billing decrement — billing writes stay on the default client. + */ +const cleanupDb = dbFor('cleanup') + const KB_ORPHAN_BINDING_BATCH_SIZE = 500 const KB_ORPHAN_BINDING_TOTAL_LIMIT = 5_000 /** @@ -80,7 +87,7 @@ async function selectExpiredWorkspaceFiles( ): Promise { const [legacyRows, multiContextRows] = await Promise.all([ selectRowsByIdChunks(workspaceIds, (chunkIds, chunkLimit) => - db + cleanupDb .select({ id: workspaceFile.id, key: workspaceFile.key, @@ -97,7 +104,7 @@ async function selectExpiredWorkspaceFiles( .limit(chunkLimit) ), selectRowsByIdChunks(workspaceIds, (chunkIds, chunkLimit) => - db + cleanupDb .select({ id: workspaceFiles.id, key: workspaceFiles.key, @@ -199,7 +206,7 @@ async function deleteExpiredLegacyWorkspaceFileRows( const result = { deleted: 0, failed: 0 } for (const batch of chunkArray(rows, DEFAULT_DELETE_CHUNK_SIZE)) { try { - const deleted = await db + const deleted = await cleanupDb .delete(workspaceFile) .where( and( @@ -239,7 +246,7 @@ async function deleteExpiredUnbilledWorkspaceFileRows( for (const [context, contextRows] of rowsByContext) { for (const batch of chunkArray(contextRows, DEFAULT_DELETE_CHUNK_SIZE)) { try { - const deleted = await db + const deleted = await cleanupDb .delete(workspaceFiles) .where( and( @@ -342,7 +349,7 @@ async function hardDeleteKnowledgeBaseDocuments( label: string ): Promise { for (let batch = 0; batch < KB_DOCUMENT_DELETE_MAX_BATCHES; batch++) { - const documentRows = await db + const documentRows = await cleanupDb .select({ id: document.id }) .from(document) .where(inArray(document.knowledgeBaseId, knowledgeBaseIds)) @@ -357,7 +364,7 @@ async function hardDeleteKnowledgeBaseDocuments( } } - const remaining = await db + const remaining = await cleanupDb .select({ id: document.id }) .from(document) .where(inArray(document.knowledgeBaseId, knowledgeBaseIds)) @@ -377,8 +384,9 @@ async function cleanupExpiredKnowledgeBases( workspaceIds, tableName: `${label}/knowledgeBase`, batchSize: KB_RETENTION_BATCH_SIZE, + dbClient: cleanupDb, selectChunk: (chunkIds, limit) => - db + cleanupDb .select({ id: knowledgeBase.id }) .from(knowledgeBase) .where( @@ -457,7 +465,7 @@ async function cleanupOrphanedKnowledgeBaseBindings( KB_ORPHAN_BINDING_BATCH_SIZE, KB_ORPHAN_BINDING_TOTAL_LIMIT - attempted ) - const rows = await db + const rows = await cleanupDb .select({ key: workspaceFiles.key }) .from(workspaceFiles) .where( @@ -535,7 +543,7 @@ export async function runCleanupSoftDeletes(payload: CleanupJobPayload): Promise // prematurely purge data. const [doomedWorkflows, fileScope, expiredSoftDeletedChats] = await Promise.all([ selectRowsByIdChunks(workspaceIds, (chunkIds, chunkLimit) => - db + cleanupDb .select({ id: workflow.id }) .from(workflow) .where( @@ -549,7 +557,7 @@ export async function runCleanupSoftDeletes(payload: CleanupJobPayload): Promise ), selectExpiredWorkspaceFiles(workspaceIds, retentionDate), selectRowsByIdChunks(workspaceIds, (chunkIds, chunkLimit) => - db + cleanupDb .select({ id: copilotChats.id }) .from(copilotChats) .where( @@ -570,7 +578,7 @@ export async function runCleanupSoftDeletes(payload: CleanupJobPayload): Promise const doomedChatIds = new Set(softDeletedChatIds) if (doomedWorkflowIds.length > 0) { const workflowChats = await selectRowsByIdChunks(doomedWorkflowIds, (chunkIds, chunkLimit) => - db + cleanupDb .select({ id: copilotChats.id }) .from(copilotChats) .where(inArray(copilotChats.workflowId, chunkIds)) @@ -595,7 +603,7 @@ export async function runCleanupSoftDeletes(payload: CleanupJobPayload): Promise // spares their backend data and files. for (const batch of chunkArray(doomedWorkflowIds, DEFAULT_DELETE_CHUNK_SIZE)) { try { - const deleted = await db + const deleted = await cleanupDb .delete(workflow) .where( and( @@ -618,7 +626,7 @@ export async function runCleanupSoftDeletes(payload: CleanupJobPayload): Promise // also re-checks row existence before purging external data). for (const batch of chunkArray(softDeletedChatIds, DEFAULT_DELETE_CHUNK_SIZE)) { try { - const deleted = await db + const deleted = await cleanupDb .delete(copilotChats) .where( and( @@ -667,6 +675,7 @@ export async function runCleanupSoftDeletes(payload: CleanupJobPayload): Promise retentionDate, tableName: `${label}/${target.name}`, requireTimestampNotNull: true, + dbClient: cleanupDb, }) totalDeleted += result.deleted } diff --git a/apps/sim/background/cleanup-tasks.ts b/apps/sim/background/cleanup-tasks.ts index e987ac41078..1745e3bba14 100644 --- a/apps/sim/background/cleanup-tasks.ts +++ b/apps/sim/background/cleanup-tasks.ts @@ -1,4 +1,4 @@ -import { db } from '@sim/db' +import { dbFor } from '@sim/db' import { copilotAsyncToolCalls, copilotChats, @@ -22,6 +22,9 @@ import { prepareChatCleanup } from '@/lib/cleanup/chat-cleanup' const logger = createLogger('CleanupTasks') +/** All cleanup queries run on the dedicated cleanup pool. */ +const cleanupDb = dbFor('cleanup') + /** * Delete copilot run checkpoints and async tool calls via join through copilotRuns. * These tables don't have a direct workspaceId — we find qualifying run IDs first. @@ -47,7 +50,7 @@ async function cleanupRunChildren( if (workspaceIds.length === 0) return [] const runIds = await selectRowsByIdChunks(workspaceIds, (chunkIds, chunkLimit) => - db + cleanupDb .select({ id: copilotRuns.id }) .from(copilotRuns) .where( @@ -63,7 +66,9 @@ async function cleanupRunChildren( const ids = runIds.map((r) => r.id) return Promise.all( - RUN_CHILD_TABLES.map((t) => deleteRowsById(t.table, t.runIdCol, ids, `${label}/${t.name}`)) + RUN_CHILD_TABLES.map((t) => + deleteRowsById(t.table, t.runIdCol, ids, `${label}/${t.name}`, cleanupDb) + ) ) } @@ -82,7 +87,7 @@ export async function runCleanupTasks(payload: CleanupJobPayload): Promise ) const doomedChats = await selectRowsByIdChunks(workspaceIds, (chunkIds, chunkLimit) => - db + cleanupDb .select({ id: copilotChats.id }) .from(copilotChats) .where( @@ -110,6 +115,7 @@ export async function runCleanupTasks(payload: CleanupJobPayload): Promise workspaceIds, retentionDate, tableName: `${label}/copilotRuns`, + dbClient: cleanupDb, }) // Delete copilot chats using the exact IDs collected above so the chat @@ -122,7 +128,7 @@ export async function runCleanupTasks(payload: CleanupJobPayload): Promise const chatsResult = { deleted: 0, failed: 0 } for (const batch of chunkArray(doomedChatIds, DEFAULT_DELETE_CHUNK_SIZE)) { try { - const deleted = await db + const deleted = await cleanupDb .delete(copilotChats) .where(and(inArray(copilotChats.id, batch), lt(copilotChats.updatedAt, retentionDate))) .returning({ id: copilotChats.id }) @@ -141,6 +147,7 @@ export async function runCleanupTasks(payload: CleanupJobPayload): Promise workspaceIds, retentionDate, tableName: `${label}/mothershipInboxTask`, + dbClient: cleanupDb, }) const totalDeleted = diff --git a/apps/sim/background/tiktok-webhook-targets.test.ts b/apps/sim/background/tiktok-webhook-targets.test.ts index d8acfbf1299..76f7ed76cbc 100644 --- a/apps/sim/background/tiktok-webhook-targets.test.ts +++ b/apps/sim/background/tiktok-webhook-targets.test.ts @@ -2,51 +2,17 @@ * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockAsc, - mockCredentialExpression, - mockEq, - mockGt, - mockLike, - mockLimit, - mockOrderBy, - mockSelect, - queryRows, -} = vi.hoisted(() => ({ +import { dbChainMock, dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockAsc, mockCredentialExpression, mockEq, mockGt, mockLike, tables } = vi.hoisted(() => ({ mockAsc: vi.fn((value: unknown) => ({ asc: value })), mockCredentialExpression: vi.fn(() => 'webhook.credentialId'), mockEq: vi.fn((left: unknown, right: unknown) => ({ left, right })), mockGt: vi.fn((left: unknown, right: unknown) => ({ gt: [left, right] })), mockLike: vi.fn((left: unknown, right: unknown) => ({ left, right })), - mockLimit: vi.fn(), - mockOrderBy: vi.fn(), - mockSelect: vi.fn(), - queryRows: { - rows: [] as Array<{ - accountId: string - webhookId: string - webhook: Record - workflow: Record - }>, - }, -})) - -vi.mock('@sim/db', () => { - const chain = { - from: vi.fn(() => chain), - innerJoin: vi.fn(() => chain), - leftJoin: vi.fn(() => chain), - where: vi.fn(() => chain), - orderBy: mockOrderBy, - limit: mockLimit, - } - mockOrderBy.mockImplementation(() => chain) - mockLimit.mockImplementation((limit: number) => Promise.resolve(queryRows.rows.slice(0, limit))) - mockSelect.mockImplementation(() => chain) - - return { + /** Table-qualified column names keep the eq/like assertions unambiguous. */ + tables: { account: { id: 'account.id', accountId: 'account.accountId', @@ -59,8 +25,6 @@ vi.mock('@sim/db', () => { type: 'credential.type', workspaceId: 'credential.workspaceId', }, - db: { select: mockSelect }, - webhookCredentialIdExpression: mockCredentialExpression, webhook: { deploymentVersionId: 'webhook.deploymentVersionId', isActive: 'webhook.isActive', @@ -80,8 +44,14 @@ vi.mock('@sim/db', () => { workflowId: 'workflowDeploymentVersion.workflowId', isActive: 'workflowDeploymentVersion.isActive', }, - } -}) + }, +})) + +vi.mock('@sim/db', () => ({ + ...dbChainMock, + ...tables, + webhookCredentialIdExpression: mockCredentialExpression, +})) vi.mock('drizzle-orm', () => ({ and: vi.fn((...conditions: unknown[]) => conditions), @@ -100,14 +70,30 @@ import { const ACCOUNT_UUID = '11111111-2222-3333-4444-555555555555' +/** Queues one page of joined rows, clipped like the SQL LIMIT would. */ +function queuePageRows( + rows: Array<{ + accountId: string + webhookId: string + webhook: Record + workflow: Record + }> +) { + queueTableRows(tables.account, rows.slice(0, TIKTOK_WEBHOOK_TARGET_PAGE_SIZE)) +} + describe('findTikTokWebhookTargetPage', () => { + afterAll(() => { + resetDbChainMock() + }) + beforeEach(() => { vi.clearAllMocks() - queryRows.rows = [] + resetDbChainMock() }) it('returns only rows whose stored account ID exactly matches user_openid', async () => { - queryRows.rows = [ + queuePageRows([ { accountId: `act.user-${ACCOUNT_UUID}`, webhookId: 'webhook-1', @@ -120,7 +106,7 @@ describe('findTikTokWebhookTargetPage', () => { webhook: { id: 'webhook-2' }, workflow: { id: 'workflow-2' }, }, - ] + ]) const page = await findTikTokWebhookTargetPage('act.user', 'request-1') @@ -151,20 +137,22 @@ describe('findTikTokWebhookTargetPage', () => { expect(mockGt).toHaveBeenCalledWith('webhook.id', 'webhook-100') expect(mockAsc).toHaveBeenCalledWith('webhook.id') - expect(mockOrderBy).toHaveBeenCalledWith({ asc: 'webhook.id' }) - expect(mockLimit).toHaveBeenCalledWith(TIKTOK_WEBHOOK_TARGET_PAGE_SIZE) + expect(dbChainMockFns.orderBy).toHaveBeenCalledWith({ asc: 'webhook.id' }) + expect(dbChainMockFns.limit).toHaveBeenCalledWith(TIKTOK_WEBHOOK_TARGET_PAGE_SIZE) }) it('returns a continuation cursor when the fixed-size page is full', async () => { - queryRows.rows = Array.from({ length: TIKTOK_WEBHOOK_TARGET_PAGE_SIZE + 1 }, (_, index) => { - const webhookId = `webhook-${String(index).padStart(3, '0')}` - return { - accountId: `act.user-${ACCOUNT_UUID}`, - webhookId, - webhook: { id: webhookId }, - workflow: { id: `workflow-${index}` }, - } - }) + queuePageRows( + Array.from({ length: TIKTOK_WEBHOOK_TARGET_PAGE_SIZE + 1 }, (_, index) => { + const webhookId = `webhook-${String(index).padStart(3, '0')}` + return { + accountId: `act.user-${ACCOUNT_UUID}`, + webhookId, + webhook: { id: webhookId }, + workflow: { id: `workflow-${index}` }, + } + }) + ) const page = await findTikTokWebhookTargetPage('act.user', 'request-4') @@ -188,6 +176,6 @@ describe('findTikTokWebhookTargetPage', () => { nextCursor: null, targets: [], }) - expect(mockSelect).not.toHaveBeenCalled() + expect(dbChainMockFns.select).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/background/webhook-execution.test.ts b/apps/sim/background/webhook-execution.test.ts index a075943ea40..fa44602b09b 100644 --- a/apps/sim/background/webhook-execution.test.ts +++ b/apps/sim/background/webhook-execution.test.ts @@ -3,7 +3,6 @@ */ import { - dbChainMock, dbChainMockFns, executionPreprocessingMock, executionPreprocessingMockFns, @@ -44,7 +43,6 @@ vi.mock('@opentelemetry/api', () => ({ trace: { getActiveSpan: mockGetActiveSpan }, })) -vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/execution/preprocessing', () => executionPreprocessingMock) vi.mock('@/lib/logs/execution/logging-session', () => loggingSessionMock) diff --git a/apps/sim/blocks/blocks/api.ts b/apps/sim/blocks/blocks/api.ts index cecd661e4c7..72450921163 100644 --- a/apps/sim/blocks/blocks/api.ts +++ b/apps/sim/blocks/blocks/api.ts @@ -123,6 +123,15 @@ Example: description: 'Allow retries for POST/PATCH requests (may create duplicate requests)', mode: 'advanced', }, + { + id: 'proxyUrl', + title: 'Proxy URL', + type: 'short-input', + placeholder: 'http://user:pass@proxy.host:port or {{PROXY_URL}}', + description: + 'Optional. Route this request through an http:// proxy (e.g. a residential proxy). Must be http://; the proxy host must be publicly reachable. Keep credentials in an environment variable and reference it like {{PROXY_URL}}.', + mode: 'advanced', + }, ], tools: { access: ['http_request'], @@ -144,6 +153,10 @@ Example: type: 'boolean', description: 'Allow retries for non-idempotent methods like POST/PATCH', }, + proxyUrl: { + type: 'string', + description: 'Optional http:// proxy URL to route the request through', + }, }, outputs: { data: { type: 'json', description: 'API response data (JSON, text, or other formats)' }, diff --git a/apps/sim/blocks/utils.test.ts b/apps/sim/blocks/utils.test.ts index 3dc571b9dd7..bf90c25d656 100644 --- a/apps/sim/blocks/utils.test.ts +++ b/apps/sim/blocks/utils.test.ts @@ -1,13 +1,10 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockIsHosted, mockIsAzureConfigured, mockIsOllamaConfigured } = vi.hoisted(() => ({ - mockIsHosted: { value: false }, - mockIsAzureConfigured: { value: false }, - mockIsOllamaConfigured: { value: false }, -})) +afterAll(resetEnvFlagsMock) const { mockGetHostedModels, @@ -34,18 +31,6 @@ const { mockProviders } = vi.hoisted(() => ({ }, })) -vi.mock('@/lib/core/config/env-flags', () => ({ - get isHosted() { - return mockIsHosted.value - }, - get isAzureConfigured() { - return mockIsAzureConfigured.value - }, - get isOllamaConfigured() { - return mockIsOllamaConfigured.value - }, -})) - vi.mock('@/providers/models', () => ({ getProviderFileAttachment: vi .fn() @@ -103,9 +88,7 @@ describe('getApiKeyCondition / shouldRequireApiKeyForModel', () => { beforeEach(() => { vi.clearAllMocks() - mockIsHosted.value = false - mockIsAzureConfigured.value = false - mockIsOllamaConfigured.value = false + setEnvFlags({ isHosted: false, isAzureConfigured: false, isOllamaConfigured: false }) mockProviders.value = { base: { models: [], isLoading: false }, ollama: { models: [], isLoading: false }, @@ -131,14 +114,14 @@ describe('getApiKeyCondition / shouldRequireApiKeyForModel', () => { describe('hosted models', () => { it('does not require API key for hosted models on hosted platform', () => { - mockIsHosted.value = true + setEnvFlags({ isHosted: true }) mockGetHostedModels.mockReturnValue(['gpt-4o', 'claude-sonnet-4-5']) expect(evaluateCondition('gpt-4o')).toBe(false) expect(evaluateCondition('claude-sonnet-4-5')).toBe(false) }) it('requires API key for non-hosted models on hosted platform', () => { - mockIsHosted.value = true + setEnvFlags({ isHosted: true }) mockGetHostedModels.mockReturnValue(['gpt-4o']) expect(evaluateCondition('claude-sonnet-4-5')).toBe(true) }) @@ -158,14 +141,14 @@ describe('getApiKeyCondition / shouldRequireApiKeyForModel', () => { describe('Azure models', () => { it('does not require API key for azure/ models when Azure is configured', () => { - mockIsAzureConfigured.value = true + setEnvFlags({ isAzureConfigured: true }) expect(evaluateCondition('azure/gpt-4o')).toBe(false) expect(evaluateCondition('azure-openai/gpt-4o')).toBe(false) expect(evaluateCondition('azure-anthropic/claude-sonnet-4-5')).toBe(false) }) it('requires API key for azure/ models when Azure is not configured', () => { - mockIsAzureConfigured.value = false + setEnvFlags({ isAzureConfigured: false }) expect(evaluateCondition('azure/gpt-4o')).toBe(true) }) }) @@ -218,7 +201,7 @@ describe('getApiKeyCondition / shouldRequireApiKeyForModel', () => { describe('Ollama — OLLAMA_URL env var (server-safe)', () => { it('does not require API key for unknown models when OLLAMA_URL is set', () => { - mockIsOllamaConfigured.value = true + setEnvFlags({ isOllamaConfigured: true }) expect(evaluateCondition('llama3:latest')).toBe(false) expect(evaluateCondition('phi3:latest')).toBe(false) expect(evaluateCondition('gemma2:latest')).toBe(false) @@ -226,7 +209,7 @@ describe('getApiKeyCondition / shouldRequireApiKeyForModel', () => { }) it('does not require API key for Ollama models that match cloud provider regex patterns', () => { - mockIsOllamaConfigured.value = true + setEnvFlags({ isOllamaConfigured: true }) expect(evaluateCondition('mistral:latest')).toBe(false) expect(evaluateCondition('mistral')).toBe(false) expect(evaluateCondition('mistral-nemo')).toBe(false) @@ -234,7 +217,7 @@ describe('getApiKeyCondition / shouldRequireApiKeyForModel', () => { }) it('requires API key for known cloud models even when OLLAMA_URL is set', () => { - mockIsOllamaConfigured.value = true + setEnvFlags({ isOllamaConfigured: true }) mockGetBaseModelProviders.mockReturnValue(BASE_CLOUD_MODELS) expect(evaluateCondition('gpt-4o')).toBe(true) expect(evaluateCondition('claude-sonnet-4-5')).toBe(true) @@ -243,7 +226,7 @@ describe('getApiKeyCondition / shouldRequireApiKeyForModel', () => { }) it('requires API key for slash-prefixed cloud models when OLLAMA_URL is set', () => { - mockIsOllamaConfigured.value = true + setEnvFlags({ isOllamaConfigured: true }) expect(evaluateCondition('azure/gpt-4o')).toBe(true) expect(evaluateCondition('fireworks/llama-3')).toBe(true) expect(evaluateCondition('openrouter/anthropic/claude')).toBe(true) @@ -253,7 +236,7 @@ describe('getApiKeyCondition / shouldRequireApiKeyForModel', () => { describe('cloud provider models that need API key', () => { it('requires API key for standard cloud models on hosted platform', () => { - mockIsHosted.value = true + setEnvFlags({ isHosted: true }) mockGetHostedModels.mockReturnValue([]) expect(evaluateCondition('gpt-4o')).toBe(true) expect(evaluateCondition('claude-sonnet-4-5')).toBe(true) @@ -262,7 +245,7 @@ describe('getApiKeyCondition / shouldRequireApiKeyForModel', () => { }) it('requires API key for prefixed cloud models on hosted platform', () => { - mockIsHosted.value = true + setEnvFlags({ isHosted: true }) expect(evaluateCondition('fireworks/llama-3')).toBe(true) expect(evaluateCondition('openrouter/anthropic/claude')).toBe(true) expect(evaluateCondition('groq/llama-3')).toBe(true) @@ -270,7 +253,7 @@ describe('getApiKeyCondition / shouldRequireApiKeyForModel', () => { }) it('requires API key for prefixed cloud models on self-hosted', () => { - mockIsHosted.value = false + setEnvFlags({ isHosted: false }) expect(evaluateCondition('fireworks/llama-3')).toBe(true) expect(evaluateCondition('openrouter/anthropic/claude')).toBe(true) expect(evaluateCondition('groq/llama-3')).toBe(true) @@ -280,8 +263,7 @@ describe('getApiKeyCondition / shouldRequireApiKeyForModel', () => { describe('self-hosted without OLLAMA_URL', () => { it('requires API key for any model (Ollama models cannot appear without OLLAMA_URL)', () => { - mockIsHosted.value = false - mockIsOllamaConfigured.value = false + setEnvFlags({ isHosted: false, isOllamaConfigured: false }) expect(evaluateCondition('llama3:latest')).toBe(true) expect(evaluateCondition('mistral:latest')).toBe(true) expect(evaluateCondition('gpt-4o')).toBe(true) diff --git a/apps/sim/components/settings/navigation.test.ts b/apps/sim/components/settings/navigation.test.ts index 2348ced7a97..3693f88fd48 100644 --- a/apps/sim/components/settings/navigation.test.ts +++ b/apps/sim/components/settings/navigation.test.ts @@ -42,6 +42,7 @@ describe('settings navigation boundaries', () => { 'inbox', 'recently-deleted', 'sso', + 'sessions', 'data-retention', 'data-drains', 'whitelabeling', @@ -63,6 +64,7 @@ describe('settings navigation boundaries', () => { 'access-control', 'audit-logs', 'sso', + 'sessions', 'data-retention', 'data-drains', 'whitelabeling', @@ -118,6 +120,7 @@ describe('settings navigation boundaries', () => { 'data-drains', 'data-retention', 'organization', + 'sessions', 'sso', 'whitelabeling', ]) diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index c03e332de9e..acf5ac6c8b6 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -1,6 +1,7 @@ import type { ComponentType } from 'react' import { ClipboardList, + Clock, Database, HexSimple, Key, @@ -41,6 +42,7 @@ export type OrganizationSettingsSection = | 'access-control' | 'audit-logs' | 'sso' + | 'sessions' | 'data-retention' | 'data-drains' | 'whitelabeling' @@ -94,6 +96,7 @@ export type UnifiedSettingsSection = | 'workflow-mcp-servers' | 'inbox' | 'admin' + | 'sessions' | 'data-retention' | 'data-drains' | 'mothership' @@ -168,6 +171,7 @@ const SETTINGS_SELF_HOSTED_OVERRIDES = { dataDrains: isTruthy(getEnv('NEXT_PUBLIC_DATA_DRAINS_ENABLED')), dataRetention: isTruthy(getEnv('NEXT_PUBLIC_DATA_RETENTION_ENABLED')), inbox: isTruthy(getEnv('NEXT_PUBLIC_INBOX_ENABLED')), + sessionPolicies: isTruthy(getEnv('NEXT_PUBLIC_SESSION_POLICIES_ENABLED')), sso: isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED')), whitelabeling: isTruthy(getEnv('NEXT_PUBLIC_WHITELABELING_ENABLED')), } as const @@ -537,6 +541,22 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] organization: { id: 'sso', group: 'security', order: 4 }, }, }, + { + label: 'Session policies', + icon: Clock, + docsLink: 'https://docs.sim.ai/platform/enterprise/session-policies', + unified: { + id: 'sessions', + description: 'Limit session lifetimes and sign out members org-wide.', + group: 'enterprise', + requiresHosted: true, + requiresEnterprise: true, + selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.sessionPolicies, + }, + planes: { + organization: { id: 'sessions', group: 'security', order: 5 }, + }, + }, { label: 'Data retention', icon: Database, @@ -551,7 +571,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.dataRetention, }, planes: { - organization: { id: 'data-retention', group: 'enterprise', order: 5 }, + organization: { id: 'data-retention', group: 'enterprise', order: 6 }, }, }, { @@ -567,7 +587,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.dataDrains, }, planes: { - organization: { id: 'data-drains', group: 'enterprise', order: 6 }, + organization: { id: 'data-drains', group: 'enterprise', order: 7 }, }, }, { @@ -583,7 +603,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] selfHostedOverride: SETTINGS_SELF_HOSTED_OVERRIDES.whitelabeling, }, planes: { - organization: { id: 'whitelabeling', group: 'enterprise', order: 7 }, + organization: { id: 'whitelabeling', group: 'enterprise', order: 8 }, }, }, { @@ -719,6 +739,7 @@ export function getOrganizationSettingsFeatures( 'access-control': SETTINGS_SELF_HOSTED_OVERRIDES.accessControl, 'audit-logs': SETTINGS_SELF_HOSTED_OVERRIDES.auditLogs, sso: SETTINGS_SELF_HOSTED_OVERRIDES.sso, + sessions: SETTINGS_SELF_HOSTED_OVERRIDES.sessionPolicies, 'data-retention': SETTINGS_SELF_HOSTED_OVERRIDES.dataRetention, 'data-drains': SETTINGS_SELF_HOSTED_OVERRIDES.dataDrains, whitelabeling: SETTINGS_SELF_HOSTED_OVERRIDES.whitelabeling, diff --git a/apps/sim/components/settings/organization-settings-renderer.tsx b/apps/sim/components/settings/organization-settings-renderer.tsx index e14714772e8..66ae7a1efc3 100644 --- a/apps/sim/components/settings/organization-settings-renderer.tsx +++ b/apps/sim/components/settings/organization-settings-renderer.tsx @@ -23,6 +23,11 @@ const AuditLogs = dynamic(() => import('@/ee/audit-logs/components/audit-logs').then((module) => module.AuditLogs) ) const SSO = dynamic(() => import('@/ee/sso/components/sso-settings').then((module) => module.SSO)) +const SessionPolicySettings = dynamic(() => + import('@/ee/session-policy/components/session-policy-settings').then( + (module) => module.SessionPolicySettings + ) +) const DataRetentionSettings = dynamic(() => import('@/ee/data-retention/components/data-retention-settings').then( (module) => module.DataRetentionSettings @@ -63,6 +68,9 @@ export function OrganizationSettingsRenderer({ } if (section === 'audit-logs') return if (section === 'sso') return + if (section === 'sessions') { + return + } if (section === 'data-retention') { return } diff --git a/apps/sim/ee/access-control/components/access-control.tsx b/apps/sim/ee/access-control/components/access-control.tsx index 11c3341d9cc..813b2d9df80 100644 --- a/apps/sim/ee/access-control/components/access-control.tsx +++ b/apps/sim/ee/access-control/components/access-control.tsx @@ -16,11 +16,17 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { ArrowRight, Plus } from 'lucide-react' import { useParams } from 'next/navigation' +import { useQueryState } from 'nuqs' import { isEnterprise } from '@/lib/billing/plan-helpers' import { getEnv, isTruthy } from '@/lib/core/config/env' +import { + groupIdParam, + groupIdUrlKeys, +} from '@/app/workspace/[workspaceId]/settings/[section]/search-params' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' +import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search' import { GroupDetail } from '@/ee/access-control/components/group-detail' import { WorkspaceSelect } from '@/ee/access-control/components/workspace-select' import { @@ -74,8 +80,11 @@ export function AccessControl({ isOrganizationAdmin, organizationId }: AccessCon const createPermissionGroup = useCreatePermissionGroup() - const [searchTerm, setSearchTerm] = useState('') - const [selectedGroupId, setSelectedGroupId] = useState(null) + const [searchTerm, setSearchTerm] = useSettingsSearch() + const [selectedGroupId, setSelectedGroupId] = useQueryState(groupIdParam.key, { + ...groupIdParam.parser, + ...groupIdUrlKeys, + }) const [showCreateModal, setShowCreateModal] = useState(false) const [newGroupName, setNewGroupName] = useState('') const [newGroupDescription, setNewGroupDescription] = useState('') @@ -160,8 +169,8 @@ export function AccessControl({ isOrganizationAdmin, organizationId }: AccessCon workspaceOptions={workspaceOptions} organizationWorkspaces={organizationWorkspaces} workspacesLoading={workspacesLoading} - onBack={() => setSelectedGroupId(null)} - onDeleted={() => setSelectedGroupId(null)} + onBack={() => void setSelectedGroupId(null, { history: 'replace' })} + onDeleted={() => void setSelectedGroupId(null, { history: 'replace' })} /> ) } @@ -198,7 +207,7 @@ export function AccessControl({ isOrganizationAdmin, organizationId }: AccessCon