diff --git a/.github/workflows/ci-host.yaml b/.github/workflows/ci-host.yaml index 653a160ccb8..491a4a3b32d 100644 --- a/.github/workflows/ci-host.yaml +++ b/.github/workflows/ci-host.yaml @@ -6,16 +6,6 @@ on: pull_request: paths: - "packages/host/**" - - "packages/base/**" - - "packages/boxel-icons/**" - - "packages/boxel-ui/**" - - "packages/catalog-realm/**" - - "packages/eslint-plugin-boxel/**" - - "packages/realm-server/**" - - "packages/runtime-common/**" - - ".github/workflows/ci-host.yaml" - - "package.json" - - "pnpm-lock.yaml" workflow_dispatch: permissions: diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1e0974c3eba..5d5b627b73d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -124,432 +124,19 @@ jobs: if: needs.change-check.outputs.boxel == 'true' || needs.change-check.outputs.boxel-ui == 'true' || needs.change-check.outputs.matrix == 'true' || needs.change-check.outputs.realm-server == 'true' || needs.change-check.outputs.software-factory == 'true' || needs.change-check.outputs.vscode-boxel-tools == 'true' || needs.change-check.outputs.workspace-sync-cli == 'true' || github.ref == 'refs/heads/main' || needs.change-check.outputs.run_all == 'true' uses: ./.github/workflows/test-web-assets.yaml - ai-bot-test: - name: AI bot Tests - needs: change-check - if: needs.change-check.outputs.ai-bot == 'true' || github.ref == 'refs/heads/main' || needs.change-check.outputs.run_all == 'true' - runs-on: ubuntu-latest - services: - postgres: - image: postgres:16 - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U postgres" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - env: - PGHOST: localhost - PGPORT: 5432 - PGUSER: postgres - PGPASSWORD: postgres - concurrency: - group: ai-bot-test-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: ./.github/actions/init - - name: AI Bot test suite - run: pnpm test - working-directory: packages/ai-bot - - bot-runner-test: - name: Bot Runner Tests - needs: change-check - if: needs.change-check.outputs.bot-runner == 'true' || github.ref == 'refs/heads/main' || needs.change-check.outputs.run_all == 'true' - runs-on: ubuntu-latest - concurrency: - group: bot-runner-test-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: ./.github/actions/init - - name: Bot Runner test suite - run: pnpm test - working-directory: packages/bot-runner - - eslint-plugin-boxel-test: - name: ESLint Plugin Boxel Tests - needs: change-check - if: needs.change-check.outputs.eslint-plugin-boxel == 'true' || github.ref == 'refs/heads/main' || needs.change-check.outputs.run_all == 'true' - runs-on: ubuntu-latest - concurrency: - group: eslint-plugin-boxel-test-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: ./.github/actions/init - - name: ESLint Plugin Boxel test suite - run: pnpm test - working-directory: packages/eslint-plugin-boxel - - postgres-migration-test: - name: Postgres Migration Test - needs: change-check - if: needs.change-check.outputs.postgres-migrations == 'true' || needs.change-check.outputs.run_all == 'true' - runs-on: ubuntu-latest - concurrency: - group: postgres-migration-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 - - uses: ./.github/actions/init - - - name: Start Postgres - run: pnpm start:pg - working-directory: packages/postgres - - - name: Determine changed migrations - id: migrations - env: - PULL_REQUEST_BASE_SHA: ${{ github.event.pull_request.base.sha || '' }} - GITHUB_EVENT_BEFORE: ${{ github.event.before || '' }} - shell: bash - run: packages/postgres/scripts/determine-changed-migrations.sh - - - name: Apply migrations - if: steps.migrations.outputs.count && steps.migrations.outputs.count != '0' - working-directory: packages/postgres - env: - PGHOST: localhost - PGPORT: 5435 - run: pnpm migrate up - - name: Run down migrations - if: steps.migrations.outputs.count && steps.migrations.outputs.count != '0' - working-directory: packages/postgres - env: - MIGRATION_DOWN_COUNT: ${{ steps.migrations.outputs.down_count }} - run: pnpm migrate down "$MIGRATION_DOWN_COUNT" - - name: Reapply migrations - if: steps.migrations.outputs.count && steps.migrations.outputs.count != '0' - working-directory: packages/postgres - run: pnpm migrate up - - boxel-ui-test: - name: Boxel UI Tests - needs: [change-check] - if: needs.change-check.outputs.boxel-ui == 'true' || github.ref == 'refs/heads/main' || needs.change-check.outputs.run_all == 'true' - runs-on: ubuntu-latest - concurrency: - group: boxel-ui-test-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: ./.github/actions/init - - name: Build boxel-icons - run: pnpm build - working-directory: packages/boxel-icons - - name: Build boxel-ui - run: pnpm build - working-directory: packages/boxel-ui/addon - - name: Run test suite - run: pnpm test - working-directory: packages/boxel-ui/test-app - - boxel-ui-raw-icon-changes-only: - name: Boxel UI ensure raw icon changes only - needs: change-check - if: needs.change-check.outputs.boxel-ui == 'true' || github.ref == 'refs/heads/main' || needs.change-check.outputs.run_all == 'true' - runs-on: ubuntu-latest - concurrency: - group: boxel-ui-raw-icon-changes-only-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: ./.github/actions/init - - name: Rebuild boxel-ui icons - run: pnpm rebuild:icons - working-directory: packages/boxel-ui/addon - - name: Fail if generated icons have been changed without underlying raw icon changing - run: git diff --exit-code - - boxel-icons-raw-icon-changes-only: - name: Boxel Icons ensure raw icon changes only - needs: change-check - if: needs.change-check.outputs.boxel-icons == 'true' || github.ref == 'refs/heads/main' || needs.change-check.outputs.run_all == 'true' - runs-on: ubuntu-latest - concurrency: - group: boxel-icons-raw-icon-changes-only-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: ./.github/actions/init - - name: Rebuild boxel-icons icons - run: pnpm rebuild:all - working-directory: packages/boxel-icons - - name: Fail if generated icons have been changed without underlying raw icon changing - run: git diff --exit-code - - matrix-client-test: - name: Matrix Client Tests - needs: [change-check, test-web-assets] - if: needs.change-check.outputs.matrix == 'true' || github.ref == 'refs/heads/main' || needs.change-check.outputs.run_all == 'true' - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - shardIndex: [1, 2, 3] - shardTotal: [3] - concurrency: - group: matrix-client-test-${{ matrix.shardIndex }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: ./.github/actions/init - - name: Download test web assets - uses: actions/download-artifact@b14cf4c92620c250e1c074ab0a5800e37df86765 # 4.2.0 - with: - name: ${{ needs.test-web-assets.outputs.artifact_name }} - path: .test-web-assets-artifact - - name: Restore test web assets into workspace - shell: bash - run: | - shopt -s dotglob - cp -a .test-web-assets-artifact/. ./ - - name: Install Playwright Browsers - run: pnpm exec playwright install - working-directory: packages/matrix - - name: Serve host dist (test assets) for realm server - uses: JarvusInnovations/background-action@2428e7b970a846423095c79d43f759abf979a635 # 1.0.7 - with: - run: pnpm serve:dist & - working-directory: packages/host - wait-for: 3m - wait-on: http-get://localhost:4200 - - name: Start realm servers - run: MATRIX_REGISTRATION_SHARED_SECRET='xxxx' pnpm start:services-for-matrix-tests | tee -a /tmp/server.log & - working-directory: packages/realm-server - - name: Run Playwright tests - run: pnpm test:group ${{ matrix.shardIndex }}/${{ matrix.shardTotal }} - working-directory: packages/matrix - - name: Print realm server logs - if: always() - run: cat /tmp/server.log - - name: Extract worker and prerender logs - if: always() - run: | - grep -E '^\[start:worker-base|^\[start:worker-development|^\[start:worker-test' /tmp/server.log > /tmp/worker-manager.log || true - grep -E '^\[start:prerender-dev' /tmp/server.log > /tmp/prerender-server.log || true - grep -E '^\[start:prerender-manager-dev' /tmp/server.log > /tmp/prerender-manager.log || true - - name: Upload realm server log - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # 4.6.1 - if: always() - with: - name: matrix-test-realm-server-log-${{ matrix.shardIndex }} - path: /tmp/server.log - retention-days: 30 - - name: Upload worker manager log - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # 4.6.1 - if: always() - with: - name: matrix-test-worker-manager-log-${{ matrix.shardIndex }} - path: /tmp/worker-manager.log - retention-days: 30 - - name: Upload prerender server log - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # 4.6.1 - if: always() - with: - name: matrix-test-prerender-server-log-${{ matrix.shardIndex }} - path: /tmp/prerender-server.log - retention-days: 30 - - name: Upload prerender manager log - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # 4.6.1 - if: always() - with: - name: matrix-test-prerender-manager-log-${{ matrix.shardIndex }} - path: /tmp/prerender-manager.log - retention-days: 30 - - - name: Upload blob report to GitHub Actions Artifacts - if: ${{ !cancelled() }} - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # 4.6.1 - with: - name: blob-report-${{ matrix.shardIndex }} - path: packages/matrix/blob-report - retention-days: 1 - - - name: Upload Playwright traces - if: ${{ !cancelled() }} - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # 4.6.1 - with: - name: playwright-traces-${{ matrix.shardIndex }} - path: packages/matrix/test-results/**/trace.zip - retention-days: 30 - if-no-files-found: ignore - - matrix-client-merge-reports-and-publish: - name: Merge Matrix reports and publish - needs: - - change-check - - matrix-client-test - # always() makes it run even if a matrix-client-test shard fails - if: always() && (needs.change-check.outputs.matrix == 'true' || github.ref == 'refs/heads/main' || needs.change-check.outputs.run_all == 'true') - runs-on: ubuntu-latest - - permissions: - id-token: write - contents: write - checks: write - statuses: write - - outputs: - timestamp: ${{ steps.timestampid.outputs.timestamp }} - - steps: - - name: Create a timestamp as a directory to store reports in - id: timestampid - run: echo "timestamp=$(date --utc +%Y%m%d_%H%M%SZ)" >> "$GITHUB_OUTPUT" - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: ./.github/actions/init - - - name: Download blob reports from GitHub Actions Artifacts - uses: actions/download-artifact@b14cf4c92620c250e1c074ab0a5800e37df86765 # 4.2.0 - with: - path: all-blob-reports - pattern: blob-report-* - merge-multiple: true - - - name: Merge blobs into one single report - run: pnpm exec playwright merge-reports --reporter html ./all-blob-reports - - - name: Upload HTML report - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # 4.6.1 - with: - name: html-report--attempt-${{ github.run_attempt }} - path: playwright-report - retention-days: 14 - - - name: Set up env - env: - INPUT_ENVIRONMENT: ${{ inputs.environment }} - run: | - echo "AWS_REGION=us-east-1" >> $GITHUB_ENV - echo "AWS_ROLE_ARN=arn:aws:iam::680542703984:role/boxel-matrix-playwright-reports" >> $GITHUB_ENV - echo "AWS_S3_BUCKET=cardstack-boxel-matrix-playwright-reports-staging" >> $GITHUB_ENV - - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@ececac1a45f3b08a01d2dd070d28d111c5fe6722 # 4.1.0 - with: - role-to-assume: ${{ env.AWS_ROLE_ARN }} - aws-region: us-east-1 - - - name: Publish consolidated report to S3 - run: aws s3 sync ./playwright-report s3://cardstack-boxel-matrix-playwright-reports-staging/${{ github.head_ref || github.ref_name }}/${{ steps.timestampid.outputs.timestamp }} - - - name: Store Playwright report URL - shell: bash - run: echo "PLAYWRIGHT_REPORT_URL=https://boxel-matrix-playwright-reports.stack.cards/${{ github.head_ref || github.ref_name }}/${{ steps.timestampid.outputs.timestamp }}/index.html" >> $GITHUB_ENV - - - name: Add status with link to Playwright report - shell: bash - env: - GITHUB_TOKEN: ${{ github.token }} - REPOSITORY: ${{ github.repository }} - HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} - MATRIX_TEST_RESULT: ${{ needs.matrix-client-test.result }} - run: | - state="success" - description="" - if [ "$MATRIX_TEST_RESULT" = "failure" ]; then - state="failure" - description="Matrix Playwright shard failures" - fi - curl \ - -X POST \ - -H "Authorization: token $GITHUB_TOKEN" \ - -H "Accept: application/vnd.github.v3+json" \ - https://api.github.com/repos/$REPOSITORY/statuses/$HEAD_SHA \ - -d '{"context":"Matrix Playwright tests report","description":"'"$description"'","target_url":"'"$PLAYWRIGHT_REPORT_URL"'","state":"'"$state"'"}' - realm-server-test: name: Realm Server Tests needs: [change-check, test-web-assets] if: needs.change-check.outputs.realm-server == 'true' || github.ref == 'refs/heads/main' || needs.change-check.outputs.run_all == 'true' runs-on: ubuntu-latest concurrency: - group: realm-server-test-${{ matrix.testModule }}-${{ github.head_ref || github.run_id }} + group: realm-server-test-${{ matrix.shardIndex }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true strategy: fail-fast: false matrix: - testModule: - [ - "auth-client-test.ts", - "billing-test.ts", - "card-dependencies-endpoint-test.ts", - "card-endpoints-test.ts", - "card-source-endpoints-test.ts", - "definition-lookup-test.ts", - "file-watcher-events-test.ts", - "indexing-event-sink-test.ts", - "indexing-test.ts", - "runtime-dependency-tracker-test.ts", - "transpile-test.ts", - "module-syntax-test.ts", - "node-realm-test.ts", - "permissions/permission-checker-test.ts", - "prerendering-test.ts", - "prerender-server-test.ts", - "prerender-manager-test.ts", - "prerender-proxy-test.ts", - "remote-prerenderer-test.ts", - "queue-test.ts", - "realm-endpoints/cancel-indexing-job-test.ts", - "realm-endpoints/dependencies-test.ts", - "realm-endpoints/directory-test.ts", - "realm-endpoints/info-test.ts", - "realm-endpoints/invalidate-urls-test.ts", - "realm-endpoints/lint-test.ts", - "realm-endpoints/mtimes-test.ts", - "realm-endpoints/permissions-test.ts", - "realm-endpoints/publishability-test.ts", - "realm-endpoints/reindex-test.ts", - "realm-endpoints/search-test.ts", - "realm-endpoints/user-test.ts", - "realm-endpoints-test.ts", - "sanitize-head-html-test.ts", - "search-prerendered-test.ts", - "types-endpoint-test.ts", - "server-endpoints/authentication-test.ts", - "server-endpoints/bot-commands-test.ts", - "server-endpoints/bot-registration-test.ts", - "server-endpoints/delete-realm-test.ts", - "server-endpoints/download-realm-test.ts", - "server-endpoints/index-responses-test.ts", - "server-endpoints/maintenance-endpoints-test.ts", - "server-endpoints/queue-status-test.ts", - "server-endpoints/realm-lifecycle-test.ts", - "server-endpoints/search-test.ts", - "server-endpoints/info-test.ts", - "server-endpoints/search-prerendered-test.ts", - "server-endpoints/stripe-session-test.ts", - "server-endpoints/stripe-webhook-test.ts", - "server-endpoints/user-and-catalog-test.ts", - "server-endpoints/incoming-webhook-test.ts", - "server-endpoints/webhook-commands-test.ts", - "server-endpoints/webhook-receiver-test.ts", - "server-endpoints/federated-types-test.ts", - "virtual-network-test.ts", - "atomic-endpoints-test.ts", - "request-forward-test.ts", - "publish-unpublish-realm-test.ts", - "boxel-domain-availability-test.ts", - "claim-boxel-domain-test.ts", - "command-parsing-utils-test.ts", - "delete-boxel-claimed-domain-test.ts", - "get-boxel-claimed-domain-test.ts", - "realm-auth-test.ts", - "run-command-task-test.ts", - "queries-test.ts", - "session-room-queries-test.ts", - "full-reindex-test.ts", - ] + shardIndex: [1, 2, 3, 4] + shardTotal: [4] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/init @@ -579,11 +166,36 @@ jobs: - name: create realm users run: pnpm register-realm-users working-directory: packages/matrix + - name: Wait for realm servers to be ready + shell: bash + run: | + set -euo pipefail + + wait_for_url() { + local name="$1" + local url="$2" + local max_attempts="${3:-180}" + + for ((attempt=1; attempt<=max_attempts; attempt++)); do + if curl --silent --show-error --fail --output /dev/null "$url"; then + echo "$name is ready" + return 0 + fi + + echo "Waiting for $name ($attempt/$max_attempts): $url" + sleep 5 + done + + echo "Timed out waiting for $name: $url" >&2 + return 1 + } + + wait_for_url "base realm" "http://localhost:4201/base/_readiness-check?acceptHeader=application%2Fvnd.api%2Bjson" + wait_for_url "synapse" "http://localhost:8008" + wait_for_url "smtp4dev" "http://localhost:5001" - name: realm server test suite - run: pnpm test:wait-for-servers + run: pnpm test --shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }} working-directory: packages/realm-server - env: - TEST_MODULE: ${{matrix.testModule}} - name: Print realm server logs if: always() run: cat /tmp/server.log @@ -591,8 +203,7 @@ jobs: id: artifact_name if: always() run: | - export SAFE_ARTIFACT_NAME=$(echo ${{ matrix.testModule }} | sed 's/[/]/_/g') - echo "artifact_name=$SAFE_ARTIFACT_NAME" >> "$GITHUB_OUTPUT" + echo "artifact_name=shard_${{ matrix.shardIndex }}" >> "$GITHUB_OUTPUT" - name: Extract worker and prerender logs if: always() run: | @@ -643,173 +254,44 @@ jobs: name: realm-server-test-test-realms-log-${{steps.artifact_name.outputs.artifact_name}} path: /tmp/test-realms.log retention-days: 30 - - software-factory-test: - name: Software Factory Tests - needs: [change-check, test-web-assets] - if: needs.change-check.outputs.software-factory == 'true' || github.ref == 'refs/heads/main' || needs.change-check.outputs.run_all == 'true' - runs-on: ubuntu-latest - concurrency: - group: software-factory-test-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: ./.github/actions/init - - name: Download test web assets - uses: actions/download-artifact@b14cf4c92620c250e1c074ab0a5800e37df86765 # 4.2.0 - with: - name: ${{ needs.test-web-assets.outputs.artifact_name }} - path: .test-web-assets-artifact - - name: Restore test web assets into workspace - shell: bash - run: | - shopt -s dotglob - cp -a .test-web-assets-artifact/. ./ - - name: Install Playwright Browsers - run: pnpm exec playwright install - working-directory: packages/software-factory - - name: Run Node tests - run: pnpm test:node - working-directory: packages/software-factory - - name: Serve host dist (test assets) - uses: JarvusInnovations/background-action@2428e7b970a846423095c79d43f759abf979a635 # 1.0.7 - with: - run: pnpm serve:dist & - working-directory: packages/host - wait-for: 3m - wait-on: http-get://localhost:4200 - - name: Serve boxel-icons - uses: JarvusInnovations/background-action@2428e7b970a846423095c79d43f759abf979a635 # 1.0.7 - with: - run: pnpm serve & - working-directory: packages/boxel-icons - wait-for: 3m - wait-on: http-get://localhost:4206/@cardstack/boxel-icons/v1/icons/code.js - - name: Run Playwright tests - run: pnpm test:playwright - working-directory: packages/software-factory - - name: Upload Playwright traces + - name: Upload blob report to GitHub Actions Artifacts if: ${{ !cancelled() }} - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # 4.6.1 + uses: actions/upload-artifact@v4 with: - name: software-factory-playwright-traces - path: packages/software-factory/test-results/**/trace.zip - retention-days: 30 - if-no-files-found: ignore - - vscode-boxel-tools-package: - name: Boxel Tools VS Code Extension package - needs: [change-check, test-web-assets] - if: needs.change-check.outputs.vscode-boxel-tools == 'true' || github.ref == 'refs/heads/main' || needs.change-check.outputs.run_all == 'true' - runs-on: ubuntu-latest - concurrency: - group: vscode-boxel-tools-test-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: ./.github/actions/init - - name: Download test web assets - uses: actions/download-artifact@b14cf4c92620c250e1c074ab0a5800e37df86765 # 4.2.0 - with: - name: ${{ needs.test-web-assets.outputs.artifact_name }} - path: .test-web-assets-artifact - - name: Restore test web assets into workspace - shell: bash - run: | - shopt -s dotglob - cp -a .test-web-assets-artifact/. ./ - - name: Prepublish - run: pnpm vscode:prepublish - working-directory: packages/vscode-boxel-tools - - name: Package - run: pnpm vscode:package - working-directory: packages/vscode-boxel-tools - - name: Upload - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # 4.6.1 + name: blob-report-${{ matrix.shardIndex }} + path: .vitest-reports/* + include-hidden-files: true + retention-days: 1 + - name: Upload attachments to GitHub Actions Artifacts + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 with: - name: vscode-boxel-tools - path: packages/vscode-boxel-tools/boxel-tools*vsix + name: blob-attachments-${{ matrix.shardIndex }} + path: .vitest-attachments/** + include-hidden-files: true + retention-days: 1 + merge-realm-server-test-reports: + if: ${{ !cancelled() }} + needs: [realm-server-test] - workspace-sync-cli-build: - name: Workspace Sync CLI Build - needs: change-check - if: needs.change-check.outputs.workspace-sync-cli == 'true' || github.ref == 'refs/heads/main' || needs.change-check.outputs.run_all == 'true' runs-on: ubuntu-latest - concurrency: - group: workspace-sync-cli-build-${{ github.head_ref || github.run_id }} - cancel-in-progress: true steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/init - - name: Build workspace-sync-cli - run: pnpm build - working-directory: packages/workspace-sync-cli - workspace-sync-cli-test: - name: Workspace Sync CLI Integration Tests - needs: [change-check, test-web-assets] - if: needs.change-check.outputs.workspace-sync-cli == 'true' || github.ref == 'refs/heads/main' || needs.change-check.outputs.run_all == 'true' - runs-on: ubuntu-latest - concurrency: - group: workspace-sync-cli-test-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: ./.github/actions/init - - name: Download test web assets - uses: actions/download-artifact@b14cf4c92620c250e1c074ab0a5800e37df86765 # 4.2.0 - with: - name: ${{ needs.test-web-assets.outputs.artifact_name }} - path: .test-web-assets-artifact - - name: Restore test web assets into workspace - shell: bash - run: | - shopt -s dotglob - cp -a .test-web-assets-artifact/. ./ - - name: Build workspace-sync-cli - run: pnpm build - working-directory: packages/workspace-sync-cli - - name: Serve host dist (test assets) - uses: JarvusInnovations/background-action@2428e7b970a846423095c79d43f759abf979a635 # 1.0.7 + - name: Download blob reports from GitHub Actions Artifacts + uses: actions/download-artifact@v4 with: - run: pnpm serve:dist & - working-directory: packages/host - wait-for: 3m - wait-on: http-get://localhost:4200 - - name: Start PostgreSQL for tests - run: pnpm start:pg | tee -a /tmp/test-services.log & - working-directory: packages/realm-server - - name: Start Matrix services for tests - run: pnpm start:matrix | tee -a /tmp/test-services.log & - working-directory: packages/realm-server - - name: Register realm users for tests - run: pnpm register-realm-users - working-directory: packages/matrix - - name: Run integration tests - run: pnpm test - working-directory: packages/workspace-sync-cli - - name: Upload test services log - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # 4.6.1 - if: always() + path: .vitest-reports + pattern: blob-report-* + merge-multiple: true + + - name: Download attachments from GitHub Actions Artifacts + uses: actions/download-artifact@v4 with: - name: workspace-sync-cli-test-services-log - path: /tmp/test-services.log - retention-days: 30 + path: .vitest-attachments + pattern: blob-attachments-* + merge-multiple: true - deploy: - name: Deploy boxel to staging - if: needs.change-check.outputs.boxel == 'true' && github.ref == 'refs/heads/main' - needs: - - change-check - - ai-bot-test - - bot-runner-test - - boxel-ui-test - - realm-server-test - uses: ./.github/workflows/manual-deploy.yml - permissions: - contents: read - deployments: write - id-token: write - secrets: inherit - with: - environment: "staging" + - name: Merge reports + run: pnpm vitest --merge-report diff --git a/.github/workflows/diff-skills.yaml b/.github/workflows/diff-skills.yaml deleted file mode 100644 index 49ff65da7c1..00000000000 --- a/.github/workflows/diff-skills.yaml +++ /dev/null @@ -1,13 +0,0 @@ -name: Diff Skills - -on: - pull_request: - -permissions: - contents: read - pull-requests: write - -jobs: - diff-skills: - uses: cardstack/gh-actions/.github/workflows/diff-skills.yml@main - secrets: inherit diff --git a/packages/realm-server/package.json b/packages/realm-server/package.json index 95b4fd3c604..938b40d0a42 100644 --- a/packages/realm-server/package.json +++ b/packages/realm-server/package.json @@ -84,20 +84,19 @@ "typescript-memoize": "catalog:", "undici": "catalog:", "uuid": "catalog:", + "vitest": "catalog:", "wait-for-localhost-cli": "catalog:", "yaml": "catalog:", "yargs": "catalog:" }, "scripts": { - "test": "./tests/scripts/run-qunit-with-test-pg.sh", - "test-module": "./tests/scripts/run-qunit-with-test-pg.sh --module ${TEST_MODULE}", + "test": "./tests-vitest/scripts/run-vitest-with-test-pg.sh", "migrate": "NODE_NO_WARNINGS=1 ts-node --transpileOnly scripts/run-migrations.ts", "start:matrix": "./scripts/start-matrix.sh", "start:smtp": "cd ../matrix && pnpm assert-smtp-running", "start:icons": "sh ./scripts/start-icons.sh", "start:pg": "./scripts/start-pg.sh", "stop:pg": "./scripts/stop-pg.sh", - "test:wait-for-servers": "WAIT_ON_TIMEOUT=900000 NODE_NO_WARNINGS=1 start-server-and-test 'pnpm run wait' 'http-get://localhost:4201/base/_readiness-check?acceptHeader=application%2Fvnd.api%2Bjson' 'pnpm run wait' 'http-get://localhost:4202/node-test/_readiness-check?acceptHeader=application%2Fvnd.api%2Bjson|http://localhost:8008|http://localhost:5001' 'test-module'", "setup:base-in-deployment": "mkdir -p /persistent/base && rsync --dry-run --itemize-changes --checksum --recursive --delete ../base/. /persistent/base/ && rsync --checksum --recursive --delete ../base/. /persistent/base/", "setup:experiments-in-deployment": "mkdir -p /persistent/experiments && rsync --dry-run --itemize-changes --checksum --recursive ../experiments-realm/. /persistent/experiments/ && rsync --checksum --recursive ../experiments-realm/. /persistent/experiments/", "setup:catalog-in-deployment": "mkdir -p /persistent/catalog && rsync --dry-run --itemize-changes --checksum --recursive --delete ../catalog-realm/. /persistent/catalog/ && rsync --checksum --recursive --delete ../catalog-realm/. /persistent/catalog/", @@ -133,7 +132,7 @@ "lint:js": "eslint . --report-unused-disable-directives --cache", "lint:js:fix": "eslint . --report-unused-disable-directives --fix", "lint:glint": "glint", - "lint:test-shards": "ts-node --transpileOnly scripts/lint-test-shards.ts", + "codemod:qunit-to-vitest": "ts-node --transpileOnly scripts/codemods/qunit-to-vitest.ts", "full-reset": "./scripts/full-reset.sh", "full-reindex": "./scripts/full-reindex.sh", "check-user-pg-connections": "./scripts/check-user-pg-connections.sh", diff --git a/packages/realm-server/scripts/codemods/qunit-to-vitest.ts b/packages/realm-server/scripts/codemods/qunit-to-vitest.ts new file mode 100644 index 00000000000..df505f2084e --- /dev/null +++ b/packages/realm-server/scripts/codemods/qunit-to-vitest.ts @@ -0,0 +1,993 @@ +import { ensureDirSync, readFileSync, writeFileSync, copyFileSync } from 'fs-extra'; +import { basename, dirname, join, relative } from 'path'; +import { glob } from 'glob'; +import ts from 'typescript'; + +const projectRoot = join(__dirname, '..', '..'); +const sourceRoot = join(projectRoot, 'tests'); +const targetRoot = join(projectRoot, 'tests-vitest'); +const generatedHeader = + '// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand.\n'; + +type TransformResult = { + code: string; + needsVitestHookImport: boolean; + needsDirnamePolyfill: boolean; + needsFilenamePolyfill: boolean; + unsupportedCalls: string[]; +}; + +function callText(node: ts.CallExpression, sourceFile: ts.SourceFile): string { + return node.getText(sourceFile).replace(/\s+/g, ' '); +} + +function isBasenameFilenameCall(node: ts.Expression): boolean { + return ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'basename' && + node.arguments.length === 1 && + ts.isIdentifier(node.arguments[0]) && + node.arguments[0].text === '__filename' + ); +} + +function replaceBasenameFilenameArg( + node: ts.Expression, + fileNameLiteral: string, +): ts.Expression { + if (isBasenameFilenameCall(node)) { + return ts.factory.createStringLiteral(fileNameLiteral); + } + if (ts.isTemplateExpression(node)) { + let value = node.head.text; + for (let span of node.templateSpans) { + if (!isBasenameFilenameCall(span.expression)) { + return node; + } + value += fileNameLiteral + span.literal.text; + } + return ts.factory.createStringLiteral(value); + } + return node; +} + +function convertAssertCall( + method: string, + node: ts.CallExpression, +): ts.CallExpression | undefined { + let args = [...node.arguments]; + let expectCall = (value: ts.Expression) => + ts.factory.createCallExpression(ts.factory.createIdentifier('expect'), undefined, [value]); + + switch (method) { + case 'strictEqual': + if (args.length >= 2) { + return ts.factory.createCallExpression( + ts.factory.createPropertyAccessExpression(expectCall(args[0]), 'toBe'), + undefined, + [args[1]], + ); + } + return undefined; + case 'notStrictEqual': + if (args.length >= 2) { + return ts.factory.createCallExpression( + ts.factory.createPropertyAccessExpression( + ts.factory.createPropertyAccessExpression(expectCall(args[0]), 'not'), + 'toBe', + ), + undefined, + [args[1]], + ); + } + return undefined; + case 'deepEqual': + if (args.length >= 2) { + return ts.factory.createCallExpression( + ts.factory.createPropertyAccessExpression(expectCall(args[0]), 'toEqual'), + undefined, + [args[1]], + ); + } + return undefined; + case 'notEqual': + if (args.length >= 2) { + return ts.factory.createCallExpression( + ts.factory.createPropertyAccessExpression( + ts.factory.createPropertyAccessExpression(expectCall(args[0]), 'not'), + 'toEqual', + ), + undefined, + [args[1]], + ); + } + return undefined; + case 'ok': + if (args.length >= 1) { + return ts.factory.createCallExpression( + ts.factory.createPropertyAccessExpression(expectCall(args[0]), 'toBeTruthy'), + undefined, + [], + ); + } + return undefined; + case 'notOk': + if (args.length >= 1) { + return ts.factory.createCallExpression( + ts.factory.createPropertyAccessExpression(expectCall(args[0]), 'toBeFalsy'), + undefined, + [], + ); + } + return undefined; + case 'true': + if (args.length >= 1) { + return ts.factory.createCallExpression( + ts.factory.createPropertyAccessExpression(expectCall(args[0]), 'toBe'), + undefined, + [ts.factory.createTrue()], + ); + } + return undefined; + case 'false': + if (args.length >= 1) { + return ts.factory.createCallExpression( + ts.factory.createPropertyAccessExpression(expectCall(args[0]), 'toBe'), + undefined, + [ts.factory.createFalse()], + ); + } + return undefined; + case 'rejects': { + if (args.length >= 1) { + let rejectsExpr = ts.factory.createPropertyAccessExpression( + ts.factory.createPropertyAccessExpression(expectCall(args[0]), 'rejects'), + 'toThrow', + ); + return ts.factory.createCallExpression( + rejectsExpr, + undefined, + args.length >= 2 ? [args[1]] : [], + ); + } + return undefined; + } + case 'codeEqual': { + if (args.length >= 2) { + return ts.factory.createCallExpression( + ts.factory.createPropertyAccessExpression(expectCall(args[0]), 'toEqual'), + undefined, + [args[1]], + ); + } + return undefined; + } + default: + return undefined; + } +} + +function addNamedImport( + statements: ts.Statement[], + moduleName: string, + importName: string, +): ts.Statement[] { + let importDecl = statements.find( + (s): s is ts.ImportDeclaration => + ts.isImportDeclaration(s) && + ts.isStringLiteral(s.moduleSpecifier) && + s.moduleSpecifier.text === moduleName, + ); + + if (!importDecl) { + let newImport = ts.factory.createImportDeclaration( + undefined, + ts.factory.createImportClause( + false, + undefined, + ts.factory.createNamedImports([ + ts.factory.createImportSpecifier(false, undefined, ts.factory.createIdentifier(importName)), + ]), + ), + ts.factory.createStringLiteral(moduleName), + undefined, + ); + return [newImport, ...statements]; + } + + let clause = importDecl.importClause; + let namedBindings = clause?.namedBindings; + if (!clause || !namedBindings || !ts.isNamedImports(namedBindings)) { + return statements; + } + + let alreadyImported = namedBindings.elements.some((e) => e.name.text === importName); + if (alreadyImported) { + return statements; + } + + let updatedNamedBindings = ts.factory.updateNamedImports(namedBindings, [ + ...namedBindings.elements, + ts.factory.createImportSpecifier(false, undefined, ts.factory.createIdentifier(importName)), + ]); + let updatedClause = ts.factory.updateImportClause( + clause, + clause.isTypeOnly, + clause.name, + updatedNamedBindings, + ); + let updatedDecl = ts.factory.updateImportDeclaration( + importDecl, + importDecl.modifiers, + updatedClause, + importDecl.moduleSpecifier, + importDecl.attributes, + ); + + return statements.map((s) => (s === importDecl ? updatedDecl : s)); +} + +function ensureVitestImport( + statements: ts.Statement[], + additionalImports: string[] = [], +): ts.Statement[] { + let vitestImport = statements.find( + (s): s is ts.ImportDeclaration => + ts.isImportDeclaration(s) && + ts.isStringLiteral(s.moduleSpecifier) && + s.moduleSpecifier.text === 'vitest', + ); + + const needed = [...new Set(['describe', 'it', 'expect', ...additionalImports])]; + if (!vitestImport) { + let importDecl = ts.factory.createImportDeclaration( + undefined, + ts.factory.createImportClause( + false, + undefined, + ts.factory.createNamedImports( + needed.map((n) => + ts.factory.createImportSpecifier(false, undefined, ts.factory.createIdentifier(n)), + ), + ), + ), + ts.factory.createStringLiteral('vitest'), + undefined, + ); + return [importDecl, ...statements]; + } + + let clause = vitestImport.importClause; + let namedBindings = clause?.namedBindings; + if (!clause || !namedBindings || !ts.isNamedImports(namedBindings)) { + return statements; + } + + let existing = new Set(namedBindings.elements.map((e) => e.name.text)); + let missing = needed.filter((n) => !existing.has(n)); + if (!missing.length) { + return statements; + } + + let updatedNamedBindings = ts.factory.updateNamedImports(namedBindings, [ + ...namedBindings.elements, + ...missing.map((n) => + ts.factory.createImportSpecifier(false, undefined, ts.factory.createIdentifier(n)), + ), + ]); + let updatedClause = ts.factory.updateImportClause( + clause, + clause.isTypeOnly, + clause.name, + updatedNamedBindings, + ); + let updatedDecl = ts.factory.updateImportDeclaration( + vitestImport, + vitestImport.modifiers, + updatedClause, + vitestImport.moduleSpecifier, + vitestImport.attributes, + ); + + return statements.map((s) => (s === vitestImport ? updatedDecl : s)); +} + +function ensureFilenameDirnamePrelude( + statements: ts.Statement[], + needsFilenamePolyfill: boolean, + needsDirnamePolyfill: boolean, +): ts.Statement[] { + if (!needsFilenamePolyfill && !needsDirnamePolyfill) { + return statements; + } + + let updatedStatements = statements; + + if (needsFilenamePolyfill) { + updatedStatements = addNamedImport(updatedStatements, 'url', 'fileURLToPath'); + } + if (needsDirnamePolyfill) { + updatedStatements = addNamedImport(updatedStatements, 'path', 'dirname'); + } + + let hasFilenameDecl = updatedStatements.some( + (s) => + ts.isVariableStatement(s) && + s.declarationList.declarations.some( + (d) => ts.isIdentifier(d.name) && d.name.text === '__filename', + ), + ); + let hasDirnameDecl = updatedStatements.some( + (s) => + ts.isVariableStatement(s) && + s.declarationList.declarations.some( + (d) => ts.isIdentifier(d.name) && d.name.text === '__dirname', + ), + ); + + let prelude: ts.Statement[] = []; + if (needsFilenamePolyfill && !hasFilenameDecl) { + prelude.push( + ts.factory.createVariableStatement( + undefined, + ts.factory.createVariableDeclarationList( + [ + ts.factory.createVariableDeclaration( + ts.factory.createIdentifier('__filename'), + undefined, + undefined, + ts.factory.createCallExpression(ts.factory.createIdentifier('fileURLToPath'), undefined, [ + ts.factory.createPropertyAccessExpression( + ts.factory.createMetaProperty( + ts.SyntaxKind.ImportKeyword, + ts.factory.createIdentifier('meta'), + ), + 'url', + ), + ]), + ), + ], + ts.NodeFlags.Const, + ), + ), + ); + } + if (needsDirnamePolyfill && !hasDirnameDecl) { + prelude.push( + ts.factory.createVariableStatement( + undefined, + ts.factory.createVariableDeclarationList( + [ + ts.factory.createVariableDeclaration( + ts.factory.createIdentifier('__dirname'), + undefined, + undefined, + ts.factory.createCallExpression(ts.factory.createIdentifier('dirname'), undefined, [ + ts.factory.createIdentifier('__filename'), + ]), + ), + ], + ts.NodeFlags.Const, + ), + ), + ); + } + + if (!prelude.length) { + return updatedStatements; + } + + let lastImportIndex = -1; + for (let i = 0; i < updatedStatements.length; i++) { + if (ts.isImportDeclaration(updatedStatements[i])) { + lastImportIndex = i; + } + } + if (lastImportIndex === -1) { + return [...prelude, ...updatedStatements]; + } + return [ + ...updatedStatements.slice(0, lastImportIndex + 1), + ...prelude, + ...updatedStatements.slice(lastImportIndex + 1), + ]; +} + +function collectUsedIdentifiers(statements: ts.Statement[]): Set { + let used = new Set(); + function walk(node: ts.Node) { + if (ts.isImportDeclaration(node)) { + return; + } + if (ts.isIdentifier(node)) { + used.add(node.text); + } + ts.forEachChild(node, walk); + } + for (let statement of statements) { + walk(statement); + } + return used; +} + +function pruneUnusedPathLikeImports(statements: ts.Statement[]): ts.Statement[] { + let used = collectUsedIdentifiers(statements); + return statements.flatMap((statement) => { + if (!ts.isImportDeclaration(statement)) { + return [statement]; + } + if (!ts.isStringLiteral(statement.moduleSpecifier)) { + return [statement]; + } + let moduleName = statement.moduleSpecifier.text; + if (moduleName !== 'path' && moduleName !== 'url') { + return [statement]; + } + + let clause = statement.importClause; + let namedBindings = clause?.namedBindings; + if (!clause || !namedBindings || !ts.isNamedImports(namedBindings)) { + return [statement]; + } + + let kept = namedBindings.elements.filter((specifier) => { + return used.has(specifier.name.text); + }); + + if (kept.length === namedBindings.elements.length) { + return [statement]; + } + + if (!kept.length && !clause.name) { + return []; + } + + let updatedBindings = kept.length + ? ts.factory.updateNamedImports(namedBindings, kept) + : undefined; + let updatedClause = ts.factory.updateImportClause( + clause, + clause.isTypeOnly, + clause.name, + updatedBindings, + ); + return [ + ts.factory.updateImportDeclaration( + statement, + statement.modifiers, + updatedClause, + statement.moduleSpecifier, + statement.attributes, + ), + ]; + }); +} + +function transformQUnitToVitest( + sourceCode: string, + targetFile: string, + sourceFileName: string, +): TransformResult { + let needsVitestHookImport = false; + let unsupportedCalls: string[] = []; + + let sourceFile = ts.createSourceFile( + targetFile, + sourceCode, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + + let transformer: ts.TransformerFactory = (context) => { + function isAssertAsyncCall(node: ts.Expression): boolean { + return ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + ts.isIdentifier(node.expression.expression) && + node.expression.expression.text === 'assert' && + node.expression.name.text === 'async' + ); + } + + function isWaitRetryTimeoutCall(stmt: ts.Statement): boolean { + if (!ts.isExpressionStatement(stmt) || !ts.isCallExpression(stmt.expression)) { + return false; + } + let call = stmt.expression; + if (!ts.isIdentifier(call.expression) || call.expression.text !== 'setTimeout') { + return false; + } + if (call.arguments.length < 1) { + return false; + } + let callback = call.arguments[0]; + if ( + !( + ts.isArrowFunction(callback) || + ts.isFunctionExpression(callback) + ) + ) { + return false; + } + let bodyExpr: ts.Expression | undefined; + if (ts.isCallExpression(callback.body)) { + bodyExpr = callback.body; + } else if ( + ts.isBlock(callback.body) && + callback.body.statements.length === 1 && + ts.isExpressionStatement(callback.body.statements[0]) && + ts.isCallExpression(callback.body.statements[0].expression) + ) { + bodyExpr = callback.body.statements[0].expression; + } + if (!bodyExpr) { + return false; + } + return ( + ts.isIdentifier(bodyExpr.expression) && + bodyExpr.expression.text === 'waitForBillingNotification' + ); + } + + function rewriteWaitRetryFunction( + fn: + | ts.FunctionExpression + | ts.ArrowFunction, + ): ts.FunctionExpression | ts.ArrowFunction { + if (!ts.isBlock(fn.body)) { + return fn; + } + function rewriteStatement(statement: ts.Statement): ts.Statement[] { + if ( + ts.isExpressionStatement(statement) && + ts.isCallExpression(statement.expression) && + ts.isIdentifier(statement.expression.expression) && + statement.expression.expression.text === 'done' + ) { + return [ts.factory.createReturnStatement()]; + } + + if (ts.isIfStatement(statement)) { + let thenStatements = ts.isBlock(statement.thenStatement) + ? statement.thenStatement.statements.flatMap(rewriteStatement) + : rewriteStatement(statement.thenStatement); + let rewrittenThen = ts.factory.createBlock(thenStatements, true); + + let rewrittenElse: ts.Statement | undefined; + if (statement.elseStatement) { + if (ts.isBlock(statement.elseStatement)) { + let elseStatements: ts.Statement[] = []; + for (let elseStmt of statement.elseStatement.statements) { + if (isWaitRetryTimeoutCall(elseStmt)) { + elseStatements.push( + ts.factory.createExpressionStatement( + ts.factory.createAwaitExpression( + ts.factory.createNewExpression( + ts.factory.createIdentifier('Promise'), + [ts.factory.createKeywordTypeNode(ts.SyntaxKind.VoidKeyword)], + [ + ts.factory.createArrowFunction( + undefined, + undefined, + [ + ts.factory.createParameterDeclaration( + undefined, + undefined, + ts.factory.createIdentifier('resolve'), + undefined, + undefined, + undefined, + ), + ], + undefined, + ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), + ts.factory.createCallExpression( + ts.factory.createIdentifier('setTimeout'), + undefined, + [ + ts.factory.createIdentifier('resolve'), + ts.factory.createNumericLiteral(1), + ], + ), + ), + ], + ), + ), + ), + ); + elseStatements.push( + ts.factory.createReturnStatement( + ts.factory.createAwaitExpression( + ts.factory.createCallExpression( + ts.factory.createIdentifier('waitForBillingNotification'), + undefined, + [], + ), + ), + ), + ); + } else { + elseStatements.push(...rewriteStatement(elseStmt)); + } + } + rewrittenElse = ts.factory.createBlock(elseStatements, true); + } else { + let elseStatements = rewriteStatement(statement.elseStatement); + rewrittenElse = + elseStatements.length === 1 + ? elseStatements[0] + : ts.factory.createBlock(elseStatements, true); + } + } + + return [ + ts.factory.updateIfStatement( + statement, + statement.expression, + rewrittenThen, + rewrittenElse, + ), + ]; + } + + return [statement]; + } + + let rewrittenStatements = fn.body.statements.flatMap(rewriteStatement); + + let rewrittenBody = ts.factory.updateBlock(fn.body, rewrittenStatements); + if (ts.isFunctionExpression(fn)) { + return ts.factory.updateFunctionExpression( + fn, + fn.modifiers, + fn.asteriskToken, + fn.name, + fn.typeParameters, + [], + fn.type, + rewrittenBody, + ); + } + return ts.factory.updateArrowFunction( + fn, + fn.modifiers, + fn.typeParameters, + [], + fn.type, + fn.equalsGreaterThanToken, + rewrittenBody, + ); + } + + const visit: ts.Visitor = (node) => { + if (ts.isExpressionStatement(node)) { + if ( + ts.isCallExpression(node.expression) && + ts.isIdentifier(node.expression.expression) && + node.expression.expression.text === 'waitForBillingNotification' && + node.expression.arguments.some((a) => isAssertAsyncCall(a)) + ) { + return ts.factory.createExpressionStatement( + ts.factory.createAwaitExpression( + ts.factory.createCallExpression( + ts.factory.createIdentifier('waitForBillingNotification'), + undefined, + [], + ), + ), + ); + } + if ( + ts.isCallExpression(node.expression) && + ts.isPropertyAccessExpression(node.expression.expression) && + ts.isIdentifier(node.expression.expression.expression) && + node.expression.expression.expression.text === 'assert' && + node.expression.expression.name.text === 'expect' + ) { + return ts.factory.createEmptyStatement(); + } + } + + if (ts.isCallExpression(node)) { + let visitedNode = ts.visitEachChild(node, visit, context) as ts.CallExpression; + + if ( + ts.isPropertyAccessExpression(visitedNode.expression) && + ts.isIdentifier(visitedNode.expression.expression) && + visitedNode.expression.expression.text === 'assert' + ) { + let method = visitedNode.expression.name.text; + let converted = convertAssertCall(method, visitedNode); + if (converted) { + return converted; + } + + if ( + method !== 'beforeEach' && + method !== 'afterEach' && + method !== 'before' && + method !== 'after' && + method !== 'expect' + ) { + unsupportedCalls.push(callText(node, sourceFile)); + } + } + + if (ts.isIdentifier(visitedNode.expression) && (visitedNode.expression.text === 'module' || visitedNode.expression.text === 'test')) { + let replacementName = visitedNode.expression.text === 'module' ? 'describe' : 'it'; + let updatedArgs = [...visitedNode.arguments]; + + if (visitedNode.expression.text === 'module' && updatedArgs.length >= 1) { + updatedArgs[0] = replaceBasenameFilenameArg( + updatedArgs[0], + sourceFileName, + ); + } + + if (visitedNode.expression.text === 'module' && updatedArgs.length >= 2) { + let callback = updatedArgs[1]; + if ( + (ts.isFunctionExpression(callback) || ts.isArrowFunction(callback)) && + callback.parameters.length === 1 && + ts.isBlock(callback.body) && + ts.isIdentifier(callback.parameters[0].name) + ) { + let paramName = callback.parameters[0].name.text; + needsVitestHookImport = true; + let hooksDecl = ts.factory.createVariableStatement( + undefined, + ts.factory.createVariableDeclarationList( + [ + ts.factory.createVariableDeclaration( + ts.factory.createIdentifier(paramName), + undefined, + undefined, + ts.factory.createObjectLiteralExpression( + [ + ts.factory.createPropertyAssignment( + ts.factory.createIdentifier('before'), + ts.factory.createIdentifier('beforeAll'), + ), + ts.factory.createPropertyAssignment( + ts.factory.createIdentifier('after'), + ts.factory.createIdentifier('afterAll'), + ), + ts.factory.createShorthandPropertyAssignment( + ts.factory.createIdentifier('beforeEach'), + undefined, + ), + ts.factory.createShorthandPropertyAssignment( + ts.factory.createIdentifier('afterEach'), + undefined, + ), + ], + true, + ), + ), + ], + ts.NodeFlags.Const, + ), + ); + let updatedBody = ts.factory.updateBlock(callback.body, [ + hooksDecl, + ...callback.body.statements, + ]); + + if (ts.isFunctionExpression(callback)) { + updatedArgs[1] = ts.factory.updateFunctionExpression( + callback, + callback.modifiers, + callback.asteriskToken, + callback.name, + callback.typeParameters, + [], + callback.type, + updatedBody, + ); + } else { + updatedArgs[1] = ts.factory.updateArrowFunction( + callback, + callback.modifiers, + callback.typeParameters, + [], + callback.type, + callback.equalsGreaterThanToken, + updatedBody, + ); + } + } + } + + if (visitedNode.expression.text === 'test' && updatedArgs.length >= 2) { + let callback = updatedArgs[1]; + if ( + (ts.isFunctionExpression(callback) || ts.isArrowFunction(callback)) && + callback.parameters.length === 1 && + ts.isIdentifier(callback.parameters[0].name) && + callback.parameters[0].name.text === 'assert' + ) { + if (ts.isFunctionExpression(callback)) { + updatedArgs[1] = ts.factory.updateFunctionExpression( + callback, + callback.modifiers, + callback.asteriskToken, + callback.name, + callback.typeParameters, + [], + callback.type, + callback.body, + ); + } else { + updatedArgs[1] = ts.factory.updateArrowFunction( + callback, + callback.modifiers, + callback.typeParameters, + [], + callback.type, + callback.equalsGreaterThanToken, + callback.body, + ); + } + } + } + + return ts.factory.updateCallExpression( + visitedNode, + ts.factory.createIdentifier(replacementName), + visitedNode.typeArguments, + updatedArgs, + ); + } + + return visitedNode; + } + + if (ts.isVariableDeclaration(node)) { + if ( + ts.isIdentifier(node.name) && + node.name.text === 'waitForBillingNotification' && + node.initializer && + (ts.isFunctionExpression(node.initializer) || + ts.isArrowFunction(node.initializer)) && + node.initializer.parameters.length >= 2 && + ts.isIdentifier(node.initializer.parameters[0].name) && + node.initializer.parameters[0].name.text === 'assert' && + ts.isIdentifier(node.initializer.parameters[1].name) && + node.initializer.parameters[1].name.text === 'done' + ) { + let rewritten = rewriteWaitRetryFunction(node.initializer); + let visitedRewritten = ts.visitEachChild(rewritten, visit, context) as + | ts.FunctionExpression + | ts.ArrowFunction; + return ts.factory.updateVariableDeclaration( + node, + node.name, + node.exclamationToken, + node.type, + visitedRewritten, + ); + } + } + + return ts.visitEachChild(node, visit, context); + }; + + return (node) => ts.visitNode(node, visit) as ts.SourceFile; + }; + + let transformed = ts.transform(sourceFile, [transformer]).transformed[0]; + let statements = [...transformed.statements].filter((s) => { + return !( + ts.isImportDeclaration(s) && + ts.isStringLiteral(s.moduleSpecifier) && + s.moduleSpecifier.text === 'qunit' + ); + }); + + statements = ensureVitestImport( + statements, + needsVitestHookImport ? ['beforeAll', 'beforeEach', 'afterAll', 'afterEach'] : [], + ); + + let usedBeforePrelude = collectUsedIdentifiers(statements); + let needsFilenamePolyfill = usedBeforePrelude.has('__filename'); + let needsDirnamePolyfill = usedBeforePrelude.has('__dirname'); + if (needsDirnamePolyfill) { + needsFilenamePolyfill = true; + } + statements = ensureFilenameDirnamePrelude( + statements, + needsFilenamePolyfill, + needsDirnamePolyfill, + ); + statements = pruneUnusedPathLikeImports(statements); + + let updatedSourceFile = ts.factory.updateSourceFile(transformed, statements); + let printer = ts.createPrinter({ + newLine: ts.NewLineKind.LineFeed, + removeComments: false, + }); + let printed = printer.printFile(updatedSourceFile).trimEnd() + '\n'; + printed = printed.replace(/(['"`])\.\.\/tests\//g, '$1./'); + + return { + code: `${generatedHeader}${printed}`, + needsVitestHookImport, + needsDirnamePolyfill, + needsFilenamePolyfill, + unsupportedCalls: [...new Set(unsupportedCalls)].sort(), + }; +} + +function isTestFile(relPath: string): boolean { + return relPath.endsWith('-test.ts'); +} + +function targetPathForTest(relPath: string): string { + return join(targetRoot, relPath.replace(/-test\.ts$/, '.test.ts')); +} + +function copySupportFiles(allFiles: string[]): number { + let copied = 0; + for (let absPath of allFiles) { + let relPath = relative(sourceRoot, absPath); + if (isTestFile(relPath)) { + continue; + } + let outPath = join(targetRoot, relPath); + ensureDirSync(dirname(outPath)); + copyFileSync(absPath, outPath); + copied++; + } + return copied; +} + +function run() { + let allFiles = glob.sync('**/*', { + cwd: sourceRoot, + absolute: true, + nodir: true, + dot: true, + }); + + ensureDirSync(targetRoot); + + let copied = copySupportFiles(allFiles); + let transformedCount = 0; + let unsupportedByFile = new Map(); + + for (let absPath of allFiles) { + let relPath = relative(sourceRoot, absPath); + if (!isTestFile(relPath)) { + continue; + } + let sourceCode = readFileSync(absPath, 'utf8'); + let outPath = targetPathForTest(relPath); + let result = transformQUnitToVitest(sourceCode, outPath, basename(relPath)); + + ensureDirSync(dirname(outPath)); + writeFileSync(outPath, result.code, 'utf8'); + transformedCount++; + + if (result.unsupportedCalls.length) { + unsupportedByFile.set(relPath, result.unsupportedCalls); + } + } + + console.log( + `QUnit -> Vitest codemod complete: transformed ${transformedCount} test files, copied ${copied} support files.`, + ); + if (unsupportedByFile.size) { + console.log('Files with unsupported assert calls (manual follow-up):'); + for (let [file, calls] of unsupportedByFile.entries()) { + console.log(`- ${file}`); + for (let call of calls) { + console.log(` - ${call}`); + } + } + } +} + +run(); diff --git a/packages/realm-server/scripts/lint-test-shards.ts b/packages/realm-server/scripts/lint-test-shards.ts deleted file mode 100755 index f0df182e839..00000000000 --- a/packages/realm-server/scripts/lint-test-shards.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { readFileSync } from 'fs-extra'; -import { glob } from 'glob'; -import yaml from 'js-yaml'; -import { join } from 'path'; - -const YAML_FILE = join( - __dirname, - '..', - '..', - '..', - '.github', - 'workflows', - 'ci.yaml', -); -const TEST_DIR = join(__dirname, '..', 'tests'); - -function getCiTestModules(yamlFilePath: string) { - try { - const yamlContent = readFileSync(yamlFilePath, 'utf8'); - const yamlData = yaml.load(yamlContent) as Record; - - const shardIndexes: string[] = - yamlData?.jobs?.['realm-server-test']?.strategy?.matrix?.testModule; - - if (!Array.isArray(shardIndexes)) { - throw new Error( - `Invalid 'jobs.realm-server-test.strategy.matrix.testModule' format in the YAML file.`, - ); - } - - return shardIndexes; - } catch (error: any) { - console.error(`Error reading shardIndex from YAML file: ${error.message}`); - process.exit(1); - } -} - -function getFilesystemTestModules(testDir: string) { - try { - const files = glob.sync(`${testDir}/**/*-test.ts`, { nodir: true }); - return files.map((file: string) => file.replace(`${testDir}/`, '')); - } catch (error: any) { - console.error( - `Error reading test files from dir ${testDir}: ${error.message}`, - ); - process.exit(1); - } -} - -function validateTestFiles(yamlFilePath: string, testDir: string) { - const ciTestModules = getCiTestModules(yamlFilePath); - const filesystemTestModules = getFilesystemTestModules(testDir); - - let errorFound = false; - - for (let filename of filesystemTestModules) { - if (!ciTestModules.includes(filename)) { - console.error( - `Error: Test file '${filename}' exists in the filesystem but not in the ${yamlFilePath} file.`, - ); - errorFound = true; - } - } - for (let filename of ciTestModules) { - if (!filesystemTestModules.includes(filename)) { - console.error( - `Error: Test file '${filename}' exists in the YAML file but not in the filesystem.`, - ); - errorFound = true; - } - } - - if (errorFound) { - process.exit(1); - } else { - console.log( - `All test files are accounted for in the ${yamlFilePath} file for the realm-server matrix strategy.`, - ); - } -} - -validateTestFiles(YAML_FILE, TEST_DIR); diff --git a/packages/realm-server/tests-vitest/.test-pg-cache/.gitignore b/packages/realm-server/tests-vitest/.test-pg-cache/.gitignore new file mode 100644 index 00000000000..d6b7ef32c84 --- /dev/null +++ b/packages/realm-server/tests-vitest/.test-pg-cache/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/packages/realm-server/tests-vitest/atomic-endpoints.test.ts b/packages/realm-server/tests-vitest/atomic-endpoints.test.ts new file mode 100644 index 00000000000..a88217baa22 --- /dev/null +++ b/packages/realm-server/tests-vitest/atomic-endpoints.test.ts @@ -0,0 +1,814 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import type { Test, SuperTest } from 'supertest'; +import type { Server } from 'http'; +import type { DirResult } from 'tmp'; +import type { Realm, RealmAdapter } from '@cardstack/runtime-common'; +import { type LooseSingleCardDocument, SupportedMimeType, } from '@cardstack/runtime-common'; +import { setupPermissionedRealmCached, createJWT, type RealmRequest, withRealmPath, } from './helpers'; +import '@cardstack/runtime-common/helpers/code-equality-assertion'; +describe("atomic-endpoints-test.ts", function () { + describe('Realm-specific Endpoints: can make request to post /_atomic', function () { + let realmURL = new URL('http://127.0.0.1:4444/test/'); + let testRealmHref = realmURL.href; + let testRealm: Realm; + let testRealmAdapter: RealmAdapter; + let request: RealmRequest; + function onRealmSetup(args: { + testRealm: Realm; + testRealmHttpServer: Server; + testRealmAdapter: RealmAdapter; + request: SuperTest; + dir: DirResult; + }) { + testRealm = args.testRealm; + testRealmAdapter = args.testRealmAdapter; + request = withRealmPath(args.request, realmURL); + } + describe('writes', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + permissions: { + '*': ['read', 'write'], + }, + realmURL, + onRealmSetup, + }); + it('can write single new module', async function () { + let source = ` + import { field, CardDef, contains } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + export class Place extends CardDef { + static displayName = 'Place'; + @field name = contains(StringField); + } + `.trim(); + let doc = { + 'atomic:operations': [ + { + op: 'add', + href: 'place-modules/place.gts', + data: { + type: 'source', + attributes: { + content: source, + }, + meta: {}, + }, + }, + ], + }; + let response = await request + .post('/_atomic') + .set('Accept', SupportedMimeType.JSONAPI) + .set('Authorization', `Bearer ${createJWT(testRealm, 'user', ['read', 'write'])}`) + .send(JSON.stringify(doc)); + expect(response.body['atomic:results'].length).toBe(1); + expect(response.status).toBe(201); + expect(response.body['atomic:results'][0].data.id).toEqual(`${testRealmHref}place-modules/place.gts`); + let sourceResponse = await request + .get('/place-modules/place.gts') + .set('Accept', SupportedMimeType.CardSource); + expect(sourceResponse.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(sourceResponse.get('X-boxel-realm-public-readable')).toBe('true'); + expect(sourceResponse.text.trim()).toBe(source); + }); + it('can write multiple new modules', async function () { + let place1Source = ` + import { field, CardDef, contains } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + export class Place extends CardDef { + static displayName = 'Place'; + @field name = contains(StringField); + } + `.trim(); + let place2Source = ` + import { field, CardDef, contains } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + export class Place2 extends CardDef { + static displayName = 'Place2'; + @field name = contains(StringField); + } + `.trim(); + let countrySource = ` + import { field, contains } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + export class Country extends CardDef { + static displayName = 'Country'; + @field name = contains(StringField); + } + `.trim(); + let doc = { + 'atomic:operations': [ + { + op: 'add', + href: 'place-modules/place1.gts', + data: { + type: 'source', + attributes: { + content: place1Source, + }, + meta: {}, + }, + }, + { + op: 'add', + href: 'place-modules/place2.gts', + data: { + type: 'source', + attributes: { + content: place2Source, + }, + }, + }, + { + op: 'add', + href: 'country.gts', + data: { + type: 'source', + attributes: { + content: countrySource, + }, + meta: {}, + }, + }, + ], + }; + let response = await request + .post('/_atomic') + .set('Accept', SupportedMimeType.JSONAPI) + .set('Authorization', `Bearer ${createJWT(testRealm, 'user', ['read', 'write'])}`) + .send(JSON.stringify(doc)); + expect(response.status).toBe(201); + expect(response.body['atomic:results'].length).toBe(3); + let place1Response = await request + .get('/place-modules/place1.gts') + .set('Accept', SupportedMimeType.CardSource); + expect(place1Response.status).toBe(200); + expect(place1Response.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(place1Response.get('X-boxel-realm-public-readable')).toBe('true'); + expect(place1Response.text.trim()).toBe(place1Source); + let place2Response = await request + .get('/place-modules/place2.gts') + .set('Accept', SupportedMimeType.CardSource); + expect(place2Response.status).toBe(200); + expect(place2Response.text.trim()).toBe(place2Source); + expect(place2Response.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(place2Response.get('X-boxel-realm-public-readable')).toBe('true'); + let countryResponse = await request + .get('/country.gts') + .set('Accept', SupportedMimeType.CardSource); + expect(countryResponse.status).toBe(200); + expect(countryResponse.text.trim()).toBe(countrySource); + expect(countryResponse.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(countryResponse.get('X-boxel-realm-public-readable')).toBe('true'); + }); + it('can write a single instance', async function () { + let doc = { + 'atomic:operations': [ + { + op: 'add', + href: 'new-person-1.json', + data: { + type: 'card', + attributes: { + firstName: 'Mango', + }, + meta: { + adoptsFrom: { + module: './person', + name: 'Person', + }, + }, + }, + }, + ], + }; + let response = await request + .post('/_atomic') + .set('Accept', SupportedMimeType.JSONAPI) + .set('Authorization', `Bearer ${createJWT(testRealm, 'user', ['read', 'write'])}`) + .send(JSON.stringify(doc)); + expect(response.status).toBe(201); + expect(response.body['atomic:results'].length).toBe(1); + let cardResponse = await request + .get('/new-person-1') + .set('Accept', SupportedMimeType.CardJson); + let json = cardResponse.body as LooseSingleCardDocument; + expect(json.data.attributes?.firstName).toBe('Mango'); + }); + it('can write multiple instances', async function () { + let doc = { + 'atomic:operations': [ + { + op: 'add', + href: 'new-person-1.json', + data: { + type: 'card', + attributes: { + firstName: 'Mango', + }, + meta: { + adoptsFrom: { + module: './person', + name: 'Person', + }, + }, + }, + }, + { + op: 'add', + href: 'new-person-2.json', + data: { + type: 'card', + attributes: { + firstName: 'Van Gogh', + }, + meta: { + adoptsFrom: { + module: './person', + name: 'Person', + }, + }, + }, + }, + ], + }; + let response = await request + .post('/_atomic') + .set('Accept', SupportedMimeType.JSONAPI) + .set('Authorization', `Bearer ${createJWT(testRealm, 'user', ['read', 'write'])}`) + .send(JSON.stringify(doc)); + expect(response.status).toBe(201); + expect(response.body['atomic:results'].length).toBe(2); + let cardResponse1 = await request + .get('/new-person-1') + .set('Accept', SupportedMimeType.CardJson); + let json1 = cardResponse1.body as LooseSingleCardDocument; + expect(json1.data.attributes?.firstName).toBe('Mango'); + let cardResponse2 = await request + .get('/new-person-2') + .set('Accept', SupportedMimeType.CardJson); + let json2 = cardResponse2.body as LooseSingleCardDocument; + expect(json2.data.attributes?.firstName).toBe('Van Gogh'); + }); + it('can write multiple modules that depend on each other', async function () { + let place1Source = ` + import { field, CardDef, contains } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + export class Place extends CardDef { + static displayName = 'Place'; + @field name = contains(StringField); + } + `.trim(); + let place2Source = ` + import { field, CardDef, contains } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + import { Place } from './place' + export class Place2 extends Place { + static displayName = 'Place2'; + @field name = contains(StringField); + } + `.trim(); + let doc = { + 'atomic:operations': [ + { + op: 'add', + href: 'place-modules/place1.gts', + data: { + type: 'source', + attributes: { + content: place1Source, + }, + meta: {}, + }, + }, + { + op: 'add', + href: 'place-modules/place2.gts', + data: { + type: 'source', + attributes: { + content: place2Source, + }, + }, + }, + ], + }; + let response = await request + .post('/_atomic') + .set('Accept', SupportedMimeType.JSONAPI) + .set('Authorization', `Bearer ${createJWT(testRealm, 'user', ['read', 'write'])}`) + .send(JSON.stringify(doc)); + expect(response.status).toBe(201); + expect(response.body['atomic:results'].length).toBe(2); + let place1Response = await request + .get('/place-modules/place1.gts') + .set('Accept', SupportedMimeType.CardSource); + expect(place1Response.status).toBe(200); + expect(place1Response.text.trim()).toBe(place1Source); + expect(place1Response.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(place1Response.get('X-boxel-realm-public-readable')).toBe('true'); + let place2Response = await request + .get('/place-modules/place2.gts') + .set('Accept', SupportedMimeType.CardSource); + expect(place2Response.status).toBe(200); + expect(place2Response.text.trim()).toBe(place2Source); + expect(place2Response.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(place2Response.get('X-boxel-realm-public-readable')).toBe('true'); + }); + it('can write multiple instances that depend on each other', async function () { + let placeSource = ` + import { field, CardDef, contains, linksTo } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + import { Country } from './country' + export class Place extends CardDef { + static displayName = 'Place'; + @field name = contains(StringField); + @field country = linksTo(()=>Country); + } + `.trim(); + let countrySource = ` + import { field, CardDef, contains } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + export class Country extends CardDef { + static displayName = 'Country'; + @field name = contains(StringField); + } + `.trim(); + let doc = { + 'atomic:operations': [ + { + op: 'add', + href: 'place.gts', + data: { + type: 'source', + attributes: { + content: placeSource, + }, + meta: {}, + }, + }, + { + op: 'add', + href: 'country.gts', + data: { + type: 'source', + attributes: { + content: countrySource, + }, + meta: {}, + }, + }, + ], + }; + let response = await request + .post('/_atomic') + .set('Accept', SupportedMimeType.JSONAPI) + .set('Authorization', `Bearer ${createJWT(testRealm, 'user', ['read', 'write'])}`) + .send(JSON.stringify(doc)); + expect(response.status).toBe(201); + expect(response.body['atomic:results'].length).toBe(2); + let placeResponse = await request + .get('/place.gts') + .set('Accept', SupportedMimeType.CardSource); + let countryResponse = await request + .get('/country.gts') + .set('Accept', SupportedMimeType.CardSource); + expect(placeResponse.text.trim()).toBe(placeSource); + expect(countryResponse.text.trim()).toBe(countrySource); + let instanceDoc = { + 'atomic:operations': [ + { + op: 'add', + href: 'malaysia.json', + data: { + type: 'card', + attributes: { + name: 'Malaysia', + }, + meta: { + adoptsFrom: { + module: './country', + name: 'Country', + }, + }, + }, + }, + { + op: 'add', + href: 'menara-kuala-lumpur.json', + data: { + type: 'card', + attributes: { + name: 'Menara Kuala Lumpur', + }, + relationships: { + country: { + links: { + self: './malaysia.json', //at least in our implementation, it seems you need this although data already exists + }, + data: { + id: '/malaysia.json', + type: 'card', + }, + }, + }, + meta: { + adoptsFrom: { + module: './place', + name: 'Place', + }, + }, + }, + }, + ], + }; + let instanceResponse = await request + .post('/_atomic') + .set('Accept', SupportedMimeType.JSONAPI) + .set('Authorization', `Bearer ${createJWT(testRealm, 'user', ['read', 'write'])}`) + .send(JSON.stringify(instanceDoc)); + expect(instanceResponse.status).toBe(201); + expect(instanceResponse.body['atomic:results'].length).toBe(2); + }); + it('can write new instance with new module', async function () { + let source = ` + import { field, CardDef, contains } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + export class Place extends CardDef { + static displayName = 'Place'; + @field name = contains(StringField); + } + `.trim(); + let doc = { + 'atomic:operations': [ + { + op: 'add', + href: 'place-modules/place.gts', + data: { + type: 'source', + attributes: { + content: source, + }, + meta: {}, + }, + }, + { + op: 'add', + href: 'place.json', + data: { + type: 'card', + attributes: { + name: 'Kuala Lumpur', + }, + meta: { + adoptsFrom: { + module: './place-modules/place.gts', + name: 'Place', + }, + }, + }, + }, + ], + }; + let response = await request + .post('/_atomic') + .set('Accept', SupportedMimeType.JSONAPI) + .set('Authorization', `Bearer ${createJWT(testRealm, 'user', ['read', 'write'])}`) + .send(JSON.stringify(doc)); + expect(response.status).toBe(201); + expect(response.body['atomic:results'].length).toBe(2); + let cardResponse = await request + .get('/place') + .set('Accept', SupportedMimeType.CardJson); + let json = cardResponse.body as LooseSingleCardDocument; + expect(json.data.attributes?.name).toBe('Kuala Lumpur'); + let sourceResponse = await request + .get('/place-modules/place.gts') + .set('Accept', SupportedMimeType.CardSource); + expect(sourceResponse.text.trim()).toBe(source); + }); + it('update is a no-op when content is unchanged', async function () { + let source = ` + import { field, CardDef, contains } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + export class Place extends CardDef { + static displayName = 'Place'; + @field name = contains(StringField); + } + `.trim(); + let addDoc = { + 'atomic:operations': [ + { + op: 'add', + href: 'place-modules/place-noop.gts', + data: { + type: 'source', + attributes: { + content: source, + }, + meta: {}, + }, + }, + ], + }; + let addResponse = await request + .post('/_atomic') + .set('Accept', SupportedMimeType.JSONAPI) + .set('Authorization', `Bearer ${createJWT(testRealm, 'user', ['read', 'write'])}`) + .send(JSON.stringify(addDoc)); + expect(addResponse.status).toBe(201); + expect(addResponse.body['atomic:results'].length).toBe(1); + let initialLastModified = await testRealmAdapter.lastModified('place-modules/place-noop.gts'); + let writeCalls = 0; + let originalWrite = testRealmAdapter.write.bind(testRealmAdapter); + testRealmAdapter.write = (async (path, contents) => { + writeCalls++; + return originalWrite(path, contents); + }) as RealmAdapter['write']; + try { + let updateDoc = { + 'atomic:operations': [ + { + op: 'update', + href: 'place-modules/place-noop.gts', + data: { + type: 'source', + attributes: { + content: source, + }, + meta: {}, + }, + }, + ], + }; + let updateResponse = await request + .post('/_atomic') + .set('Accept', SupportedMimeType.JSONAPI) + .set('Authorization', `Bearer ${createJWT(testRealm, 'user', ['read', 'write'])}`) + .send(JSON.stringify(updateDoc)); + expect(updateResponse.status).toBe(201); + expect(updateResponse.body['atomic:results'].length).toBe(1); + expect(writeCalls).toBe(0); + expect(await testRealmAdapter.lastModified('place-modules/place-noop.gts')).toBe(initialLastModified); + } + finally { + testRealmAdapter.write = originalWrite; + } + }); + }); + describe('error handling', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + permissions: { + '*': ['read', 'write'], + }, + realmURL, + onRealmSetup, + }); + it('returns error when resource already exists', async function () { + let source = ` + import { field, CardDef, contains } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + export class Place extends CardDef { + static displayName = 'Place'; + @field name = contains(StringField); + } + `.trim(); + let doc = { + 'atomic:operations': [ + { + op: 'add', + href: 'person.gts', + data: { + type: 'source', + attributes: { + content: source, + }, + meta: {}, + }, + }, + ], + }; + let response = await request + .post('/_atomic') + .set('Accept', SupportedMimeType.JSONAPI) + .set('Authorization', `Bearer ${createJWT(testRealm, 'user', ['read', 'write'])}`) + .send(JSON.stringify(doc)); + expect(response.status).toBe(409); + expect(response.body.errors.length).toBe(1); + expect(response.body.errors[0].title).toBe('Resource already exists'); + expect(response.body.errors[0].detail).toBe(`Resource person.gts already exists`); + }); + it('returns error when failing to serialize a card resource', async function () { + let doc = { + 'atomic:operations': [ + { + op: 'add', + href: 'place.json', + data: { + type: 'card', + attributes: { + name: 'Kuala Lumpur', + }, + meta: { + adoptsFrom: { + module: './missing-place/does-not-exist', + name: 'Place', + }, + }, + }, + }, + ], + }; + let response = await request + .post('/_atomic') + .set('Accept', SupportedMimeType.JSONAPI) + .set('Authorization', `Bearer ${createJWT(testRealm, 'user', ['read', 'write'])}`) + .send(JSON.stringify(doc)); + expect(response.status).toBe(500); + expect(response.body.errors.length).toBe(1); + expect(response.body.errors[0].title).toBe('Write Error'); + expect(response.body.errors[0].detail).toBe(`Your filter refers to a nonexistent type: import { Place } from "${testRealmHref}missing-place/does-not-exist"`); + }); + it('can update an existing instance', async function () { + let addDoc = { + 'atomic:operations': [ + { + op: 'add', + href: 'update-person.json', + data: { + type: 'card', + attributes: { + firstName: 'Initial', + }, + meta: { + adoptsFrom: { + module: './person', + name: 'Person', + }, + }, + }, + }, + ], + }; + await request + .post('/_atomic') + .set('Accept', SupportedMimeType.JSONAPI) + .set('Authorization', `Bearer ${createJWT(testRealm, 'user', ['read', 'write'])}`) + .send(JSON.stringify(addDoc)) + .expect(201); + let updateDoc = { + 'atomic:operations': [ + { + op: 'update', + href: 'update-person.json', + data: { + type: 'card', + attributes: { + firstName: 'Updated', + }, + meta: { + adoptsFrom: { + module: './person', + name: 'Person', + }, + }, + }, + }, + ], + }; + let response = await request + .post('/_atomic') + .set('Accept', SupportedMimeType.JSONAPI) + .set('Authorization', `Bearer ${createJWT(testRealm, 'user', ['read', 'write'])}`) + .send(JSON.stringify(updateDoc)); + expect(response.status).toBe(201); + expect(response.body['atomic:results'].length).toBe(1); + let updatedCardResponse = await request + .get('/update-person') + .set('Accept', SupportedMimeType.CardJson); + let updatedCard = updatedCardResponse.body as LooseSingleCardDocument; + expect(updatedCard.data.attributes?.firstName).toBe('Updated'); + }); + }); + describe('validation', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + permissions: { + '*': ['read', 'write'], + }, + realmURL, + onRealmSetup, + }); + it('rejects non-array atomic:operations', async function () { + let response = await request + .post('/_atomic') + .set('Accept', SupportedMimeType.JSONAPI) + .send({ 'atomic:operations': 'not-an-array' }) + .expect(400); + expect(response.status).toBe(400); + expect(response.body.errors.length).toBe(1); + let error = response.body.errors[0]; + expect(error.status).toBe(400); + expect(error.detail).toBe(`Request body must contain 'atomic:operations' array`); + expect(response.body.errors[0].title).toBe('Invalid atomic:operations format'); + }); + it('rejects request without atomic:operations array', async function () { + let response = await request + .post('/_atomic') + .set('Accept', SupportedMimeType.JSONAPI) + .send({ data: { something: 'else' } }) + .expect(400); + expect(response.body.errors.length).toBe(1); + let error = response.body.errors[0]; + expect(error.title).toBe('Invalid atomic:operations format'); + expect(error.status).toBe(400); + expect(error.detail).toBe(`Request body must contain 'atomic:operations' array`); + }); + it('rejects if href is not present', async function () { + let response = await request + .post('/_atomic') + .set('Accept', SupportedMimeType.JSONAPI) + .send({ + 'atomic:operations': [{ op: 'add', data: { type: 'card' } }], + }) + .expect(400); + expect(response.body.errors.length).toBe(1); + let error = response.body.errors[0]; + expect(error.title).toBe('Invalid atomic:operations format'); + expect(error.status).toBe(400); + expect(error.detail).toBe(`Request operation must contain 'href' property`); + }); + it('rejects unsupported operation types', async function () { + let response = await request + .post('/_atomic') + .set('Accept', SupportedMimeType.JSONAPI) + .send({ + 'atomic:operations': [{ op: 'delete', data: { type: 'card' } }], + }) + .expect(400); + expect(response.body.errors.length).toBe(2); + let [error1, error2] = response.body.errors; + expect(error1.title).toBe('Invalid atomic:operations format'); + expect(error1.status).toBe(422); + expect(error1.detail).toBe(`You tried to use an unsupported operation type: 'delete'. Only 'add' and 'update' operations are currently supported`); + expect(error2.title).toBe('Invalid atomic:operations format'); + expect(error2.status).toBe(400); + expect(error2.detail).toBe(`Request operation must contain 'href' property`); + }); + it('rejects unsupported resource types', async function () { + let response = await request + .post('/_atomic') + .set('Accept', SupportedMimeType.JSONAPI) + .send({ + 'atomic:operations': [ + { op: 'add', href: 'file.json', data: { type: 'file' } }, + ], + }) + .expect(400); + expect(response.body.errors.length).toBe(1); + let error = response.body.errors[0]; + expect(error.title).toBe('Invalid atomic:operations format'); + expect(error.status).toBe(422); + expect(error.detail).toBe(`You tried to use an unsupported resource type: 'file'. Only 'card' and 'source' resource types are currently supported`); + }); + it('rejects update when resource does not exist', async function () { + let response = await request + .post('/_atomic') + .set('Accept', SupportedMimeType.JSONAPI) + .send({ + 'atomic:operations': [ + { + op: 'update', + href: 'missing.json', + data: { type: 'card' }, + }, + ], + }) + .expect(404); + expect(response.body.errors.length).toBe(1); + let error = response.body.errors[0]; + expect(error.title).toBe('Resource does not exist'); + expect(error.status).toBe(404); + expect(error.detail).toBe('Resource missing.json does not exist'); + }); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/auth-client.test.ts b/packages/realm-server/tests-vitest/auth-client.test.ts new file mode 100644 index 00000000000..ef2a3f61772 --- /dev/null +++ b/packages/realm-server/tests-vitest/auth-client.test.ts @@ -0,0 +1,132 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import '@cardstack/runtime-common/helpers/code-equality-assertion'; +import { RealmAuthClient, type RealmAuthMatrixClientInterface, } from '@cardstack/runtime-common/realm-auth-client'; +import { VirtualNetwork } from '@cardstack/runtime-common'; +import jwt from 'jsonwebtoken'; +import type ms from 'ms'; +function createJWT(expiresIn: ms.StringValue, payload: Record = {}) { + return jwt.sign(payload, 'secret', { expiresIn }); +} +describe("auth-client-test.ts", function () { + describe('realm-auth-client', function () { + const assert = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let client: RealmAuthClient; + let sessionHandler: (request: Request) => Promise; + let openIdToken: { + access_token: string; + expires_in: number; + matrix_server_name: string; + token_type: string; + }; + assert.beforeEach(function () { + openIdToken = { + access_token: 'matrix-openid-token', + expires_in: 3600, + matrix_server_name: 'synapse', + token_type: 'Bearer', + }; + let mockMatrixClient = { + isLoggedIn() { + return true; + }, + getUserId() { + return 'userId'; + }, + async getJoinedRooms() { + return Promise.resolve({ joined_rooms: [] }); + }, + async joinRoom() { + return Promise.resolve(); + }, + async sendEvent() { + return Promise.resolve(); + }, + async hashMessageWithSecret(_message: string): Promise { + throw new Error('Method not implemented.'); + }, + async getAccountDataFromServer() { + return {}; + }, + async setAccountData() { + return Promise.resolve(); + }, + async getOpenIdToken() { + return openIdToken; + }, + } as RealmAuthMatrixClientInterface; + let virtualNetwork = new VirtualNetwork(); + sessionHandler = async () => new Response(null, { + status: 201, + headers: { + Authorization: createJWT('1h', { + sessionRoom: 'room', + realmServerURL: 'http://testrealm.com/', + }), + }, + }); + virtualNetwork.mount(async (request) => { + if (request.url === 'http://testrealm.com/_session') { + return sessionHandler(request); + } + return null; + }); + client = new RealmAuthClient(new URL('http://testrealm.com/'), mockMatrixClient, virtualNetwork.fetch) as any; + }); + it('it authenticates and caches the jwt until it expires', async function () { + let jwtFromClient = await client.getJWT(); + expect(jwtFromClient.split('.').length).toBe(3); + expect(jwtFromClient).toBe(await client.getJWT()); + }); + it('it refreshes the jwt if it is about to expire in the client', async function () { + let jwtFromClient = createJWT('10s'); // Expires very soon, so the client will first refresh it + client['_jwt'] = jwtFromClient; + expect(jwtFromClient).not.toEqual(await client.getJWT()); + }); + it('it refreshes the jwt if it expired in the client', async function () { + let jwtFromClient = createJWT('-1s'); // Expired 1 second ago + client['_jwt'] = jwtFromClient; + expect(jwtFromClient).not.toEqual(await client.getJWT()); + }); + it('it includes the realm server url in the jwt claims', async function () { + let jwtFromClient = await client.getJWT(); + let [_header, payload] = jwtFromClient.split('.'); + let claims = JSON.parse(atob(payload)) as { + realmServerURL: string; + }; + expect(claims.realmServerURL).toBe('http://testrealm.com/'); + }); + it('it sends the openid token when requesting a realm session', async function () { + ; + sessionHandler = async (request) => { + let requestToken = await request.json(); + expect(requestToken).toEqual(openIdToken); + return new Response(null, { + status: 201, + headers: { + Authorization: createJWT('1h', { + sessionRoom: 'room', + realmServerURL: 'http://testrealm.com/', + }), + }, + }); + }; + let jwtFromClient = await client.getJWT(); + expect(jwtFromClient).toBeTruthy(); + }); + it('it throws when the openid token cannot be verified by the realm', async function () { + sessionHandler = async () => { + return new Response(JSON.stringify({ errors: ['invalid token'] }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + }; + await expect(client.getJWT()).rejects.toThrow(/expected 'Authorization' header/); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/billing.test.ts b/packages/realm-server/tests-vitest/billing.test.ts new file mode 100644 index 00000000000..54731a05985 --- /dev/null +++ b/packages/realm-server/tests-vitest/billing.test.ts @@ -0,0 +1,908 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import type { LedgerEntry, Plan, Subscription, SubscriptionCycle, User, } from '@cardstack/runtime-common'; +import { logger, param, query } from '@cardstack/runtime-common'; +import { createTestPgAdapter, fetchSubscriptionsByUserId, insertPlan, insertUser, prepareTestDB, } from './helpers'; +import type { PgAdapter } from '@cardstack/postgres'; +import { handlePaymentSucceeded } from '@cardstack/billing/stripe-webhook-handlers/payment-succeeded'; +import { handleSubscriptionDeleted } from '@cardstack/billing/stripe-webhook-handlers/subscription-deleted'; +import { handleCheckoutSessionCompleted } from '@cardstack/billing/stripe-webhook-handlers/checkout-session-completed'; +import { insertSubscriptionCycle, sumUpCreditsLedger, addToCreditsLedger, insertSubscription, spendCredits, } from '@cardstack/billing/billing-queries'; +import type { TaskArgs } from '@cardstack/runtime-common/tasks'; +import { dailyCreditGrant } from '@cardstack/runtime-common/tasks/daily-credit-grant'; +import type { StripeInvoicePaymentSucceededWebhookEvent, StripeSubscriptionDeletedWebhookEvent, StripeCheckoutSessionCompletedWebhookEvent, } from '@cardstack/billing/stripe-webhook-handlers'; +async function fetchStripeEvents(dbAdapter: PgAdapter) { + return await query(dbAdapter, [`SELECT * FROM stripe_events`]); +} +async function fetchSubscriptionCyclesBySubscriptionId(dbAdapter: PgAdapter, subscriptionId: string): Promise { + let results = await query(dbAdapter, [ + `SELECT * FROM subscription_cycles WHERE subscription_id = `, + param(subscriptionId), + ]); + return results.map((result) => ({ + id: result.id as string, + subscriptionId: result.subscription_id as string, + periodStart: parseInt(result.period_start as string), + periodEnd: parseInt(result.period_end as string), + })); +} +async function fetchCreditsLedgerByUser(dbAdapter: PgAdapter, userId: string): Promise { + let results = await query(dbAdapter, [ + `SELECT * FROM credits_ledger WHERE user_id = `, + param(userId), + ]); + return results.map((result) => ({ + id: result.id, + userId: result.user_id, + creditAmount: result.credit_amount, + creditType: result.credit_type, + subscriptionCycleId: result.subscription_cycle_id, + }) as LedgerEntry); +} +function buildDailyCreditGrantTaskArgs(dbAdapter: PgAdapter): TaskArgs { + return { + dbAdapter, + queuePublisher: {} as TaskArgs['queuePublisher'], + indexWriter: {} as TaskArgs['indexWriter'], + prerenderer: {} as TaskArgs['prerenderer'], + definitionLookup: {} as TaskArgs['definitionLookup'], + log: logger('test-daily-credit-grant'), + matrixURL: 'http://matrix.invalid', + getReader: () => { + throw new Error('getReader should not be called in daily credit grant'); + }, + getAuthedFetch: async () => { + throw new Error('getAuthedFetch should not be called in daily credit grant'); + }, + createPrerenderAuth: () => '', + reportStatus: () => { }, + }; +} +describe("billing-test.ts", function () { + describe('billing', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let dbAdapter: PgAdapter; + hooks.beforeEach(async function () { + prepareTestDB(); + dbAdapter = await createTestPgAdapter(); + }); + hooks.afterEach(async function () { + await dbAdapter.close(); + }); + describe('invoice payment succeeded', function () { + describe('new subscription without any previous subscription', function () { + it('creates a new subscription and adds plan allowance in credits', async function () { + let user = await insertUser(dbAdapter, 'user@test', 'cus_123', 'user@test.com'); + let plan = await insertPlan(dbAdapter, 'Free plan', 0, 100, 'prod_free'); + // Omitted version of a real stripe invoice.payment_succeeded event + let stripeInvoicePaymentSucceededEvent = { + id: 'evt_1234567890', + object: 'event', + type: 'invoice.payment_succeeded', + data: { + object: { + id: 'in_1234567890', + object: 'invoice', + amount_paid: 0, // free plan + billing_reason: 'subscription_create', + period_end: 1638465600, + period_start: 1635873600, + subscription: 'sub_1234567890', + customer: 'cus_123', + lines: { + data: [ + { + amount: 0, + type: 'subscription', + proration: false, + price: { product: 'prod_free' }, + period: { + start: 1635873600, + end: 1638465600, + }, + }, + ], + }, + }, + }, + } as StripeInvoicePaymentSucceededWebhookEvent; + await handlePaymentSucceeded(dbAdapter, stripeInvoicePaymentSucceededEvent); + // Assert that the stripe event was inserted and processed + let stripeEvents = await fetchStripeEvents(dbAdapter); + expect(stripeEvents.length).toBe(1); + expect(stripeEvents[0].stripe_event_id).toBe(stripeInvoicePaymentSucceededEvent.id); + expect(stripeEvents[0].is_processed).toBe(true); + // Assert that the subscription was created + let subscriptions = await fetchSubscriptionsByUserId(dbAdapter, user.id); + expect(subscriptions.length).toBe(1); + let subscription = subscriptions[0]; + expect(subscription.userId).toBe(user.id); + expect(subscription.planId).toBe(plan.id); + expect(subscription.status).toBe('active'); + expect(subscription.stripeSubscriptionId).toBe('sub_1234567890'); + // Assert that the subscription cycle was created + let subscriptionCycles = await fetchSubscriptionCyclesBySubscriptionId(dbAdapter, subscription.id); + expect(subscriptionCycles.length).toBe(1); + let subscriptionCycle = subscriptionCycles[0]; + expect(subscriptionCycle.subscriptionId).toBe(subscription.id); + expect(subscriptionCycle.periodStart).toBe(stripeInvoicePaymentSucceededEvent.data.object.period_start); + expect(subscriptionCycle.periodEnd).toBe(stripeInvoicePaymentSucceededEvent.data.object.period_end); + // Assert that the credits were added to the user's balance + let creditsLedger = await fetchCreditsLedgerByUser(dbAdapter, user.id); + expect(creditsLedger.length).toBe(1); + let creditLedgerEntry = creditsLedger[0]; + expect(creditLedgerEntry.userId).toBe(user.id); + expect(creditLedgerEntry.creditAmount).toBe(plan.creditsIncluded); + expect(creditLedgerEntry.creditType).toBe('plan_allowance'); + expect(creditLedgerEntry.subscriptionCycleId).toBe(subscriptionCycle.id); + // Error if stripe event is attempted to be processed again when it's already been processed + await expect(handlePaymentSucceeded(dbAdapter, stripeInvoicePaymentSucceededEvent)).rejects.toThrow('error: duplicate key value violates unique constraint "stripe_events_pkey"'); + }); + }); + describe('subscription update', function () { + it('updates the subscription and prorates credits', async function () { + let user = await insertUser(dbAdapter, 'user@test', 'cus_123', 'user@test.com'); + let freePlan = await insertPlan(dbAdapter, 'Free plan', 0, 1000, 'prod_free'); + let creatorPlan = await insertPlan(dbAdapter, 'Creator', 12, 5000, 'prod_creator'); + let powerUserPlan = await insertPlan(dbAdapter, 'Power User', 49, 25000, 'prod_power_user'); + let subscription = await insertSubscription(dbAdapter, { + user_id: user.id, + plan_id: freePlan.id, + started_at: 1, + status: 'active', + stripe_subscription_id: 'sub_1234567890', + }); + let subscriptionCycle = await insertSubscriptionCycle(dbAdapter, { + subscriptionId: subscription.id, + periodStart: 1, + periodEnd: 2, + }); + await addToCreditsLedger(dbAdapter, { + userId: user.id, + creditAmount: 1000, + creditType: 'plan_allowance', + subscriptionCycleId: subscriptionCycle.id, + }); + // User spent 500 credits from his plan allowance, now he has 500 left + await addToCreditsLedger(dbAdapter, { + userId: user.id, + creditAmount: -500, + creditType: 'plan_allowance_used', + subscriptionCycleId: subscriptionCycle.id, + }); + let stripeInvoicePaymentSucceededEvent = { + id: 'evt_1234567890', + object: 'event', + type: 'invoice.payment_succeeded', + data: { + object: { + id: 'in_1234567890', + object: 'invoice', + amount_paid: creatorPlan.monthlyPrice * 100, + billing_reason: 'subscription_update', + period_start: 1, + period_end: 2, + subscription: 'sub_1234567890', + customer: 'cus_123', + lines: { + data: [ + { + amount: creatorPlan.monthlyPrice * 100, + type: 'subscription', + proration: false, + price: { product: 'prod_creator' }, + period: { start: 1, end: 2 }, + }, + ], + }, + }, + }, + } as StripeInvoicePaymentSucceededWebhookEvent; + // User upgraded to the creator plan for $12 + await handlePaymentSucceeded(dbAdapter, stripeInvoicePaymentSucceededEvent); + // Assert that new subscription was created + let subscriptions = await fetchSubscriptionsByUserId(dbAdapter, user.id); + expect(subscriptions.length).toBe(2); + // Assert that old subscription was ended due to plan change + expect(subscriptions[0].status).toBe('ended_due_to_plan_change'); + expect(subscriptions[0].endedAt).toBeTruthy(); + // Assert that new subscription is active + expect(subscriptions[1].status).toBe('active'); + // Assert that there is a new subscription cycle + let subscriptionCycles = await fetchSubscriptionCyclesBySubscriptionId(dbAdapter, subscriptions[1].id); + expect(subscriptionCycles.length).toBe(1); + expect(subscriptionCycles[0].periodStart).toBe(stripeInvoicePaymentSucceededEvent.data.object.period_start); + expect(subscriptionCycles[0].periodEnd).toBe(stripeInvoicePaymentSucceededEvent.data.object.period_end); + let creditsBalance = await sumUpCreditsLedger(dbAdapter, { + userId: user.id, + }); + subscriptionCycle = subscriptionCycles[0]; + // User received 5000 credits from the creator plan, but the 500 credits from the plan allowance they had left from the free plan were expired + expect(creditsBalance).toBe(5000); + // User spent 2000 credits from the plan allowance + await addToCreditsLedger(dbAdapter, { + userId: user.id, + creditAmount: -2000, + creditType: 'plan_allowance_used', + subscriptionCycleId: subscriptionCycle.id, + }); + // Assert that the user now has 3000 credits left + creditsBalance = await sumUpCreditsLedger(dbAdapter, { + userId: user.id, + }); + expect(creditsBalance).toBe(3000); + // Now, user upgrades to power user plan ($49 monthly) in the middle of the month: + let amountCreditedForUnusedTimeOnPreviousPlan = 200; + let amountCreditedForRemainingTimeOnNewPlan = 3800; + stripeInvoicePaymentSucceededEvent = { + id: 'evt_1234567891', + object: 'event', + type: 'invoice.payment_succeeded', + data: { + object: { + id: 'in_1234567890', + object: 'invoice', + amount_paid: 3400, // prorated amount for going from creator to power user plan + billing_reason: 'subscription_update', + period_start: 3, + period_end: 4, + subscription: 'sub_1234567890', + customer: 'cus_123', + lines: { + data: [ + { + amount: -amountCreditedForUnusedTimeOnPreviousPlan, + description: 'Unused time on Creator plan', + type: 'subscription', + proration: false, + price: { product: 'prod_creator' }, + period: { start: 3, end: 4 }, + }, + { + amount: amountCreditedForRemainingTimeOnNewPlan, + description: 'Remaining time on Power User plan', + type: 'subscription', + proration: false, + price: { product: 'prod_power_user' }, + period: { start: 4, end: 5 }, + }, + ], + }, + }, + }, + } as StripeInvoicePaymentSucceededWebhookEvent; + await handlePaymentSucceeded(dbAdapter, stripeInvoicePaymentSucceededEvent); + // Assert there are now three subscriptions and last one is active + subscriptions = await fetchSubscriptionsByUserId(dbAdapter, user.id); + expect(subscriptions.length).toBe(3); + expect(subscriptions[0].status).toBe('ended_due_to_plan_change'); + expect(subscriptions[1].status).toBe('ended_due_to_plan_change'); + expect(subscriptions[2].status).toBe('active'); + // Assert that subscriptions have correct plan ids + expect(subscriptions[0].planId).toBe(freePlan.id); + expect(subscriptions[1].planId).toBe(creatorPlan.id); + expect(subscriptions[2].planId).toBe(powerUserPlan.id); + // Assert that the new subscription has the correct period start and end + expect(subscriptions[2].startedAt).toBe(4); + expect(subscriptions[2].endedAt).toBe(null); + subscriptionCycles = await fetchSubscriptionCyclesBySubscriptionId(dbAdapter, subscriptions[2].id); + // Assert that latest subscription cycle has the correct period start and end + expect(subscriptionCycles.length).toBe(1); + expect(subscriptionCycles[0].periodStart).toBe(4); + expect(subscriptionCycles[0].periodEnd).toBe(5); + let previousCreditsBalance = creditsBalance; + creditsBalance = await sumUpCreditsLedger(dbAdapter, { + userId: user.id, + }); + // Assert that the credits balance is the prorated amount for going from creator to power user plan + let creditsToExpireforUnusedTimeOnPreviousPlan = Math.round((amountCreditedForUnusedTimeOnPreviousPlan / + (creatorPlan.monthlyPrice * 100)) * + creatorPlan.creditsIncluded); + let creditsToAddForRemainingTime = Math.round((amountCreditedForRemainingTimeOnNewPlan / + (powerUserPlan.monthlyPrice * 100)) * + powerUserPlan.creditsIncluded); + expect(creditsBalance).toBe(previousCreditsBalance - + creditsToExpireforUnusedTimeOnPreviousPlan + + creditsToAddForRemainingTime); + // Downgrade to creator plan + stripeInvoicePaymentSucceededEvent = { + id: 'evt_12345678901', + object: 'event', + type: 'invoice.payment_succeeded', + data: { + object: { + id: 'in_1234567890', + object: 'invoice', + amount_paid: creatorPlan.monthlyPrice * 100, + billing_reason: 'subscription_update', + period_start: 5, + period_end: 6, + subscription: 'sub_1234567890', + customer: 'cus_123', + lines: { + data: [ + { + amount: creatorPlan.monthlyPrice * 100, + type: 'subscription', + proration: false, + price: { product: 'prod_creator' }, + period: { start: 5, end: 6 }, + }, + ], + }, + }, + }, + } as StripeInvoicePaymentSucceededWebhookEvent; + await handlePaymentSucceeded(dbAdapter, stripeInvoicePaymentSucceededEvent); + // Assert there are now four subscriptions and last one is active + subscriptions = await fetchSubscriptionsByUserId(dbAdapter, user.id); + expect(subscriptions.length).toBe(4); + expect(subscriptions[0].status).toBe('ended_due_to_plan_change'); + expect(subscriptions[1].status).toBe('ended_due_to_plan_change'); + expect(subscriptions[2].status).toBe('ended_due_to_plan_change'); + expect(subscriptions[3].status).toBe('active'); + // Assert that subscriptions have correct plan ids + expect(subscriptions[0].planId).toBe(freePlan.id); + expect(subscriptions[1].planId).toBe(creatorPlan.id); + expect(subscriptions[2].planId).toBe(powerUserPlan.id); + expect(subscriptions[3].planId).toBe(creatorPlan.id); + // Assert that the new subscription has the correct period start and end + expect(subscriptions[3].startedAt).toBe(5); + expect(subscriptions[3].endedAt).toBe(null); + subscriptionCycles = await fetchSubscriptionCyclesBySubscriptionId(dbAdapter, subscriptions[3].id); + // Assert that latest subscription cycle has the correct period start and end + expect(subscriptionCycles.length).toBe(1); + expect(subscriptionCycles[0].periodStart).toBe(5); + expect(subscriptionCycles[0].periodEnd).toBe(6); + // Assert that user now has the plan's allowance (No proration will happen because Stripe assures us that downgrading to a cheaper plan will happen at the end of the billing period) + // (This is a setting in Stripe's customer portal) + creditsBalance = await sumUpCreditsLedger(dbAdapter, { + userId: user.id, + }); + expect(creditsBalance).toBe(creatorPlan.creditsIncluded); + // Now user switches back to free plan + stripeInvoicePaymentSucceededEvent = { + id: 'evt_123456789011', + object: 'event', + type: 'invoice.payment_succeeded', + data: { + object: { + id: 'in_1234567890', + object: 'invoice', + amount_paid: 0, + billing_reason: 'subscription_update', + period_start: 1635873600, + period_end: 1638465600, + subscription: 'sub_1234567890', + customer: 'cus_123', + lines: { + data: [ + { + amount: 0, + type: 'subscription', + proration: false, + price: { product: 'prod_free' }, + period: { + start: 1635873600, + end: 1638465600, + }, + }, + ], + }, + }, + }, + } as StripeInvoicePaymentSucceededWebhookEvent; + await handlePaymentSucceeded(dbAdapter, stripeInvoicePaymentSucceededEvent); + // Assert there are now 5 subscriptions and last one is active + subscriptions = await fetchSubscriptionsByUserId(dbAdapter, user.id); + expect(subscriptions.length).toBe(5); + expect(subscriptions[0].status).toBe('ended_due_to_plan_change'); + expect(subscriptions[1].status).toBe('ended_due_to_plan_change'); + expect(subscriptions[2].status).toBe('ended_due_to_plan_change'); + expect(subscriptions[3].status).toBe('ended_due_to_plan_change'); + expect(subscriptions[4].status).toBe('active'); + // Assert that subscriptions have correct plan ids + expect(subscriptions[0].planId).toBe(freePlan.id); + expect(subscriptions[1].planId).toBe(creatorPlan.id); + expect(subscriptions[2].planId).toBe(powerUserPlan.id); + expect(subscriptions[3].planId).toBe(creatorPlan.id); + expect(subscriptions[4].planId).toBe(freePlan.id); + creditsBalance = await sumUpCreditsLedger(dbAdapter, { + userId: user.id, + }); + expect(creditsBalance).toBe(freePlan.creditsIncluded); + }); + }); + describe('subscription cycle', function () { + it('renews the subscription', async function () { + let user = await insertUser(dbAdapter, 'user@test', 'cus_123', 'user@test.com'); + let plan = await insertPlan(dbAdapter, 'Creator', 12, 2500, 'prod_creator'); + let subscription = await insertSubscription(dbAdapter, { + user_id: user.id, + plan_id: plan.id, + started_at: 1, + status: 'active', + stripe_subscription_id: 'sub_1234567890', + }); + let subscriptionCycle = await insertSubscriptionCycle(dbAdapter, { + subscriptionId: subscription.id, + periodStart: 1, + periodEnd: 2, + }); + await addToCreditsLedger(dbAdapter, { + userId: user.id, + creditAmount: plan.creditsIncluded, + creditType: 'plan_allowance', + subscriptionCycleId: subscriptionCycle.id, + }); + // User spent 2000 credits in this cycle (from his plan allowance, which is 2500 credits) + await addToCreditsLedger(dbAdapter, { + userId: user.id, + creditAmount: -1000, + creditType: 'plan_allowance_used', + subscriptionCycleId: subscriptionCycle.id, + }); + await addToCreditsLedger(dbAdapter, { + userId: user.id, + creditAmount: -1000, + creditType: 'plan_allowance_used', + subscriptionCycleId: subscriptionCycle.id, + }); + // User added 100 additional credits in this cycle (even though user has some plan allowance left but for the sake of a more thorough test we want to simulate a purchase of extra credits) + await addToCreditsLedger(dbAdapter, { + userId: user.id, + creditAmount: 100, + creditType: 'extra_credit', + subscriptionCycleId: subscriptionCycle.id, + }); + // Next cycle + let stripeInvoicePaymentSucceededEvent = { + id: 'evt_1234567890', + object: 'event', + type: 'invoice.payment_succeeded', + data: { + object: { + id: 'in_1234567890', + object: 'invoice', + amount_paid: 12, + billing_reason: 'subscription_cycle', + period_start: 2, + period_end: 3, + subscription: 'sub_1234567890', + customer: 'cus_123', + lines: { + data: [ + { + amount: 1200, + type: 'subscription', + proration: false, + price: { product: 'prod_creator' }, + period: { + start: 20, + end: 30, + }, + }, + ], + }, + }, + }, + } as StripeInvoicePaymentSucceededWebhookEvent; + let availableCredits = await sumUpCreditsLedger(dbAdapter, { + userId: user.id, + }); + expect(availableCredits).toBe(plan.creditsIncluded - 2000 + 100); + await handlePaymentSucceeded(dbAdapter, stripeInvoicePaymentSucceededEvent); + // Assert that there are now two subscription cycles + let subscriptionCycles = await fetchSubscriptionCyclesBySubscriptionId(dbAdapter, subscription.id); + expect(subscriptionCycles.length).toBe(2); + // Assert both subscription cycles have the correct period start and end + expect(subscriptionCycles[0].periodStart).toBe(1); + expect(subscriptionCycles[0].periodEnd).toBe(2); + expect(subscriptionCycles[1].periodStart).toBe(20); + expect(subscriptionCycles[1].periodEnd).toBe(30); + // Assert that the ledger has the correct sum of credits going in and out + availableCredits = await sumUpCreditsLedger(dbAdapter, { + userId: user.id, + }); + expect(availableCredits).toBe(plan.creditsIncluded + 100); // Remaining credits from the previous cycle expired, new credits added, plus 100 from the extra credit + }); + }); + }); + describe('subscription deleted', function () { + it('handles subscription cancellation', async function () { + let user = await insertUser(dbAdapter, 'user@test', 'cus_123', 'user@test.com'); + let plan = await insertPlan(dbAdapter, 'Creator', 12, 2500, 'prod_creator'); + let subscription = await insertSubscription(dbAdapter, { + user_id: user.id, + plan_id: plan.id, + started_at: 1, + status: 'active', + stripe_subscription_id: 'sub_1234567890', + }); + await insertSubscriptionCycle(dbAdapter, { + subscriptionId: subscription.id, + periodStart: 1, + periodEnd: 2, + }); + let stripeSubscriptionDeletedEvent = { + id: 'evt_sub_deleted_1', + object: 'event', + type: 'customer.subscription.deleted', + data: { + object: { + id: 'sub_1234567890', + canceled_at: 2, + cancellation_details: { + reason: 'cancellation_requested', + }, + }, + }, + } as StripeSubscriptionDeletedWebhookEvent; + await handleSubscriptionDeleted(dbAdapter, stripeSubscriptionDeletedEvent); + let subscriptions = await fetchSubscriptionsByUserId(dbAdapter, user.id); + expect(subscriptions.length).toBe(1); + expect(subscriptions[0].status).toBe('canceled'); + expect(subscriptions[0].endedAt).toBe(2); + let availableCredits = await sumUpCreditsLedger(dbAdapter, { + userId: user.id, + }); + expect(availableCredits).toBe(0); + }); + }); + describe('checkout session completed', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let user: User; + let matrixUserId = '@pepe:cardstack.com'; + hooks.beforeEach(async function () { + user = await insertUser(dbAdapter, matrixUserId, 'cus_123', 'user@test.com'); + }); + describe('user has a subscription', function () { + it('add extra credits to user ledger when checkout session completed', async function () { + let creatorPlan = await insertPlan(dbAdapter, 'Creator', 12, 2500, 'prod_creator'); + let subscription = await insertSubscription(dbAdapter, { + user_id: user.id, + plan_id: creatorPlan.id, + started_at: 1, + status: 'active', + stripe_subscription_id: 'sub_1234567890', + }); + await insertSubscriptionCycle(dbAdapter, { + subscriptionId: subscription.id, + periodStart: 1, + periodEnd: 2, + }); + let stripeCheckoutSessionCompletedEvent = { + id: 'evt_1234567890', + object: 'event', + data: { + object: { + id: 'cs_test_1234567890', + object: 'checkout.session', + customer: null, + metadata: { + credit_reload_amount: '25000', + user_id: user.id, + }, + }, + }, + type: 'checkout.session.completed', + } as StripeCheckoutSessionCompletedWebhookEvent; + await handleCheckoutSessionCompleted(dbAdapter, stripeCheckoutSessionCompletedEvent); + let availableExtraCredits = await sumUpCreditsLedger(dbAdapter, { + userId: user.id, + creditType: 'extra_credit', + }); + expect(availableExtraCredits).toBe(25000); + }); + }); + describe('user does not have a subscription', function () { + it('add extra credits to user ledger when checkout session completed', async function () { + let stripeCheckoutSessionCompletedEvent = { + id: 'evt_1234567890', + object: 'event', + data: { + object: { + id: 'cs_test_1234567890', + object: 'checkout.session', + customer: null, + metadata: { + user_id: user.id, + credit_reload_amount: '25000', + }, + }, + }, + type: 'checkout.session.completed', + } as StripeCheckoutSessionCompletedWebhookEvent; + await handleCheckoutSessionCompleted(dbAdapter, stripeCheckoutSessionCompletedEvent); + let availableExtraCredits = await sumUpCreditsLedger(dbAdapter, { + userId: user.id, + creditType: 'extra_credit', + }); + expect(availableExtraCredits).toBe(25000); + }); + }); + }); + describe('AI usage tracking', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let user: User; + let creatorPlan: Plan; + let subscription: Subscription; + let subscriptionCycle: SubscriptionCycle; + hooks.beforeEach(async function () { + user = await insertUser(dbAdapter, 'testuser', 'cus_123', 'user@test.com'); + creatorPlan = await insertPlan(dbAdapter, 'Creator', 12, 2500, 'prod_creator'); + subscription = await insertSubscription(dbAdapter, { + user_id: user.id, + plan_id: creatorPlan.id, + started_at: 1, + status: 'active', + stripe_subscription_id: 'sub_1234567890', + }); + subscriptionCycle = await insertSubscriptionCycle(dbAdapter, { + subscriptionId: subscription.id, + periodStart: 1, + periodEnd: 2, + }); + }); + it('spends ai credits correctly when no extra credits are available', async function () { + // User receives 2500 credits for the creator plan and spends 2490 credits + await addToCreditsLedger(dbAdapter, { + userId: user.id, + creditAmount: creatorPlan.creditsIncluded, + creditType: 'plan_allowance', + subscriptionCycleId: subscriptionCycle.id, + }); + await addToCreditsLedger(dbAdapter, { + userId: user.id, + creditAmount: -2490, + creditType: 'plan_allowance_used', + subscriptionCycleId: subscriptionCycle.id, + }); + expect(await sumUpCreditsLedger(dbAdapter, { + userId: user.id, + })).toBe(10); + await spendCredits(dbAdapter, user.id, 2); + expect(await sumUpCreditsLedger(dbAdapter, { + userId: user.id, + })).toBe(8); + await spendCredits(dbAdapter, user.id, 5); + expect(await sumUpCreditsLedger(dbAdapter, { + userId: user.id, + })).toBe(3); + // Make sure that we can't spend more credits than the user has - in this case user has 3 credits left and we try to spend 5 + await spendCredits(dbAdapter, user.id, 5); + expect(await sumUpCreditsLedger(dbAdapter, { + userId: user.id, + })).toBe(0); + }); + it('does not spend previous cycle plan allowance in current cycle', async function () { + await addToCreditsLedger(dbAdapter, { + userId: user.id, + creditAmount: 100, + creditType: 'plan_allowance', + subscriptionCycleId: subscriptionCycle.id, + }); + let nextSubscriptionCycle = await insertSubscriptionCycle(dbAdapter, { + subscriptionId: subscription.id, + periodStart: 2, + periodEnd: 3, + }); + await addToCreditsLedger(dbAdapter, { + userId: user.id, + creditAmount: 50, + creditType: 'plan_allowance', + subscriptionCycleId: nextSubscriptionCycle.id, + }); + await spendCredits(dbAdapter, user.id, 80); + expect(await sumUpCreditsLedger(dbAdapter, { + subscriptionCycleId: subscriptionCycle.id, + creditType: [ + 'plan_allowance', + 'plan_allowance_used', + 'plan_allowance_expired', + ], + })).toBe(100); + expect(await sumUpCreditsLedger(dbAdapter, { + subscriptionCycleId: nextSubscriptionCycle.id, + creditType: [ + 'plan_allowance', + 'plan_allowance_used', + 'plan_allowance_expired', + ], + })).toBe(0); + expect(await sumUpCreditsLedger(dbAdapter, { + userId: user.id, + creditType: [ + 'plan_allowance', + 'plan_allowance_used', + 'plan_allowance_expired', + ], + })).toBe(100); + }); + it('spends ai credits correctly when extra credits are available', async function () { + // User receives 2500 credits for the creator plan and spends 2490 credits + await addToCreditsLedger(dbAdapter, { + userId: user.id, + creditAmount: creatorPlan.creditsIncluded, + creditType: 'plan_allowance', + subscriptionCycleId: subscriptionCycle.id, + }); + await addToCreditsLedger(dbAdapter, { + userId: user.id, + creditAmount: -2490, + creditType: 'plan_allowance_used', + subscriptionCycleId: subscriptionCycle.id, + }); + expect(await sumUpCreditsLedger(dbAdapter, { + userId: user.id, + })).toBe(10); + // Add 5 extra credits + await addToCreditsLedger(dbAdapter, { + userId: user.id, + creditAmount: 5, + creditType: 'extra_credit', + subscriptionCycleId: null, + }); + // User has 15 credits in total: 10 credits from the plan allowance and 5 extra credits + expect(await sumUpCreditsLedger(dbAdapter, { + userId: user.id, + })).toBe(15); + // This should spend 10 credits from the plan allowance and 2 from the extra credits + await spendCredits(dbAdapter, user.id, 12); + // Plan allowance is now 0, 3 credits left from the extra credits + expect(await sumUpCreditsLedger(dbAdapter, { + userId: user.id, + })).toBe(3); + // Make sure the available credits come from the extra credits and not the plan allowance + expect(await sumUpCreditsLedger(dbAdapter, { + userId: user.id, + creditType: ['plan_allowance', 'plan_allowance_used'], + })).toBe(0); + expect(await sumUpCreditsLedger(dbAdapter, { + userId: user.id, + creditType: ['extra_credit', 'extra_credit_used'], + })).toBe(3); + }); + it('spends ai credits using daily credits before extra credits', async function () { + await addToCreditsLedger(dbAdapter, { + userId: user.id, + creditAmount: 5, + creditType: 'plan_allowance', + subscriptionCycleId: subscriptionCycle.id, + }); + await addToCreditsLedger(dbAdapter, { + userId: user.id, + creditAmount: 4, + creditType: 'daily_credit', + subscriptionCycleId: null, + }); + await addToCreditsLedger(dbAdapter, { + userId: user.id, + creditAmount: 3, + creditType: 'extra_credit', + subscriptionCycleId: null, + }); + await spendCredits(dbAdapter, user.id, 10); + expect(await sumUpCreditsLedger(dbAdapter, { + userId: user.id, + creditType: [ + 'plan_allowance', + 'plan_allowance_used', + 'plan_allowance_expired', + ], + })).toBe(0); + expect(await sumUpCreditsLedger(dbAdapter, { + userId: user.id, + creditType: ['daily_credit', 'daily_credit_used'], + })).toBe(0); + expect(await sumUpCreditsLedger(dbAdapter, { + userId: user.id, + creditType: ['extra_credit', 'extra_credit_used'], + })).toBe(2); + }); + }); + describe('AI usage tracking without subscription', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let user: User; + hooks.beforeEach(async function () { + user = await insertUser(dbAdapter, 'free-user', 'cus_free', 'free-user@test.com'); + }); + it('spends daily credits before extra credits on free plan', async function () { + await addToCreditsLedger(dbAdapter, { + userId: user.id, + creditAmount: 4, + creditType: 'daily_credit', + subscriptionCycleId: null, + }); + await addToCreditsLedger(dbAdapter, { + userId: user.id, + creditAmount: 3, + creditType: 'extra_credit', + subscriptionCycleId: null, + }); + await spendCredits(dbAdapter, user.id, 5); + expect(await sumUpCreditsLedger(dbAdapter, { + userId: user.id, + creditType: ['daily_credit', 'daily_credit_used'], + })).toBe(0); + expect(await sumUpCreditsLedger(dbAdapter, { + userId: user.id, + creditType: ['extra_credit', 'extra_credit_used'], + })).toBe(2); + }); + }); + describe('daily credit grant', function () { + it('grants credits when user falls below the threshold', async function () { + let user = await insertUser(dbAdapter, 'low-credits@test', 'cus_low', 'low@test.com'); + await addToCreditsLedger(dbAdapter, { + userId: user.id, + creditAmount: 3, + creditType: 'extra_credit', + subscriptionCycleId: null, + }); + let task = dailyCreditGrant(buildDailyCreditGrantTaskArgs(dbAdapter)); + await task({ lowCreditThreshold: 10 }); + expect(await sumUpCreditsLedger(dbAdapter, { + userId: user.id, + creditType: ['daily_credit', 'daily_credit_used'], + })).toBe(7); + expect(await sumUpCreditsLedger(dbAdapter, { + userId: user.id, + })).toBe(10); + }); + it('does not grant twice on the same day', async function () { + let user = await insertUser(dbAdapter, 'repeat@test', 'cus_repeat', 'repeat@test.com'); + await addToCreditsLedger(dbAdapter, { + userId: user.id, + creditAmount: 2, + creditType: 'extra_credit', + subscriptionCycleId: null, + }); + let task = dailyCreditGrant(buildDailyCreditGrantTaskArgs(dbAdapter)); + await task({ lowCreditThreshold: 10 }); + await task({ lowCreditThreshold: 10 }); + let ledgerEntries = await fetchCreditsLedgerByUser(dbAdapter, user.id); + let dailyGrants = ledgerEntries.filter((entry) => entry.creditType === 'daily_credit'); + expect(dailyGrants.length).toBe(1); + }); + it('does not grant when user already meets threshold', async function () { + let user = await insertUser(dbAdapter, 'enough-credits@test', 'cus_enough', 'enough@test.com'); + await addToCreditsLedger(dbAdapter, { + userId: user.id, + creditAmount: 10, + creditType: 'extra_credit', + subscriptionCycleId: null, + }); + let task = dailyCreditGrant(buildDailyCreditGrantTaskArgs(dbAdapter)); + await task({ lowCreditThreshold: 10 }); + expect(await sumUpCreditsLedger(dbAdapter, { + userId: user.id, + creditType: ['daily_credit', 'daily_credit_used'], + })).toBe(0); + expect(await sumUpCreditsLedger(dbAdapter, { + userId: user.id, + })).toBe(10); + }); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/boxel-domain-availability.test.ts b/packages/realm-server/tests-vitest/boxel-domain-availability.test.ts new file mode 100644 index 00000000000..a947a3092cf --- /dev/null +++ b/packages/realm-server/tests-vitest/boxel-domain-availability.test.ts @@ -0,0 +1,171 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { fileURLToPath } from "url"; +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import { join, dirname } from 'path'; +import type { PgAdapter } from '@cardstack/postgres'; +import type { User } from '@cardstack/runtime-common'; +import { query, insert, asExpressions } from '@cardstack/runtime-common'; +import { setupDB, insertUser, runTestRealmServer, createVirtualNetwork, matrixURL, closeServer, } from './helpers'; +import type { RealmServerTokenClaim } from '../utils/jwt'; +import { createJWT as createRealmServerJWT } from '../utils/jwt'; +import { realmSecretSeed } from './helpers'; +import type { SuperTest, Test } from 'supertest'; +import supertest from 'supertest'; +import type { Server } from 'http'; +import { dirSync, type DirResult } from 'tmp'; +import { copySync, ensureDirSync } from 'fs-extra'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const testRealmURL = new URL('http://127.0.0.1:0/test/'); +describe("boxel-domain-availability-test.ts", function () { + describe('boxel domain availability endpoint', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let testRealmServer: Server; + let request: SuperTest; + let dir: DirResult; + let dbAdapter: PgAdapter; + let user: User; + let boxelSiteDomain = 'boxel.site'; + let defaultToken: RealmServerTokenClaim; + hooks.beforeEach(async function () { + dir = dirSync(); + }); + setupDB(hooks, { + beforeEach: async (_dbAdapter, publisher, runner) => { + dbAdapter = _dbAdapter; + let testRealmDir = join(dir.name, 'realm_server_5', 'test'); + ensureDirSync(testRealmDir); + copySync(join(__dirname, 'cards'), testRealmDir); + testRealmServer = (await runTestRealmServer({ + virtualNetwork: createVirtualNetwork(), + testRealmDir, + realmsRootPath: join(dir.name, 'realm_server_5'), + realmURL: testRealmURL, + dbAdapter, + publisher, + runner, + matrixURL, + domainsForPublishedRealms: { boxelSite: boxelSiteDomain }, + })).testRealmHttpServer; + request = supertest(testRealmServer); + user = await insertUser(dbAdapter, 'matrix-user-id', 'test-user', 'test-user@example.com'); + defaultToken = { + user: 'matrix-user-id', + sessionRoom: 'test-session', + }; + }, + afterEach: async () => { + await closeServer(testRealmServer); + }, + }); + async function makeCheckBoxelDomainRequest(token: RealmServerTokenClaim | null, subdomain?: string) { + let requestBuilder = request + .get('/_check-boxel-domain-availability') + .set('Accept', 'application/json'); + if (token) { + const jwt = createRealmServerJWT(token, realmSecretSeed); + requestBuilder = requestBuilder.set('Authorization', `Bearer ${jwt}`); + } + if (subdomain !== undefined) { + requestBuilder = requestBuilder.query({ subdomain }); + } + return await requestBuilder; + } + it('should return 422 when subdomain is missing', async function () { + const response = await makeCheckBoxelDomainRequest(defaultToken); + expect(response.status).toBe(422); + expect(response.text.includes('subdomain query parameter is required')).toBeTruthy(); + }); + it('should return 200 with error for invalid subdomains', async function () { + const invalidSubdomains = [ + 'api', + 'admin', + 'test', + 'api-v2', + 'v1', + '123', + 'my-api', + 'test-admin', + 'app-test', + '', + 'a', + 'a'.repeat(64), + 'test@domain', + 'test.domain', + '-test', + 'test-', + 'MyApp', + 'TEST', + // Punycode/homoglyph attack protection + 'xn--test', + 'xn--example-123', + 'tëst', // non-ASCII character + 'test™', // trademark symbol + 'tеst', // Cyrillic 'e' (homoglyph) + ]; + for (const subdomain of invalidSubdomains) { + const response = await makeCheckBoxelDomainRequest(defaultToken, subdomain); + expect(response.status).toBe(200); + const responseBody = response.body; + expect(responseBody.available).toBe(false); + expect(responseBody.error).toBeTruthy(); + } + }); + it('should return 200 with available=true for valid unclaimed subdomains', async function () { + const validSubdomains = ['mike', 'my-company']; + for (const subdomain of validSubdomains) { + const response = await makeCheckBoxelDomainRequest(defaultToken, subdomain); + expect(response.status).toBe(200); + const responseBody = response.body; + expect(responseBody).toBeTruthy(); + expect(responseBody.available).toBe(true); + expect(responseBody.error).toBe(undefined); + expect(responseBody.hostname).toBeTruthy(); + expect(responseBody.hostname.includes(subdomain)).toBeTruthy(); + } + }); + it('should return 200 with available=false for claimed subdomains', async function () { + const subdomain = 'claimed-site'; + const hostname = `${subdomain}.${boxelSiteDomain}`; + let { valueExpressions, nameExpressions: nameExpressions } = asExpressions({ + user_id: user.id, + source_realm_url: `https://${boxelSiteDomain}/test-realm`, + hostname: hostname, + claimed_at: Math.floor(Date.now() / 1000), + }); + await query(dbAdapter, insert('claimed_domains_for_sites', nameExpressions, valueExpressions)); + const response = await makeCheckBoxelDomainRequest(defaultToken, subdomain); + expect(response.status).toBe(200); + const responseBody = response.body; + expect(responseBody).toBeTruthy(); + expect(responseBody.available).toBe(false); + expect(responseBody.error).toBe(undefined); + expect(responseBody.hostname).toBe(hostname); + }); + it('should return available=true for removed/unclaimed subdomains', async function () { + const subdomain = 'removed-site'; + const hostname = `${subdomain}.${boxelSiteDomain}`; + // Insert a claimed domain that has been removed + let { valueExpressions, nameExpressions } = asExpressions({ + user_id: user.id, + source_realm_url: `https://${boxelSiteDomain}/test-realm`, + hostname: hostname, + claimed_at: Math.floor(Date.now() / 1000) - 86400, // claimed yesterday + removed_at: Math.floor(Date.now() / 1000), // removed now + }); + await query(dbAdapter, insert('claimed_domains_for_sites', nameExpressions, valueExpressions)); + const response = await makeCheckBoxelDomainRequest(defaultToken, subdomain); + expect(response.status).toBe(200); + const responseBody = response.body; + expect(responseBody).toBeTruthy(); + expect(responseBody.available).toBe(true); + expect(responseBody.error).toBe(undefined); + expect(responseBody.hostname.includes(subdomain)).toBeTruthy(); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/card-dependencies-endpoint.test.ts b/packages/realm-server/tests-vitest/card-dependencies-endpoint.test.ts new file mode 100644 index 00000000000..db5ae78b1eb --- /dev/null +++ b/packages/realm-server/tests-vitest/card-dependencies-endpoint.test.ts @@ -0,0 +1,114 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import type { Test, SuperTest } from 'supertest'; +import type { Server } from 'http'; +import type { DirResult } from 'tmp'; +import type { Realm } from '@cardstack/runtime-common'; +import { setupPermissionedRealmCached, testRealmHref, createJWT, } from './helpers'; +import '@cardstack/runtime-common/helpers/code-equality-assertion'; +describe("card-dependencies-endpoint-test.ts", function () { + describe('Realm-specific Endpoints | card dependencies requests', function () { + let testRealm: Realm; + let request: SuperTest; + function onRealmSetup(args: { + testRealm: Realm; + testRealmHttpServer: Server; + request: SuperTest; + dir: DirResult; + }) { + testRealm = args.testRealm; + request = args.request; + } + describe('card dependencies GET request', function () { + const _hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + describe('public readable realm', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + permissions: { + '*': ['read'], + }, + onRealmSetup, + }); + it('serves the request', async function () { + let response = await request + .get(`/_card-dependencies?url=${testRealm.url}person`) + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'john', ['read'])}`); + expect(response.status).toBe(200); + expect(response.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(response.get('X-boxel-realm-public-readable')).toBe('true'); + let result: string[] = JSON.parse(response.text.trim()); + expect(result.includes('https://cardstack.com/base/card-api')).toBeTruthy(); + expect(result.includes('http://127.0.0.1:4444/person')).toBe(false); + }); + it('serves the request with a .json extension', async function () { + let response = await request + .get(`/_card-dependencies?url=${testRealm.url}person.json`) + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'john', ['read'])}`); + let result: string[] = JSON.parse(response.text.trim()); + expect(result.includes('https://cardstack.com/base/card-api')).toBeTruthy(); + expect(result.includes('http://127.0.0.1:4444/person')).toBe(false); + }); + it('gives 404 for a non-existent card', async function () { + let response = await request + .get(`/_card-dependencies?url=${testRealm.url}non-existent-card`) + .set('Accept', 'application/json'); + expect(response.status).toBe(404); + }); + }); + describe('permissioned realm', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + permissions: { + john: ['read'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + onRealmSetup, + }); + it('401 with invalid JWT', async function () { + let response = await request + .get(`/_card-dependencies?url=${testRealm.url}person`) + .set('Accept', 'application/json') + .set('Authorization', `Bearer invalid-token`); + expect(response.status).toBe(401); + }); + it('401 without a JWT', async function () { + let response = await request + .get(`/_card-dependencies?url=${testRealm.url}person`) + .set('Accept', 'application/json'); // no Authorization header + expect(response.status).toBe(401); + }); + it('403 without permission', async function () { + let response = await request + .get(`/_card-dependencies?url=${testRealm.url}person`) + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'not-john')}`); + expect(response.status).toBe(403); + }); + it('200 with permission', async function () { + let response = await request + .get(`/_card-dependencies?url=${testRealm.url}person`) + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'john', ['read'])}`); + expect(response.status).toBe(200); + }); + }); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/card-endpoints.test.ts b/packages/realm-server/tests-vitest/card-endpoints.test.ts new file mode 100644 index 00000000000..9c8bec798c9 --- /dev/null +++ b/packages/realm-server/tests-vitest/card-endpoints.test.ts @@ -0,0 +1,3308 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import type { Test, SuperTest } from 'supertest'; +import supertest from 'supertest'; +import { join } from 'path'; +import type { Server } from 'http'; +import type { DirResult } from 'tmp'; +import { existsSync, readJSONSync, statSync, writeFileSync } from 'fs-extra'; +import type { Realm, Relationship, ResourceID, } from '@cardstack/runtime-common'; +import { baseRealm, isSingleCardDocument, type LooseSingleCardDocument, type SingleCardDocument, } from '@cardstack/runtime-common'; +import { parse } from 'qs'; +import type { Query } from '@cardstack/runtime-common/query'; +import { setupPermissionedRealmCached, setupPermissionedRealmsCached, setupMatrixRoom, closeServer, testRealmInfo, cleanWhiteSpace, createJWT, testRealmServerMatrixUserId, cardInfo, type RealmRequest, withRealmPath, } from './helpers'; +import { expectIncrementalIndexEvent } from './helpers/indexing'; +import '@cardstack/runtime-common/helpers/code-equality-assertion'; +import { resetCatalogRealms } from '../handlers/handle-fetch-catalog-realms'; +import type { PgAdapter } from '@cardstack/postgres'; +function parseSearchQuery(searchURL: URL) { + let queryParam = searchURL.searchParams.get('query'); + if (queryParam != null) { + return parse(queryParam) as Record; + } + return parse(searchURL.searchParams.toString()) as Record; +} +// Create minimal valid PNG bytes for testing +function makeMinimalPng(): Uint8Array { + let signature = [137, 80, 78, 71, 13, 10, 26, 10]; + let ihdrData = new Uint8Array(13); + let ihdrView = new DataView(ihdrData.buffer); + ihdrView.setUint32(0, 1); // width + ihdrView.setUint32(4, 1); // height + ihdrData[8] = 8; // bit depth + ihdrData[9] = 2; // color type (RGB) + let ihdrChunk = buildPngChunk('IHDR', ihdrData); + let idatData = new Uint8Array([ + 0x08, 0xd7, 0x01, 0x00, 0x00, 0xff, 0xff, 0x00, 0x01, 0x00, 0x01, + ]); + let idatChunk = buildPngChunk('IDAT', idatData); + let iendChunk = buildPngChunk('IEND', new Uint8Array(0)); + let totalLength = signature.length + ihdrChunk.length + idatChunk.length + iendChunk.length; + let png = new Uint8Array(totalLength); + let offset = 0; + png.set(signature, offset); + offset += signature.length; + png.set(ihdrChunk, offset); + offset += ihdrChunk.length; + png.set(idatChunk, offset); + offset += idatChunk.length; + png.set(iendChunk, offset); + return png; +} +function buildPngChunk(type: string, data: Uint8Array): Uint8Array { + let chunk = new Uint8Array(4 + 4 + data.length + 4); + let view = new DataView(chunk.buffer); + view.setUint32(0, data.length); + for (let i = 0; i < 4; i++) { + chunk[4 + i] = type.charCodeAt(i); + } + chunk.set(data, 8); + let crc = 0xffffffff; + let crcData = chunk.slice(4, 8 + data.length); + for (let i = 0; i < crcData.length; i++) { + crc ^= crcData[i]!; + for (let j = 0; j < 8; j++) { + crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1; + } + } + view.setUint32(8 + data.length, (crc ^ 0xffffffff) >>> 0); + return chunk; +} +describe("card-endpoints-test.ts", function () { + describe('Realm-specific Endpoints | card URLs', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let realmURL = new URL('http://127.0.0.1:4444/test/'); + let testRealmHref = realmURL.href; + let testRealm: Realm; + let testRealmHttpServer: Server; + let request: RealmRequest; + let serverRequest: SuperTest; + let dir: DirResult; + let dbAdapter: PgAdapter; + function onRealmSetup(args: { + testRealm: Realm; + testRealmHttpServer: Server; + request: SuperTest; + dir: DirResult; + dbAdapter: PgAdapter; + }) { + testRealm = args.testRealm; + testRealmHttpServer = args.testRealmHttpServer; + serverRequest = args.request; + request = withRealmPath(args.request, realmURL); + dir = args.dir; + dbAdapter = args.dbAdapter; + } + function getRealmSetup() { + return { + testRealm, + testRealmHttpServer, + request, + serverRequest, + dir, + dbAdapter, + }; + } + hooks.afterEach(async function () { + await closeServer(testRealmHttpServer); + resetCatalogRealms(); + }); + describe('card GET request', function () { + const _hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + describe('public readable realm', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + realmURL, + permissions: { + '*': ['read'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + onRealmSetup, + }); + it('serves the request', async function () { + let response = await request + .get('/person-1') + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(200); + let json = response.body; + expect(json.data.meta.lastModified).toBeTruthy(); + delete json.data.meta.lastModified; + delete json.data.meta.resourceCreatedAt; + expect(response.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(response.get('X-boxel-realm-public-readable')).toBe('true'); + expect(json).toEqual({ + data: { + id: `${testRealmHref}person-1`, + type: 'card', + attributes: { + cardTitle: 'Mango', + cardInfo, + firstName: 'Mango', + cardDescription: null, + cardThumbnailURL: null, + }, + relationships: { + 'cardInfo.theme': { + links: { + self: null, + }, + }, + }, + meta: { + adoptsFrom: { + module: `./person`, + name: 'Person', + }, + realmInfo: testRealmInfo, + realmURL: testRealmHref, + }, + links: { + self: `${testRealmHref}person-1`, + }, + }, + }); + }); + it('serves a card error request without last known good state', async function () { + let response = await request + .get('/missing-link') + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(500); + let json = response.body; + expect(response.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(response.get('X-boxel-realm-public-readable')).toBe('true'); + let errorBody = json.errors[0]; + expect(errorBody.meta.stack.includes('at Realm.getSourceOrRedirect')).toBeTruthy(); + delete errorBody.meta.stack; + expect(errorBody.id).toBe(`${testRealmHref}missing-link`); + expect(errorBody.status).toBe(404); + expect(errorBody.title).toBe('Link Not Found'); + expect(errorBody.message).toBe(`missing file ${testRealmHref}does-not-exist.json`); + expect(errorBody.realm).toBe(testRealmHref); + expect(errorBody.meta.lastKnownGoodHtml).toBe(null); + expect(errorBody.meta.cardTitle).toBe(null); + expect(Array.isArray(errorBody.meta.scopedCssUrls)).toBeTruthy(); + if (errorBody.meta.scopedCssUrls.length > 0) { + expect(errorBody.meta.scopedCssUrls.every((scopedCssUrl: string) => scopedCssUrl.endsWith('.glimmer-scoped.css'))).toBeTruthy(); + } + else { + expect(errorBody.meta.scopedCssUrls).toEqual([]); + } + }); + it('includes FileDef resources for file links in included payload', async function () { + let { testRealm: realm, request, dir: testDir } = getRealmSetup(); + // Write image files directly to the filesystem so they are on disk + // but NOT yet in the index. This exercises the render-store's + // extractFileMetaDirectly path: when the card is prerendered, the + // images haven't been indexed yet, so getFileMetaInstance must fetch + // and extract attributes directly from the raw file bytes. + let realmDir = join(testDir.name, 'realm_server_1', 'test'); + let pngBytes = makeMinimalPng(); + writeFileSync(join(realmDir, 'hero.png'), pngBytes); + writeFileSync(join(realmDir, 'first.png'), pngBytes); + writeFileSync(join(realmDir, 'second.png'), pngBytes); + // Write module + card instance — card is indexed before images + await realm.writeMany(new Map([ + [ + 'gallery.gts', + ` + import { CardDef, field, linksTo, linksToMany } from "https://cardstack.com/base/card-api"; + import { FileDef } from "https://cardstack.com/base/file-api"; + + export class Gallery extends CardDef { + @field hero = linksTo(FileDef); + @field attachments = linksToMany(FileDef); + } + `, + ], + [ + 'gallery.json', + JSON.stringify({ + data: { + attributes: {}, + relationships: { + hero: { + links: { + self: './hero.png', + }, + }, + 'attachments.0': { + links: { + self: './first.png', + }, + }, + 'attachments.1': { + links: { + self: './second.png', + }, + }, + }, + meta: { + adoptsFrom: { + module: './gallery.gts', + name: 'Gallery', + }, + }, + }, + }), + ], + ])); + // Now index the image files so they appear in loadLinks results + await realm.writeMany(new Map([ + ['hero.png', pngBytes], + ['first.png', pngBytes], + ['second.png', pngBytes], + ])); + let response = await request + .get('/gallery') + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(200); + let doc = response.body as LooseSingleCardDocument; + expect(Array.isArray(doc.included)).toBeTruthy(); + let included = doc.included ?? []; + let hero = included.find((resource) => resource.id === `${testRealmHref}hero.png`); + let first = included.find((resource) => resource.id === `${testRealmHref}first.png`); + let second = included.find((resource) => resource.id === `${testRealmHref}second.png`); + expect(hero).toBeTruthy(); + expect(first).toBeTruthy(); + expect(second).toBeTruthy(); + expect(hero?.type).toBe('file-meta'); + expect(hero?.attributes?.name).toBe('hero.png'); + expect(hero?.attributes?.contentType).toBe('image/png'); + expect(hero?.meta?.adoptsFrom).toEqual({ + module: `${baseRealm.url}png-image-def`, + name: 'PngDef', + }); + expect((doc.data.relationships?.hero as Relationship)?.data).toEqual({ + type: 'file-meta', + id: `${testRealmHref}hero.png`, + }); + expect((doc.data.relationships?.['attachments.0'] as Relationship)?.data).toEqual({ + type: 'file-meta', + id: `${testRealmHref}first.png`, + }); + expect((doc.data.relationships?.['attachments.1'] as Relationship)?.data).toEqual({ + type: 'file-meta', + id: `${testRealmHref}second.png`, + }); + }); + it('linksTo relationship for CardDef uses card type not file-meta', async function () { + let { testRealm: realm, request, dbAdapter } = getRealmSetup(); + let writes = new Map([ + [ + 'tag.gts', + ` + import { CardDef, field, contains } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Tag extends CardDef { + @field label = contains(StringField); + @field cardTitle = contains(StringField, { + computeVia: function (this: Tag) { + return this.label; + }, + }); + } + `, + ], + [ + 'article.gts', + ` + import { CardDef, field, contains, linksTo } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + import { Tag } from "./tag"; + + export class Article extends CardDef { + @field title = contains(StringField); + @field tag = linksTo(Tag); + @field cardTitle = contains(StringField, { + computeVia: function (this: Article) { + return this.title; + }, + }); + } + `, + ], + [ + 'Tag/programming.json', + JSON.stringify({ + data: { + attributes: { + label: 'Programming', + }, + meta: { + adoptsFrom: { + module: '../tag.gts', + name: 'Tag', + }, + }, + }, + }), + ], + [ + 'Article/hello-world.json', + JSON.stringify({ + data: { + attributes: { + title: 'Hello World', + }, + relationships: { + tag: { + links: { + self: '../Tag/programming', + }, + }, + }, + meta: { + adoptsFrom: { + module: '../article.gts', + name: 'Article', + }, + }, + }, + }), + ], + ]); + await realm.writeMany(writes); + // Verify the relationship is correct with a fresh index + let response = await request + .get('/Article/hello-world') + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(200); + let doc = response.body as LooseSingleCardDocument; + let tagRelationship = doc.data.relationships?.tag as Relationship; + expect(tagRelationship).toBeTruthy(); + expect(tagRelationship.data).toEqual({ + type: 'card', + id: `${testRealmHref}Tag/programming`, + }); + // Now simulate a stale index where the pristine_doc relationship + // lacks data.type (as it would be before commit 480362eb12 which + // added data to NotLoadedValue serialization in LinksTo.serialize). + // Also remove the linked card's instance entry so getInstance + // returns nothing, forcing the getFile fallback path. + let articleAlias = `${testRealmHref}Article/hello-world`; + let tagAlias = `${testRealmHref}Tag/programming`; + await dbAdapter.execute(`UPDATE boxel_index + SET pristine_doc = pristine_doc #- '{relationships,tag,data}' + WHERE file_alias = '${articleAlias}' + AND type = 'instance'`); + await dbAdapter.execute(`UPDATE boxel_index + SET is_deleted = TRUE + WHERE file_alias = '${tagAlias}' + AND type = 'instance'`); + let response2 = await request + .get('/Article/hello-world') + .set('Accept', 'application/vnd.card+json'); + expect(response2.status).toBe(200); + let doc2 = response2.body as LooseSingleCardDocument; + let tagRelationship2 = doc2.data.relationships?.tag as Relationship; + expect(tagRelationship2).toBeTruthy(); + expect((tagRelationship2.data as ResourceID)?.type).toBe('card'); + }); + it('stale linksTo(FileDef subclass) relationship data.type of card is corrected to file-meta', async function () { + let { testRealm: realm, request, dbAdapter } = getRealmSetup(); + await realm.writeMany(new Map([ + [ + 'skill-card.gts', + ` + import { CardDef, field, contains, linksTo } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + import { MarkdownDef } from "https://cardstack.com/base/markdown-file-def"; + + export class SkillCard extends CardDef { + @field cardTitle = contains(StringField); + @field instructionsSource = linksTo(MarkdownDef); + } + `, + ], + [ + 'Skill/example.json', + JSON.stringify({ + data: { + attributes: { + cardTitle: 'Example Skill', + }, + relationships: { + instructionsSource: { + links: { + self: '../instructions.md', + }, + }, + }, + meta: { + adoptsFrom: { + module: '../skill-card.gts', + name: 'SkillCard', + }, + }, + }, + }), + ], + ['instructions.md', '# Example Instructions'], + ])); + let response = await request + .get('/Skill/example') + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(200); + let doc = response.body as LooseSingleCardDocument; + let relationship = doc.data.relationships + ?.instructionsSource as Relationship; + expect(relationship?.data).toEqual({ + type: 'file-meta', + id: `${testRealmHref}instructions.md`, + }); + let included = doc.included ?? []; + let linkedFile = included.find((resource) => resource.id === `${testRealmHref}instructions.md`); + expect(linkedFile).toBeTruthy(); + expect(linkedFile?.type).toBe('file-meta'); + let instanceAlias = `${testRealmHref}Skill/example`; + let markdownFileURL = `${testRealmHref}instructions.md`; + await dbAdapter.execute(`UPDATE boxel_index + SET pristine_doc = jsonb_set( + pristine_doc, + '{relationships,instructionsSource,data}', + '{"type":"card","id":"${markdownFileURL}"}'::jsonb, + true + ) + WHERE file_alias = '${instanceAlias}' + AND type = 'instance'`); + let staleResponse = await request + .get('/Skill/example') + .set('Accept', 'application/vnd.card+json'); + expect(staleResponse.status).toBe(200); + let staleDoc = staleResponse.body as LooseSingleCardDocument; + let staleRelationship = staleDoc.data.relationships + ?.instructionsSource as Relationship; + expect(staleRelationship?.data).toEqual({ + type: 'file-meta', + id: markdownFileURL, + }); + }); + it('card-level query-backed relationships resolve via search at read time', async function () { + let { testRealm: realm, request } = getRealmSetup(); + let writes = new Map([ + [ + 'query-person-finder.gts', + ` + import { CardDef, field, contains, linksTo, linksToMany } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + import { Person } from "./person"; + + export class QueryPersonFinder extends CardDef { + @field cardTitle = contains(StringField); + @field favorite = linksTo(Person, { + query: { + filter: { + eq: { firstName: '$this.cardTitle' }, + }, + }, + }); + @field matches = linksToMany(Person, { + query: { + filter: { + eq: { firstName: '$this.cardTitle' }, + }, + }, + }); + } + `, + ], + [ + 'query-person-finder.json', + JSON.stringify({ + data: { + attributes: { + cardTitle: 'Mango', + }, + meta: { + adoptsFrom: { + module: './query-person-finder.gts', + name: 'QueryPersonFinder', + }, + }, + }, + }), + ], + ]); + await realm.writeMany(writes); + let response = await request + .get('/query-person-finder') + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(200); + let doc = response.body; + let favorite = doc.data.relationships.favorite; + expect(favorite.data).toEqual({ type: 'card', id: `${testRealmHref}person-1` }); + expect(favorite.links.self).toBe(`./person-1`); + let matchesRelationship = doc.data.relationships['matches.0']; + expect(matchesRelationship).toBeTruthy(); + expect(matchesRelationship.data).toEqual({ type: 'card', id: `${testRealmHref}person-1` }); + expect(Array.isArray(doc.included)).toBeTruthy(); + expect(doc.included.some((resource: any) => resource.id === `${testRealmHref}person-1`)).toBeTruthy(); + }); + it('field-level query-backed relationships resolve at read time (nested contains)', async function () { + let { testRealm: realm, request } = getRealmSetup(); + let writes = new Map([ + [ + 'query-person-finder-nested.gts', + ` + import { CardDef, FieldDef, field, contains, linksTo, linksToMany } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + import { Person } from "./person"; + + export class QueryLinksField extends FieldDef { + @field cardTitle = contains(StringField); + @field favorite = linksTo(Person, { + query: { + filter: { + eq: { firstName: '$this.cardTitle' }, + }, + }, + }); + @field matches = linksToMany(Person, { + query: { + filter: { + eq: { firstName: '$this.cardTitle' }, + }, + }, + }); + } + + export class WrapperField extends FieldDef { + @field queries = contains(QueryLinksField); + } + + export class OuterQueryCard extends CardDef { + @field info = contains(WrapperField); + } + + export class DeepWrapperField extends FieldDef { + @field inner = contains(WrapperField); + } + + export class DeepOuterQueryCard extends CardDef { + @field details = contains(DeepWrapperField); + } + `, + ], + [ + 'query-person-finder-nested.json', + JSON.stringify({ + data: { + attributes: { + info: { + queries: { + cardTitle: 'Mango', + }, + }, + }, + meta: { + adoptsFrom: { + module: './query-person-finder-nested.gts', + name: 'OuterQueryCard', + }, + }, + }, + }), + ], + [ + 'query-person-finder-deep.json', + JSON.stringify({ + data: { + attributes: { + details: { + inner: { + queries: { + cardTitle: 'Mango', + }, + }, + }, + }, + meta: { + adoptsFrom: { + module: './query-person-finder-nested.gts', + name: 'DeepOuterQueryCard', + }, + }, + }, + }), + ], + ]); + await realm.writeMany(writes); + let response = await request + .get('/query-person-finder-nested') + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(200); + let doc = response.body; + expect(doc.data.relationships['info.queries.favorite']?.data).toEqual({ type: 'card', id: `${testRealmHref}person-1` }); + expect(doc.data.relationships['info.queries.favorite']?.links?.self).toBe(`./person-1`); + expect(doc.data.relationships['info.queries.matches.0']?.data).toEqual({ type: 'card', id: `${testRealmHref}person-1` }); + let deepResponse = await request + .get('/query-person-finder-deep') + .set('Accept', 'application/vnd.card+json'); + expect(deepResponse.status).toBe(200); + let deepDoc = deepResponse.body; + expect(deepDoc.data.relationships['details.inner.queries.favorite']?.data).toEqual({ type: 'card', id: `${testRealmHref}person-1` }); + expect(deepDoc.data.relationships['details.inner.queries.matches.0']?.data).toEqual({ type: 'card', id: `${testRealmHref}person-1` }); + }); + }); + describe('published realm', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + realmURL, + permissions: { + '*': ['read'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + onRealmSetup, + published: true, + }); + it('serves the request', async function () { + let response = await request + .get('/person-1') + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(200); + let json = response.body; + delete json.data.meta.lastModified; + delete json.data.meta.resourceCreatedAt; + expect(response.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(response.get('X-boxel-realm-public-readable')).toBe('true'); + expect(json).toEqual({ + data: { + id: `${testRealmHref}person-1`, + type: 'card', + attributes: { + cardTitle: 'Mango', + firstName: 'Mango', + cardDescription: null, + cardThumbnailURL: null, + cardInfo, + }, + meta: { + adoptsFrom: { + module: `./person`, + name: 'Person', + }, + realmInfo: testRealmInfo, + realmURL: testRealmHref, + }, + relationships: { + 'cardInfo.theme': { + links: { + self: null, + }, + }, + }, + links: { + self: `${testRealmHref}person-1`, + }, + }, + }); + }); + }); + // using public writable realm to make it easy for test setup for the error tests + describe('public writable realm', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + realmURL, + permissions: { + '*': ['read', 'write'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + onRealmSetup, + }); + it('serves a card error request with last known good state', async function () { + await request + .patch('/hassan') + .send({ + data: { + type: 'card', + relationships: { + friend: { + links: { + self: './does-not-exist', + }, + }, + }, + meta: { + adoptsFrom: { + module: './friend.gts', + name: 'Friend', + }, + }, + }, + }) + .set('Accept', 'application/vnd.card+json'); + let response = await request + .get('/hassan') + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(500); + let json = response.body; + expect(response.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(response.get('X-boxel-realm-public-readable')).toBe('true'); + let errorBody = json.errors[0]; + let lastKnownGoodHtml = cleanWhiteSpace(errorBody.meta.lastKnownGoodHtml); + expect(errorBody.meta.stack.includes('at Realm.getSourceOrRedirect')).toBeTruthy(); + expect(errorBody.status).toBe(404); + expect(errorBody.title).toBe('Link Not Found'); + expect(errorBody.message).toBe(`missing file ${testRealmHref}does-not-exist.json`); + expect(lastKnownGoodHtml.includes('Hassan has a friend')).toBeTruthy(); + expect(lastKnownGoodHtml.includes('Jade')).toBeTruthy(); + let scopedCssUrls = errorBody.meta.scopedCssUrls; + assertScopedCssUrlsContain(assert, scopedCssUrls, cardDefModuleDependencies); + }); + }); + describe('permissioned realm', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + realmURL, + permissions: { + john: ['read'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + onRealmSetup, + }); + it('401 with invalid JWT', async function () { + let response = await request + .get('/person-1') + .set('Accept', 'application/vnd.card+json') + .set('Authorization', `Bearer invalid-token`); + expect(response.status).toBe(401); + expect(response.get('X-boxel-realm-public-readable')).toBe(undefined); + }); + it('401 without a JWT', async function () { + let response = await request + .get('/person-1') + .set('Accept', 'application/vnd.card+json'); // no Authorization header + expect(response.status).toBe(401); + expect(response.get('X-boxel-realm-public-readable')).toBe(undefined); + }); + it('403 without permission', async function () { + let response = await request + .get('/person-1') + .set('Accept', 'application/vnd.card+json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'not-john')}`); + expect(response.status).toBe(403); + expect(response.get('X-boxel-realm-public-readable')).toBe(undefined); + }); + it('200 with permission', async function () { + let response = await request + .get('/person-1') + .set('Accept', 'application/vnd.card+json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'john', ['read'])}`); + expect(response.status).toBe(200); + expect(response.get('X-boxel-realm-public-readable')).toBe(undefined); + }); + it('200 when server user assumes user that has read permission', async function () { + let response = await request + .get('/person-1') + .set('Accept', 'application/vnd.card+json') + .set('X-Boxel-Assume-User', 'john') + .set('Authorization', `Bearer ${createJWT(testRealm, testRealmServerMatrixUserId, ['assume-user'])}`); + expect(response.status).toBe(200); + expect(response.get('X-boxel-realm-public-readable')).toBe(undefined); + }); + it('403 when server user assumes user that has no read permission', async function () { + let response = await request + .get('/person-1') + .set('Accept', 'application/vnd.card+json') + .set('X-Boxel-Assume-User', 'not-john') + .set('Authorization', `Bearer ${createJWT(testRealm, testRealmServerMatrixUserId, ['assume-user'])}`); + expect(response.status).toBe(403); + expect(response.get('X-boxel-realm-public-readable')).toBe(undefined); + }); + }); + }); + describe('card POST request', function () { + const _hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + describe('public writable realm', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + realmURL, + permissions: { + '*': ['read', 'write'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + onRealmSetup, + }); + let { getMessagesSince } = setupMatrixRoom(hooks, getRealmSetup); + it('serves the request', async function () { + let realmEventTimestampStart = Date.now(); + let response = await request + .post('/') + .send({ + data: { + type: 'card', + attributes: {}, + meta: { + adoptsFrom: { + module: 'https://cardstack.com/base/card-api', + name: 'CardDef', + }, + }, + }, + }) + .set('Accept', 'application/vnd.card+json'); + let incrementalEventContent = await expectIncrementalIndexEvent(testRealmHref, realmEventTimestampStart, { + assert, + getMessagesSince, + realm: testRealmHref, + timeout: 5000, + }); + let id = incrementalEventContent.invalidations[0].split('/').pop()!; + expect(response.status).toBe(201); + expect(response.get('x-created')).toBeTruthy(); + expect(response.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(response.get('X-boxel-realm-public-readable')).toBe('true'); + let json = response.body; + expect(isSingleCardDocument(json)).toBe(true); + expect(json.data.id).toBe(`${testRealmHref}CardDef/${id}`); + expect(json.data.meta.lastModified).toBeTruthy(); + let cardFile = join(dir.name, 'realm_server_1', 'test', 'CardDef', `${id}.json`); + expect(existsSync(cardFile)).toBeTruthy(); + let card = readJSONSync(cardFile); + expect(card).toEqual({ + data: { + attributes: {}, + type: 'card', + meta: { + adoptsFrom: { + module: 'https://cardstack.com/base/card-api', + name: 'CardDef', + }, + }, + }, + }); + }); + it('creates card instances when it encounters "lid" in the request', async function () { + let response = await request + .post('/') + .send({ + data: { + type: 'card', + attributes: { + firstName: 'Hassan', + }, + relationships: { + friend: { + data: { + lid: 'local-id-1', + type: 'card', + }, + }, + }, + meta: { + adoptsFrom: { + module: 'http://localhost:4202/node-test/friend', + name: 'Friend', + }, + }, + }, + included: [ + { + lid: 'local-id-1', + type: 'card', + attributes: { + firstName: 'Jade', + }, + relationships: { + 'friends.0': { + data: { + lid: 'local-id-2', + type: 'card', + }, + }, + 'friends.1': { + data: { + lid: 'local-id-3', + type: 'card', + }, + }, + }, + meta: { + adoptsFrom: { + module: 'http://localhost:4202/node-test/friend', + name: 'Friend', + }, + }, + }, + { + lid: 'local-id-2', + type: 'card', + attributes: { + firstName: 'Germaine', + }, + meta: { + adoptsFrom: { + module: 'http://localhost:4202/node-test/friend', + name: 'Friend', + }, + }, + }, + { + lid: 'local-id-3', + type: 'card', + attributes: { + firstName: 'Boris', + }, + meta: { + adoptsFrom: { + module: 'http://localhost:4202/node-test/friend', + name: 'Friend', + }, + }, + }, + ], + } as LooseSingleCardDocument) + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(201); + expect(response.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(response.get('X-boxel-realm-public-readable')).toBe('true'); + let json = response.body as SingleCardDocument; + let id = json.data.id!.split('/').pop()!; + { + let cardFile = join(dir.name, 'realm_server_1', 'test', 'Friend', `${id}.json`); + expect(existsSync(cardFile)).toBeTruthy(); + let card = readJSONSync(cardFile); + expect(card).toEqual({ + data: { + type: 'card', + attributes: { + firstName: 'Hassan', + }, + relationships: { + friend: { + links: { + self: './local-id-1', + }, + }, + }, + meta: { + adoptsFrom: { + module: 'http://localhost:4202/node-test/friend', + name: 'Friend', + }, + }, + }, + } as LooseSingleCardDocument); + } + { + let cardFile = join(dir.name, 'realm_server_1', 'test', 'Friend', `local-id-1.json`); + expect(existsSync(cardFile)).toBeTruthy(); + let card = readJSONSync(cardFile); + expect(card).toEqual({ + data: { + type: 'card', + attributes: { + firstName: 'Jade', + }, + relationships: { + 'friends.0': { + links: { + self: './local-id-2', + }, + }, + 'friends.1': { + links: { + self: './local-id-3', + }, + }, + }, + meta: { + adoptsFrom: { + module: 'http://localhost:4202/node-test/friend', + name: 'Friend', + }, + }, + }, + } as LooseSingleCardDocument); + } + { + let cardFile = join(dir.name, 'realm_server_1', 'test', 'Friend', `local-id-2.json`); + expect(existsSync(cardFile)).toBeTruthy(); + let card = readJSONSync(cardFile); + expect(card).toEqual({ + data: { + type: 'card', + attributes: { + firstName: 'Germaine', + }, + meta: { + adoptsFrom: { + module: 'http://localhost:4202/node-test/friend', + name: 'Friend', + }, + }, + }, + } as LooseSingleCardDocument); + } + { + let cardFile = join(dir.name, 'realm_server_1', 'test', 'Friend', `local-id-3.json`); + expect(existsSync(cardFile)).toBeTruthy(); + let card = readJSONSync(cardFile); + expect(card).toEqual({ + data: { + type: 'card', + attributes: { + firstName: 'Boris', + }, + meta: { + adoptsFrom: { + module: 'http://localhost:4202/node-test/friend', + name: 'Friend', + }, + }, + }, + } as LooseSingleCardDocument); + } + { + let response = await request + .get(`/Friend/${id}`) + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(200); + let json = response.body; + expect(json.data.meta.lastModified).toBeTruthy(); + delete json.data.meta.lastModified; + delete json.data.meta.resourceCreatedAt; + expect(response.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(json.data).toEqual({ + id: `${testRealmHref}Friend/${id}`, + type: 'card', + attributes: { + firstName: 'Hassan', + cardTitle: 'Hassan', + cardDescription: null, + cardThumbnailURL: null, + cardInfo, + }, + relationships: { + friend: { + links: { + self: './local-id-1', + }, + data: { + type: 'card', + id: `${testRealmHref}Friend/local-id-1`, + }, + }, + 'cardInfo.theme': { + links: { + self: null, + }, + }, + }, + meta: { + adoptsFrom: { + name: 'Friend', + module: 'http://localhost:4202/node-test/friend', + }, + realmInfo: testRealmInfo, + realmURL: testRealmHref, + }, + links: { + self: `${testRealmHref}Friend/${id}`, + }, + }); + for (let resource of json.included!) { + delete resource.meta.realmURL; + delete resource.meta.realmInfo; + delete resource.meta.lastModified; + delete resource.meta.resourceCreatedAt; + delete resource.links; + } + expect(json.included).toEqual([ + { + id: `${testRealmHref}Friend/local-id-1`, + type: 'card', + attributes: { + firstName: 'Jade', + cardTitle: 'Jade', + cardDescription: null, + cardThumbnailURL: null, + cardInfo, + }, + relationships: { + 'friends.0': { + links: { + self: './local-id-2', + }, + data: { + id: `${testRealmHref}Friend/local-id-2`, + type: 'card', + }, + }, + 'friends.1': { + links: { + self: './local-id-3', + }, + data: { + id: `${testRealmHref}Friend/local-id-3`, + type: 'card', + }, + }, + friend: { + links: { + self: null, + }, + }, + 'cardInfo.theme': { + links: { + self: null, + }, + }, + }, + meta: { + adoptsFrom: { + module: 'http://localhost:4202/node-test/friend', + name: 'Friend', + }, + }, + }, + { + id: `${testRealmHref}Friend/local-id-2`, + type: 'card', + attributes: { + firstName: 'Germaine', + cardTitle: 'Germaine', + cardDescription: null, + cardThumbnailURL: null, + cardInfo, + }, + relationships: { + friend: { + links: { + self: null, + }, + }, + 'cardInfo.theme': { + links: { + self: null, + }, + }, + }, + meta: { + adoptsFrom: { + module: 'http://localhost:4202/node-test/friend', + name: 'Friend', + }, + }, + }, + { + id: `${testRealmHref}Friend/local-id-3`, + type: 'card', + attributes: { + firstName: 'Boris', + cardTitle: 'Boris', + cardDescription: null, + cardThumbnailURL: null, + cardInfo, + }, + relationships: { + friend: { + links: { + self: null, + }, + }, + 'cardInfo.theme': { + links: { + self: null, + }, + }, + }, + meta: { + adoptsFrom: { + module: 'http://localhost:4202/node-test/friend', + name: 'Friend', + }, + }, + }, + ]); + } + { + let response = await request + .get(`/Friend/local-id-1`) + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(200); + let json = response.body; + expect(json.data.meta.lastModified).toBeTruthy(); + delete json.data.meta.lastModified; + delete json.data.meta.resourceCreatedAt; + expect(response.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(json.data).toEqual({ + id: `${testRealmHref}Friend/local-id-1`, + type: 'card', + attributes: { + firstName: 'Jade', + cardTitle: 'Jade', + cardDescription: null, + cardThumbnailURL: null, + cardInfo, + }, + relationships: { + 'friends.0': { + links: { + self: './local-id-2', + }, + data: { + id: `${testRealmHref}Friend/local-id-2`, + type: 'card', + }, + }, + 'friends.1': { + links: { + self: './local-id-3', + }, + data: { + id: `${testRealmHref}Friend/local-id-3`, + type: 'card', + }, + }, + friend: { + links: { + self: null, + }, + }, + 'cardInfo.theme': { + links: { + self: null, + }, + }, + }, + meta: { + adoptsFrom: { + name: 'Friend', + module: 'http://localhost:4202/node-test/friend', + }, + realmInfo: testRealmInfo, + realmURL: testRealmHref, + }, + links: { + self: `${testRealmHref}Friend/local-id-1`, + }, + }); + for (let resource of json.included!) { + delete resource.meta.realmURL; + delete resource.meta.realmInfo; + delete resource.meta.lastModified; + delete resource.meta.resourceCreatedAt; + delete resource.links; + } + expect(json.included).toEqual([ + { + id: `${testRealmHref}Friend/local-id-2`, + type: 'card', + attributes: { + firstName: 'Germaine', + cardTitle: 'Germaine', + cardDescription: null, + cardThumbnailURL: null, + cardInfo, + }, + relationships: { + friend: { + links: { + self: null, + }, + }, + 'cardInfo.theme': { + links: { + self: null, + }, + }, + }, + meta: { + adoptsFrom: { + module: 'http://localhost:4202/node-test/friend', + name: 'Friend', + }, + }, + }, + { + id: `${testRealmHref}Friend/local-id-3`, + type: 'card', + attributes: { + firstName: 'Boris', + cardTitle: 'Boris', + cardDescription: null, + cardThumbnailURL: null, + cardInfo, + }, + relationships: { + friend: { + links: { + self: null, + }, + }, + 'cardInfo.theme': { + links: { + self: null, + }, + }, + }, + meta: { + adoptsFrom: { + module: 'http://localhost:4202/node-test/friend', + name: 'Friend', + }, + }, + }, + ]); + } + { + let response = await request + .get(`/Friend/local-id-2`) + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(200); + let json = response.body; + expect(json.data.meta.lastModified).toBeTruthy(); + delete json.data.meta.lastModified; + delete json.data.meta.resourceCreatedAt; + expect(response.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(json).toEqual({ + data: { + id: `${testRealmHref}Friend/local-id-2`, + type: 'card', + attributes: { + firstName: 'Germaine', + cardTitle: 'Germaine', + cardDescription: null, + cardThumbnailURL: null, + cardInfo, + }, + relationships: { + friend: { + links: { + self: null, + }, + }, + 'cardInfo.theme': { + links: { + self: null, + }, + }, + }, + meta: { + adoptsFrom: { + name: 'Friend', + module: 'http://localhost:4202/node-test/friend', + }, + realmInfo: testRealmInfo, + realmURL: testRealmHref, + }, + links: { + self: `${testRealmHref}Friend/local-id-2`, + }, + }, + }); + } + { + let response = await request + .get(`/Friend/local-id-3`) + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(200); + let json = response.body; + expect(json.data.meta.lastModified).toBeTruthy(); + delete json.data.meta.lastModified; + delete json.data.meta.resourceCreatedAt; + expect(response.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(json).toEqual({ + data: { + id: `${testRealmHref}Friend/local-id-3`, + type: 'card', + attributes: { + firstName: 'Boris', + cardTitle: 'Boris', + cardDescription: null, + cardThumbnailURL: null, + cardInfo, + }, + relationships: { + friend: { + links: { + self: null, + }, + }, + 'cardInfo.theme': { + links: { + self: null, + }, + }, + }, + meta: { + adoptsFrom: { + name: 'Friend', + module: 'http://localhost:4202/node-test/friend', + }, + realmInfo: testRealmInfo, + realmURL: testRealmHref, + }, + links: { + self: `${testRealmHref}Friend/local-id-3`, + }, + }, + }); + } + }); + it('ignores "lid" for other realms', async function () { + let response = await request + .post('/') + .send({ + data: { + type: 'card', + attributes: { + firstName: 'Hassan', + }, + relationships: { + friend: { + data: { + lid: 'local-id-3', + type: 'card', + }, + }, + }, + meta: { + adoptsFrom: { + module: 'http://localhost:4202/node-test/friend', + name: 'Friend', + }, + }, + }, + included: [ + { + lid: 'local-id-3', + type: 'card', + attributes: { + firstName: 'Boris', + }, + meta: { + adoptsFrom: { + module: 'http://localhost:4202/node-test/friend', + name: 'Friend', + }, + realmURL: `http://some-other-realm/`, + }, + }, + ], + } as LooseSingleCardDocument) + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(201); + expect(response.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(response.get('X-boxel-realm-public-readable')).toBe('true'); + let json = response.body as SingleCardDocument; + let id = json.data.id!.split('/').pop()!; + { + let cardFile = join(dir.name, 'realm_server_1', 'test', 'Friend', `${id}.json`); + expect(existsSync(cardFile)).toBeTruthy(); + let card = readJSONSync(cardFile); + expect(card).toEqual({ + data: { + type: 'card', + attributes: { + firstName: 'Hassan', + }, + relationships: { + friend: { + links: { self: null }, + }, + }, + meta: { + adoptsFrom: { + module: 'http://localhost:4202/node-test/friend', + name: 'Friend', + }, + }, + }, + } as LooseSingleCardDocument); + } + { + let cardFile = join(dir.name, 'realm_server_1', 'test', 'Friend', `local-id-3.json`); + expect(existsSync(cardFile)).toBe(false); + } + }); + it('creates card instance when it encounters "lid" in the primary resource', async function () { + let response = await request + .post('/') + .send({ + data: { + type: 'card', + lid: 'local-id-1', + attributes: { + firstName: 'Hassan', + }, + meta: { + adoptsFrom: { + module: 'http://localhost:4202/node-test/friend', + name: 'Friend', + }, + realmURL: testRealmHref.replace(/\/$/, ''), + }, + }, + } as LooseSingleCardDocument) + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(201); + expect(response.get('X-boxel-realm-url')).toBe(testRealmHref); + let json = response.body as SingleCardDocument; + let id = json.data.id!.split('/').pop()!; + let cardFile = join(dir.name, 'realm_server_1', 'test', 'Friend', `${id}.json`); + expect(existsSync(cardFile)).toBeTruthy(); + let card = readJSONSync(cardFile); + expect(card).toEqual({ + data: { + type: 'card', + attributes: { + firstName: 'Hassan', + }, + meta: { + adoptsFrom: { + module: 'http://localhost:4202/node-test/friend', + name: 'Friend', + }, + }, + }, + } as LooseSingleCardDocument); + { + let response = await request + .get(`/Friend/${id}`) + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(200); + let json = response.body; + expect(json.data.meta.lastModified).toBeTruthy(); + delete json.data.meta.lastModified; + delete json.data.meta.resourceCreatedAt; + expect(response.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(json.data).toEqual({ + id: `${testRealmHref}Friend/${id}`, + type: 'card', + attributes: { + firstName: 'Hassan', + cardTitle: 'Hassan', + cardDescription: null, + cardThumbnailURL: null, + cardInfo, + }, + relationships: { + friend: { + links: { + self: null, + }, + }, + 'cardInfo.theme': { + links: { + self: null, + }, + }, + }, + meta: { + adoptsFrom: { + name: 'Friend', + module: 'http://localhost:4202/node-test/friend', + }, + realmInfo: testRealmInfo, + realmURL: testRealmHref, + }, + links: { + self: `${testRealmHref}Friend/${id}`, + }, + }); + } + }); + }); + describe('permissioned realm', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + realmURL, + permissions: { + john: ['read', 'write'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + onRealmSetup, + }); + it('401 with invalid JWT', async function () { + let response = await request + .post('/') + .send({}) + .set('Accept', 'application/vnd.card+json') + .set('Authorization', `Bearer invalid-token`); + expect(response.status).toBe(401); + }); + it('401 without a JWT', async function () { + let response = await request + .post('/') + .send({}) + .set('Accept', 'application/vnd.card+json'); // no Authorization header + expect(response.status).toBe(401); + }); + it('401 permissions have been updated', async function () { + let response = await request + .post('/') + .send({}) + .set('Accept', 'application/vnd.card+json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'john', ['read'])}`); + expect(response.status).toBe(401); + }); + it('403 without permission', async function () { + let response = await request + .post('/') + .send({}) + .set('Accept', 'application/vnd.card+json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'not-john')}`); + expect(response.status).toBe(403); + }); + it('201 with permission', async function () { + let response = await request + .post('/') + .send({ + data: { + type: 'card', + attributes: {}, + meta: { + adoptsFrom: { + module: 'https://cardstack.com/base/card-api', + name: 'CardDef', + }, + }, + }, + }) + .set('Accept', 'application/vnd.card+json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'john', ['read', 'write'])}`); + expect(response.status).toBe(201); + }); + }); + }); + describe('card PATCH request', function () { + const _hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + describe('public writable realm', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + realmURL, + permissions: { + '*': ['read', 'write'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + onRealmSetup, + }); + let { getMessagesSince } = setupMatrixRoom(hooks, getRealmSetup); + it('serves the request', async function () { + let entry = 'person-1.json'; + let response = await request + .patch('/person-1') + .send({ + data: { + type: 'card', + attributes: { + firstName: 'Van Gogh', + }, + meta: { + adoptsFrom: { + module: './person.gts', + name: 'Person', + }, + }, + }, + }) + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(200); + expect(response.get('x-created')).toBeTruthy(); + expect(response.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(response.get('X-boxel-realm-public-readable')).toBe('true'); + let json = response.body; + expect(json.data.meta.lastModified).toBeTruthy(); + expect(isSingleCardDocument(json)).toBe(true); + expect(json.data.attributes?.firstName).toBe('Van Gogh'); + expect(json.data.meta.lastModified).toBeTruthy(); + delete json.data.meta.lastModified; + delete json.data.meta.resourceCreatedAt; + let cardFile = join(dir.name, 'realm_server_1', 'test', entry); + expect(existsSync(cardFile)).toBeTruthy(); + let card = readJSONSync(cardFile); + expect(card).toEqual({ + data: { + type: 'card', + attributes: { + firstName: 'Van Gogh', + cardInfo, + }, + relationships: { + 'cardInfo.theme': { + links: { + self: null, + }, + }, + }, + meta: { + adoptsFrom: { + module: `./person`, + name: 'Person', + }, + }, + }, + }); + let query: Query = { + filter: { + on: { + module: `${testRealmHref}person`, + name: 'Person', + }, + eq: { + firstName: 'Van Gogh', + }, + }, + }; + response = await request + .post('/_search') + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send({ ...query }); + expect(response.status).toBe(200); + expect(response.body.data.length).toBe(1); + }); + it('no-op patch returns existing lastModified and does not rewrite file', async function () { + let cardFile = join(dir.name, 'realm_server_1', 'test', 'person-1.json'); + let initialStat = statSync(cardFile); + let initialResponse = await request + .get('/person-1') + .set('Accept', 'application/vnd.card+json'); + expect(initialResponse.status).toBe(200); + let initialLastModified = initialResponse.body.data.meta.lastModified; + expect(initialLastModified).toBeTruthy(); + let response = await request + .patch('/person-1') + .send({ + data: { + type: 'card', + meta: { + adoptsFrom: { + module: './person', + name: 'Person', + }, + }, + }, + }) + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(200); + expect(response.body.data.meta.lastModified).toBe(initialLastModified); + expect(response.body.data.attributes?.firstName).toBe('Mango'); + let afterStat = statSync(cardFile); + expect(afterStat.mtimeMs).toBe(initialStat.mtimeMs); + }); + it('patches card when index entry is an error using pristine doc', async function () { + let cardURL = `${testRealmHref}person-1`; + let errorDoc = { + message: 'render failed', + status: 500, + additionalErrors: null, + }; + for (let table of ['boxel_index', 'boxel_index_working']) { + await dbAdapter.execute(`UPDATE ${table} + SET has_error = TRUE, error_doc = $1::jsonb + WHERE url = $2`, { + bind: [JSON.stringify(errorDoc), cardURL], + }); + } + let response = await request + .patch('/person-1') + .send({ + data: { + type: 'card', + attributes: { + firstName: 'Recovered', + }, + meta: { + adoptsFrom: { + module: './person.gts', + name: 'Person', + }, + }, + }, + }) + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(200); + expect(response.body.data.attributes?.firstName).toBe('Recovered'); + let cardFile = join(dir.name, 'realm_server_1', 'test', 'person-1.json'); + let card = readJSONSync(cardFile); + expect(card.data.attributes?.firstName).toBe('Recovered'); + expect(card.data.relationships?.['cardInfo.theme']).toEqual({ links: { self: null } }); + }); + it('patches card when index entry is an error without pristine doc', async function () { + let cardURL = `${testRealmHref}person-1`; + let errorDoc = { + message: 'render failed', + status: 500, + additionalErrors: null, + }; + for (let table of ['boxel_index', 'boxel_index_working']) { + await dbAdapter.execute(`UPDATE ${table} + SET has_error = TRUE, error_doc = $1::jsonb, pristine_doc = NULL + WHERE url = $2`, { + bind: [JSON.stringify(errorDoc), cardURL], + }); + } + let response = await request + .patch('/person-1') + .send({ + data: { + type: 'card', + attributes: { + firstName: 'Fresh Start', + }, + meta: { + adoptsFrom: { + module: './person.gts', + name: 'Person', + }, + }, + }, + }) + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(200); + expect(response.body.data.attributes?.firstName).toBe('Fresh Start'); + let cardFile = join(dir.name, 'realm_server_1', 'test', 'person-1.json'); + let card = readJSONSync(cardFile); + expect(card.data.attributes?.firstName).toBe('Fresh Start'); + expect(card.data.meta.adoptsFrom).toEqual({ + module: './person', + name: 'Person', + }); + expect(card.data.type).toBe('card'); + }); + it('creates card instances when it encounters "lid" in the request', async function () { + let response = await request + .patch('/hassan') + .send({ + data: { + type: 'card', + attributes: { + firstName: 'Paper', + }, + relationships: { + friend: { + data: { + lid: 'local-id-1', + type: 'card', + }, + }, + }, + meta: { + adoptsFrom: { + module: './friend', + name: 'Friend', + }, + }, + }, + included: [ + { + lid: 'local-id-1', + type: 'card', + attributes: { + firstName: 'Jade', + }, + relationships: { + 'friends.0': { + data: { + lid: 'local-id-2', + type: 'card', + }, + }, + 'friends.1': { + data: { + lid: 'local-id-3', + type: 'card', + }, + }, + }, + meta: { + adoptsFrom: { + module: './friend', + name: 'Friend', + }, + }, + }, + { + lid: 'local-id-2', + type: 'card', + attributes: { + firstName: 'Germaine', + }, + meta: { + adoptsFrom: { + module: './friend', + name: 'Friend', + }, + }, + }, + { + lid: 'local-id-3', + type: 'card', + attributes: { + firstName: 'Boris', + }, + meta: { + adoptsFrom: { + module: './friend', + name: 'Friend', + }, + }, + }, + ], + } as LooseSingleCardDocument) + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(200); + expect(response.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(response.get('X-boxel-realm-public-readable')).toBe('true'); + let json = response.body; + expect(json.data.meta.lastModified).toBeTruthy(); + expect(isSingleCardDocument(json)).toBe(true); + expect(json.data.attributes?.firstName).toBe('Paper'); + expect(json.data.meta.lastModified).toBeTruthy(); + delete json.data.meta.lastModified; + delete json.data.meta.resourceCreatedAt; + { + let cardFile = join(dir.name, 'realm_server_1', 'test', 'hassan.json'); + expect(existsSync(cardFile)).toBeTruthy(); + let card = readJSONSync(cardFile); + expect(card).toEqual({ + data: { + type: 'card', + attributes: { + firstName: 'Paper', + cardInfo, + }, + relationships: { + friend: { + links: { + self: './Friend/local-id-1', + }, + }, + 'cardInfo.theme': { + links: { + self: null, + }, + }, + }, + meta: { + adoptsFrom: { + module: `./friend`, + name: 'Friend', + }, + }, + }, + }); + } + { + let cardFile = join(dir.name, 'realm_server_1', 'test', 'Friend', `local-id-1.json`); + expect(existsSync(cardFile)).toBeTruthy(); + let card = readJSONSync(cardFile); + expect(card).toEqual({ + data: { + type: 'card', + attributes: { + firstName: 'Jade', + }, + relationships: { + 'friends.0': { + links: { + self: './local-id-2', + }, + }, + 'friends.1': { + links: { + self: './local-id-3', + }, + }, + }, + meta: { + adoptsFrom: { + module: '../friend', + name: 'Friend', + }, + }, + }, + } as LooseSingleCardDocument); + } + { + let cardFile = join(dir.name, 'realm_server_1', 'test', 'Friend', `local-id-2.json`); + expect(existsSync(cardFile)).toBeTruthy(); + let card = readJSONSync(cardFile); + expect(card).toEqual({ + data: { + type: 'card', + attributes: { + firstName: 'Germaine', + }, + meta: { + adoptsFrom: { + module: '../friend', + name: 'Friend', + }, + }, + }, + } as LooseSingleCardDocument); + } + { + let cardFile = join(dir.name, 'realm_server_1', 'test', 'Friend', `local-id-3.json`); + expect(existsSync(cardFile)).toBeTruthy(); + let card = readJSONSync(cardFile); + expect(card).toEqual({ + data: { + type: 'card', + attributes: { + firstName: 'Boris', + }, + meta: { + adoptsFrom: { + module: '../friend', + name: 'Friend', + }, + }, + }, + } as LooseSingleCardDocument); + } + { + let response = await request + .get(`/hassan`) + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(200); + let json = response.body; + expect(json.data.meta.lastModified).toBeTruthy(); + delete json.data.meta.lastModified; + delete json.data.meta.resourceCreatedAt; + expect(response.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(json.data).toEqual({ + id: `${testRealmHref}hassan`, + type: 'card', + attributes: { + firstName: 'Paper', + cardInfo, + cardTitle: 'Paper', + cardDescription: null, + cardThumbnailURL: null, + }, + relationships: { + friend: { + links: { + self: './Friend/local-id-1', + }, + data: { + type: 'card', + id: `${testRealmHref}Friend/local-id-1`, + }, + }, + 'cardInfo.theme': { + links: { + self: null, + }, + }, + }, + meta: { + adoptsFrom: { + name: 'Friend', + module: './friend', + }, + realmInfo: testRealmInfo, + realmURL: testRealmHref, + }, + links: { + self: `${testRealmHref}hassan`, + }, + }); + for (let resource of json.included!) { + delete resource.meta.realmURL; + delete resource.meta.realmInfo; + delete resource.meta.lastModified; + delete resource.meta.resourceCreatedAt; + delete resource.links; + } + expect(json.included).toEqual([ + { + id: `${testRealmHref}Friend/local-id-1`, + type: 'card', + attributes: { + firstName: 'Jade', + cardTitle: 'Jade', + cardInfo, + cardDescription: null, + cardThumbnailURL: null, + }, + relationships: { + 'friends.0': { + links: { + self: './Friend/local-id-2', + }, + data: { + id: `${testRealmHref}Friend/local-id-2`, + type: 'card', + }, + }, + 'friends.1': { + links: { + self: './Friend/local-id-3', + }, + data: { + id: `${testRealmHref}Friend/local-id-3`, + type: 'card', + }, + }, + friend: { + links: { + self: null, + }, + }, + 'cardInfo.theme': { + links: { + self: null, + }, + }, + }, + meta: { + adoptsFrom: { + module: './friend', + name: 'Friend', + }, + }, + }, + { + id: `${testRealmHref}Friend/local-id-2`, + type: 'card', + attributes: { + cardInfo, + firstName: 'Germaine', + cardTitle: 'Germaine', + cardDescription: null, + cardThumbnailURL: null, + }, + relationships: { + friend: { + links: { + self: null, + }, + }, + 'cardInfo.theme': { + links: { + self: null, + }, + }, + }, + meta: { + adoptsFrom: { + module: './friend', + name: 'Friend', + }, + }, + }, + { + id: `${testRealmHref}Friend/local-id-3`, + type: 'card', + attributes: { + cardInfo, + firstName: 'Boris', + cardTitle: 'Boris', + cardDescription: null, + cardThumbnailURL: null, + }, + relationships: { + friend: { + links: { + self: null, + }, + }, + 'cardInfo.theme': { + links: { + self: null, + }, + }, + }, + meta: { + adoptsFrom: { + module: './friend', + name: 'Friend', + }, + }, + }, + ]); + } + { + let response = await request + .get(`/Friend/local-id-1`) + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(200); + let json = response.body; + expect(json.data.meta.lastModified).toBeTruthy(); + delete json.data.meta.lastModified; + delete json.data.meta.resourceCreatedAt; + expect(response.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(json.data).toEqual({ + id: `${testRealmHref}Friend/local-id-1`, + type: 'card', + attributes: { + firstName: 'Jade', + cardTitle: 'Jade', + cardDescription: null, + cardThumbnailURL: null, + cardInfo, + }, + relationships: { + 'friends.0': { + links: { + self: './local-id-2', + }, + data: { + id: `${testRealmHref}Friend/local-id-2`, + type: 'card', + }, + }, + 'friends.1': { + links: { + self: './local-id-3', + }, + data: { + id: `${testRealmHref}Friend/local-id-3`, + type: 'card', + }, + }, + friend: { + links: { + self: null, + }, + }, + 'cardInfo.theme': { + links: { + self: null, + }, + }, + }, + meta: { + adoptsFrom: { + name: 'Friend', + module: '../friend', + }, + realmInfo: testRealmInfo, + realmURL: testRealmHref, + }, + links: { + self: `${testRealmHref}Friend/local-id-1`, + }, + }); + for (let resource of json.included!) { + delete resource.meta.realmURL; + delete resource.meta.realmInfo; + delete resource.meta.lastModified; + delete resource.meta.resourceCreatedAt; + delete resource.links; + } + expect(json.included).toEqual([ + { + id: `${testRealmHref}Friend/local-id-2`, + type: 'card', + attributes: { + firstName: 'Germaine', + cardTitle: 'Germaine', + cardDescription: null, + cardThumbnailURL: null, + cardInfo, + }, + relationships: { + friend: { + links: { + self: null, + }, + }, + 'cardInfo.theme': { + links: { + self: null, + }, + }, + }, + meta: { + adoptsFrom: { + module: '../friend', + name: 'Friend', + }, + }, + }, + { + id: `${testRealmHref}Friend/local-id-3`, + type: 'card', + attributes: { + firstName: 'Boris', + cardTitle: 'Boris', + cardDescription: null, + cardThumbnailURL: null, + cardInfo, + }, + relationships: { + friend: { + links: { + self: null, + }, + }, + 'cardInfo.theme': { + links: { + self: null, + }, + }, + }, + meta: { + adoptsFrom: { + module: '../friend', + name: 'Friend', + }, + }, + }, + ]); + } + { + let response = await request + .get(`/Friend/local-id-2`) + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(200); + let json = response.body; + expect(json.data.meta.lastModified).toBeTruthy(); + delete json.data.meta.lastModified; + delete json.data.meta.resourceCreatedAt; + expect(response.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(json).toEqual({ + data: { + id: `${testRealmHref}Friend/local-id-2`, + type: 'card', + attributes: { + firstName: 'Germaine', + cardTitle: 'Germaine', + cardDescription: null, + cardThumbnailURL: null, + cardInfo, + }, + relationships: { + friend: { + links: { + self: null, + }, + }, + 'cardInfo.theme': { + links: { + self: null, + }, + }, + }, + meta: { + adoptsFrom: { + name: 'Friend', + module: '../friend', + }, + realmInfo: testRealmInfo, + realmURL: testRealmHref, + }, + links: { + self: `${testRealmHref}Friend/local-id-2`, + }, + }, + }); + } + { + let response = await request + .get(`/Friend/local-id-3`) + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(200); + let json = response.body; + expect(json.data.meta.lastModified).toBeTruthy(); + delete json.data.meta.lastModified; + delete json.data.meta.resourceCreatedAt; + expect(response.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(json).toEqual({ + data: { + id: `${testRealmHref}Friend/local-id-3`, + type: 'card', + attributes: { + firstName: 'Boris', + cardTitle: 'Boris', + cardDescription: null, + cardThumbnailURL: null, + cardInfo, + }, + relationships: { + friend: { + links: { + self: null, + }, + }, + 'cardInfo.theme': { + links: { + self: null, + }, + }, + }, + meta: { + adoptsFrom: { + name: 'Friend', + module: '../friend', + }, + realmInfo: testRealmInfo, + realmURL: testRealmHref, + }, + links: { + self: `${testRealmHref}Friend/local-id-3`, + }, + }, + }); + } + }); + it('creates card instances when it encounters "lid" in the request for requests that has "isUsed: true" links', async function () { + let response = await request + .patch('/hassan-x') + .send({ + data: { + type: 'card', + attributes: { + firstName: 'Paper', + }, + relationships: { + friend: { + data: { + lid: 'local-id-1', + type: 'card', + }, + }, + }, + meta: { + adoptsFrom: { + module: 'http://localhost:4202/node-test/friend-with-used-link', + name: 'FriendWithUsedLink', + }, + }, + }, + included: [ + { + lid: 'local-id-1', + type: 'card', + attributes: { + firstName: 'Jade', + }, + meta: { + adoptsFrom: { + module: 'http://localhost:4202/node-test/friend-with-used-link', + name: 'FriendWithUsedLink', + }, + }, + }, + ], + } as LooseSingleCardDocument) + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(200); + expect(response.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(response.get('X-boxel-realm-public-readable')).toBe('true'); + let json = response.body; + expect(json.data.meta.lastModified).toBeTruthy(); + expect(isSingleCardDocument(json)).toBe(true); + expect(json.data.attributes?.firstName).toBe('Paper'); + expect(json.data.meta.lastModified).toBeTruthy(); + delete json.data.meta.lastModified; + delete json.data.meta.resourceCreatedAt; + { + let cardFile = join(dir.name, 'realm_server_1', 'test', 'hassan-x.json'); + expect(existsSync(cardFile)).toBeTruthy(); + let card = readJSONSync(cardFile); + expect(card).toEqual({ + data: { + type: 'card', + attributes: { + firstName: 'Paper', + cardInfo, + }, + relationships: { + friend: { + links: { + self: './FriendWithUsedLink/local-id-1', + }, + }, + 'cardInfo.theme': { + links: { + self: null, + }, + }, + }, + meta: { + adoptsFrom: { + module: 'http://localhost:4202/node-test/friend-with-used-link', + name: 'FriendWithUsedLink', + }, + }, + }, + }); + } + { + let cardFile = join(dir.name, 'realm_server_1', 'test', 'FriendWithUsedLink', `local-id-1.json`); + expect(existsSync(cardFile)).toBeTruthy(); + let card = readJSONSync(cardFile); + expect(card).toEqual({ + data: { + type: 'card', + attributes: { + firstName: 'Jade', + }, + meta: { + adoptsFrom: { + module: 'http://localhost:4202/node-test/friend-with-used-link', + name: 'FriendWithUsedLink', + }, + }, + }, + } as LooseSingleCardDocument); + } + { + let response = await request + .get(`/hassan-x`) + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(200); + let json = response.body; + expect(json.data.meta.lastModified).toBeTruthy(); + delete json.data.meta.lastModified; + delete json.data.meta.resourceCreatedAt; + expect(response.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(json.data).toEqual({ + id: `${testRealmHref}hassan-x`, + type: 'card', + attributes: { + firstName: 'Paper', + cardTitle: 'Paper', + cardDescription: null, + cardThumbnailURL: null, + cardInfo, + }, + relationships: { + friend: { + links: { + self: './FriendWithUsedLink/local-id-1', + }, + data: { + type: 'card', + id: `${testRealmHref}FriendWithUsedLink/local-id-1`, + }, + }, + 'cardInfo.theme': { + links: { + self: null, + }, + }, + }, + meta: { + adoptsFrom: { + name: 'FriendWithUsedLink', + module: 'http://localhost:4202/node-test/friend-with-used-link', + }, + realmInfo: testRealmInfo, + realmURL: testRealmHref, + }, + links: { + self: `${testRealmHref}hassan-x`, + }, + }); + for (let resource of json.included!) { + delete resource.meta.realmURL; + delete resource.meta.realmInfo; + delete resource.meta.lastModified; + delete resource.meta.resourceCreatedAt; + delete resource.links; + } + expect(json.included).toEqual([ + { + id: `${testRealmHref}FriendWithUsedLink/local-id-1`, + type: 'card', + attributes: { + firstName: 'Jade', + cardTitle: 'Jade', + cardDescription: null, + cardThumbnailURL: null, + cardInfo, + }, + relationships: { + friend: { + links: { + self: null, + }, + }, + 'cardInfo.theme': { + links: { + self: null, + }, + }, + }, + meta: { + adoptsFrom: { + module: 'http://localhost:4202/node-test/friend-with-used-link', + name: 'FriendWithUsedLink', + }, + }, + }, + ]); + } + { + let response = await request + .get(`/FriendWithUsedLink/local-id-1`) + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(200); + let json = response.body; + expect(json.data.meta.lastModified).toBeTruthy(); + delete json.data.meta.lastModified; + delete json.data.meta.resourceCreatedAt; + expect(response.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(json.data).toEqual({ + id: `${testRealmHref}FriendWithUsedLink/local-id-1`, + type: 'card', + attributes: { + firstName: 'Jade', + cardTitle: 'Jade', + cardDescription: null, + cardThumbnailURL: null, + cardInfo, + }, + relationships: { + friend: { + links: { + self: null, + }, + }, + 'cardInfo.theme': { + links: { + self: null, + }, + }, + }, + meta: { + adoptsFrom: { + name: 'FriendWithUsedLink', + module: 'http://localhost:4202/node-test/friend-with-used-link', + }, + realmInfo: testRealmInfo, + realmURL: testRealmHref, + }, + links: { + self: `${testRealmHref}FriendWithUsedLink/local-id-1`, + }, + }); + } + }); + it('ignores "lid" for other realms', async function () { + let response = await request + .patch('/hassan') + .send({ + data: { + type: 'card', + attributes: { + firstName: 'Paper', + }, + relationships: { + friend: { + data: { + lid: 'local-id-3', + type: 'card', + }, + }, + }, + meta: { + adoptsFrom: { + module: './friend.gts', + name: 'Friend', + }, + }, + }, + included: [ + { + lid: 'local-id-3', + type: 'card', + attributes: { + firstName: 'Boris', + }, + meta: { + adoptsFrom: { + module: 'http://localhost:4202/node-test/friend', + name: 'Friend', + }, + realmURL: `http://some-other-realm/`, + }, + }, + ], + } as LooseSingleCardDocument) + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(200); + expect(response.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(response.get('X-boxel-realm-public-readable')).toBe('true'); + { + let cardFile = join(dir.name, 'realm_server_1', 'test', 'hassan.json'); + expect(existsSync(cardFile)).toBeTruthy(); + let card = readJSONSync(cardFile); + expect(card).toEqual({ + data: { + type: 'card', + attributes: { + firstName: 'Paper', + cardInfo, + }, + relationships: { + friend: { + links: { self: './jade' }, + }, + 'cardInfo.theme': { + links: { + self: null, + }, + }, + }, + meta: { + adoptsFrom: { + module: './friend', + name: 'Friend', + }, + }, + }, + } as LooseSingleCardDocument); + } + { + let cardFile = join(dir.name, 'realm_server_1', 'test', 'Friend', `local-id-3.json`); + expect(existsSync(cardFile)).toBe(false); + } + }); + it('broadcasts realm events', async function () { + let realmEventTimestampStart = Date.now(); + await request + .patch('/person-1') + .send({ + data: { + type: 'card', + attributes: { + firstName: 'Van Gogh', + }, + meta: { + adoptsFrom: { + module: './person.gts', + name: 'Person', + }, + }, + }, + }) + .set('Accept', 'application/vnd.card+json'); + await expectIncrementalIndexEvent(`${testRealmHref}person-1.json`, realmEventTimestampStart, { + assert, + getMessagesSince, + realm: testRealmHref, + }); + }); + }); + describe('public writable realm with size limit', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + realmURL, + permissions: { + '*': ['read', 'write'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + cardSizeLimitBytes: 512, + onRealmSetup, + }); + it('returns 413 when card payload exceeds size limit', async function () { + let oversized = 'a'.repeat(2048); + let response = await request + .patch('/person-1') + .send({ + data: { + type: 'card', + attributes: { + firstName: oversized, + }, + meta: { + adoptsFrom: { + module: './person.gts', + name: 'Person', + }, + }, + }, + }) + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(413); + expect(response.body.errors[0].title).toBe('Payload Too Large'); + expect(response.body.errors[0].status).toBe(413); + expect(response.body.errors[0].message.includes('Card size')).toBeTruthy(); + }); + }); + describe('permissioned realm', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + realmURL, + permissions: { + john: ['read', 'write'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + onRealmSetup, + }); + it('401 with invalid JWT', async function () { + let response = await request + .patch('/person-1') + .send({}) + .set('Accept', 'application/vnd.card+json') + .set('Authorization', `Bearer invalid-token`); + expect(response.status).toBe(401); + }); + it('403 without permission', async function () { + let response = await request + .patch('/person-1') + .send({ + data: { + type: 'card', + attributes: { + firstName: 'Van Gogh', + }, + meta: { + adoptsFrom: { + module: './person.gts', + name: 'Person', + }, + }, + }, + }) + .set('Accept', 'application/vnd.card+json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'not-john')}`); + expect(response.status).toBe(403); + }); + it('200 with permission', async function () { + let response = await request + .patch('/person-1') + .send({ + data: { + type: 'card', + attributes: { + firstName: 'Van Gogh', + }, + meta: { + adoptsFrom: { + module: './person.gts', + name: 'Person', + }, + }, + }, + }) + .set('Accept', 'application/vnd.card+json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'john', ['read', 'write'])}`); + expect(response.status).toBe(200); + }); + }); + }); + describe('card DELETE request', function () { + const _hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + describe('public writable realm', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + realmURL, + permissions: { + '*': ['read', 'write'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + onRealmSetup, + }); + let { getMessagesSince } = setupMatrixRoom(hooks, getRealmSetup); + it('serves the request', async function () { + let entry = 'person-1.json'; + let response = await request + .delete('/person-1') + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(204); + expect(response.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(response.get('X-boxel-realm-public-readable')).toBe('true'); + let cardFile = join(dir.name, entry); + expect(existsSync(cardFile)).toBe(false); + }); + it('broadcasts realm events', async function () { + let realmEventTimestampStart = Date.now(); + await request + .delete('/person-1') + .set('Accept', 'application/vnd.card+json'); + await expectIncrementalIndexEvent(`${testRealmHref}person-1.json`, realmEventTimestampStart, { + assert, + getMessagesSince, + realm: testRealmHref, + }); + }); + it('serves a card DELETE request with .json extension in the url', async function () { + let entry = 'person-1.json'; + let response = await request + .delete('/person-1.json') + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(204); + expect(response.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(response.get('X-boxel-realm-public-readable')).toBe('true'); + let cardFile = join(dir.name, entry); + expect(existsSync(cardFile)).toBe(false); + }); + it('removes card JSON file meta when card is deleted', async function () { + // confirm meta.resourceCreatedAt exists prior to deletion + let initial = await request + .get('/person-1') + .set('Accept', 'application/vnd.card+json'); + expect(initial.status).toBe(200); + let initialCreatedAt = initial.body?.data?.meta?.resourceCreatedAt; + expect(initialCreatedAt).toBeTruthy(); + // delete the card + let delResp = await request + .delete('/person-1') + .set('Accept', 'application/vnd.card+json'); + expect(delResp.status).toBe(204); + // subsequent GET should not expose resourceCreatedAt (file meta removed) + let after = await request + .get('/person-1') + .set('Accept', 'application/vnd.card+json'); + // Depending on implementation could be 404 Not Found; just assert it's not 200 + expect(after.status).not.toBe(200); + let afterCreatedAt = after.body?.data?.meta?.resourceCreatedAt; + expect(afterCreatedAt).toBe(undefined); + expect(JSON.stringify(after.body).includes('resourceCreatedAt')).toBe(false); + }); + }); + describe('permissioned realm', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + realmURL, + permissions: { + john: ['read', 'write'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + onRealmSetup, + }); + it('401 with invalid JWT', async function () { + let response = await request + .delete('/person-1') + .set('Accept', 'application/vnd.card+json') + .set('Authorization', `Bearer invalid-token`); + expect(response.status).toBe(401); + }); + it('403 without permission', async function () { + let response = await request + .delete('/person-1') + .set('Accept', 'application/vnd.card+json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'not-john')}`); + expect(response.status).toBe(403); + }); + it('204 with permission', async function () { + let response = await request + .delete('/person-1') + .set('Accept', 'application/vnd.card+json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'john', ['read', 'write'])}`); + expect(response.status).toBe(204); + }); + }); + }); + describe('file URLs', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + realmURL, + permissions: { + '*': ['read', 'write'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + fileSystem: { + 'greeting.txt': 'hello', + }, + onRealmSetup, + }); + it('rejects HTTP requests to file URLs', async function () { + let response; + response = await request + .get('/greeting.txt') + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(415); + response = await request + .patch('/greeting.txt') + .send({ + data: { + type: 'card', + attributes: {}, + meta: { + adoptsFrom: { + module: 'https://cardstack.com/base/card-api', + name: 'CardDef', + }, + }, + }, + }) + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(415); + response = await request + .delete('/greeting.txt') + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(415); + }); + }); + }); + describe('Query-backed relationships runtime resolver', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + const providerRealmURL = 'http://127.0.0.1:5521/test/'; + const consumerRealmURL = 'http://127.0.0.1:5522/test/'; + const UNREACHABLE_REALM_URL = 'https://example.invalid/offline/'; + let consumerRequest: RealmRequest; + setupPermissionedRealmsCached(hooks, { + realms: [ + { + realmURL: providerRealmURL, + permissions: { + '*': ['read', 'write', 'realm-owner'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + fileSystem: { + 'person.gts': ` + import { CardDef, field, contains } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Person extends CardDef { + @field name = contains(StringField); + } + `, + 'person-remote.json': { + data: { + attributes: { + name: 'Zed', + }, + meta: { + adoptsFrom: { + module: './person', + name: 'Person', + }, + }, + }, + }, + }, + }, + { + realmURL: consumerRealmURL, + permissions: { + '*': ['read', 'write', 'realm-owner'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + fileSystem: { + 'favorite-finder.gts': ` + import { CardDef, field, linksTo, linksToMany } from "https://cardstack.com/base/card-api"; + import { Person } from "${providerRealmURL}person"; + + export class FavoriteLookup extends CardDef { + @field favorite = linksTo(Person, { + query: { + realm: '$thisRealm', + page: { size: 1 }, + }, + }); + @field matches = linksToMany(Person, { + query: { + realm: '${providerRealmURL}', + sort: [ + { by: 'name', direction: 'desc' }, + ], + page: { size: 1 }, + }, + }); + @field failingMatches = linksToMany(Person, { + query: { + realm: '${UNREACHABLE_REALM_URL}', + page: { size: 1 }, + }, + }); + } + `, + 'favorite.json': { + data: { + meta: { + adoptsFrom: { + module: './favorite-finder', + name: 'FavoriteLookup', + }, + }, + }, + }, + 'local-person.json': { + data: { + attributes: { + name: 'Abe', + }, + meta: { + adoptsFrom: { + module: `${providerRealmURL}person`, + name: 'Person', + }, + }, + }, + }, + }, + }, + ], + onRealmSetup({ realms }) { + let latestRealms = realms.slice(-2); + consumerRequest = withRealmPath(supertest(latestRealms[1].realmHttpServer), new URL(consumerRealmURL)); + }, + }); + hooks.afterEach(() => { + resetCatalogRealms(); + }); + it('linksTo query resolves the first aggregated result and includes it', async function () { + let response = await consumerRequest + .get('/favorite') + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(200); + let doc = response.body; + let favoriteRelationship = doc.data.relationships.favorite; + expect(favoriteRelationship?.data).toEqual({ type: 'card', id: `${consumerRealmURL}local-person` }); + let favoriteSearchLink = favoriteRelationship?.links?.search; + expect(favoriteSearchLink).toBeTruthy(); + let favoriteSearchURL = new URL(favoriteSearchLink); + expect(favoriteSearchURL.href.split('?')[0]).toBe(new URL('_search', consumerRealmURL).href); + let favoriteQueryParams = parseSearchQuery(favoriteSearchURL); + expect(favoriteQueryParams.page).toEqual({ size: '1', number: '0' }); + expect(favoriteQueryParams.filter?.type?.module).toBe(`${providerRealmURL}person`); + expect(favoriteQueryParams.filter?.type?.name).toBe('Person'); + expect(Array.isArray(doc.included)).toBeTruthy(); + expect(doc.included.some((resource: any) => resource.id === `${consumerRealmURL}local-person`)).toBeTruthy(); + expect(favoriteRelationship?.data).toEqual({ type: 'card', id: `${consumerRealmURL}local-person` }); + expect(doc.included.find((resource: any) => resource.id === `${consumerRealmURL}local-person`)).toBeTruthy(); + }); + it('linksToMany query returns remote results and records errors for failing realm', async function () { + let response = await consumerRequest + .get('/favorite') + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(200); + let doc = response.body; + let relationships = doc.data.relationships as Record; + let remoteRelationship = relationships['matches.0']; + let matchesRelationship = relationships.matches; + expect(remoteRelationship).toBeTruthy(); + expect(remoteRelationship?.data).toEqual({ type: 'card', id: `${providerRealmURL}person-remote` }); + expect(matchesRelationship?.meta?.errors).toBeFalsy(); + expect(matchesRelationship?.data).toEqual([{ type: 'card', id: `${providerRealmURL}person-remote` }]); + let matchesSearchLink = matchesRelationship?.links?.search; + expect(matchesSearchLink).toBeTruthy(); + let matchesSearchURL = new URL(matchesSearchLink); + expect(matchesSearchURL.searchParams.get('query')).toBeTruthy(); + expect(matchesSearchURL.href.split('?')[0]).toBe(new URL('_search', providerRealmURL).href); + let matchesQueryParams = parseSearchQuery(matchesSearchURL); + expect(matchesQueryParams.page).toEqual({ size: '1', number: '0' }); + expect(matchesQueryParams.sort?.[0]?.by).toBe('name'); + expect(matchesQueryParams.sort?.[0]?.direction).toBe('desc'); + expect(matchesQueryParams.sort?.[0]?.on?.module).toBe(`${providerRealmURL}person`); + expect(matchesQueryParams.sort?.[0]?.on?.name).toBe('Person'); + let failingRelationship = relationships.failingMatches; + expect(failingRelationship?.meta?.errors).toBeTruthy(); + expect(failingRelationship.meta.errors.some((error: any) => error.realm === UNREACHABLE_REALM_URL)).toBeTruthy(); + let failingSearchLink = failingRelationship.links?.search; + expect(failingSearchLink).toBeTruthy(); + let failingSearchURL = new URL(failingSearchLink); + expect(failingSearchURL.searchParams.get('query')).toBeTruthy(); + expect(failingSearchURL.href.split('?')[0]).toBe(new URL('_search', UNREACHABLE_REALM_URL).href); + let failingQueryParams = parseSearchQuery(failingSearchURL); + expect(failingQueryParams.page).toEqual({ size: '1', number: '0' }); + expect(failingQueryParams.filter?.type?.module).toBe(`${providerRealmURL}person`); + expect(failingQueryParams.filter?.type?.name).toBe('Person'); + expect(failingRelationship.data).toEqual([]); + expect(Array.isArray(doc.included)).toBeTruthy(); + let includedIds = (doc.included ?? []).map((resource: any) => resource.id); + expect(includedIds.includes(`${consumerRealmURL}local-person`)).toBeTruthy(); + expect(includedIds.includes(`${providerRealmURL}person-remote`)).toBeTruthy(); + }); + }); +}); +function assertScopedCssUrlsContain(assert: Assert, scopedCssUrls: string[], moduleUrls: string[]) { + moduleUrls.forEach((url) => { + let pattern = new RegExp(`^${url}\\.[^.]+\\.glimmer-scoped\\.css$`); + expect(scopedCssUrls.some((scopedCssUrl) => pattern.test(scopedCssUrl))).toBe(true); + }); +} +// These modules have CSS that CardDef consumes, so we expect to see them in all relationships of a prerendered card +let cardDefModuleDependencies = [ + 'https://cardstack.com/base/default-templates/embedded.gts', + 'https://cardstack.com/base/default-templates/isolated-and-edit.gts', + 'https://cardstack.com/base/default-templates/field-edit.gts', + 'https://cardstack.com/base/field-component.gts', + 'https://cardstack.com/base/contains-many-component.gts', + 'https://cardstack.com/base/links-to-editor.gts', + 'https://cardstack.com/base/links-to-many-component.gts', +]; diff --git a/packages/realm-server/tests-vitest/card-source-endpoints.test.ts b/packages/realm-server/tests-vitest/card-source-endpoints.test.ts new file mode 100644 index 00000000000..9111e09ca32 --- /dev/null +++ b/packages/realm-server/tests-vitest/card-source-endpoints.test.ts @@ -0,0 +1,978 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { fileURLToPath } from "url"; +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import type { Test, SuperTest } from 'supertest'; +import { join, resolve, dirname } from 'path'; +import type { Server } from 'http'; +import type { DirResult } from 'tmp'; +import { existsSync, readFileSync } from 'fs-extra'; +import { cardSrc, compiledCard, } from '@cardstack/runtime-common/etc/test-fixtures'; +import type { Realm } from '@cardstack/runtime-common'; +import { RealmPaths, type LooseSingleCardDocument, } from '@cardstack/runtime-common'; +import { setupPermissionedRealmCached, setupMatrixRoom, createJWT, cardInfo, type RealmRequest, withRealmPath, } from './helpers'; +import { query, param } from '@cardstack/runtime-common'; +import type { PgAdapter } from '@cardstack/postgres'; +import { expectIncrementalIndexEvent } from './helpers/indexing'; +import '@cardstack/runtime-common/helpers/code-equality-assertion'; +import stripScopedCSSGlimmerAttributes from '@cardstack/runtime-common/helpers/strip-scoped-css-glimmer-attributes'; +import { APP_BOXEL_REALM_EVENT_TYPE } from '@cardstack/runtime-common/matrix-constants'; +import type { MatrixEvent } from 'https://cardstack.com/base/matrix-event'; +import isEqual from 'lodash/isEqual'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +describe("card-source-endpoints-test.ts", function () { + describe('Realm-specific Endpoints | card source requests', function () { + let realmURL = new URL('http://127.0.0.1:4444/test/'); + let testRealmHref = realmURL.href; + let testRealmURL = realmURL; + let testRealm: Realm; + let testRealmHttpServer: Server; + let request: RealmRequest; + let serverRequest: SuperTest; + let dir: DirResult; + let dbAdapter: PgAdapter; + function onRealmSetup(args: { + testRealm: Realm; + testRealmHttpServer: Server; + request: SuperTest; + dir: DirResult; + dbAdapter: PgAdapter; + }) { + testRealm = args.testRealm; + testRealmHttpServer = args.testRealmHttpServer; + serverRequest = args.request; + request = withRealmPath(args.request, realmURL); + dir = args.dir; + dbAdapter = args.dbAdapter; + } + function getRealmSetup() { + return { + testRealm, + testRealmHttpServer, + request, + serverRequest, + dir, + dbAdapter, + }; + } + describe('card source GET request', function () { + const _hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + describe('public readable realm', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + realmURL, + permissions: { + '*': ['read'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + onRealmSetup, + }); + it('serves the request', async function () { + let response = await request + .get('/person.gts') + .set('Accept', 'application/vnd.card+source'); + expect(response.status).toBe(200); + expect(response.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(response.get('X-boxel-realm-public-readable')).toBe('true'); + let result = response.text.trim(); + expect(result).toBe(cardSrc); + expect(response.headers['last-modified']).toBeTruthy(); + }); + it('caches responses and invalidates on write', async function () { + let cacheTestPath = 'cache-test.gts'; + let initialContent = '// initial cache test content'; + await testRealm.write(cacheTestPath, initialContent); + let firstResponse = await request + .get(`/${cacheTestPath}`) + .set('Accept', 'application/vnd.card+source'); + expect(firstResponse.status).toBe(200); + expect(firstResponse.text).toBe(initialContent); + let cachedResponse = await request + .get(`/${cacheTestPath}`) + .set('Accept', 'application/vnd.card+source'); + expect(cachedResponse.status).toBe(200); + expect(cachedResponse.headers['x-boxel-cache']).toBe('hit'); + expect(cachedResponse.text).toBe(initialContent); + let updatedContent = `${initialContent}\n// updated by test`; + await testRealm.write(cacheTestPath, updatedContent); + let afterWriteResponse = await request + .get(`/${cacheTestPath}`) + .set('Accept', 'application/vnd.card+source'); + expect(afterWriteResponse.status).toBe(200); + expect(afterWriteResponse.text).toBe(updatedContent); + let repopulatedResponse = await request + .get(`/${cacheTestPath}`) + .set('Accept', 'application/vnd.card+source'); + expect(repopulatedResponse.status).toBe(200); + expect(repopulatedResponse.headers['x-boxel-cache']).toBe('hit'); + expect(repopulatedResponse.text).toBe(updatedContent); + }); + it('supports noCache query param to bypass cache', async function () { + let cacheTestPath = 'cache-test-nocache.gts'; + let initialContent = '// initial cache test content'; + await testRealm.write(cacheTestPath, initialContent); + await request + .get(`/${cacheTestPath}`) + .set('Accept', 'application/vnd.card+source'); + let updatedContent = `${initialContent}\n// updated by test`; + await testRealm.write(cacheTestPath, updatedContent); + let noCacheResponse = await request + .get(`/${cacheTestPath}?noCache=true`) + .set('Accept', 'application/vnd.card+source'); + expect(noCacheResponse.status).toBe(200); + expect(noCacheResponse.headers['x-boxel-cache']).toBe('miss'); + expect(noCacheResponse.text).toBe(updatedContent); + let cachedResponse = await request + .get(`/${cacheTestPath}`) + .set('Accept', 'application/vnd.card+source'); + expect(cachedResponse.headers['x-boxel-cache']).toBe('miss'); + expect(cachedResponse.text).toBe(updatedContent); + }); + it('serves a card-source GET request that results in redirect', async function () { + let response = await request + .get('/person') + .set('Accept', 'application/vnd.card+source'); + expect(response.status).toBe(302); + expect(response.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(response.get('X-boxel-realm-public-readable')).toBe('true'); + expect(response.headers['location']).toBe(new URL('person.gts', realmURL).pathname); + }); + it('serves a card instance GET request with card-source accept header that results in redirect', async function () { + let response = await request + .get('/person-1') + .set('Accept', 'application/vnd.card+source'); + expect(response.status).toBe(302); + expect(response.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(response.get('X-boxel-realm-public-readable')).toBe('true'); + expect(response.headers['location']).toBe(new URL('person-1.json', realmURL).pathname); + }); + it('serves source of a card module that is in error state', async function () { + let response = await request + .get('/person-with-error.gts') + .set('Accept', 'application/vnd.card+source'); + expect(response.headers['content-type']).toBe('text/typescript+glimmer'); + expect(readFileSync(join(__dirname, './cards', 'person-with-error.gts'), { + encoding: 'utf8', + })).toBe(response.text); + expect(response.status).toBe(200); + }); + it('serves a card instance GET request with a .json extension and json accept header that results in redirect', async function () { + let response = await request + .get('/person.json') + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(302); + expect(response.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(response.get('X-boxel-realm-public-readable')).toBe('true'); + expect(response.headers['location']).toBe(new URL('person', realmURL).pathname); + }); + it('serves a module GET request', async function () { + let response = await request.get('/person'); + expect(response.status).toBe(200); + expect(response.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(response.get('X-boxel-realm-public-readable')).toBe('true'); + let body = response.text.trim(); + let moduleAbsolutePath = resolve(join(__dirname, '..', 'person.gts')); + // Remove platform-dependent id, from https://github.com/emberjs/babel-plugin-ember-template-compilation/blob/d67cca121cfb3bbf5327682b17ed3f2d5a5af528/__tests__/tests.ts#LL1430C1-L1431C1 + body = stripScopedCSSGlimmerAttributes(body.replace(/"id":\s"[^"]+"/, '"id": ""')); + expect(body).toEqual(compiledCard('""', moduleAbsolutePath)); + }); + }); + describe('permissioned realm', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + realmURL, + permissions: { + john: ['read'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + onRealmSetup, + }); + it('401 with invalid JWT', async function () { + let response = await request + .get('/person.gts') + .set('Accept', 'application/vnd.card+source') + .set('Authorization', `Bearer invalid-token`); + expect(response.status).toBe(401); + }); + it('401 without a JWT', async function () { + let response = await request + .get('/person.gts') + .set('Accept', 'application/vnd.card+source'); // no Authorization header + expect(response.status).toBe(401); + }); + it('403 without permission', async function () { + let response = await request + .get('/person.gts') + .set('Accept', 'application/vnd.card+source') + .set('Authorization', `Bearer ${createJWT(testRealm, 'not-john')}`); + expect(response.status).toBe(403); + }); + it('200 with permission', async function () { + let response = await request + .get('/person.gts') + .set('Accept', 'application/vnd.card+source') + .set('Authorization', `Bearer ${createJWT(testRealm, 'john', ['read'])}`); + expect(response.status).toBe(200); + }); + }); + }); + describe('card source HEAD request', function () { + const _hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + describe('public readable realm', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + realmURL, + permissions: { + '*': ['read'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + onRealmSetup, + }); + it('serves the request', async function () { + let response = await request + .head('/person.gts') + .set('Accept', 'application/vnd.card+source'); + expect(response.status).toBe(200); + expect(response.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(response.get('X-boxel-realm-public-readable')).toBe('true'); + expect(response.text).toBeFalsy(); + }); + it('serves a card-source HEAD request that results in redirect', async function () { + let response = await request + .head('/person') + .set('Accept', 'application/vnd.card+source'); + expect(response.status).toBe(302); + expect(response.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(response.get('X-boxel-realm-public-readable')).toBe('true'); + expect(response.headers['location']).toBe(new URL('person.gts', realmURL).pathname); + }); + it('serves a card-source HEAD request for a regular file without redirect', async function () { + await testRealm.write('notes.md', '# Notes\n'); + let response = await request + .head('/notes.md') + .set('Accept', 'application/vnd.card+source'); + expect(response.status).toBe(200); + expect(response.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(response.get('X-boxel-realm-public-readable')).toBe('true'); + expect(response.headers['location']).toBeFalsy(); + }); + }); + }); + describe('card-source DELETE request', function () { + const _hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + describe('public writable realm', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + realmURL, + permissions: { + '*': ['read', 'write'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + onRealmSetup, + }); + let { getMessagesSince } = setupMatrixRoom(hooks, getRealmSetup); + it('serves the request', async function () { + let entry = 'unused-card.gts'; + let response = await request + .delete('/unused-card.gts') + .set('Accept', 'application/vnd.card+source'); + expect(response.status).toBe(204); + expect(response.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(response.get('X-boxel-realm-public-readable')).toBe('true'); + let cardFile = join(dir.name, entry); + expect(existsSync(cardFile)).toBe(false); + }); + it('broadcasts realm events', async function () { + let realmEventTimestampStart = Date.now(); + await request + .delete('/unused-card.gts') + .set('Accept', 'application/vnd.card+source'); + await expectIncrementalIndexEvent(`${testRealmURL}unused-card.gts`, realmEventTimestampStart, { + assert, + getMessagesSince, + realm: testRealmHref, + }); + }); + it('serves a card-source DELETE request for a card instance', async function () { + let entry = 'person-1'; + let response = await request + .delete('/person-1') + .set('Accept', 'application/vnd.card+source'); + expect(response.status).toBe(204); + expect(response.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(response.get('X-boxel-realm-public-readable')).toBe('true'); + let cardFile = join(dir.name, entry); + expect(existsSync(cardFile)).toBe(false); + }); + }); + describe('permissioned realm', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + realmURL, + permissions: { + john: ['read', 'write'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + onRealmSetup, + }); + it('401 with invalid JWT', async function () { + let response = await request + .delete('/unused-card.gts') + .set('Accept', 'application/vnd.card+source') + .set('Authorization', `Bearer invalid-token`); + expect(response.status).toBe(401); + }); + it('403 without permission', async function () { + let response = await request + .delete('/unused-card.gts') + .set('Accept', 'application/vnd.card+source') + .set('Authorization', `Bearer ${createJWT(testRealm, 'not-john')}`); + expect(response.status).toBe(403); + }); + it('204 with permission', async function () { + let response = await request + .delete('/unused-card.gts') + .set('Accept', 'application/vnd.card+source') + .set('Authorization', `Bearer ${createJWT(testRealm, 'john', ['read', 'write'])}`); + expect(response.status).toBe(204); + }); + }); + }); + describe('card-source POST request', function () { + const _hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + describe('public writable realm', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + realmURL, + permissions: { + '*': ['read', 'write'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + onRealmSetup, + }); + let { getMessagesSince } = setupMatrixRoom(hooks, getRealmSetup); + it('serves a card-source POST request', async function () { + let entry = 'unused-card.gts'; + let response = await request + .post('/unused-card.gts') + .set('Accept', 'application/vnd.card+source') + .send(`//TEST UPDATE\n${cardSrc}`); + expect(response.status).toBe(204); + expect(response.headers['x-created']).toBeTruthy(); + expect(response.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(response.get('X-boxel-realm-public-readable')).toBe('true'); + let srcFile = join(dir.name, 'realm_server_1', 'test', entry); + expect(existsSync(srcFile)).toBeTruthy(); + let src = readFileSync(srcFile, { encoding: 'utf8' }); + expect(src).toEqual(`//TEST UPDATE + ${cardSrc}`); + }); + it('broadcasts realm events', async function () { + let realmEventTimestampStart = Date.now(); + await request + .post('/unused-card.gts') + .set('Accept', 'application/vnd.card+source') + .send(`//TEST UPDATE\n${cardSrc}`); + await expectIncrementalIndexEvent(`${testRealmURL}unused-card.gts`, realmEventTimestampStart, { + assert, + getMessagesSince, + realm: testRealmHref, + }); + }); + it('serves a card-source POST request for a .txt file', async function () { + let response = await request + .post('/hello-world.txt') + .set('Accept', 'application/vnd.card+source') + .send(`Hello World`); + expect(response.status).toBe(204); + let fileResponse = await request + .get('/hello-world.txt') + .set('Accept', 'application/vnd.card+source'); + expect(fileResponse.headers['x-created']).toBeTruthy(); + let txtFile = join(dir.name, 'realm_server_1', 'test', 'hello-world.txt'); + expect(existsSync(txtFile)).toBeTruthy(); + let src = readFileSync(txtFile, { encoding: 'utf8' }); + expect(src).toBe('Hello World'); + }); + it('removes file meta on delete', async function () { + // ensure an existing file (write like hello-world first) + let reqPath = '/hello-world.txt'; + let dbPath = 'hello-world.txt'; + let post = await request + .post(reqPath) + .set('Accept', 'application/vnd.card+source') + .send('hello-world'); + expect(post.status).toBe(204); + expect(post.headers['x-created']).toBeTruthy(); + // row exists in realm_file_meta + let rowsBefore = await query(dbAdapter, [ + 'SELECT created_at FROM realm_file_meta WHERE realm_url =', + param(testRealmHref), + 'AND file_path =', + param(dbPath), + ]); + expect(rowsBefore.length).toBe(1); + // delete the file + let del = await request + .delete(reqPath) + .set('Accept', 'application/vnd.card+source'); + expect(del.status).toBe(204); + // row removed from realm_file_meta + let rowsAfter = await query(dbAdapter, [ + 'SELECT 1 FROM realm_file_meta WHERE realm_url =', + param(testRealmHref), + 'AND file_path =', + param(dbPath), + ]); + expect(rowsAfter.length).toBe(0); + }); + it('can serialize a card instance correctly after card definition is changed', async function () { + let realmEventTimestampStart = Date.now(); + // create a card def + { + let response = await request + .post('/test-card.gts') + .set('Accept', 'application/vnd.card+source').send(` + import { contains, field, CardDef } from 'https://cardstack.com/base/card-api'; + import StringField from 'https://cardstack.com/base/string'; + + export class TestCard extends CardDef { + @field field1 = contains(StringField); + @field field2 = contains(StringField); + } + `); + expect(response.status).toBe(204); + } + // make an instance of the card def + let maybeId: string | undefined; + { + let response = await request + .post('/') + .send({ + data: { + type: 'card', + attributes: { + field1: 'a', + field2: 'b', + }, + meta: { + adoptsFrom: { + module: `${testRealmURL}test-card`, + name: 'TestCard', + }, + }, + }, + }) + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(201); + maybeId = response.body.data.id; + } + if (!maybeId) { + expect(false).toBeTruthy(); + // eslint-disable-next-line qunit/no-early-return + return; + } + let id = maybeId; + // modify field + { + let response = await request + .post('/test-card.gts') + .set('Accept', 'application/vnd.card+source').send(` + import { contains, field, CardDef } from 'https://cardstack.com/base/card-api'; + import StringField from 'https://cardstack.com/base/string'; + + export class TestCard extends CardDef { + @field field1 = contains(StringField); + @field field2a = contains(StringField); // rename field2 -> field2a + } + `); + expect(response.status).toBe(204); + } + // verify serialization matches new card def + { + let response = await request + .get(new URL(id).pathname) + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(200); + let json = response.body; + expect(json.data.attributes).toEqual({ + field1: 'a', + field2a: null, + cardTitle: 'Untitled Card', + cardDescription: null, + cardThumbnailURL: null, + cardInfo, + }); + } + // set value on renamed field + { + let response = await request + .patch(new URL(id).pathname) + .send({ + data: { + type: 'card', + attributes: { + field2a: 'c', + }, + meta: { + adoptsFrom: { + module: `${testRealmURL}test-card`, + name: 'TestCard', + }, + }, + }, + }) + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(200); + expect(response.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(response.get('X-boxel-realm-public-readable')).toBe('true'); + let json = response.body; + expect(json.data.attributes).toEqual({ + field1: 'a', + field2a: 'c', + cardTitle: 'Untitled Card', + cardDescription: null, + cardThumbnailURL: null, + cardInfo, + }); + } + // verify file serialization is correct + { + let localPath = new RealmPaths(testRealmURL).local(new URL(id)); + let jsonFile = `${join(dir.name, 'realm_server_1', 'test', localPath)}.json`; + let doc = JSON.parse(readFileSync(jsonFile, { encoding: 'utf8' })) as LooseSingleCardDocument; + expect(doc).toEqual({ + data: { + type: 'card', + attributes: { + field1: 'a', + field2a: 'c', + cardInfo, + }, + relationships: { + 'cardInfo.theme': { + links: { + self: null, + }, + }, + }, + meta: { + adoptsFrom: { + module: '../test-card', + name: 'TestCard', + }, + }, + }, + }); + } + // verify instance GET is correct + { + let response = await request + .get(new URL(id).pathname) + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(200); + let json = response.body; + expect(json.data.attributes).toEqual({ + field1: 'a', + field2a: 'c', + cardTitle: 'Untitled Card', + cardDescription: null, + cardThumbnailURL: null, + cardInfo, + }); + } + let messages = await getMessagesSince(realmEventTimestampStart); + let expected = [ + { + type: APP_BOXEL_REALM_EVENT_TYPE, + content: { + eventName: 'index', + indexType: 'incremental-index-initiation', + updatedFile: `${testRealmURL}test-card.gts`, + realmURL: testRealmURL.href, + }, + }, + { + type: APP_BOXEL_REALM_EVENT_TYPE, + content: { + eventName: 'index', + indexType: 'incremental', + invalidations: [`${testRealmURL}test-card.gts`], + clientRequestId: null, + realmURL: testRealmURL.href, + }, + }, + { + type: APP_BOXEL_REALM_EVENT_TYPE, + content: { + eventName: 'index', + indexType: 'incremental-index-initiation', + updatedFile: `${testRealmURL}test-card.gts`, + realmURL: testRealmURL.href, + }, + }, + { + type: APP_BOXEL_REALM_EVENT_TYPE, + content: { + eventName: 'index', + indexType: 'incremental', + invalidations: [`${testRealmURL}test-card.gts`, id], + clientRequestId: null, + realmURL: testRealmURL.href, + }, + }, + { + type: APP_BOXEL_REALM_EVENT_TYPE, + content: { + eventName: 'index', + indexType: 'incremental-index-initiation', + updatedFile: `${id}.json`, + realmURL: testRealmURL.href, + }, + }, + { + type: APP_BOXEL_REALM_EVENT_TYPE, + content: { + eventName: 'index', + indexType: 'incremental', + invalidations: [id], + clientRequestId: null, + realmURL: testRealmURL.href, + }, + }, + ]; + for (let expectedEvent of expected) { + // FIXME is there a better way? + let actualEvent = matchRealmEvent(messages, expectedEvent); + expect(actualEvent?.content).toEqual(expectedEvent.content); + } + }); + }); + describe('public writable realm with size limit', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + realmURL, + permissions: { + '*': ['read', 'write'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + fileSizeLimitBytes: 512, + onRealmSetup, + }); + it('returns 413 when source payload exceeds size limit', async function () { + let oversized = 'a'.repeat(2048); + let response = await request + .post('/too-large.gts') + .set('Accept', 'application/vnd.card+source') + .send(oversized); + expect(response.status).toBe(413); + expect(response.body.errors[0].title).toBe('Payload Too Large'); + expect(response.body.errors[0].status).toBe(413); + expect(response.body.errors[0].message.includes('File size')).toBeTruthy(); + }); + }); + describe('permissioned realm', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + realmURL, + permissions: { + john: ['read', 'write'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + onRealmSetup, + }); + it('401 with invalid JWT', async function () { + let response = await request + .post('/unused-card.gts') + .set('Accept', 'application/vnd.card+source') + .send(`//TEST UPDATE\n${cardSrc}`) + .set('Authorization', `Bearer invalid-token`); + expect(response.status).toBe(401); + }); + it('401 without a JWT', async function () { + let response = await request + .post('/unused-card.gts') + .set('Accept', 'application/vnd.card+source') + .send(`//TEST UPDATE\n${cardSrc}`); // no Authorization header + expect(response.status).toBe(401); + }); + it('403 without permission', async function () { + let response = await request + .post('/unused-card.gts') + .set('Accept', 'application/vnd.card+source') + .send(`//TEST UPDATE\n${cardSrc}`) + .set('Authorization', `Bearer ${createJWT(testRealm, 'not-john')}`); + expect(response.status).toBe(403); + }); + it('204 with permission', async function () { + let response = await request + .post('/unused-card.gts') + .set('Accept', 'application/vnd.card+source') + .send(`//TEST UPDATE\n${cardSrc}`) + .set('Authorization', `Bearer ${createJWT(testRealm, 'john', ['read', 'write'])}`); + expect(response.status).toBe(204); + }); + }); + }); + describe('binary file POST request', function () { + const _hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + describe('public writable realm', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + realmURL, + permissions: { + '*': ['read', 'write'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + onRealmSetup, + }); + let { getMessagesSince } = setupMatrixRoom(hooks, getRealmSetup); + it('serves a binary file POST request', async function () { + let bytes = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0xfe, + ]); + let response = await request + .post('/test-image.png') + .set('Content-Type', 'application/octet-stream') + .send(Buffer.from(bytes)); + expect(response.status).toBe(204); + expect(response.headers['x-created']).toBeTruthy(); + let filePath = join(dir.name, 'realm_server_1', 'test', 'test-image.png'); + expect(existsSync(filePath)).toBeTruthy(); + let fileBytes = readFileSync(filePath); + expect(new Uint8Array(fileBytes)).toEqual(bytes); + }); + it('card source GET returns correct content-type for image files', async function () { + let bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); + await request + .post('/photo.png') + .set('Content-Type', 'application/octet-stream') + .send(Buffer.from(bytes)); + let response = await request + .get('/photo.png') + .set('Accept', 'application/vnd.card+source'); + expect(response.status).toBe(200); + expect(response.headers['content-type']).toBe('image/png'); + }); + it('card source GET returns correct content-type for PDF files', async function () { + let bytes = new Uint8Array([0x25, 0x50, 0x44, 0x46]); // %PDF + await request + .post('/report.pdf') + .set('Content-Type', 'application/octet-stream') + .send(Buffer.from(bytes)); + let response = await request + .get('/report.pdf') + .set('Accept', 'application/vnd.card+source'); + expect(response.status).toBe(200); + expect(response.headers['content-type']).toBe('application/pdf'); + }); + it('card source GET returns correct content-type for audio files', async function () { + let bytes = new Uint8Array([0x49, 0x44, 0x33]); // ID3 + await request + .post('/clip.mp3') + .set('Content-Type', 'application/octet-stream') + .send(Buffer.from(bytes)); + let response = await request + .get('/clip.mp3') + .set('Accept', 'application/vnd.card+source'); + expect(response.status).toBe(200); + expect(response.headers['content-type']).toBe('audio/mpeg'); + }); + it('card source GET returns correct content-type for video files', async function () { + let bytes = new Uint8Array([0x00, 0x00, 0x00, 0x1c]); + await request + .post('/demo.mp4') + .set('Content-Type', 'application/octet-stream') + .send(Buffer.from(bytes)); + let response = await request + .get('/demo.mp4') + .set('Accept', 'application/vnd.card+source'); + expect(response.status).toBe(200); + expect(response.headers['content-type']).toBe('video/mp4'); + }); + it('creates file metadata for binary upload', async function () { + let bytes = new Uint8Array([0x00, 0x01, 0x02, 0x03]); + await request + .post('/meta-test.bin') + .set('Content-Type', 'application/octet-stream') + .send(Buffer.from(bytes)); + let rows = await query(dbAdapter, [ + 'SELECT content_hash FROM realm_file_meta WHERE realm_url =', + param(testRealmHref), + 'AND file_path =', + param('meta-test.bin'), + ]); + expect(rows.length).toBe(1); + expect(rows[0].content_hash).toBeTruthy(); + }); + it('overwrites existing binary file', async function () { + let bytes1 = new Uint8Array([0x01, 0x02, 0x03]); + let bytes2 = new Uint8Array([0x04, 0x05, 0x06]); + let response1 = await request + .post('/overwrite-test.bin') + .set('Content-Type', 'application/octet-stream') + .send(Buffer.from(bytes1)); + expect(response1.status).toBe(204); + let response2 = await request + .post('/overwrite-test.bin') + .set('Content-Type', 'application/octet-stream') + .send(Buffer.from(bytes2)); + expect(response2.status).toBe(204); + let filePath = join(dir.name, 'realm_server_1', 'test', 'overwrite-test.bin'); + let fileBytes = readFileSync(filePath); + expect(new Uint8Array(fileBytes)).toEqual(bytes2); + }); + it('broadcasts realm events for binary upload', async function () { + let realmEventTimestampStart = Date.now(); + await request + .post('/event-test.bin') + .set('Content-Type', 'application/octet-stream') + .send(Buffer.from(new Uint8Array([0xca, 0xfe]))); + await expectIncrementalIndexEvent(`${testRealmURL}event-test.bin`, realmEventTimestampStart, { + assert, + getMessagesSince, + realm: testRealmHref, + }); + }); + }); + describe('public writable realm with size limit for binary', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + realmURL, + permissions: { + '*': ['read', 'write'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + fileSizeLimitBytes: 512, + onRealmSetup, + }); + it('returns 413 when binary payload exceeds size limit', async function () { + let oversized = new Uint8Array(2048).fill(0xff); + let response = await request + .post('/too-large.bin') + .set('Content-Type', 'application/octet-stream') + .send(Buffer.from(oversized)); + expect(response.status).toBe(413); + expect(response.body.errors[0].title).toBe('Payload Too Large'); + }); + }); + describe('permissioned realm for binary', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + realmURL, + permissions: { + john: ['read', 'write'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + onRealmSetup, + }); + it('401 without a JWT for binary upload', async function () { + let response = await request + .post('/secret.bin') + .set('Content-Type', 'application/octet-stream') + .send(Buffer.from(new Uint8Array([0x01]))); + expect(response.status).toBe(401); + }); + it('403 without permission for binary upload', async function () { + let response = await request + .post('/secret.bin') + .set('Content-Type', 'application/octet-stream') + .send(Buffer.from(new Uint8Array([0x01]))) + .set('Authorization', `Bearer ${createJWT(testRealm, 'not-john')}`); + expect(response.status).toBe(403); + }); + it('204 with permission for binary upload', async function () { + let response = await request + .post('/secret.bin') + .set('Content-Type', 'application/octet-stream') + .send(Buffer.from(new Uint8Array([0x01]))) + .set('Authorization', `Bearer ${createJWT(testRealm, 'john', ['read', 'write'])}`); + expect(response.status).toBe(204); + }); + }); + }); + }); +}); +function matchRealmEvent(events: MatrixEvent[], event: any) { + return events.find((m) => m.type === event.type && isEqual(event.content, m.content)); +} diff --git a/packages/realm-server/tests-vitest/cards/%F0%9F%98%80.gts b/packages/realm-server/tests-vitest/cards/%F0%9F%98%80.gts new file mode 100644 index 00000000000..513d62f9674 --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/%F0%9F%98%80.gts @@ -0,0 +1,7 @@ +// This is a file that contains url encoded file name to assert +// that the realm server can handle this type of file + +import { CardDef } from 'https://cardstack.com/base/card-api'; +export class Test extends CardDef { + static displayName = 'test'; +} diff --git a/packages/realm-server/tests-vitest/cards/.realm.json b/packages/realm-server/tests-vitest/cards/.realm.json new file mode 100644 index 00000000000..cb2749f2937 --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/.realm.json @@ -0,0 +1,3 @@ +{ + "name": "Test Realm" +} diff --git a/packages/realm-server/tests-vitest/cards/ChessGallery/2059b2be-791e-47a5-9a9c-be59dee4ea83.json b/packages/realm-server/tests-vitest/cards/ChessGallery/2059b2be-791e-47a5-9a9c-be59dee4ea83.json new file mode 100644 index 00000000000..8b4241ed0cc --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/ChessGallery/2059b2be-791e-47a5-9a9c-be59dee4ea83.json @@ -0,0 +1,20 @@ +{ + "data": { + "type": "card", + "attributes": { + "pgn": "1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 4. Ba4 Nf6 5. O-O Be7 6. Re1 b5 7. Bb3 d6 8. c3 O-O 9. h3 Nb8 10. d4 Nbd7 11. c4 c6 12. Nc3 Bb7 13. Bg5 h6 14. Bh4 Re8 15. cxb5 axb5 16. a3 Bf8 17. Rc1 Qb6 18. Bg3 Rad8 19. d5 c5 20. Nd2 g6 21. Bc2 Bg7 22. Bd3 c4 23. Bc2 Nc5 24. b4 cxb3 25. Nxb3 Nxb3 26. Bxb3 Rc8 27. Qd3 Ba6 28. Na2 Nd7 29. Nb4 Nc5 30. Qb1 Bb7 31. Kh1 Re7 32. f3 Rec7 33. Bf2 Bf8 34. Rc3 Qa7 35. Rec1 Qa8 36. Qa2 Be7 37. Be3 Bf8 38. Kh2 Be7 39. R1c2 Qa7 40. Qb1 Bg5 41. Bxg5 hxg5 42. Nd3 Kg7 43. Ba2 f5 44. Qc1 fxe4 45. Qxg5 Ba6 46. Nb4 Bb7 47. fxe4 Qa8 48. Rg3 Kh7 49. Qxg6+ Kh8 50. Qh6+ Rh7 51. Qf6+ Rg7 52. Rg4 Ba6 53. Nc6 Re8 54. Bb3 Bc8 55. Rg5 Rf8 56. Qxd6 Nxe4 57. Qxe5 Nxg5 58. Qxg5 Rxg5 59. d6 Bd7 60. Ne5 Be8 61. d7 Bxd7 62. Nxd7 Rff2 63. Rc8+ Kh7 64. Nf6+ Kg6 65. Bd5 Rxf6 66. Be4+ Kh5 67. Rc3 Rf2 68. Re3 Ra2 69. Bf3+ Kg6 70. Bg4 Qxg2#", + "dateOfGame": "2022-12-12", + "whitePlayer": "Magnus Carlsen", + "blackPlayer": "Ian Nepomniachtchi", + "cardTitle": "Magnus Carlsen vs Ian Nepomniachtchi - 2022 World Championship", + "cardDescription": "A thrilling game in the 2022 World Championship where Magnus Carlsen faced Ian Nepomniachtchi. An intense battle showcasing advanced strategies.", + "cardThumbnailURL": "https://www.chessdom.com/wp-content/uploads/2022/09/nepo-magnus-end-nr.jpeg" + }, + "meta": { + "adoptsFrom": { + "module": "../chess-gallery", + "name": "ChessGallery" + } + } + } +} diff --git a/packages/realm-server/tests-vitest/cards/ChessGallery/401aa8da-7559-41b5-ae4d-74e455087bd6.json b/packages/realm-server/tests-vitest/cards/ChessGallery/401aa8da-7559-41b5-ae4d-74e455087bd6.json new file mode 100644 index 00000000000..820ee58061a --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/ChessGallery/401aa8da-7559-41b5-ae4d-74e455087bd6.json @@ -0,0 +1,20 @@ +{ + "data": { + "type": "card", + "attributes": { + "pgn": "1. e4 c5 2. Nf3 Nc6 3. Bb5 g6 4. O-O Bg7 5. Re1 e5 6. c3 Nge7 7. d4 cxd4 8. cxd4 exd4 9. e5 a6 10. Bf1 O-O 11. Na3 d6 12. exd6 Qxd6 13. Nc4 Qc7 14. g3 Nd5 15. Bg2 Rd8 16. Bg5 f6 17. Bd2 b5 18. Na3 Kh8 19. Rc1 Qb6 20. Qb3 Bf8 21. Nh4 Nde7 22. Qf7 Bg7 23. Rxc6 Qxc6 24. Rxe7 Qxg2+ 25. Kxg2 Rg8 26. Bh6 Bh3+ 27. Kxh3 Ra7 28. Bxg7+ Rxg7 29. Re8+ Rg8 30. Rxg8#", + "dateOfGame": "2022-08-06", + "whitePlayer": "Gukesh D", + "blackPlayer": "Alexei Shirov", + "cardTitle": "Gukesh D vs Alexei Shirov - 2022 Chess Olympiad", + "cardDescription": "A remarkable victory by Gukesh over Alexei Shirov at the 2022 Chess Olympiad, showcasing his extraordinary talent and strategic prowess.", + "cardThumbnailURL": "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRqPDi__ejymzQm5Ho7m6zWAOExXkYS-DcAmw&s" + }, + "meta": { + "adoptsFrom": { + "module": "../chess-gallery", + "name": "ChessGallery" + } + } + } +} diff --git a/packages/realm-server/tests-vitest/cards/ChessGallery/4a46ad31-357f-42fa-8618-0e96f35d1ab9.json b/packages/realm-server/tests-vitest/cards/ChessGallery/4a46ad31-357f-42fa-8618-0e96f35d1ab9.json new file mode 100644 index 00000000000..8188c39e7d4 --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/ChessGallery/4a46ad31-357f-42fa-8618-0e96f35d1ab9.json @@ -0,0 +1,20 @@ +{ + "data": { + "type": "card", + "attributes": { + "pgn": "1. e4 d6 2. d4 Nf6 3. Nc3 g6 4. f4 Bg7 5. Nf3 O-O 6. Bd3 c5 7. dxc5 Qa5 8. O-O Qxc5+ 9. Kh1 Nc6 10. Qe1 Bg4 11. Be3 Qa5 12. Nd2 Be6 13. f5 Bd7 14. Qh4 Ne5 15. Bh6 Bxh6 16. Qxh6 Neg4 17. Qh4 Kg7 18. Nc4 Qc5 19. Rae1 Bc6 20. h3 Ne5 21. Nxe5 Qxe5 22. Nd5 Bxd5 23. exd5 Qxb2 24. Rxe7 b5 25. a4 a6 26. Qg5 Rae8 27. Re6 bxa4 28. Rxd6 Qe5 29. Rxa6 Nxd5 30. f6+ Kh8 31. Qh6 Rg8 32. Rxa4 Nxf6 33. Rh4 Rg7 34. c4 Nh5 35. c5 Ng3+ 36. Kg1 Nxf1 37. Bxf1 Qxc5+ 38. Kh2 Qd6+ 39. Kg1 Qc5+ 40. Kh2 Re1 41. Qf4 Qe5 42. Rg4 Qxf4+ 43. Rxf4 f5 44. h4 Ra7 45. Kg3 Ra3+ 46. Kh2 Raa1 47. Bc4 Re4 48. Rxe4 fxe4 49. Kg3 Kg7 50. Kf4 Ra3 51. Be2 e3 52. Kf3 Kf6 53. g4 Ke5 54. h5 g5 55. Bc4 Rc3 56. Bg8 h6 57. Bf7 Kd4 58. Bg6 Rc6 59. Be4 Rf6+ 60. Bf5 Ke5 61. Kxe3 Rxf5 62. gxf5 Kxf5 63. Kf3 g4+ 64. Kg3 Kg5 65. Kg2 Kxh5 66. Kg3 Kg5 67. Kg2 h5 68. Kg3 h4+ 69. Kg2 h3+ 70. Kg3 Kh5 71. f3 gxf3 72. Kxh3 Kg5 73. Kg3 Kf5 74. Kf2 Ke4 75. Ke1 Kd3 76. Kf2 Ke4 77. Ke1 Kf4 78. Kf2", + "dateOfGame": "1999-10-23", + "whitePlayer": "Garry Kasparov", + "blackPlayer": "Veselin Topalov", + "cardTitle": "Kasparov's Immortal Game - 1999", + "cardDescription": "Garry Kasparov's stunning victory against Veselin Topalov at the 1999 Wijk aan Zee tournament, often referred to as Kasparov's Immortal.", + "cardThumbnailURL": "https://i.ytimg.com/vi/9PwSa7hkiKc/maxresdefault.jpg" + }, + "meta": { + "adoptsFrom": { + "module": "../chess-gallery", + "name": "ChessGallery" + } + } + } +} diff --git a/packages/realm-server/tests-vitest/cards/FamilyPhotoCard/265bb8ca-4289-43e2-8a3d-dd1a73c1b024.json b/packages/realm-server/tests-vitest/cards/FamilyPhotoCard/265bb8ca-4289-43e2-8a3d-dd1a73c1b024.json new file mode 100644 index 00000000000..27490cfddbf --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/FamilyPhotoCard/265bb8ca-4289-43e2-8a3d-dd1a73c1b024.json @@ -0,0 +1,34 @@ +{ + "data": { + "meta": { + "adoptsFrom": { + "name": "FamilyPhotoCard", + "module": "../family_photo_card" + } + }, + "type": "card", + "attributes": { + "cardInfo": { + "notes": null, + "name": null, + "summary": null, + "cardThumbnailURL": null + }, + "photoUrl": null, + "widthInches": null, + "heightInches": null + }, + "relationships": { + "cardInfo.theme": { + "links": { + "self": null + } + }, + "taggedPeople.0": { + "links": { + "self": "../PersonCard/bae37f39-8ee5-4072-a82a-85ef8ec15d6b" + } + } + } + } +} \ No newline at end of file diff --git a/packages/realm-server/tests-vitest/cards/FamilyPhotoCard/9be794c4-fb87-4d44-973b-482c4ef0c1c5.json b/packages/realm-server/tests-vitest/cards/FamilyPhotoCard/9be794c4-fb87-4d44-973b-482c4ef0c1c5.json new file mode 100644 index 00000000000..7976eade74b --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/FamilyPhotoCard/9be794c4-fb87-4d44-973b-482c4ef0c1c5.json @@ -0,0 +1,44 @@ +{ + "data": { + "meta": { + "adoptsFrom": { + "name": "FamilyPhotoCard", + "module": "../family_photo_card" + } + }, + "type": "card", + "attributes": { + "cardInfo": { + "name": null, + "notes": null, + "summary": null, + "cardThumbnailURL": null + }, + "photoUrl": null, + "widthInches": null, + "heightInches": null + }, + "relationships": { + "cardInfo.theme": { + "links": { + "self": null + } + }, + "taggedPeople.0": { + "links": { + "self": "../PersonCard/936d9784-5e80-4fb5-88e2-8d29a07d07db" + } + }, + "taggedPeople.1": { + "links": { + "self": "../PersonCard/19c90c40-1df9-42e2-8f85-795229a4bc7c" + } + }, + "taggedPeople.2": { + "links": { + "self": "../PersonCard/50812f7d-fa48-46ed-a45d-548cb254cf48" + } + } + } + } +} \ No newline at end of file diff --git a/packages/realm-server/tests-vitest/cards/PersonCard/19c90c40-1df9-42e2-8f85-795229a4bc7c.json b/packages/realm-server/tests-vitest/cards/PersonCard/19c90c40-1df9-42e2-8f85-795229a4bc7c.json new file mode 100644 index 00000000000..98044b1d281 --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/PersonCard/19c90c40-1df9-42e2-8f85-795229a4bc7c.json @@ -0,0 +1,16 @@ +{ + "data": { + "type": "card", + "attributes": { + "name": "Mango", + "cardDescription": null, + "cardThumbnailURL": null + }, + "meta": { + "adoptsFrom": { + "module": "../person-with-error", + "name": "PersonCard" + } + } + } +} diff --git a/packages/realm-server/tests-vitest/cards/PersonCard/50812f7d-fa48-46ed-a45d-548cb254cf48.json b/packages/realm-server/tests-vitest/cards/PersonCard/50812f7d-fa48-46ed-a45d-548cb254cf48.json new file mode 100644 index 00000000000..0dce094f090 --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/PersonCard/50812f7d-fa48-46ed-a45d-548cb254cf48.json @@ -0,0 +1,16 @@ +{ + "data": { + "type": "card", + "attributes": { + "name": "Paper", + "cardDescription": null, + "cardThumbnailURL": null + }, + "meta": { + "adoptsFrom": { + "module": "../person-with-error", + "name": "PersonCard" + } + } + } +} diff --git a/packages/realm-server/tests-vitest/cards/PersonCard/936d9784-5e80-4fb5-88e2-8d29a07d07db.json b/packages/realm-server/tests-vitest/cards/PersonCard/936d9784-5e80-4fb5-88e2-8d29a07d07db.json new file mode 100644 index 00000000000..ecbff07154b --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/PersonCard/936d9784-5e80-4fb5-88e2-8d29a07d07db.json @@ -0,0 +1,16 @@ +{ + "data": { + "type": "card", + "attributes": { + "name": "Van Gogh", + "cardDescription": null, + "cardThumbnailURL": null + }, + "meta": { + "adoptsFrom": { + "module": "../person-with-error", + "name": "PersonCard" + } + } + } +} diff --git a/packages/realm-server/tests-vitest/cards/PersonCard/bae37f39-8ee5-4072-a82a-85ef8ec15d6b.json b/packages/realm-server/tests-vitest/cards/PersonCard/bae37f39-8ee5-4072-a82a-85ef8ec15d6b.json new file mode 100644 index 00000000000..7ef29dc8fd8 --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/PersonCard/bae37f39-8ee5-4072-a82a-85ef8ec15d6b.json @@ -0,0 +1,16 @@ +{ + "data": { + "type": "card", + "attributes": { + "name": "Jade", + "cardDescription": null, + "cardThumbnailURL": null + }, + "meta": { + "adoptsFrom": { + "module": "../person-with-error", + "name": "PersonCard" + } + } + } +} diff --git a/packages/realm-server/tests-vitest/cards/a.js b/packages/realm-server/tests-vitest/cards/a.js new file mode 100644 index 00000000000..3d475b831ee --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/a.js @@ -0,0 +1,5 @@ +import { b } from './b'; + +export function a() { + return 'a' + b(); +} diff --git a/packages/realm-server/tests-vitest/cards/b.js b/packages/realm-server/tests-vitest/cards/b.js new file mode 100644 index 00000000000..9d43c4dd741 --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/b.js @@ -0,0 +1,5 @@ +import { c } from './c'; + +export function b() { + return 'b' + c(); +} diff --git a/packages/realm-server/tests-vitest/cards/c.js b/packages/realm-server/tests-vitest/cards/c.js new file mode 100644 index 00000000000..a180c42eb4d --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/c.js @@ -0,0 +1,3 @@ +export function c() { + return 'c'; +} diff --git a/packages/realm-server/tests-vitest/cards/chess-gallery.gts b/packages/realm-server/tests-vitest/cards/chess-gallery.gts new file mode 100644 index 00000000000..3dbc536e561 --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/chess-gallery.gts @@ -0,0 +1,60 @@ +import DateField from 'https://cardstack.com/base/date'; +import { + FieldDef, + field, + contains, + StringField, +} from 'https://cardstack.com/base/card-api'; +import { Component } from 'https://cardstack.com/base/card-api'; + +// This is intentionally using a FieldDef so it can replicate the error in +// https://linear.app/cardstack/issue/CS-7797/indexer-hangs-when-encountering-instance-json-that-refers-to-a-field +export class ChessGallery extends FieldDef { + @field pgn = contains(StringField); + @field dateOfGame = contains(DateField); + @field whitePlayer = contains(StringField); + @field blackPlayer = contains(StringField); + static displayName = 'Chess Gallery'; + + static edit = class Edit extends Component { + + }; + + static embedded = class Embedded extends Component { + + }; +} diff --git a/packages/realm-server/tests-vitest/cards/code-ref-test.gts b/packages/realm-server/tests-vitest/cards/code-ref-test.gts new file mode 100644 index 00000000000..fe1e70fdfae --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/code-ref-test.gts @@ -0,0 +1,16 @@ +import { + contains, + field, + Component, + CardDef, +} from 'https://cardstack.com/base/card-api'; +import CodeRefField from 'https://cardstack.com/base/code-ref'; + +export class TestCard extends CardDef { + @field ref = contains(CodeRefField); + static embedded = class Embedded extends Component { + + }; +} diff --git a/packages/realm-server/tests-vitest/cards/cycle-one.js b/packages/realm-server/tests-vitest/cards/cycle-one.js new file mode 100644 index 00000000000..26298cd6dc5 --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/cycle-one.js @@ -0,0 +1,5 @@ +import { two } from './cycle-two'; + +export function one() { + return two() - 1; +} diff --git a/packages/realm-server/tests-vitest/cards/cycle-two.js b/packages/realm-server/tests-vitest/cards/cycle-two.js new file mode 100644 index 00000000000..628e6a80c23 --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/cycle-two.js @@ -0,0 +1,9 @@ +import { one } from './cycle-one'; + +export function two() { + return 2; +} + +export function three() { + return one() * 3; +} diff --git a/packages/realm-server/tests-vitest/cards/d.js b/packages/realm-server/tests-vitest/cards/d.js new file mode 100644 index 00000000000..d72a1e24548 --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/d.js @@ -0,0 +1,6 @@ +import { a } from './a'; +import { e } from './e'; + +export function d() { + return a() + e(); +} diff --git a/packages/realm-server/tests-vitest/cards/deadlock/a.js b/packages/realm-server/tests-vitest/cards/deadlock/a.js new file mode 100644 index 00000000000..da74837d509 --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/deadlock/a.js @@ -0,0 +1,9 @@ +import { b } from './b'; + +export function d() { + return 'd'; +} + +export function a() { + return 'a' + b(); +} diff --git a/packages/realm-server/tests-vitest/cards/deadlock/b.js b/packages/realm-server/tests-vitest/cards/deadlock/b.js new file mode 100644 index 00000000000..9d43c4dd741 --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/deadlock/b.js @@ -0,0 +1,5 @@ +import { c } from './c'; + +export function b() { + return 'b' + c(); +} diff --git a/packages/realm-server/tests-vitest/cards/deadlock/c.js b/packages/realm-server/tests-vitest/cards/deadlock/c.js new file mode 100644 index 00000000000..7a7fc343682 --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/deadlock/c.js @@ -0,0 +1,5 @@ +import { d } from './a'; + +export function c() { + return 'c' + d(); +} diff --git a/packages/realm-server/tests-vitest/cards/dir/bar.txt b/packages/realm-server/tests-vitest/cards/dir/bar.txt new file mode 100644 index 00000000000..ba0e162e1c4 --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/dir/bar.txt @@ -0,0 +1 @@ +bar \ No newline at end of file diff --git a/packages/realm-server/tests-vitest/cards/dir/foo.txt b/packages/realm-server/tests-vitest/cards/dir/foo.txt new file mode 100644 index 00000000000..19102815663 --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/dir/foo.txt @@ -0,0 +1 @@ +foo \ No newline at end of file diff --git a/packages/realm-server/tests-vitest/cards/dir/subdir/.gitkeep b/packages/realm-server/tests-vitest/cards/dir/subdir/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/realm-server/tests-vitest/cards/e.js b/packages/realm-server/tests-vitest/cards/e.js new file mode 100644 index 00000000000..bcc3726786d --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/e.js @@ -0,0 +1 @@ +throw new Error('intentional error thrown'); diff --git a/packages/realm-server/tests-vitest/cards/f.js b/packages/realm-server/tests-vitest/cards/f.js new file mode 100644 index 00000000000..873ba1b7165 --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/f.js @@ -0,0 +1,6 @@ +import { b } from './b'; +import { g } from './g'; + +export function f() { + return b() + g(); +} diff --git a/packages/realm-server/tests-vitest/cards/family_photo_card.gts b/packages/realm-server/tests-vitest/cards/family_photo_card.gts new file mode 100644 index 00000000000..86b4cdba3f6 --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/family_photo_card.gts @@ -0,0 +1,57 @@ +import { + contains, + linksToMany, + field, + CardDef, + Component, +} from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import NumberField from 'https://cardstack.com/base/number'; +import { PersonCard } from './person-with-error'; + +export class FamilyPhotoCard extends CardDef { + static displayName = 'Family Photo Card'; + + // URL of the photo + @field photoUrl = contains(StringField, { + description: 'URL of the photo', + }); + @field thumbnailUrl = contains(StringField, { + computeVia: function (this: FamilyPhotoCard) { + return this.photoUrl; + }, + }); + + // Tags: People linked to this photo + @field taggedPeople = linksToMany(PersonCard); + @field widthInches = contains(NumberField); + @field heightInches = contains(NumberField); + + static isolated = class Isolated extends Component { + + }; + + static embedded = this.isolated; +} diff --git a/packages/realm-server/tests-vitest/cards/friend-with-used-link.gts b/packages/realm-server/tests-vitest/cards/friend-with-used-link.gts new file mode 100644 index 00000000000..873cf3e4231 --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/friend-with-used-link.gts @@ -0,0 +1,39 @@ +import { + contains, + linksTo, + linksToMany, + field, + CardDef, + Component, +} from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; + +export class FriendWithUsedLink extends CardDef { + @field firstName = contains(StringField); + @field friend = linksTo(() => FriendWithUsedLink, { isUsed: true }); // using isUsed: true will throw when ensureLinksLoaded encounters broken links + @field friends = linksToMany(() => FriendWithUsedLink); + @field cardTitle = contains(StringField, { + computeVia: function (this: FriendWithUsedLink) { + return this.firstName; + }, + }); + static embedded = class Embedded extends Component { + + }; + static isolated = class Isolated extends Component { + + }; +} diff --git a/packages/realm-server/tests-vitest/cards/friend.gts b/packages/realm-server/tests-vitest/cards/friend.gts new file mode 100644 index 00000000000..15fc8bc00ef --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/friend.gts @@ -0,0 +1,39 @@ +import { + contains, + linksTo, + linksToMany, + field, + CardDef, + Component, +} from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; + +export class Friend extends CardDef { + @field firstName = contains(StringField); + @field friend = linksTo(() => Friend); + @field friends = linksToMany(() => Friend); + @field cardTitle = contains(StringField, { + computeVia: function (this: Friend) { + return this.firstName; + }, + }); + static embedded = class Embedded extends Component { + + }; + static isolated = class Isolated extends Component { + + }; +} diff --git a/packages/realm-server/tests-vitest/cards/g.js b/packages/realm-server/tests-vitest/cards/g.js new file mode 100644 index 00000000000..302b4aae72b --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/g.js @@ -0,0 +1,3 @@ +export function g() { + return 'g'; +} diff --git a/packages/realm-server/tests-vitest/cards/hassan-x.json b/packages/realm-server/tests-vitest/cards/hassan-x.json new file mode 100644 index 00000000000..63a9cb4b186 --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/hassan-x.json @@ -0,0 +1,21 @@ +{ + "data": { + "type": "card", + "attributes": { + "firstName": "Hassan X" + }, + "relationships": { + "friend": { + "links": { + "self": "./jade-x" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "http://localhost:4202/node-test/friend-with-used-link", + "name": "FriendWithUsedLink" + } + } + } +} diff --git a/packages/realm-server/tests-vitest/cards/hassan.json b/packages/realm-server/tests-vitest/cards/hassan.json new file mode 100644 index 00000000000..626f5e94e6e --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/hassan.json @@ -0,0 +1,21 @@ +{ + "data": { + "type": "card", + "attributes": { + "firstName": "Hassan" + }, + "relationships": { + "friend": { + "links": { + "self": "./jade" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "./friend.gts", + "name": "Friend" + } + } + } +} diff --git a/packages/realm-server/tests-vitest/cards/home.gts b/packages/realm-server/tests-vitest/cards/home.gts new file mode 100644 index 00000000000..19457eff667 --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/home.gts @@ -0,0 +1,9 @@ +import { Component, CardDef } from 'https://cardstack.com/base/card-api'; + +export class Home extends CardDef { + static isolated = class Isolated extends Component { + + }; +} diff --git a/packages/realm-server/tests-vitest/cards/index.json b/packages/realm-server/tests-vitest/cards/index.json new file mode 100644 index 00000000000..f20df53720a --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/index.json @@ -0,0 +1,12 @@ +{ + "data": { + "type": "card", + "attributes": {}, + "meta": { + "adoptsFrom": { + "module": "./home.gts", + "name": "Home" + } + } + } +} diff --git a/packages/realm-server/tests-vitest/cards/jade-x.json b/packages/realm-server/tests-vitest/cards/jade-x.json new file mode 100644 index 00000000000..1212b7bb03b --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/jade-x.json @@ -0,0 +1,14 @@ +{ + "data": { + "type": "card", + "attributes": { + "firstName": "Jade X" + }, + "meta": { + "adoptsFrom": { + "module": "http://localhost:4202/node-test/friend-with-used-link", + "name": "FriendWithUsedLink" + } + } + } +} diff --git a/packages/realm-server/tests-vitest/cards/jade.json b/packages/realm-server/tests-vitest/cards/jade.json new file mode 100644 index 00000000000..04e5eb527a3 --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/jade.json @@ -0,0 +1,21 @@ +{ + "data": { + "type": "card", + "attributes": { + "firstName": "Jade" + }, + "relationships": { + "friend": { + "links": { + "self": "./hassan" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "./friend.gts", + "name": "Friend" + } + } + } +} diff --git a/packages/realm-server/tests-vitest/cards/missing-link.json b/packages/realm-server/tests-vitest/cards/missing-link.json new file mode 100644 index 00000000000..31d3c82a77b --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/missing-link.json @@ -0,0 +1,21 @@ +{ + "data": { + "type": "card", + "attributes": { + "firstName": "Boris" + }, + "relationships": { + "friend": { + "links": { + "self": "./does-not-exist" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "./friend.gts", + "name": "Friend" + } + } + } +} diff --git a/packages/realm-server/tests-vitest/cards/multiple-default-exports-card.gts b/packages/realm-server/tests-vitest/cards/multiple-default-exports-card.gts new file mode 100644 index 00000000000..308f6aa2bc8 --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/multiple-default-exports-card.gts @@ -0,0 +1,10 @@ +import { CardDef, Component } from 'https://cardstack.com/base/card-api'; +import MultipleDefaultExports from './multiple-default-exports'; + +export class MultipleDefaultExportsCard extends CardDef { + static isolated = class Isolated extends Component { + + }; +} diff --git a/packages/realm-server/tests-vitest/cards/multiple-default-exports-card.json b/packages/realm-server/tests-vitest/cards/multiple-default-exports-card.json new file mode 100644 index 00000000000..bf230b1f8cb --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/multiple-default-exports-card.json @@ -0,0 +1,11 @@ +{ + "data": { + "type": "card", + "meta": { + "adoptsFrom": { + "module": "./multiple-default-exports-card", + "name": "MultipleDefaultExportsCard" + } + } + } +} diff --git a/packages/realm-server/tests-vitest/cards/multiple-default-exports.gts b/packages/realm-server/tests-vitest/cards/multiple-default-exports.gts new file mode 100644 index 00000000000..723ef3339fe --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/multiple-default-exports.gts @@ -0,0 +1,12 @@ +// Success is the worker being able to process this module and not hang + +function a() { + return 'a'; +} +function b() { + return 'b'; +} +// @ts-ignore-error intentional multiple default exports +export default a; +// @ts-ignore-error intentional multiple default exports +export default b; diff --git a/packages/realm-server/tests-vitest/cards/nested/example.js b/packages/realm-server/tests-vitest/cards/nested/example.js new file mode 100644 index 00000000000..fbd9472402a --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/nested/example.js @@ -0,0 +1 @@ +export const value = 'canonical-path'; diff --git a/packages/realm-server/tests-vitest/cards/person-1.json b/packages/realm-server/tests-vitest/cards/person-1.json new file mode 100644 index 00000000000..e380b1c0e16 --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/person-1.json @@ -0,0 +1,14 @@ +{ + "data": { + "type": "card", + "attributes": { + "firstName": "Mango" + }, + "meta": { + "adoptsFrom": { + "module": "./person.gts", + "name": "Person" + } + } + } +} diff --git a/packages/realm-server/tests-vitest/cards/person-2.json b/packages/realm-server/tests-vitest/cards/person-2.json new file mode 100644 index 00000000000..21b8b957c5a --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/person-2.json @@ -0,0 +1,14 @@ +{ + "data": { + "type": "card", + "attributes": { + "firstName": "Jackie" + }, + "meta": { + "adoptsFrom": { + "module": "./person.gts", + "name": "Person" + } + } + } +} diff --git a/packages/realm-server/tests-vitest/cards/person-with-error.gts b/packages/realm-server/tests-vitest/cards/person-with-error.gts new file mode 100644 index 00000000000..9ee58bc9582 --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/person-with-error.gts @@ -0,0 +1,86 @@ +import { + CardDef, + Component, + field, + contains, + realmURL, + StringField, +} from 'https://cardstack.com/base/card-api'; + +function removeFileExtension(cardUrl: string) { + return cardUrl?.replace(/\.[^/.]+$/, ''); +} + +export class PersonCard extends CardDef { + static displayName = 'Person'; + + // Name of the person + @field name = contains(StringField, { + description: 'Name of the person', + }); + @field cardTitle = contains(StringField, { + computeVia: function (this: PersonCard) { + return this.name; + }, + }); + + static isolated = class Isolated extends Component { + get query() { + return { + filter: { + type: { + // @ts-expect-error "import.meta" is actually fine to here since + // were actually transpile this module in our realm server + module: new URL('./family_photo_card.gts', import.meta.url).href, + name: 'FamilyPhotoCard', + }, + }, + }; + } + get realms() { + return [this.args.model[realmURL]!]; + } + get realmHrefs() { + return this.realms.map((url) => url.href); + } + + }; + + static embedded = this.isolated; +} diff --git a/packages/realm-server/tests-vitest/cards/person.gts b/packages/realm-server/tests-vitest/cards/person.gts new file mode 100644 index 00000000000..3bea2be3c66 --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/person.gts @@ -0,0 +1,27 @@ +import { + contains, + field, + Component, + CardDef, +} from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; + +export class Person extends CardDef { + static displayName = 'Person'; + @field firstName = contains(StringField); + @field cardTitle = contains(StringField, { + computeVia: function (this: Person) { + return this.firstName; + }, + }); + static isolated = class Isolated extends Component { + + }; +} + +export let counter = 0; +export function increment() { + counter++; +} diff --git a/packages/realm-server/tests-vitest/cards/person.json b/packages/realm-server/tests-vitest/cards/person.json new file mode 100644 index 00000000000..bf0bf40f211 --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/person.json @@ -0,0 +1,14 @@ +{ + "data": { + "type": "card", + "attributes": { + "firstName": "Mango 2" + }, + "meta": { + "adoptsFrom": { + "module": "./person.gts", + "name": "Person" + } + } + } +} diff --git a/packages/realm-server/tests-vitest/cards/query-test-cards.gts b/packages/realm-server/tests-vitest/cards/query-test-cards.gts new file mode 100644 index 00000000000..d658e9bd3e6 --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/query-test-cards.gts @@ -0,0 +1,50 @@ +import { + contains, + field, + linksTo, + linksToMany, + containsMany, + FieldDef, + CardDef, +} from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import CodeRefField from 'https://cardstack.com/base/code-ref'; +import DateField from 'https://cardstack.com/base/date'; +import NumberField from 'https://cardstack.com/base/number'; +import BooleanField from 'https://cardstack.com/base/boolean'; + +export class Address extends FieldDef { + @field street = contains(StringField); + @field city = contains(StringField); + @field number = contains(NumberField); +} + +export class Person extends CardDef { + @field name = contains(StringField); + @field nickNames = containsMany(StringField); + @field address = contains(Address); + @field bestFriend = linksTo(() => Person); + @field friends = linksToMany(() => Person); + @field age = contains(NumberField); + @field isHairy = contains(BooleanField); + @field lotteryNumbers = containsMany(NumberField); +} + +export class FancyPerson extends Person { + @field favoriteColor = contains(StringField); +} + +export class Cat extends CardDef { + @field name = contains(StringField); +} + +export class SimpleSpec extends CardDef { + @field cardTitle = contains(StringField); + @field ref = contains(CodeRefField); +} + +export class Event extends CardDef { + @field cardTitle = contains(StringField); + @field venue = contains(StringField); + @field date = contains(DateField); +} diff --git a/packages/realm-server/tests-vitest/cards/sample.md b/packages/realm-server/tests-vitest/cards/sample.md new file mode 100644 index 00000000000..fd551cf4a68 --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/sample.md @@ -0,0 +1,3 @@ +# Sample + +Hello markdown diff --git a/packages/realm-server/tests-vitest/cards/timers-card.gts b/packages/realm-server/tests-vitest/cards/timers-card.gts new file mode 100644 index 00000000000..1e541dba187 --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/timers-card.gts @@ -0,0 +1,24 @@ +import { CardDef, Component } from 'https://cardstack.com/base/card-api'; + +// Success is the worker being able to process this module and not die/thrash +export class TimersCard extends CardDef { + static isolated = class Isolated extends Component { + + + get mischief() { + setTimeout(() => { + throw new Error( + `I'm an intentional error being thrown in a setTimeout() in the timers-card.gts module`, + ); + }, 100); + setInterval(() => { + throw new Error( + `I'm an intentional error being thrown in a setInterval() in the timers-card.gts module`, + ); + }, 100); + return ''; + } + }; +} diff --git a/packages/realm-server/tests-vitest/cards/timers-card.json b/packages/realm-server/tests-vitest/cards/timers-card.json new file mode 100644 index 00000000000..f862c0e8d17 --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/timers-card.json @@ -0,0 +1,11 @@ +{ + "data": { + "type": "card", + "meta": { + "adoptsFrom": { + "module": "./timers-card", + "name": "TimersCard" + } + } + } +} diff --git a/packages/realm-server/tests-vitest/cards/unused-card.gts b/packages/realm-server/tests-vitest/cards/unused-card.gts new file mode 100644 index 00000000000..98419e53f0f --- /dev/null +++ b/packages/realm-server/tests-vitest/cards/unused-card.gts @@ -0,0 +1,21 @@ +import { + contains, + field, + Component, + CardDef, +} from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; + +export class UnusedCard extends CardDef { + @field firstName = contains(StringField); + @field cardTitle = contains(StringField, { + computeVia: function (this: UnusedCard) { + return this.firstName; + }, + }); + static isolated = class Isolated extends Component { + + }; +} diff --git a/packages/realm-server/tests-vitest/claim-boxel-domain.test.ts b/packages/realm-server/tests-vitest/claim-boxel-domain.test.ts new file mode 100644 index 00000000000..932daee9152 --- /dev/null +++ b/packages/realm-server/tests-vitest/claim-boxel-domain.test.ts @@ -0,0 +1,255 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { fileURLToPath } from "url"; +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import { join, dirname } from 'path'; +import type { PgAdapter } from '@cardstack/postgres'; +import type { User } from '@cardstack/runtime-common'; +import { query, insert, asExpressions } from '@cardstack/runtime-common'; +import { setupDB, insertUser, runTestRealmServer, createVirtualNetwork, matrixURL, closeServer, realmSecretSeed, } from './helpers'; +import type { RealmServerTokenClaim } from '../utils/jwt'; +import { createJWT as createRealmServerJWT } from '../utils/jwt'; +import type { SuperTest, Test } from 'supertest'; +import supertest from 'supertest'; +import type { Server } from 'http'; +import { dirSync, type DirResult } from 'tmp'; +import { copySync, ensureDirSync } from 'fs-extra'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const testRealmURL = new URL('http://127.0.0.1:0/test/'); +describe("claim-boxel-domain-test.ts", function () { + describe('claim boxel claimed domain endpoint', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let testRealmServer: Server; + let request: SuperTest; + let dir: DirResult; + let dbAdapter: PgAdapter; + let user: User; + let boxelSiteDomain = 'boxel.site'; + let defaultToken: RealmServerTokenClaim; + hooks.beforeEach(async function () { + dir = dirSync(); + }); + setupDB(hooks, { + beforeEach: async (_dbAdapter, publisher, runner) => { + dbAdapter = _dbAdapter; + let testRealmDir = join(dir.name, 'realm_server_5', 'test'); + ensureDirSync(testRealmDir); + copySync(join(__dirname, 'cards'), testRealmDir); + testRealmServer = (await runTestRealmServer({ + virtualNetwork: createVirtualNetwork(), + testRealmDir, + realmsRootPath: join(dir.name, 'realm_server_5'), + realmURL: testRealmURL, + dbAdapter, + publisher, + runner, + matrixURL, + domainsForPublishedRealms: { boxelSite: boxelSiteDomain }, + })).testRealmHttpServer; + request = supertest(testRealmServer); + user = await insertUser(dbAdapter, 'matrix-user-id', 'test-user', 'test-user@example.com'); + defaultToken = { + user: 'matrix-user-id', + sessionRoom: 'test-session', + }; + }, + afterEach: async () => { + await closeServer(testRealmServer); + }, + }); + async function makePostRequest(token: RealmServerTokenClaim | null, body?: any) { + let requestBuilder = request + .post('/_boxel-claimed-domains') + .set('Accept', 'application/json') + .set('Content-Type', 'application/json'); + if (token) { + const jwt = createRealmServerJWT(token, realmSecretSeed); + requestBuilder = requestBuilder.set('Authorization', `Bearer ${jwt}`); + } + if (body !== undefined) { + requestBuilder = requestBuilder.send(body); + } + return await requestBuilder; + } + function assertErrorIncludes(response: any, message: string) { + return response.body.errors && response.body.errors[0].includes(message); + } + it('should return 400 when body is not valid JSON', async function () { + const response = await makePostRequest(defaultToken, 'invalid json{'); + expect(response.status).toBe(400); + expect(assertErrorIncludes(response, 'Request body is not valid JSON')).toBeTruthy(); + }); + it('should return 400 for invalid JSON-API format', async function () { + const response = await makePostRequest(defaultToken, { + hostname: 'test.boxel.site', + }); + expect(response.status).toBe(400); + expect(assertErrorIncludes(response, 'json is missing "data" object')).toBeTruthy(); + }); + it('should return 400 when source_realm_url is missing', async function () { + const response = await makePostRequest(defaultToken, { + data: { + type: 'claimed-domain', + attributes: { + hostname: 'test.boxel.site', + }, + }, + }); + expect(response.status).toBe(400); + expect(assertErrorIncludes(response, 'source_realm_url is required')).toBeTruthy(); + }); + it('should return 400 when hostname is missing', async function () { + const response = await makePostRequest(defaultToken, { + data: { + type: 'claimed-domain', + attributes: { + source_realm_url: 'https://test-realm.com', + }, + }, + }); + expect(response.status).toBe(400); + expect(assertErrorIncludes(response, 'hostname is required')).toBeTruthy(); + }); + it('should return 422 when hostname is just the domain without subdomain', async function () { + const response = await makePostRequest(defaultToken, { + data: { + type: 'claimed-domain', + attributes: { + source_realm_url: 'https://test-realm.com', + hostname: boxelSiteDomain, + }, + }, + }); + expect(response.status).toBe(422); + expect(assertErrorIncludes(response, 'Hostname must include a subdomain')).toBeTruthy(); + }); + it('should return 422 when hostname does not end with the correct domain', async function () { + const response = await makePostRequest(defaultToken, { + data: { + type: 'claimed-domain', + attributes: { + source_realm_url: 'https://test-realm.com', + hostname: 'something.not-boxel.site', + }, + }, + }); + expect(response.status).toBe(422); + expect(assertErrorIncludes(response, `Hostname must end with .boxel.site`)).toBeTruthy(); + }); + it('should return 422 for invalid subdomain names', async function () { + const invalidSubdomains = [ + 'api', + 'admin', + 'test', + '-invalid', + 'invalid-', + 'a', + 'a'.repeat(64), + 'test@domain', + 'test.domain', + 'MyApp', + 'TEST', + 'xn--test', + 'tëst', + ]; + for (const subdomain of invalidSubdomains) { + const response = await makePostRequest(defaultToken, { + data: { + type: 'claimed-domain', + attributes: { + source_realm_url: 'https://test-realm.com', + hostname: `${subdomain}.${boxelSiteDomain}`, + }, + }, + }); + expect(response.status).toBe(422); + expect(response.body).toBeTruthy(); + } + }); + it('should return 422 when hostname is already claimed', async function () { + const hostname = 'claimed-site.boxel.site'; + let { valueExpressions, nameExpressions } = asExpressions({ + user_id: user.id, + source_realm_url: 'https://existing-realm.com', + hostname: hostname, + claimed_at: Math.floor(Date.now() / 1000), + }); + await query(dbAdapter, insert('claimed_domains_for_sites', nameExpressions, valueExpressions)); + const response = await makePostRequest(defaultToken, { + data: { + type: 'claimed-domain', + attributes: { + source_realm_url: 'https://test-realm.com', + hostname: hostname, + }, + }, + }); + expect(response.status).toBe(422); + expect(assertErrorIncludes(response, 'Hostname is already claimed')).toBeTruthy(); + }); + it('should successfully claim a valid hostname', async function () { + const hostname = 'my-site.boxel.site'; + const sourceRealmURL = 'https://test-realm.com'; + const response = await makePostRequest(defaultToken, { + data: { + type: 'claimed-domain', + attributes: { + source_realm_url: sourceRealmURL, + hostname: hostname, + }, + }, + }); + expect(response.status).toBe(201); + // Check JSON-API response body + expect(response.body.data).toBeTruthy(); + expect(response.body.data.type).toBe('claimed-domain'); + expect(response.body.data.id).toBeTruthy(); + expect(response.body.data.attributes.hostname).toBe(hostname); + expect(response.body.data.attributes.subdomain).toBe('my-site'); + expect(response.body.data.attributes.sourceRealmURL).toBe(sourceRealmURL); + // Verify the claim was saved to database + const claims = await query(dbAdapter, [ + `SELECT * FROM claimed_domains_for_sites WHERE hostname = '${hostname}'`, + ]); + expect(claims.length).toBe(1); + expect(claims[0].user_id).toBe(user.id); + expect(claims[0].source_realm_url).toBe(sourceRealmURL); + expect(claims[0].claimed_at).toBeTruthy(); + expect(claims[0].removed_at).toBe(null); + }); + it('should allow claiming a hostname that was previously removed', async function () { + const hostname = 'removed-site.boxel.site'; + // Insert a removed claim + let { valueExpressions, nameExpressions } = asExpressions({ + user_id: user.id, + source_realm_url: 'https://old-realm.com', + hostname: hostname, + claimed_at: Math.floor(Date.now() / 1000) - 86400, + removed_at: Math.floor(Date.now() / 1000) - 3600, + }); + await query(dbAdapter, insert('claimed_domains_for_sites', nameExpressions, valueExpressions)); + const sourceRealmURL = 'https://new-realm.com'; + const response = await makePostRequest(defaultToken, { + data: { + type: 'claimed-domain', + attributes: { + source_realm_url: sourceRealmURL, + hostname: hostname, + }, + }, + }); + expect(response.status).toBe(201); + // Verify the new claim was saved + const claims = await query(dbAdapter, [ + `SELECT * FROM claimed_domains_for_sites WHERE hostname = '${hostname}' AND removed_at IS NULL`, + ]); + expect(claims.length).toBe(1); + expect(claims[0].source_realm_url).toBe(sourceRealmURL); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/command-parsing-utils.test.ts b/packages/realm-server/tests-vitest/command-parsing-utils.test.ts new file mode 100644 index 00000000000..d065bc06b2b --- /dev/null +++ b/packages/realm-server/tests-vitest/command-parsing-utils.test.ts @@ -0,0 +1,44 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect } from "vitest"; +import { runSharedTest } from '@cardstack/runtime-common/helpers'; +import commandParsingTests from '@cardstack/runtime-common/tests/command-parsing-utils-test'; +describe("command-parsing-utils-test.ts", function () { + describe('command parsing utils', function () { + it('parseBoxelHostCommandSpecifier parses scoped command specifier', async function () { + await runSharedTest(commandParsingTests, assert, {}); + }); + it('parseBoxelHostCommandSpecifier rejects unscoped command specifier', async function () { + await runSharedTest(commandParsingTests, assert, {}); + }); + it('parseBoxelHostCommandSpecifier rejects specifier without export name', async function () { + await runSharedTest(commandParsingTests, assert, {}); + }); + it('parseBoxelHostCommandSpecifier rejects query/hash forms', async function () { + await runSharedTest(commandParsingTests, assert, {}); + }); + it('requires explicit export for cardstack/boxel-host command specifier', async function () { + await runSharedTest(commandParsingTests, assert, {}); + }); + it('parses cardstack/boxel-host command specifier with explicit export', async function () { + await runSharedTest(commandParsingTests, assert, {}); + }); + it('parses absolute /commands URL into realm code ref', async function () { + await runSharedTest(commandParsingTests, assert, {}); + }); + it('parses absolute /commands URL without export into default export', async function () { + await runSharedTest(commandParsingTests, assert, {}); + }); + it('rejects nested /commands paths', async function () { + await runSharedTest(commandParsingTests, assert, {}); + }); + it('rejects traversal-like command segments', async function () { + await runSharedTest(commandParsingTests, assert, {}); + }); + it('rejects extra path segments beyond command and export', async function () { + await runSharedTest(commandParsingTests, assert, {}); + }); + it('returns undefined for unknown command formats', async function () { + await runSharedTest(commandParsingTests, assert, {}); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/definition-lookup.test.ts b/packages/realm-server/tests-vitest/definition-lookup.test.ts new file mode 100644 index 00000000000..0c9f992add6 --- /dev/null +++ b/packages/realm-server/tests-vitest/definition-lookup.test.ts @@ -0,0 +1,885 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import { CachingDefinitionLookup, internalKeyFor, trimExecutableExtension, type ErrorEntry, type ModuleDefinitionResult, type ModulePrerenderArgs, type ModuleRenderResponse, type Prerenderer, type VirtualNetwork, } from '@cardstack/runtime-common'; +import { setupPermissionedRealmsCached, createVirtualNetwork, testCreatePrerenderAuth, } from './helpers'; +import type { PgAdapter } from '@cardstack/postgres/pg-adapter'; +function buildDefinition(moduleURL: string, name: string): ModuleDefinitionResult { + let moduleAlias = trimExecutableExtension(new URL(moduleURL)).href; + return { + type: 'definition', + moduleURL: moduleAlias, + definition: { + type: 'card-def', + codeRef: { + module: moduleAlias, + name, + }, + displayName: name, + fields: {}, + }, + types: [], + }; +} +function buildModuleError(moduleURL: string, message: string, deps: string[] = [], additionalErrors: ErrorEntry['error']['additionalErrors'] = null): ErrorEntry { + return { + type: 'module-error', + error: { + id: moduleURL, + message, + status: 404, + title: 'Module error', + deps, + additionalErrors, + }, + }; +} +function buildModuleResponse(moduleURL: string, name: string, deps: string[], error?: ErrorEntry): ModuleRenderResponse { + let definitionId = internalKeyFor({ module: moduleURL, name }, undefined); + let definitions = error + ? {} + : { + [definitionId]: buildDefinition(moduleURL, name), + }; + return { + id: moduleURL, + status: error ? 'error' : 'ready', + nonce: 'test-nonce', + isShimmed: false, + lastModified: Date.now(), + createdAt: Date.now(), + deps, + definitions, + error, + }; +} +describe("definition-lookup-test.ts", function () { + describe('DefinitionLookup', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let definitionLookup: CachingDefinitionLookup; + let realmURL = 'http://127.0.0.1:4450/'; + let testUserId = '@user1:localhost'; + let mockRemotePrerenderer: Prerenderer; + let dbAdapter: PgAdapter; + let prerenderModuleCalls: number = 0; + let virtualNetwork: VirtualNetwork; + hooks.beforeEach(async () => { + prerenderModuleCalls = 0; + }); + hooks.before(async () => { + virtualNetwork = createVirtualNetwork(); + mockRemotePrerenderer = { + async prerenderCard() { + throw new Error('Not implemented in mock'); + }, + async prerenderModule(args: ModulePrerenderArgs) { + prerenderModuleCalls++; + let moduleURL = new URL(args.url); + let modulePathWithoutExtension = moduleURL.href.replace(/\.gts$/, ''); + return Promise.resolve({ + id: 'example-id', + status: 'ready', + nonce: '12345', + isShimmed: false, + lastModified: +new Date(), + createdAt: +new Date(), + deps: ['dep/a', 'dep/b'], + definitions: { + [`${modulePathWithoutExtension}/Person`]: { + type: 'definition', + moduleURL: moduleURL.href, + definition: { + type: 'card-def', + codeRef: { + module: moduleURL.href, + name: 'Person', + }, + displayName: 'Person', + fields: { + name: { + type: 'contains', + isPrimitive: true, + isComputed: false, + fieldOrCard: { + module: 'https://cardstack.com/base/string', + name: 'default', + }, + serializerName: undefined, + }, + }, + }, + types: [], + }, + }, + }); + }, + async prerenderFileExtract() { + throw new Error('Not implemented in mock'); + }, + async prerenderFileRender() { + throw new Error('Not implemented in mock'); + }, + async runCommand() { + throw new Error('Not implemented in mock'); + }, + }; + definitionLookup = new CachingDefinitionLookup(dbAdapter, mockRemotePrerenderer, virtualNetwork, testCreatePrerenderAuth); + definitionLookup.registerRealm({ + url: realmURL, + async getRealmOwnerUserId() { + return testUserId; + }, + async visibility() { + return 'private'; + }, + }); + }); + setupPermissionedRealmsCached(hooks, { + realms: [ + { + realmURL, + permissions: { + [testUserId]: ['read', 'write', 'realm-owner'], + }, + fileSystem: { + 'person.gts': ` + import { CardDef, field, contains, StringField, Component } from 'https://cardstack.com/base/card-api'; + export class Person extends CardDef { + static displayName = "Person"; + @field name = contains(StringField); + static isolated = class extends Component { + + } + } + `, + '1.json': { + data: { + attributes: { + name: 'Maple', + }, + meta: { + adoptsFrom: { + module: './person', + name: 'Person', + }, + }, + }, + }, + }, + }, + ], + onRealmSetup({ dbAdapter: pgAdapter }) { + dbAdapter = pgAdapter; + definitionLookup = new CachingDefinitionLookup(dbAdapter, mockRemotePrerenderer, virtualNetwork, testCreatePrerenderAuth); + definitionLookup.registerRealm({ + url: realmURL, + async getRealmOwnerUserId() { + return testUserId; + }, + async visibility() { + return 'private'; + }, + }); + }, + }); + it('lookupDefinition', async function () { + let definition = await definitionLookup.lookupDefinition({ + module: `${realmURL}person.gts`, + name: 'Person', + }); + expect(definition?.displayName).toBe('Person'); + expect(prerenderModuleCalls).toBe(1); + // second call should hit the cache and not call prerenderModule again + definition = await definitionLookup.lookupDefinition({ + module: `${realmURL}person.gts`, + name: 'Person', + }); + expect(definition?.displayName).toBe('Person'); + expect(prerenderModuleCalls).toBe(1); + }); + it('invalidation', async function () { + let definition = await definitionLookup.lookupDefinition({ + module: `${realmURL}person.gts`, + name: 'Person', + }); + expect(definition?.displayName).toBe('Person'); + expect(prerenderModuleCalls).toBe(1); + await definitionLookup.invalidate('http://some-realm-url/person.gts'); + definition = await definitionLookup.lookupDefinition({ + module: `${realmURL}person.gts`, + name: 'Person', + }); + expect(definition?.displayName).toBe('Person'); + expect(prerenderModuleCalls).toBe(1); + await definitionLookup.invalidate(`${realmURL}person.gts`); + definition = await definitionLookup.lookupDefinition({ + module: `${realmURL}person.gts`, + name: 'Person', + }); + expect(definition?.displayName).toBe('Person'); + expect(prerenderModuleCalls).toBe(2); + }); + it('invalidates module cache entries without file extensions', async function () { + let definition = await definitionLookup.lookupDefinition({ + module: `${realmURL}person.gts`, + name: 'Person', + }); + expect(definition?.displayName).toBe('Person'); + expect(prerenderModuleCalls).toBe(1); + await definitionLookup.invalidate(`${realmURL}person`); + definition = await definitionLookup.lookupDefinition({ + module: `${realmURL}person.gts`, + name: 'Person', + }); + expect(definition?.displayName).toBe('Person'); + expect(prerenderModuleCalls).toBe(2); + }); + it('invalidates cached module after module update', async function () { + await dbAdapter.execute('DELETE FROM modules'); + let moduleURL = `${realmURL}person.gts`; + let version = 1; + let calls = 0; + let prerenderer: Prerenderer = { + async prerenderCard() { + throw new Error('Not implemented in mock'); + }, + async prerenderFileExtract() { + throw new Error('Not implemented in mock'); + }, + async prerenderFileRender() { + throw new Error('Not implemented in mock'); + }, + async runCommand() { + throw new Error('Not implemented in mock'); + }, + async prerenderModule(args: ModulePrerenderArgs) { + calls++; + let moduleAlias = trimExecutableExtension(new URL(args.url)).href; + let definitionId = internalKeyFor({ module: args.url, name: 'Person' }, undefined); + return { + id: args.url, + status: 'ready', + nonce: 'test-nonce', + isShimmed: false, + lastModified: Date.now(), + createdAt: Date.now(), + deps: [], + definitions: { + [definitionId]: { + type: 'definition', + moduleURL: moduleAlias, + definition: { + type: 'card-def', + codeRef: { + module: moduleAlias, + name: 'Person', + }, + displayName: `Person v${version}`, + fields: {}, + }, + types: [], + }, + }, + }; + }, + }; + let lookup = new CachingDefinitionLookup(dbAdapter, prerenderer, virtualNetwork, testCreatePrerenderAuth); + lookup.registerRealm({ + url: realmURL, + async getRealmOwnerUserId() { + return testUserId; + }, + async visibility() { + return 'private'; + }, + }); + let definition = await lookup.lookupDefinition({ + module: moduleURL, + name: 'Person', + }); + expect(definition?.displayName).toBe('Person v1'); + expect(calls).toBe(1); + version = 2; + await lookup.invalidate(moduleURL); + definition = await lookup.lookupDefinition({ + module: moduleURL, + name: 'Person', + }); + expect(definition?.displayName).toBe('Person v2'); + expect(calls).toBe(2); + }); + it('invalidates cached module after module deletion', async function () { + await dbAdapter.execute('DELETE FROM modules'); + let moduleURL = `${realmURL}deleted-card.gts`; + let modulePresent = true; + let calls = 0; + let prerenderer: Prerenderer = { + async prerenderCard() { + throw new Error('Not implemented in mock'); + }, + async prerenderFileExtract() { + throw new Error('Not implemented in mock'); + }, + async prerenderFileRender() { + throw new Error('Not implemented in mock'); + }, + async runCommand() { + throw new Error('Not implemented in mock'); + }, + async prerenderModule(args: ModulePrerenderArgs) { + calls++; + if (!modulePresent) { + return buildModuleResponse(args.url, 'DeletedCard', [], buildModuleError(args.url, 'missing deleted-card')); + } + return buildModuleResponse(args.url, 'DeletedCard', []); + }, + }; + let lookup = new CachingDefinitionLookup(dbAdapter, prerenderer, virtualNetwork, testCreatePrerenderAuth); + lookup.registerRealm({ + url: realmURL, + async getRealmOwnerUserId() { + return testUserId; + }, + async visibility() { + return 'private'; + }, + }); + let definition = await lookup.lookupDefinition({ + module: moduleURL, + name: 'DeletedCard', + }); + expect(definition).toBeTruthy(); + expect(calls).toBe(1); + modulePresent = false; + await lookup.invalidate(moduleURL); + await expect(lookup.lookupDefinition({ + module: moduleURL, + name: 'DeletedCard', + })).rejects.toThrow('lookup fails after module deletion invalidation'); + expect(calls).toBe(2); + }); + it('invalidates module cache entries using dependency graph', async function () { + await dbAdapter.execute('DELETE FROM modules'); + let deepModule = `${realmURL}deep-card.gts`; + let middleModule = `${realmURL}middle-field.gts`; + let leafModule = `${realmURL}leaf-field.gts`; + let otherModule = `${realmURL}other-card.gts`; + let deepAlias = trimExecutableExtension(new URL(deepModule)).href; + let middleAlias = trimExecutableExtension(new URL(middleModule)).href; + let leafAlias = trimExecutableExtension(new URL(leafModule)).href; + let otherAlias = trimExecutableExtension(new URL(otherModule)).href; + let calls = new Map(); + let prerenderer: Prerenderer = { + async prerenderCard() { + throw new Error('Not implemented in mock'); + }, + async prerenderFileExtract() { + throw new Error('Not implemented in mock'); + }, + async prerenderFileRender() { + throw new Error('Not implemented in mock'); + }, + async runCommand() { + throw new Error('Not implemented in mock'); + }, + async prerenderModule(args: ModulePrerenderArgs) { + calls.set(args.url, (calls.get(args.url) ?? 0) + 1); + switch (args.url) { + case deepModule: + return buildModuleResponse(args.url, 'DeepCard', [ + './middle-field.gts', + ]); + case middleModule: + return buildModuleResponse(args.url, 'MiddleField', [ + './leaf-field.gts', + ]); + case leafModule: + return buildModuleResponse(args.url, 'LeafField', []); + case otherModule: + return buildModuleResponse(args.url, 'OtherCard', []); + default: + throw new Error(`Unexpected module URL: ${args.url}`); + } + }, + }; + let lookup = new CachingDefinitionLookup(dbAdapter, prerenderer, virtualNetwork, testCreatePrerenderAuth); + lookup.registerRealm({ + url: realmURL, + async getRealmOwnerUserId() { + return testUserId; + }, + async visibility() { + return 'private'; + }, + }); + await lookup.lookupDefinition({ + module: deepModule, + name: 'DeepCard', + }); + await lookup.lookupDefinition({ + module: middleModule, + name: 'MiddleField', + }); + await lookup.lookupDefinition({ + module: leafModule, + name: 'LeafField', + }); + await lookup.lookupDefinition({ + module: otherModule, + name: 'OtherCard', + }); + let rows = (await dbAdapter.execute(`SELECT url FROM modules + WHERE url IN ($1, $2, $3) + OR file_alias IN ($4, $5, $6)`, { + bind: [ + deepModule, + middleModule, + leafModule, + deepAlias, + middleAlias, + leafAlias, + ], + })) as { + url: string; + }[]; + expect(rows.length).toBe(3); + await lookup.invalidate(leafModule); + rows = (await dbAdapter.execute(`SELECT url FROM modules + WHERE url IN ($1, $2, $3) + OR file_alias IN ($4, $5, $6)`, { + bind: [ + deepModule, + middleModule, + leafModule, + deepAlias, + middleAlias, + leafAlias, + ], + })) as { + url: string; + }[]; + expect(rows.length).toBe(0); + rows = (await dbAdapter.execute(`SELECT url FROM modules + WHERE url = $1 OR file_alias = $2`, { + bind: [otherModule, otherAlias], + })) as { + url: string; + }[]; + expect(rows.length).toBe(1); + await lookup.lookupDefinition({ + module: deepModule, + name: 'DeepCard', + }); + expect(calls.get(deepModule)).toBe(2); + }); + it('invalidates module cache entries for branching dependency graph', async function () { + await dbAdapter.execute('DELETE FROM modules'); + let blogAppModule = `${realmURL}blog-app.gts`; + let authorModule = `${realmURL}author.gts`; + let blogCategoryModule = `${realmURL}blog-category.gts`; + let blogPostModule = `${realmURL}blog-post.gts`; + let otherModule = `${realmURL}other-card.gts`; + let blogAppAlias = trimExecutableExtension(new URL(blogAppModule)).href; + let authorAlias = trimExecutableExtension(new URL(authorModule)).href; + let blogCategoryAlias = trimExecutableExtension(new URL(blogCategoryModule)).href; + let blogPostAlias = trimExecutableExtension(new URL(blogPostModule)).href; + let otherAlias = trimExecutableExtension(new URL(otherModule)).href; + let calls = new Map(); + let prerenderer: Prerenderer = { + async prerenderCard() { + throw new Error('Not implemented in mock'); + }, + async prerenderFileExtract() { + throw new Error('Not implemented in mock'); + }, + async prerenderFileRender() { + throw new Error('Not implemented in mock'); + }, + async runCommand() { + throw new Error('Not implemented in mock'); + }, + async prerenderModule(args: ModulePrerenderArgs) { + calls.set(args.url, (calls.get(args.url) ?? 0) + 1); + switch (args.url) { + case blogAppModule: + return buildModuleResponse(args.url, 'BlogApp', []); + case authorModule: + return buildModuleResponse(args.url, 'Author', [ + './blog-app.gts', + ]); + case blogCategoryModule: + return buildModuleResponse(args.url, 'BlogCategory', [ + './blog-app.gts', + ]); + case blogPostModule: + return buildModuleResponse(args.url, 'BlogPost', [ + './author.gts', + './blog-app.gts', + ]); + case otherModule: + return buildModuleResponse(args.url, 'OtherCard', []); + default: + throw new Error(`Unexpected module URL: ${args.url}`); + } + }, + }; + let lookup = new CachingDefinitionLookup(dbAdapter, prerenderer, virtualNetwork, testCreatePrerenderAuth); + lookup.registerRealm({ + url: realmURL, + async getRealmOwnerUserId() { + return testUserId; + }, + async visibility() { + return 'private'; + }, + }); + await lookup.lookupDefinition({ + module: blogAppModule, + name: 'BlogApp', + }); + await lookup.lookupDefinition({ + module: authorModule, + name: 'Author', + }); + await lookup.lookupDefinition({ + module: blogCategoryModule, + name: 'BlogCategory', + }); + await lookup.lookupDefinition({ + module: blogPostModule, + name: 'BlogPost', + }); + await lookup.lookupDefinition({ + module: otherModule, + name: 'OtherCard', + }); + let rows = (await dbAdapter.execute(`SELECT url FROM modules + WHERE url IN ($1, $2, $3, $4, $5) + OR file_alias IN ($6, $7, $8, $9, $10)`, { + bind: [ + blogAppModule, + authorModule, + blogCategoryModule, + blogPostModule, + otherModule, + blogAppAlias, + authorAlias, + blogCategoryAlias, + blogPostAlias, + otherAlias, + ], + })) as { + url: string; + }[]; + expect(rows.length).toBe(5); + await lookup.invalidate(blogAppModule); + rows = (await dbAdapter.execute(`SELECT url FROM modules + WHERE url IN ($1, $2, $3, $4) + OR file_alias IN ($5, $6, $7, $8)`, { + bind: [ + blogAppModule, + authorModule, + blogCategoryModule, + blogPostModule, + blogAppAlias, + authorAlias, + blogCategoryAlias, + blogPostAlias, + ], + })) as { + url: string; + }[]; + expect(rows.length).toBe(0); + rows = (await dbAdapter.execute(`SELECT url FROM modules + WHERE url = $1 OR file_alias = $2`, { + bind: [otherModule, otherAlias], + })) as { + url: string; + }[]; + expect(rows.length).toBe(1); + await lookup.lookupDefinition({ + module: blogPostModule, + name: 'BlogPost', + }); + expect(calls.get(blogPostModule)).toBe(2); + }); + it('propagates module errors to dependents and recovers after missing modules are added', async function () { + await dbAdapter.execute('DELETE FROM modules'); + let deepModule = `${realmURL}deep-card.gts`; + let middleModule = `${realmURL}middle-field.gts`; + let leafModule = `${realmURL}leaf-field.gts`; + let state = { + deep: false, + middle: false, + leaf: false, + }; + let prerenderer: Prerenderer = { + async prerenderCard() { + throw new Error('Not implemented in mock'); + }, + async prerenderFileExtract() { + throw new Error('Not implemented in mock'); + }, + async prerenderFileRender() { + throw new Error('Not implemented in mock'); + }, + async runCommand() { + throw new Error('Not implemented in mock'); + }, + async prerenderModule(args: ModulePrerenderArgs) { + switch (args.url) { + case deepModule: { + if (!state.deep) { + return buildModuleResponse(args.url, 'DeepCard', [], buildModuleError(args.url, 'missing deep-card')); + } + if (!state.middle || !state.leaf) { + return buildModuleResponse(args.url, 'DeepCard', ['./middle-field.gts'], buildModuleError(args.url, 'missing middle-field', [ + './middle-field.gts', + ])); + } + return buildModuleResponse(args.url, 'DeepCard', [ + './middle-field.gts', + ]); + } + case middleModule: { + if (!state.middle) { + return buildModuleResponse(args.url, 'MiddleField', [], buildModuleError(args.url, 'missing middle-field', [ + './leaf-field.gts', + ])); + } + if (!state.leaf) { + return buildModuleResponse(args.url, 'MiddleField', ['./leaf-field.gts'], buildModuleError(args.url, 'missing leaf-field', [ + './leaf-field.gts', + ])); + } + return buildModuleResponse(args.url, 'MiddleField', [ + './leaf-field.gts', + ]); + } + case leafModule: { + if (!state.leaf) { + return buildModuleResponse(args.url, 'LeafField', [], buildModuleError(args.url, 'missing leaf-field')); + } + return buildModuleResponse(args.url, 'LeafField', []); + } + default: + throw new Error(`Unexpected module URL: ${args.url}`); + } + }, + }; + let lookup = new CachingDefinitionLookup(dbAdapter, prerenderer, virtualNetwork, testCreatePrerenderAuth); + lookup.registerRealm({ + url: realmURL, + async getRealmOwnerUserId() { + return testUserId; + }, + async visibility() { + return 'private'; + }, + }); + await expect(lookup.lookupDefinition({ + module: deepModule, + name: 'DeepCard', + })).rejects.toThrow('deep-card errors when missing'); + state.deep = true; + await lookup.invalidate(deepModule); + await expect(lookup.lookupDefinition({ + module: middleModule, + name: 'MiddleField', + })).rejects.toThrow('middle-field errors when missing'); + await expect(lookup.lookupDefinition({ + module: deepModule, + name: 'DeepCard', + })).rejects.toThrow('deep-card errors when middle-field is missing'); + let rows = (await dbAdapter.execute(`SELECT error_doc FROM modules WHERE url = $1`, { + bind: [deepModule], + coerceTypes: { error_doc: 'JSON' }, + })) as { + error_doc: ErrorEntry | null; + }[]; + let deepError = rows[0]?.error_doc; + expect(deepError?.type).toBe('module-error'); + if (deepError?.error) { + let additionalErrors = Array.isArray(deepError.error.additionalErrors) + ? deepError.error.additionalErrors + : []; + expect(additionalErrors.some((error) => String(error.message ?? '').includes('middle-field'))).toBeTruthy(); + } + state.middle = true; + await lookup.invalidate(middleModule); + await expect(lookup.lookupDefinition({ + module: leafModule, + name: 'LeafField', + })).rejects.toThrow('leaf-field errors when missing'); + await expect(lookup.lookupDefinition({ + module: middleModule, + name: 'MiddleField', + })).rejects.toThrow('middle-field errors when leaf-field is missing'); + await expect(lookup.lookupDefinition({ + module: deepModule, + name: 'DeepCard', + })).rejects.toThrow('deep-card errors when leaf-field is missing'); + rows = (await dbAdapter.execute(`SELECT error_doc FROM modules WHERE url = $1`, { + bind: [deepModule], + coerceTypes: { error_doc: 'JSON' }, + })) as { + error_doc: ErrorEntry | null; + }[]; + deepError = rows[0]?.error_doc; + if (deepError?.error) { + let additionalErrors = Array.isArray(deepError.error.additionalErrors) + ? deepError.error.additionalErrors + : []; + expect(additionalErrors.some((error) => String(error.message ?? '').includes('leaf-field'))).toBeTruthy(); + } + state.leaf = true; + await lookup.invalidate(leafModule); + await lookup.lookupDefinition({ + module: leafModule, + name: 'LeafField', + }); + await lookup.lookupDefinition({ + module: middleModule, + name: 'MiddleField', + }); + await lookup.lookupDefinition({ + module: deepModule, + name: 'DeepCard', + }); + let sqlNullRows = (await dbAdapter.execute(`SELECT error_doc IS NULL AS is_sql_null + FROM modules + WHERE url = $1`, { bind: [deepModule] })) as { + is_sql_null: boolean; + }[]; + expect(sqlNullRows[0]?.is_sql_null).toBe(true); + }); + it('uses public cache scope when realm is public', async function () { + await dbAdapter.execute('DELETE FROM modules'); + await dbAdapter.execute(`INSERT INTO realm_user_permissions (realm_url, username, read, write, realm_owner) VALUES ($1, '*', true, false, false)`, { bind: [realmURL] }); + // rebuild definition lookup to clear cached visibility + definitionLookup = new CachingDefinitionLookup(dbAdapter, mockRemotePrerenderer, virtualNetwork, testCreatePrerenderAuth); + definitionLookup.registerRealm({ + url: realmURL, + async getRealmOwnerUserId() { + return testUserId; + }, + async visibility() { + // after inserting '*' the realm is public + return 'public'; + }, + }); + await definitionLookup.lookupDefinition({ + module: `${realmURL}person.gts`, + name: 'Person', + }); + let rows = (await dbAdapter.execute(`SELECT cache_scope, auth_user_id FROM modules WHERE url = $1`, { bind: [`${realmURL}person.gts`] })) as { + cache_scope: string; + auth_user_id: string; + }[]; + expect(rows[0]?.cache_scope).toBe('public'); + expect(rows[0]?.auth_user_id).toBe(''); + }); + it('uses realm-auth cache scope when realm is private on the same server', async function () { + await dbAdapter.execute('DELETE FROM modules'); + await definitionLookup.lookupDefinition({ + module: `${realmURL}person.gts`, + name: 'Person', + }); + let rows = (await dbAdapter.execute(`SELECT cache_scope, auth_user_id FROM modules WHERE url = $1`, { bind: [`${realmURL}person.gts`] })) as { + cache_scope: string; + auth_user_id: string; + }[]; + expect(rows[0]?.cache_scope).toBe('realm-auth'); + expect(rows[0]?.auth_user_id).toBe(testUserId); + }); + it('uses public cache scope when requesting a public realm on another server', async function () { + await dbAdapter.execute('DELETE FROM modules'); + let remoteRealmURL = 'http://remote-realm/'; + let remoteModuleURL = `${remoteRealmURL}person.gts`; + let handler = async (request: Request) => { + if (request.method === 'HEAD' && request.url === remoteModuleURL) { + return new Response(null, { + status: 200, + headers: { + 'x-boxel-realm-public-readable': 'true', + 'x-boxel-realm-url': remoteRealmURL, + }, + }); + } + return null; + }; + virtualNetwork.mount(handler); + try { + let scopedLookup = definitionLookup.forRealm({ + url: realmURL, + async getRealmOwnerUserId() { + return testUserId; + }, + async visibility() { + return 'private'; + }, + }); + await scopedLookup.lookupDefinition({ + module: remoteModuleURL, + name: 'Person', + }); + let rows = (await dbAdapter.execute(`SELECT cache_scope, auth_user_id FROM modules WHERE url = $1`, { bind: [remoteModuleURL] })) as { + cache_scope: string; + auth_user_id: string; + }[]; + expect(rows[0]?.cache_scope).toBe('public'); + expect(rows[0]?.auth_user_id).toBe(''); + } + finally { + virtualNetwork.unmount(handler); + } + }); + it('uses realm-auth cache scope when requesting a private realm on another server', async function () { + await dbAdapter.execute('DELETE FROM modules'); + let remoteRealmURL = 'http://private-remote-realm/'; + let remoteModuleURL = `${remoteRealmURL}person.gts`; + let requestingUserId = '@other-user:localhost'; + let handler = async (request: Request) => { + if (request.method === 'HEAD' && request.url === remoteModuleURL) { + return new Response(null, { + status: 200, + headers: { + 'x-boxel-realm-public-readable': 'false', + 'x-boxel-realm-url': remoteRealmURL, + }, + }); + } + return null; + }; + virtualNetwork.mount(handler); + try { + let scopedLookup = definitionLookup.forRealm({ + url: realmURL, + async getRealmOwnerUserId() { + return requestingUserId; + }, + async visibility() { + return 'private'; + }, + }); + await scopedLookup.lookupDefinition({ + module: remoteModuleURL, + name: 'Person', + }); + let rows = (await dbAdapter.execute(`SELECT cache_scope, auth_user_id FROM modules WHERE url = $1`, { bind: [remoteModuleURL] })) as { + cache_scope: string; + auth_user_id: string; + }[]; + expect(rows[0]?.cache_scope).toBe('realm-auth'); + expect(rows[0]?.auth_user_id).toBe(requestingUserId); + } + finally { + virtualNetwork.unmount(handler); + } + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/delete-boxel-claimed-domain.test.ts b/packages/realm-server/tests-vitest/delete-boxel-claimed-domain.test.ts new file mode 100644 index 00000000000..60596aab9ec --- /dev/null +++ b/packages/realm-server/tests-vitest/delete-boxel-claimed-domain.test.ts @@ -0,0 +1,169 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { fileURLToPath } from "url"; +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import { join, dirname } from 'path'; +import type { PgAdapter } from '@cardstack/postgres'; +import type { User } from '@cardstack/runtime-common'; +import { query, insert, asExpressions, uuidv4, } from '@cardstack/runtime-common'; +import { setupDB, insertUser, runTestRealmServer, createVirtualNetwork, matrixURL, closeServer, } from './helpers'; +import type { RealmServerTokenClaim } from '../utils/jwt'; +import { createJWT as createRealmServerJWT } from '../utils/jwt'; +import { realmSecretSeed } from './helpers'; +import type { SuperTest, Test } from 'supertest'; +import supertest from 'supertest'; +import type { Server } from 'http'; +import { dirSync, type DirResult } from 'tmp'; +import { copySync, ensureDirSync } from 'fs-extra'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const testRealmURL = new URL('http://127.0.0.1:0/test/'); +describe("delete-boxel-claimed-domain-test.ts", function () { + describe('delete boxel claimed domain endpoint', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let testRealmServer: Server; + let request: SuperTest; + let dir: DirResult; + let dbAdapter: PgAdapter; + let user: User; + let otherUser: User; + let boxelSiteDomain = 'boxel.site'; + let defaultToken: RealmServerTokenClaim; + hooks.beforeEach(async function () { + dir = dirSync(); + }); + setupDB(hooks, { + beforeEach: async (_dbAdapter, publisher, runner) => { + dbAdapter = _dbAdapter; + let testRealmDir = join(dir.name, 'realm_server_5', 'test'); + ensureDirSync(testRealmDir); + copySync(join(__dirname, 'cards'), testRealmDir); + testRealmServer = (await runTestRealmServer({ + virtualNetwork: createVirtualNetwork(), + testRealmDir, + realmsRootPath: join(dir.name, 'realm_server_5'), + realmURL: testRealmURL, + dbAdapter, + publisher, + runner, + matrixURL, + domainsForPublishedRealms: { boxelSite: boxelSiteDomain }, + })).testRealmHttpServer; + request = supertest(testRealmServer); + user = await insertUser(dbAdapter, 'matrix-user-id', 'test-user', 'test-user@example.com'); + otherUser = await insertUser(dbAdapter, 'other-matrix-user-id', 'other-user', 'other-user@example.com'); + defaultToken = { + user: 'matrix-user-id', + sessionRoom: 'test-session', + }; + }, + afterEach: async () => { + await closeServer(testRealmServer); + }, + }); + async function makeDeleteRequest(token: RealmServerTokenClaim | null, claimedDomainId: string) { + let requestBuilder = request + .delete(`/_boxel-claimed-domains/${claimedDomainId}`) + .set('Accept', 'application/json'); + if (token) { + const jwt = createRealmServerJWT(token, realmSecretSeed); + requestBuilder = requestBuilder.set('Authorization', `Bearer ${jwt}`); + } + return await requestBuilder; + } + function assertErrorIncludes(response: any, message: string) { + return response.body.errors && response.body.errors[0].includes(message); + } + async function createClaim(userId: string, hostname: string, sourceRealmURL: string, removedAt?: number): Promise { + let claimData: any = { + user_id: userId, + source_realm_url: sourceRealmURL, + hostname: hostname, + claimed_at: Math.floor(Date.now() / 1000), + }; + if (removedAt) { + claimData.removed_at = removedAt; + } + let { valueExpressions, nameExpressions } = asExpressions(claimData); + const result = await query(dbAdapter, insert('claimed_domains_for_sites', nameExpressions, valueExpressions)); + const claimedDomainId = result[0]?.id; + if (!claimedDomainId) { + throw new Error('Failed to create claim - no ID returned'); + } + return claimedDomainId as string; + } + it('should return 422 when claimed domain ID does not exist', async function () { + const response = await makeDeleteRequest(defaultToken, uuidv4()); + expect(response.status).toBe(422); + expect(assertErrorIncludes(response, 'No active hostname claim found for this claimed domain ID')).toBeTruthy(); + }); + it('should return 422 when claim was already removed', async function () { + const hostname = 'removed-site.boxel.site'; + const sourceRealmURL = 'https://test-realm.com'; + // Create a removed claim + const claimedDomainId = await createClaim(user.id, hostname, sourceRealmURL, Math.floor(Date.now() / 1000) - 3600); + const response = await makeDeleteRequest(defaultToken, claimedDomainId); + expect(response.status).toBe(422); + expect(assertErrorIncludes(response, 'No active hostname claim found for this claimed domain ID')).toBeTruthy(); + }); + it('should return 422 when user does not own the claim', async function () { + const hostname = 'other-user-site.boxel.site'; + const sourceRealmURL = 'https://test-realm.com'; + // Create a claim for the other user + const claimedDomainId = await createClaim(otherUser.id, hostname, sourceRealmURL); + const response = await makeDeleteRequest(defaultToken, claimedDomainId); + expect(response.status).toBe(422); + expect(assertErrorIncludes(response, 'You do not have permission to delete this hostname claim')).toBeTruthy(); + }); + it('should successfully delete a hostname claim', async function () { + const hostname = 'my-site.boxel.site'; + const sourceRealmURL = 'https://test-realm.com'; + // Create a claim for the user + const claimedDomainId = await createClaim(user.id, hostname, sourceRealmURL); + const response = await makeDeleteRequest(defaultToken, claimedDomainId); + expect(response.status).toBe(204); + expect(response.text).toBe(''); + // Verify the claim was soft-deleted in the database + const claims = await query(dbAdapter, [ + `SELECT * FROM claimed_domains_for_sites WHERE id = '${claimedDomainId}'`, + ]); + expect(claims.length).toBe(1); + expect(claims[0].removed_at).toBeTruthy(); + expect(claims[0].user_id).toBe(user.id); + expect(claims[0].source_realm_url).toBe(sourceRealmURL); + }); + it('should verify removed_at timestamp is recent', async function () { + const hostname = 'timestamp-test.boxel.site'; + const sourceRealmURL = 'https://test-realm.com'; + // Create a claim for the user + const claimedDomainId = await createClaim(user.id, hostname, sourceRealmURL); + const beforeDelete = Math.floor(Date.now() / 1000); + await makeDeleteRequest(defaultToken, claimedDomainId); + const afterDelete = Math.floor(Date.now() / 1000); + // Verify the removed_at timestamp is recent + const claims = await query(dbAdapter, [ + `SELECT * FROM claimed_domains_for_sites WHERE id = '${claimedDomainId}'`, + ]); + const removedAt = Number(claims[0].removed_at); + expect(removedAt >= beforeDelete).toBeTruthy(); + expect(removedAt <= afterDelete).toBeTruthy(); + }); + it('should not be able to delete the same claim twice', async function () { + const hostname = 'double-delete.boxel.site'; + const sourceRealmURL = 'https://test-realm.com'; + // Create a claim for the user + const claimedDomainId = await createClaim(user.id, hostname, sourceRealmURL); + // First delete should succeed + const response1 = await makeDeleteRequest(defaultToken, claimedDomainId); + expect(response1.status).toBe(204); + // Second delete should fail with 422 + const response2 = await makeDeleteRequest(defaultToken, claimedDomainId); + expect(response2.status).toBe(422); + expect(assertErrorIncludes(response2, 'No active hostname claim found for this claimed domain ID')).toBeTruthy(); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/file-watcher-events.test.ts b/packages/realm-server/tests-vitest/file-watcher-events.test.ts new file mode 100644 index 00000000000..f0e546348d1 --- /dev/null +++ b/packages/realm-server/tests-vitest/file-watcher-events.test.ts @@ -0,0 +1,268 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import type { Test, SuperTest } from 'supertest'; +import { join, basename } from 'path'; +import type { Server } from 'http'; +import type { DirResult } from 'tmp'; +import { removeSync, writeJSONSync, writeFileSync } from 'fs-extra'; +import type { Realm } from '@cardstack/runtime-common'; +import { setupPermissionedRealmCached, setupMatrixRoom, waitForRealmEvent, type RealmRequest, withRealmPath, } from './helpers'; +import '@cardstack/runtime-common/helpers/code-equality-assertion'; +import type { PgAdapter } from '@cardstack/postgres'; +import type { RealmEvent, UpdateRealmEventContent, } from 'https://cardstack.com/base/matrix-event'; +describe("file-watcher-events-test.ts", function () { + describe('file watcher realm events', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let realmURL = new URL('http://127.0.0.1:4444/test/'); + let testRealm: Realm; + let testRealmHttpServer: Server; + let request: RealmRequest; + let serverRequest: SuperTest; + let dir: DirResult; + let dbAdapter: PgAdapter; + let realmEventTimestampStart: number; + function onRealmSetup(args: { + testRealm: Realm; + testRealmHttpServer: Server; + request: SuperTest; + dir: DirResult; + dbAdapter: PgAdapter; + }) { + testRealm = args.testRealm; + testRealmHttpServer = args.testRealmHttpServer; + serverRequest = args.request; + request = withRealmPath(args.request, realmURL); + dir = args.dir; + dbAdapter = args.dbAdapter; + } + function getRealmSetup() { + return { + testRealm, + testRealmHttpServer, + request, + serverRequest, + dir, + dbAdapter, + }; + } + setupPermissionedRealmCached(hooks, { + realmURL, + permissions: { + '*': ['read'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + subscribeToRealmEvents: true, + onRealmSetup, + fileSystem: { + 'person.gts': ` + import { contains, field, CardDef, Component } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Person extends CardDef { + @field firstName = contains(StringField); + static isolated = class Isolated extends Component { + + } + static embedded = class Embedded extends Component { + + } + static fitted = class Fitted extends Component { + + } + } + `, + 'louis.json': { + data: { + attributes: { + firstName: 'Louis', + }, + meta: { + adoptsFrom: { + module: './person', + name: 'Person', + }, + }, + }, + }, + }, + }); + let { getMessagesSince } = setupMatrixRoom(hooks, getRealmSetup); + type FileChangeType = 'added' | 'updated' | 'removed'; + function matchesFileChange(event: RealmEvent, changeType: FileChangeType, fileName: string): boolean { + if (event.content.eventName !== 'update') { + return false; + } + let content = event.content as UpdateRealmEventContent; + switch (changeType) { + case 'added': + return 'added' in content && content.added === fileName; + case 'updated': + return 'updated' in content && content.updated === fileName; + case 'removed': + return 'removed' in content && content.removed === fileName; + } + } + async function waitForFileChange(changeType: FileChangeType, fileName: string): Promise { + try { + return await waitForRealmEvent(getMessagesSince, realmEventTimestampStart, { + predicate: (event) => matchesFileChange(event, changeType, fileName), + timeout: 20000, + timeoutMessage: `Waiting for ${changeType} event for ${fileName} exceeded timeout`, + }); + } + catch (error) { + // Log all received events to help debug failures + let allMessages = await getMessagesSince(realmEventTimestampStart); + console.log(`Failed waiting for ${changeType} event for ${fileName}. Received ${allMessages.length} messages:`); + allMessages.forEach((msg, index) => { + console.log(`Message ${index + 1}:`, JSON.stringify(msg, null, 2)); + }); + throw error; + } + } + it('file creation produces an added event', async function () { + realmEventTimestampStart = Date.now(); + let newFilePath = join(dir.name, 'realm_server_1', 'test', 'new-file.json'); + writeJSONSync(newFilePath, { + data: { + type: 'card', + attributes: { + cardTitle: 'Mango', + name: 'Mango', + }, + meta: { + adoptsFrom: { + module: './sample-card', + name: 'SampleCard', + }, + }, + }, + }); + let updateEvent = await waitForFileChange('added', basename(newFilePath)); + expect(updateEvent.content).toEqual({ + eventName: 'update', + added: basename(newFilePath), + realmURL: realmURL.href, + }); + }); + it('file updating produces an updated event', async function () { + realmEventTimestampStart = Date.now(); + let updatedFilePath = join(dir.name, 'realm_server_1', 'test', 'louis.json'); + writeJSONSync(updatedFilePath, { + data: { + attributes: { + firstName: 'Louis.', + }, + meta: { + adoptsFrom: { + module: './person', + name: 'Person', + }, + }, + }, + }); + let updateEvent = await waitForFileChange('updated', basename(updatedFilePath)); + expect(updateEvent.content).toEqual({ + eventName: 'update', + updated: basename(updatedFilePath), + realmURL: realmURL.href, + }); + }); + it('file deletion produces a removed event', async function () { + realmEventTimestampStart = Date.now(); + let deletedFilePath = join(dir.name, 'realm_server_1', 'test', 'louis.json'); + removeSync(deletedFilePath); + let updateEvent = await waitForFileChange('removed', basename(deletedFilePath)); + expect(updateEvent.content).toEqual({ + eventName: 'update', + removed: basename(deletedFilePath), + realmURL: realmURL.href, + }); + }); + it('file watcher invalidates caches after external edits', async function () { + const personFilePath = join(dir.name, 'realm_server_1', 'test', 'person.gts'); + const louisFilePath = join(dir.name, 'realm_server_1', 'test', 'louis.json'); + testRealm.__testOnlyClearCaches(); + let initialSourceResponse = await request + .get('/person.gts') + .set('Accept', 'application/vnd.card+source'); + expect(initialSourceResponse.headers['x-boxel-cache']).toBe('miss'); + let cachedSourceResponse = await request + .get('/person.gts') + .set('Accept', 'application/vnd.card+source'); + expect(cachedSourceResponse.headers['x-boxel-cache']).toBe('hit'); + let cachedSourceBody = cachedSourceResponse.text.trim(); + await request.get('/louis').set('Accept', 'application/vnd.card+json'); + realmEventTimestampStart = Date.now(); + let updatedPersonSource = ` +import { contains, field, CardDef, Component } from "https://cardstack.com/base/card-api"; +import StringField from "https://cardstack.com/base/string"; + +export class Person extends CardDef { + @field firstName = contains(StringField); + @field lastName = contains(StringField); + static isolated = class Isolated extends Component { + + } + static embedded = class Embedded extends Component { + + } + static fitted = class Fitted extends Component { + + } +} + `.trim(); + writeFileSync(personFilePath, `${updatedPersonSource}\n`); + writeJSONSync(louisFilePath, { + data: { + attributes: { + firstName: 'Louis', + lastName: 'Riel', + }, + meta: { + adoptsFrom: { + module: './person', + name: 'Person', + }, + }, + }, + }); + await waitForFileChange('updated', basename(personFilePath)); + await waitForFileChange('updated', basename(louisFilePath)); + await testRealm.flushUpdateEvents(); + let updatedSourceResponse = await request + .get('/person.gts') + .set('Accept', 'application/vnd.card+source'); + expect(updatedSourceResponse.text.trim()).toBe(updatedPersonSource); + expect(updatedSourceResponse.text.trim()).not.toBe(cachedSourceBody); + let repopulatedSourceResponse = await request + .get('/person.gts') + .set('Accept', 'application/vnd.card+source'); + expect(repopulatedSourceResponse.headers['x-boxel-cache']).toBe('hit'); + expect(repopulatedSourceResponse.text.trim()).toBe(updatedPersonSource); + let updatedCardResponse = await request + .get('/louis') + .set('Accept', 'application/vnd.card+json'); + expect(updatedCardResponse.status).toBe(200); + expect(updatedCardResponse.body.data.attributes.firstName).toBe('Louis'); + expect(updatedCardResponse.body.data.attributes.lastName).toBe('Riel'); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/full-reindex.test.ts b/packages/realm-server/tests-vitest/full-reindex.test.ts new file mode 100644 index 00000000000..ab0d712cab8 --- /dev/null +++ b/packages/realm-server/tests-vitest/full-reindex.test.ts @@ -0,0 +1,109 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import type { PgAdapter } from '@cardstack/postgres'; +import type { IndexWriter, DefinitionLookup, Prerenderer, QueuePublisher, } from '@cardstack/runtime-common'; +import { asExpressions, fullReindex, insert, insertPermissions, logger, query, uuidv4, } from '@cardstack/runtime-common'; +import { setupDB } from './helpers'; +describe("full-reindex-test.ts", function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let dbAdapter: PgAdapter; + let queuePublisher: QueuePublisher; + setupDB(hooks, { + beforeEach: async (_dbAdapter: PgAdapter, _publisher: QueuePublisher): Promise => { + dbAdapter = _dbAdapter; + queuePublisher = _publisher; + }, + }); + function buildFullReindexTask() { + return fullReindex({ + reportStatus: () => { }, + log: logger('full-reindex-test'), + dbAdapter, + queuePublisher, + indexWriter: null as unknown as IndexWriter, + prerenderer: null as unknown as Prerenderer, + definitionLookup: null as unknown as DefinitionLookup, + matrixURL: 'http://localhost:8008', + getReader: () => { + throw new Error('getReader is not used by full-reindex'); + }, + getAuthedFetch: async () => globalThis.fetch, + createPrerenderAuth: () => '', + }); + } + async function insertPublishedRealm({ sourceRealmURL, publishedRealmURL, ownerUsername, }: { + sourceRealmURL: string; + publishedRealmURL: string; + ownerUsername: string; + }) { + let { nameExpressions, valueExpressions } = asExpressions({ + id: uuidv4(), + owner_username: ownerUsername, + source_realm_url: sourceRealmURL, + published_realm_url: publishedRealmURL, + last_published_at: Date.now().toString(), + }); + await query(dbAdapter, insert('published_realms', nameExpressions, valueExpressions)); + } + it('enqueues jobs for source and published realms using the source owner', async function () { + const ownerUserId = '@owner:localhost'; + const sourceRealmURL = 'http://example.com/source/'; + const publishedRealmURL = 'http://example.com/published/'; + await insertPermissions(dbAdapter, new URL(sourceRealmURL), { + [ownerUserId]: ['read', 'realm-owner'], + }); + await insertPublishedRealm({ + sourceRealmURL, + publishedRealmURL, + ownerUsername: '@realm/published-owner', + }); + let reindex = buildFullReindexTask(); + await reindex({ + realmUrls: [sourceRealmURL, publishedRealmURL], + }); + type JobArgs = { + realmURL: string; + realmUsername: string; + }; + type JobRow = { + job_type: string; + concurrency_group: string | null; + args: JobArgs; + }; + let jobs = (await dbAdapter.execute('select * from jobs')) as JobRow[]; + expect(jobs.length).toBe(2); + let jobsByRealm = new Map(jobs.map((job) => [job.args.realmURL, job])); + let sourceJob = jobsByRealm.get(sourceRealmURL); + expect(sourceJob).toBeTruthy(); + expect(sourceJob?.job_type).toBe('from-scratch-index'); + expect(sourceJob?.concurrency_group).toBe(`indexing:${sourceRealmURL}`); + expect(sourceJob?.args).toEqual({ + realmURL: sourceRealmURL, + realmUsername: 'owner', + }); + let publishedJob = jobsByRealm.get(publishedRealmURL); + expect(publishedJob).toBeTruthy(); + expect(publishedJob?.job_type).toBe('from-scratch-index'); + expect(publishedJob?.concurrency_group).toBe(`indexing:${publishedRealmURL}`); + expect(publishedJob?.args).toEqual({ + realmURL: publishedRealmURL, + realmUsername: 'owner', + }); + }); + it('skips bot-owned realms', async function () { + const botUserId = '@realm/bot:localhost'; + const botRealmURL = 'http://example.com/bot/'; + await insertPermissions(dbAdapter, new URL(botRealmURL), { + [botUserId]: ['read', 'realm-owner'], + }); + let reindex = buildFullReindexTask(); + await reindex({ realmUrls: [botRealmURL] }); + let jobs = await dbAdapter.execute('select * from jobs'); + expect(jobs.length).toBe(0); + }); +}); diff --git a/packages/realm-server/tests-vitest/get-boxel-claimed-domain.test.ts b/packages/realm-server/tests-vitest/get-boxel-claimed-domain.test.ts new file mode 100644 index 00000000000..9689eb96711 --- /dev/null +++ b/packages/realm-server/tests-vitest/get-boxel-claimed-domain.test.ts @@ -0,0 +1,155 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { fileURLToPath } from "url"; +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import { join, dirname } from 'path'; +import type { PgAdapter } from '@cardstack/postgres'; +import type { User } from '@cardstack/runtime-common'; +import { query, insert, asExpressions } from '@cardstack/runtime-common'; +import { setupDB, insertUser, runTestRealmServer, createVirtualNetwork, matrixURL, closeServer, realmSecretSeed, } from './helpers'; +import type { RealmServerTokenClaim } from '../utils/jwt'; +import { createJWT as createRealmServerJWT } from '../utils/jwt'; +import type { SuperTest, Test } from 'supertest'; +import supertest from 'supertest'; +import type { Server } from 'http'; +import { dirSync, type DirResult } from 'tmp'; +import { copySync, ensureDirSync } from 'fs-extra'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const testRealmURL = new URL('http://127.0.0.1:0/test/'); +describe("get-boxel-claimed-domain-test.ts", function () { + describe('get boxel claimed domain endpoint', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let testRealmServer: Server; + let request: SuperTest; + let dir: DirResult; + let dbAdapter: PgAdapter; + let user: User; + let boxelSiteDomain = 'boxel.site'; + let defaultToken: RealmServerTokenClaim; + hooks.beforeEach(async function () { + dir = dirSync(); + }); + setupDB(hooks, { + beforeEach: async (_dbAdapter, publisher, runner) => { + dbAdapter = _dbAdapter; + let testRealmDir = join(dir.name, 'realm_server_5', 'test'); + ensureDirSync(testRealmDir); + copySync(join(__dirname, 'cards'), testRealmDir); + testRealmServer = (await runTestRealmServer({ + virtualNetwork: createVirtualNetwork(), + testRealmDir, + realmsRootPath: join(dir.name, 'realm_server_5'), + realmURL: testRealmURL, + dbAdapter, + publisher, + runner, + matrixURL, + domainsForPublishedRealms: { boxelSite: boxelSiteDomain }, + })).testRealmHttpServer; + request = supertest(testRealmServer); + user = await insertUser(dbAdapter, 'matrix-user-id', 'test-user', 'test-user@example.com'); + defaultToken = { + user: 'matrix-user-id', + sessionRoom: 'test-session', + }; + }, + afterEach: async () => { + await closeServer(testRealmServer); + }, + }); + async function makeGetRequest(token: RealmServerTokenClaim | null, queryParams?: Record) { + let requestBuilder = request + .get('/_boxel-claimed-domains') + .set('Accept', 'application/json'); + if (token) { + const jwt = createRealmServerJWT(token, realmSecretSeed); + requestBuilder = requestBuilder.set('Authorization', `Bearer ${jwt}`); + } + if (queryParams) { + requestBuilder = requestBuilder.query(queryParams); + } + return await requestBuilder; + } + function assertErrorIncludes(response: any, message: string) { + return response.body.errors && response.body.errors[0].includes(message); + } + it('should return 400 when source_realm_url is missing', async function () { + const response = await makeGetRequest(defaultToken, {}); + expect(response.status).toBe(400); + expect(assertErrorIncludes(response, 'source_realm_url query parameter is required')).toBeTruthy(); + }); + it('should return 404 when no claim exists for the realm', async function () { + const response = await makeGetRequest(defaultToken, { + source_realm_url: 'https://nonexistent-realm.com', + }); + expect(response.status).toBe(404); + expect(assertErrorIncludes(response, 'No hostname claim found for this realm')).toBeTruthy(); + }); + it('should successfully get a claimed hostname', async function () { + const hostname = 'my-site.boxel.site'; + const sourceRealmURL = 'https://test-realm.com'; + // Insert a claim + let { valueExpressions, nameExpressions } = asExpressions({ + user_id: user.id, + source_realm_url: sourceRealmURL, + hostname: hostname, + claimed_at: Math.floor(Date.now() / 1000), + }); + await query(dbAdapter, insert('claimed_domains_for_sites', nameExpressions, valueExpressions)); + const response = await makeGetRequest(defaultToken, { + source_realm_url: sourceRealmURL, + }); + expect(response.status).toBe(200); + // Check JSON-API response body + expect(response.body.data).toBeTruthy(); + expect(response.body.data.type).toBe('claimed-domain'); + expect(response.body.data.id).toBeTruthy(); + expect(response.body.data.attributes.hostname).toBe(hostname); + expect(response.body.data.attributes.subdomain).toBe('my-site'); + expect(response.body.data.attributes.sourceRealmURL).toBe(sourceRealmURL); + }); + it('should not return removed claims', async function () { + const hostname = 'removed-site.boxel.site'; + const sourceRealmURL = 'https://test-realm.com'; + // Insert a removed claim + let { valueExpressions, nameExpressions } = asExpressions({ + user_id: user.id, + source_realm_url: sourceRealmURL, + hostname: hostname, + claimed_at: Math.floor(Date.now() / 1000) - 86400, + removed_at: Math.floor(Date.now() / 1000) - 3600, + }); + await query(dbAdapter, insert('claimed_domains_for_sites', nameExpressions, valueExpressions)); + const response = await makeGetRequest(defaultToken, { + source_realm_url: sourceRealmURL, + }); + expect(response.status).toBe(404); + expect(assertErrorIncludes(response, 'No hostname claim found for this realm')).toBeTruthy(); + }); + it('should only return claims for the authenticated user', async function () { + const hostname = 'other-user-site.boxel.site'; + const sourceRealmURL = 'https://test-realm.com'; + // Create another user + const otherUser = await insertUser(dbAdapter, 'other-matrix-user-id', 'other-user', 'other-user@example.com'); + // Insert a claim for the other user + let { valueExpressions, nameExpressions } = asExpressions({ + user_id: otherUser.id, + source_realm_url: sourceRealmURL, + hostname: hostname, + claimed_at: Math.floor(Date.now() / 1000), + }); + await query(dbAdapter, insert('claimed_domains_for_sites', nameExpressions, valueExpressions)); + // Try to get the claim as the default user + const response = await makeGetRequest(defaultToken, { + source_realm_url: sourceRealmURL, + }); + expect(response.status).toBe(404); + expect(assertErrorIncludes(response, 'No hostname claim found for this realm')).toBeTruthy(); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/helpers/index.ts b/packages/realm-server/tests-vitest/helpers/index.ts new file mode 100644 index 00000000000..fd374a299d1 --- /dev/null +++ b/packages/realm-server/tests-vitest/helpers/index.ts @@ -0,0 +1,2571 @@ +import { + writeFileSync, + writeJSONSync, + readdirSync, + statSync, + ensureDirSync, + copySync, +} from 'fs-extra'; +import { NodeAdapter } from '../../node-realm'; +import { dirname, join } from 'path'; +import { createHash } from 'crypto'; +import type { + LooseSingleCardDocument, + RealmPermissions, + User, + Subscription, + Plan, + RealmAdapter, + DefinitionLookup, +} from '@cardstack/runtime-common'; +import { + Realm, + baseRealm, + VirtualNetwork, + Worker, + insertPermissions, + IndexWriter, + asExpressions, + query, + insert, + param, + unixTime, + uuidv4, + RealmPaths, + PUBLISHED_DIRECTORY_NAME, + DEFAULT_CARD_SIZE_LIMIT_BYTES, + DEFAULT_FILE_SIZE_LIMIT_BYTES, + type MatrixConfig, + type QueuePublisher, + type QueueRunner, + type Definition, + type Prerenderer, + CachingDefinitionLookup, +} from '@cardstack/runtime-common'; +import { resetCatalogRealms } from '../../handlers/handle-fetch-catalog-realms'; +import { dirSync, setGracefulCleanup, type DirResult } from 'tmp'; +import { getLocalConfig as getSynapseConfig } from '../../synapse'; +import { RealmServer } from '../../server'; + +import { + PgAdapter, + PgQueuePublisher, + PgQueueRunner, +} from '@cardstack/postgres'; +import type { Server } from 'http'; +import { MatrixClient } from '@cardstack/runtime-common/matrix-client'; +import { + Prerenderer as LocalPrerenderer, + type Prerenderer as TestPrerenderer, +} from '../../prerender'; + +import type { SuperTest, Test } from 'supertest'; +import supertest from 'supertest'; +import { APP_BOXEL_REALM_EVENT_TYPE } from '@cardstack/runtime-common/matrix-constants'; +import type { + IncrementalIndexEventContent, + MatrixEvent, + RealmEvent, + RealmEventContent, +} from 'https://cardstack.com/base/matrix-event'; +import { createRemotePrerenderer } from '../../prerender/remote-prerenderer'; +import { createPrerenderHttpServer } from '../../prerender/prerender-app'; +import { buildCreatePrerenderAuth } from '../../prerender/auth'; +import { Client as PgClient } from 'pg'; +import { + isEnvironmentMode, + getEnvironmentSlug, + serviceURL, +} from '../../lib/dev-service-registry'; + +/** + * In environment mode we shift test ports by a deterministic offset derived + * from the environment slug so that parallel environments never collide. + */ +function environmentPortOffset(): number { + if (!isEnvironmentMode()) { + return 0; + } + let slug = getEnvironmentSlug(); + let hash = 0; + for (let i = 0; i < slug.length; i++) { + hash = ((hash << 5) - hash + slug.charCodeAt(i)) | 0; + } + // offset in range [1000, 9000) — keeps ports well within valid range + return 1000 + (Math.abs(hash) % 8000); +} + +/** Return a test port, shifted by a per-environment offset when needed. */ +export function testPort(basePort: number): number { + return basePort + environmentPortOffset(); +} + +const testRealmURL = new URL(`http://127.0.0.1:${testPort(4444)}/`); +const testRealmHref = testRealmURL.href; + +/** Build the default test-realm URL with an optional sub-path. */ +export function testRealmURLFor(path: string): URL { + return new URL(path, testRealmURL); +} + +const migratedTestDatabaseTemplate = 'boxel_migrated_template'; + +export const testRealmServerMatrixUsername = 'node-test_realm-server'; +export const testRealmServerMatrixUserId = `@${testRealmServerMatrixUsername}:localhost`; + +export type RealmRequest = { + get(path: string): Test; + post(path: string): Test; + put(path: string): Test; + patch(path: string): Test; + delete(path: string): Test; + head(path: string): Test; +}; + +export function withRealmPath( + request: SuperTest, + realmURL: URL, +): RealmRequest { + let realmPath = realmURL.pathname.replace(/\/?$/, '/'); + let prefixPath = (path: string) => { + if (path.startsWith('http://') || path.startsWith('https://')) { + return path; + } + if (path.startsWith(realmPath)) { + return path; + } + if (path.startsWith('/')) { + return `${realmPath}${path.slice(1)}`; + } + return `${realmPath}${path}`; + }; + return { + get: (path: string) => request.get(prefixPath(path)), + post: (path: string) => request.post(prefixPath(path)), + put: (path: string) => request.put(prefixPath(path)), + patch: (path: string) => request.patch(prefixPath(path)), + delete: (path: string) => request.delete(prefixPath(path)), + head: (path: string) => request.head(prefixPath(path)), + }; +} + +export { testRealmHref, testRealmURL }; + +const REALM_EVENT_TS_SKEW_BUFFER_MS = 2000; + +export async function waitUntil( + condition: () => Promise, + options: { + timeout?: number; + interval?: number; + timeoutMessage?: string; + } = {}, +): Promise { + let timeout = options.timeout ?? 1000; + let interval = options.interval ?? 250; + + const start = Date.now(); + while (Date.now() - start < timeout) { + const result = await condition(); + if (result) { + return result; + } + await new Promise((resolve) => setTimeout(resolve, interval)); + } + throw new Error( + 'Timeout waiting for condition' + + (options.timeoutMessage ? `: ${options.timeoutMessage}` : ''), + ); +} + +export const testRealm = 'http://test-realm/'; +export const localBaseRealm = isEnvironmentMode() + ? `${serviceURL('realm-server')}/base` + : 'http://localhost:4201/base'; +export const matrixURL = new URL( + isEnvironmentMode() ? serviceURL('matrix') : 'http://localhost:8008', +); +const testPrerenderHost = '127.0.0.1'; +const testPrerenderPort = testPort(4460); + +export const testRealmInfo = { + name: 'Test Realm', + backgroundURL: null, + iconURL: null, + showAsCatalog: null, + interactHome: null, + hostHome: null, + visibility: 'public', + realmUserId: testRealmServerMatrixUserId, + publishable: null, + lastPublishedAt: null, +}; + +export const realmServerTestMatrix: MatrixConfig = { + url: matrixURL, + username: 'node-test_realm-server', +}; +export const realmServerSecretSeed = "mum's the word"; +export const realmSecretSeed = `shhh! it's a secret`; +export const grafanaSecret = `shhh! it's a secret`; + +function getMatrixRegistrationSecret(): string { + let secret = + getSynapseConfig()?.registration_shared_secret ?? + process.env.MATRIX_REGISTRATION_SHARED_SECRET; + + if (!secret) { + throw new Error( + 'Missing Matrix registration shared secret. Start Synapse first or set MATRIX_REGISTRATION_SHARED_SECRET.', + ); + } + + return secret; +} + +export const matrixRegistrationSecret = getMatrixRegistrationSecret(); +export const testCreatePrerenderAuth = + buildCreatePrerenderAuth(realmSecretSeed); + +let prerenderServer: Server | undefined; +let prerenderServerStart: Promise | undefined; +const trackedServers = new Set(); +const trackedPrerenderers = new Set(); +const trackedDbAdapters = new Set(); +const trackedQueuePublishers = new Set(); +const trackedQueueRunners = new Set(); + +export function cleanWhiteSpace(text: string) { + return text + .replace(//g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +export function createVirtualNetwork() { + let virtualNetwork = new VirtualNetwork(); + virtualNetwork.addURLMapping(new URL(baseRealm.url), new URL(localBaseRealm)); + return virtualNetwork; +} + +export function prepareTestDB(): void { + process.env.PGDATABASE = `test_db_${Math.floor(10000000 * Math.random())}`; +} + +function pgAdminConnectionConfig() { + return { + host: process.env.PGHOST || 'localhost', + port: Number(process.env.PGPORT || '5432'), + user: process.env.PGUSER || 'postgres', + password: process.env.PGPASSWORD || undefined, + database: 'postgres', + }; +} + +function quotePgIdentifier(identifier: string): string { + if (!/^[a-zA-Z0-9_]+$/.test(identifier)) { + throw new Error(`unsafe postgres identifier: ${identifier}`); + } + return `"${identifier}"`; +} + +async function withPgDatabaseEnv( + databaseName: string, + fn: () => Promise | T, +): Promise { + let previousDatabase = process.env.PGDATABASE; + process.env.PGDATABASE = databaseName; + try { + return await fn(); + } finally { + if (previousDatabase == null) { + delete process.env.PGDATABASE; + } else { + process.env.PGDATABASE = previousDatabase; + } + } +} + +async function dropDatabase(databaseName: string): Promise { + let client = new PgClient(pgAdminConnectionConfig()); + try { + await client.connect(); + await client.query( + `SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [databaseName], + ); + await client.query( + `DROP DATABASE IF EXISTS ${quotePgIdentifier(databaseName)}`, + ); + } finally { + await client.end(); + } +} + +async function createTemplateSnapshot( + sourceDatabaseName: string, + templateDatabaseName: string, +): Promise { + let client = new PgClient(pgAdminConnectionConfig()); + try { + await client.connect(); + await client.query( + `SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid()`, + [templateDatabaseName], + ); + await client.query( + `DROP DATABASE IF EXISTS ${quotePgIdentifier(templateDatabaseName)}`, + ); + await client.query( + `CREATE DATABASE ${quotePgIdentifier(templateDatabaseName)} TEMPLATE ${quotePgIdentifier( + sourceDatabaseName, + )}`, + ); + await client.query( + `ALTER DATABASE ${quotePgIdentifier(templateDatabaseName)} WITH IS_TEMPLATE true`, + ); + } finally { + await client.end(); + } +} + +async function cloneTestDBFromTemplate( + templateDatabaseName: string, + databaseName?: string, +): Promise { + let database = databaseName ?? process.env.PGDATABASE; + if (!database) { + throw new Error( + 'PGDATABASE must be set before cloning a test database (call prepareTestDB())', + ); + } + + if (database === templateDatabaseName) { + throw new Error( + `refusing to create test DB using the same name as template database '${templateDatabaseName}'`, + ); + } + + let client = new PgClient(pgAdminConnectionConfig()); + try { + await client.connect(); + await client.query( + `CREATE DATABASE ${quotePgIdentifier(database)} TEMPLATE ${quotePgIdentifier( + templateDatabaseName, + )}`, + ); + } catch (e: any) { + if (e?.message?.includes('does not exist')) { + throw new Error( + `template database '${templateDatabaseName}' is missing. Run packages/realm-server/tests/scripts/prepare-test-pg.sh first.`, + ); + } + throw e; + } finally { + await client.end(); + } +} + +export async function cloneTestDBFromMigratedTemplate(): Promise { + await cloneTestDBFromTemplate(migratedTestDatabaseTemplate); +} + +export async function createTestPgAdapter(options?: { + templateDatabase?: string; + databaseName?: string; +}): Promise { + let databaseName = options?.databaseName ?? process.env.PGDATABASE; + if (!databaseName) { + throw new Error( + 'PGDATABASE must be set before creating a test adapter (call prepareTestDB())', + ); + } + await cloneTestDBFromTemplate( + options?.templateDatabase ?? migratedTestDatabaseTemplate, + databaseName, + ); + return await withPgDatabaseEnv(databaseName, async () => new PgAdapter()); +} + +export async function closeServer(server: Server) { + await new Promise((r) => (server ? server.close(() => r()) : r())); +} + +function trackServer(server: Server): Server { + trackedServers.add(server); + server.once('close', () => trackedServers.delete(server)); + return server; +} + +export async function closeTrackedServers(): Promise { + let servers = [...trackedServers].filter((server) => server.listening); + await Promise.all(servers.map((server) => closeServer(server))); +} + +export function trackPrerenderer(prerenderer: TestPrerenderer): void { + trackedPrerenderers.add(prerenderer); +} + +export function getPrerendererForTesting(options: { + serverURL: string; + maxPages?: number; +}): TestPrerenderer { + let prerenderer = new LocalPrerenderer(options); + trackPrerenderer(prerenderer); + return prerenderer; +} + +export async function stopTrackedPrerenderers(): Promise { + let prerenderers = [...trackedPrerenderers]; + trackedPrerenderers.clear(); + await Promise.all( + prerenderers.map(async (prerenderer) => { + try { + await prerenderer.stop(); + } catch { + // best-effort cleanup + } + }), + ); +} + +export async function closeTrackedDbAdapters(): Promise { + let adapters = [...trackedDbAdapters]; + trackedDbAdapters.clear(); + for (let adapter of adapters) { + if (!adapter.isClosed) { + try { + await adapter.close(); + } catch { + // best-effort cleanup + } + } + } +} + +export async function destroyTrackedQueuePublishers(): Promise { + let publishers = [...trackedQueuePublishers]; + trackedQueuePublishers.clear(); + for (let publisher of publishers) { + try { + await publisher.destroy(); + } catch { + // best-effort cleanup + } + } +} + +export async function destroyTrackedQueueRunners(): Promise { + let runners = [...trackedQueueRunners]; + trackedQueueRunners.clear(); + for (let runner of runners) { + try { + await runner.destroy(); + } catch { + // best-effort cleanup + } + } +} + +async function waitForQueueIdle( + databaseName: string, + timeout = 30000, +): Promise { + await waitUntil( + async () => { + let client = new PgClient({ + ...pgAdminConnectionConfig(), + database: databaseName, + }); + try { + await client.connect(); + let { + rows: [{ count: unfulfilledJobs }], + } = await client.query<{ count: number }>( + `SELECT COUNT(*)::int AS count FROM jobs WHERE status = 'unfulfilled'`, + ); + let { + rows: [{ count: activeReservations }], + } = await client.query<{ count: number }>( + `SELECT COUNT(*)::int AS count FROM job_reservations WHERE completed_at IS NULL`, + ); + return unfulfilledJobs === 0 && activeReservations === 0; + } finally { + await client.end(); + } + }, + { + timeout, + interval: 50, + timeoutMessage: 'waiting for queue to become idle', + }, + ); +} + +interface CachedPermissionedRealmTemplateEntry { + ready: Promise; +} + +const permissionedRealmTemplateCache = new Map< + string, + CachedPermissionedRealmTemplateEntry +>(); +const permissionedRealmTemplateNamePrefix = `rs_tpl_${process.pid}_`; +const permissionedRealmBuilderDbNamePrefix = `rs_bld_${process.pid}_`; +const prerendererCacheIds = new WeakMap(); +let nextPrerendererCacheId = 1; + +function stableStringify(value: unknown): string { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((item) => stableStringify(item)).join(',')}]`; + } + let record = value as Record; + let keys = Object.keys(record).sort(); + return `{${keys + .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`) + .join(',')}}`; +} + +function hashCacheKeyPayload(payload: unknown): string { + return createHash('sha256').update(stableStringify(payload)).digest('hex'); +} + +function templateDatabaseNameForCacheKey(cacheKey: string): string { + return `${permissionedRealmTemplateNamePrefix}${cacheKey.slice(0, 24)}`; +} + +function builderDatabaseNameForCacheKey(cacheKey: string): string { + return `${permissionedRealmBuilderDbNamePrefix}${cacheKey.slice(0, 24)}`; +} + +function prerendererCacheKeyPart(prerenderer?: Prerenderer): string | null { + if (!prerenderer) { + return null; + } + let key = prerenderer as unknown as object; + let id = prerendererCacheIds.get(key); + if (!id) { + id = nextPrerendererCacheId++; + prerendererCacheIds.set(key, id); + } + return `injected:${id}`; +} + +const testPrerenderURL = `http://${testPrerenderHost}:${testPrerenderPort}`; + +async function startTestPrerenderServer(): Promise { + if (prerenderServer?.listening) { + return testPrerenderURL; + } + if (prerenderServerStart) { + await prerenderServerStart; + return testPrerenderURL; + } + let server = createPrerenderHttpServer({ + silent: Boolean(process.env.SILENT_PRERENDERER), + maxPages: 1, + }); + prerenderServer = server; + trackServer(server); + prerenderServerStart = new Promise((resolve, reject) => { + let onError = (error: Error) => { + server.off('error', onError); + prerenderServer = undefined; + prerenderServerStart = undefined; + if (server.listening) { + server.close(() => reject(error)); + } else { + reject(error); + } + }; + server.once('error', onError); + server.listen(testPrerenderPort, testPrerenderHost, () => { + server.off('error', onError); + prerenderServerStart = undefined; + resolve(); + }); + }); + await prerenderServerStart; + return testPrerenderURL; +} + +export async function stopTestPrerenderServer() { + if (prerenderServer && prerenderServer.listening) { + if (hasStopPrerenderer(prerenderServer)) { + await prerenderServer.__stopPrerenderer?.(); + } + await closeServer(prerenderServer); + } + prerenderServer = undefined; + prerenderServerStart = undefined; +} + +interface StoppablePrerenderServer extends Server { + __stopPrerenderer?: () => Promise; +} + +function hasStopPrerenderer( + server: Server, +): server is StoppablePrerenderServer { + return ( + typeof (server as StoppablePrerenderServer).__stopPrerenderer === 'function' + ); +} + +export async function getTestPrerenderer(): Promise { + let url = await startTestPrerenderServer(); + return createRemotePrerenderer(url); +} + +type BeforeAfterCallback = ( + dbAdapter: PgAdapter, + publisher: QueuePublisher, + runner: QueueRunner, +) => Promise; + +type TestDatabaseTemplateProvider = string | (() => string | undefined); + +export function setupDB( + hooks: NestedHooks, + args: { + before?: BeforeAfterCallback; + after?: BeforeAfterCallback; + beforeEach?: BeforeAfterCallback; + afterEach?: BeforeAfterCallback; + templateDatabase?: TestDatabaseTemplateProvider; + } = {}, +) { + let dbAdapter: PgAdapter; + let publisher: QueuePublisher; + let runner: QueueRunner; + + const runBeforeHook = async () => { + prepareTestDB(); + let templateDatabase = + typeof args.templateDatabase === 'function' + ? args.templateDatabase() + : args.templateDatabase; + dbAdapter = await createTestPgAdapter({ + templateDatabase, + }); + trackedDbAdapters.add(dbAdapter); + publisher = new PgQueuePublisher(dbAdapter); + trackedQueuePublishers.add(publisher); + runner = new PgQueueRunner({ adapter: dbAdapter, workerId: 'test-worker' }); + trackedQueueRunners.add(runner); + }; + + const runAfterHook = async () => { + await publisher?.destroy(); + if (publisher) { + trackedQueuePublishers.delete(publisher); + } + await runner?.destroy(); + if (runner) { + trackedQueueRunners.delete(runner); + } + await dbAdapter?.close(); + if (dbAdapter) { + trackedDbAdapters.delete(dbAdapter); + } + }; + + // we need to pair before/after and beforeEach/afterEach. within this setup + // function we can't mix before/after with beforeEach/afterEach as that will + // result in an unbalanced DB lifecycle (e.g. creating a DB in the before hook and + // destroying in the afterEach hook) + if (args.before) { + if (args.beforeEach || args.afterEach) { + throw new Error( + `cannot pair a "before" hook with a "beforeEach" or "afterEach" hook in setupDB--the DB setup must be balanced, you can either create a new DB in "before" or in "beforeEach" but not both`, + ); + } + hooks.before(async function () { + await runBeforeHook(); + await args.before!(dbAdapter, publisher, runner); + }); + + hooks.after(async function () { + await args.after?.(dbAdapter, publisher, runner); + await runAfterHook(); + }); + } + + if (args.beforeEach) { + if (args.before || args.after) { + throw new Error( + `cannot pair a "beforeEach" hook with a "before" or "after" hook in setupDB--the DB setup must be balanced, you can either create a new DB in "before" or in "beforeEach" but not both`, + ); + } + hooks.beforeEach(async function () { + await runBeforeHook(); + await args.beforeEach!(dbAdapter, publisher, runner); + }); + + hooks.afterEach(async function () { + await args.afterEach?.(dbAdapter, publisher, runner); + await runAfterHook(); + }); + } +} + +export async function getIndexHTML() { + let url = + process.env.HOST_URL ?? + (isEnvironmentMode() ? serviceURL('host') : 'http://localhost:4200/'); + let request = await fetch(url); + return await request.text(); +} + +export async function createRealm({ + dir, + definitionLookup, + fileSystem = {}, + realmURL = testRealm, + permissions = { '*': ['read'] }, + virtualNetwork, + runner, + publisher, + dbAdapter, + withWorker, + prerenderer: providedPrerenderer, + enableFileWatcher = false, + cardSizeLimitBytes, + fileSizeLimitBytes, +}: { + dir: string; + definitionLookup: DefinitionLookup; + fileSystem?: Record; + realmURL?: string; + permissions?: RealmPermissions; + virtualNetwork: VirtualNetwork; + matrixConfig?: MatrixConfig; + publisher: QueuePublisher; + runner?: QueueRunner; + dbAdapter: PgAdapter; + deferStartUp?: true; + prerenderer?: Prerenderer; + enableFileWatcher?: boolean; + cardSizeLimitBytes?: number; + fileSizeLimitBytes?: number; + // if you are creating a realm to test it directly without a server, you can + // also specify `withWorker: true` to also include a worker with your realm + withWorker?: true; +}): Promise<{ realm: Realm; adapter: RealmAdapter }> { + await insertPermissions(dbAdapter, new URL(realmURL), permissions); + + for (let username of Object.keys(permissions)) { + if (username !== '*') { + await ensureTestUser(dbAdapter, username); + } + } + + for (let [filename, contents] of Object.entries(fileSystem)) { + let path = join(dir, filename); + ensureDirSync(dirname(path)); + if (typeof contents === 'string') { + writeFileSync(path, contents); + } else { + writeJSONSync(path, contents); + } + } + + let adapter = new NodeAdapter(dir, enableFileWatcher); + let worker: Worker | undefined; + if (withWorker) { + if (!runner) { + throw new Error(`must provider a QueueRunner when using withWorker`); + } + let prerenderer = providedPrerenderer ?? (await getTestPrerenderer()); + worker = new Worker({ + indexWriter: new IndexWriter(dbAdapter), + queue: runner, + dbAdapter, + queuePublisher: publisher, + virtualNetwork, + matrixURL: realmServerTestMatrix.url, + secretSeed: realmSecretSeed, + realmServerMatrixUsername: testRealmServerMatrixUsername, + prerenderer, + createPrerenderAuth: testCreatePrerenderAuth, + }); + } + let matrixClient = new MatrixClient({ + matrixURL: realmServerTestMatrix.url, + username: realmServerTestMatrix.username, + seed: realmSecretSeed, + }); + let realm = new Realm({ + url: realmURL, + adapter, + secretSeed: realmSecretSeed, + virtualNetwork, + dbAdapter, + queue: publisher, + matrixClient, + realmServerURL: new URL(new URL(realmURL).origin).href, + definitionLookup, + cardSizeLimitBytes: + cardSizeLimitBytes ?? + Number( + process.env.CARD_SIZE_LIMIT_BYTES ?? DEFAULT_CARD_SIZE_LIMIT_BYTES, + ), + fileSizeLimitBytes: + fileSizeLimitBytes ?? + Number( + process.env.FILE_SIZE_LIMIT_BYTES ?? DEFAULT_FILE_SIZE_LIMIT_BYTES, + ), + }); + if (worker) { + virtualNetwork.mount(realm.handle); + await worker.run(); + } + return { realm, adapter }; +} + +export async function runTestRealmServer({ + testRealmDir, + realmsRootPath, + fileSystem, + realmURL, + virtualNetwork, + publisher, + runner, + dbAdapter, + matrixConfig, + matrixURL, + permissions = { '*': ['read'] }, + enableFileWatcher = false, + cardSizeLimitBytes, + fileSizeLimitBytes, + domainsForPublishedRealms = { + boxelSpace: 'localhost', + boxelSite: 'localhost', + }, + prerenderer: providedPrerenderer, +}: { + testRealmDir: string; + realmsRootPath: string; + fileSystem?: Record; + realmURL: URL; + permissions?: RealmPermissions; + virtualNetwork: VirtualNetwork; + publisher: QueuePublisher; + runner: QueueRunner; + dbAdapter: PgAdapter; + matrixURL: URL; + matrixConfig?: MatrixConfig; + enableFileWatcher?: boolean; + cardSizeLimitBytes?: number; + fileSizeLimitBytes?: number; + domainsForPublishedRealms?: { + boxelSpace?: string; + boxelSite?: string; + }; + prerenderer?: Prerenderer; +}) { + let prerenderer = providedPrerenderer ?? (await getTestPrerenderer()); + let definitionLookup = new CachingDefinitionLookup( + dbAdapter, + prerenderer, + virtualNetwork, + testCreatePrerenderAuth, + ); + let worker = new Worker({ + indexWriter: new IndexWriter(dbAdapter), + queue: runner, + dbAdapter, + queuePublisher: publisher, + virtualNetwork, + matrixURL, + secretSeed: realmSecretSeed, + realmServerMatrixUsername: testRealmServerMatrixUsername, + prerenderer, + createPrerenderAuth: testCreatePrerenderAuth, + }); + await worker.run(); + let { realm: testRealm, adapter: testRealmAdapter } = await createRealm({ + dir: testRealmDir, + fileSystem, + realmURL: realmURL.href, + permissions, + virtualNetwork, + matrixConfig, + publisher, + dbAdapter, + enableFileWatcher, + definitionLookup, + cardSizeLimitBytes, + fileSizeLimitBytes, + }); + + await testRealm.logInToMatrix(); + + virtualNetwork.mount(testRealm.handle); + let realms = [testRealm]; + let matrixClient = new MatrixClient({ + matrixURL: realmServerTestMatrix.url, + username: realmServerTestMatrix.username, + seed: realmSecretSeed, + }); + + let testRealmServer = new RealmServer({ + realms, + virtualNetwork, + matrixClient, + realmServerSecretSeed, + realmSecretSeed, + matrixRegistrationSecret, + realmsRootPath, + dbAdapter, + queue: publisher, + getIndexHTML, + grafanaSecret, + serverURL: new URL(realmURL.origin), + assetsURL: new URL(`http://example.com/notional-assets-host/`), + domainsForPublishedRealms, + definitionLookup, + prerenderer, + }); + let testRealmHttpServer = testRealmServer.listen(parseInt(realmURL.port)); + trackServer(testRealmHttpServer); + await testRealmServer.start(); + return { + testRealmDir, + testRealm, + testRealmServer, + testRealmHttpServer, + testRealmAdapter, + matrixClient, + virtualNetwork, + }; +} + +// Use when a single RealmServer instance must expose multiple realms +// (e.g. server endpoints that federate across realms like /_search). +export async function runTestRealmServerWithRealms({ + realmsRootPath, + realms, + virtualNetwork, + publisher, + runner, + dbAdapter, + matrixURL, + enableFileWatcher = false, + domainsForPublishedRealms = { + boxelSpace: 'localhost', + boxelSite: 'localhost', + }, + prerenderer: providedPrerenderer, +}: { + realmsRootPath: string; + realms: { + realmURL: URL; + fileSystem?: Record; + permissions?: RealmPermissions; + matrixConfig?: MatrixConfig; + }[]; + virtualNetwork: VirtualNetwork; + publisher: QueuePublisher; + runner: QueueRunner; + dbAdapter: PgAdapter; + matrixURL: URL; + enableFileWatcher?: boolean; + domainsForPublishedRealms?: { + boxelSpace?: string; + boxelSite?: string; + }; + prerenderer?: Prerenderer; +}) { + ensureDirSync(realmsRootPath); + + let prerenderer = providedPrerenderer ?? (await getTestPrerenderer()); + let definitionLookup = new CachingDefinitionLookup( + dbAdapter, + prerenderer, + virtualNetwork, + testCreatePrerenderAuth, + ); + let worker = new Worker({ + indexWriter: new IndexWriter(dbAdapter), + queue: runner, + dbAdapter, + queuePublisher: publisher, + virtualNetwork, + matrixURL, + secretSeed: realmSecretSeed, + realmServerMatrixUsername: testRealmServerMatrixUsername, + prerenderer, + createPrerenderAuth: testCreatePrerenderAuth, + }); + await worker.run(); + + let createdRealms: Realm[] = []; + let realmAdapters: RealmAdapter[] = []; + let matrixUsers = ['test_realm', 'node-test_realm']; + + for (let [index, realmConfig] of realms.entries()) { + let realmDir = join(realmsRootPath, `realm_${index}`); + ensureDirSync(realmDir); + let { realm, adapter } = await createRealm({ + dir: realmDir, + fileSystem: realmConfig.fileSystem, + realmURL: realmConfig.realmURL.href, + permissions: realmConfig.permissions, + virtualNetwork, + matrixConfig: realmConfig.matrixConfig ?? { + url: matrixURL, + username: matrixUsers[index] ?? matrixUsers[0], + }, + publisher, + dbAdapter, + enableFileWatcher, + definitionLookup, + }); + await realm.logInToMatrix(); + virtualNetwork.mount(realm.handle); + createdRealms.push(realm); + realmAdapters.push(adapter); + } + + let matrixClient = new MatrixClient({ + matrixURL: realmServerTestMatrix.url, + username: realmServerTestMatrix.username, + seed: realmSecretSeed, + }); + + let serverURL = new URL(realms[0].realmURL.origin); + let testRealmServer = new RealmServer({ + realms: createdRealms, + virtualNetwork, + matrixClient, + realmServerSecretSeed, + realmSecretSeed, + matrixRegistrationSecret, + realmsRootPath, + dbAdapter, + queue: publisher, + getIndexHTML, + grafanaSecret, + serverURL, + assetsURL: new URL(`http://example.com/notional-assets-host/`), + domainsForPublishedRealms, + definitionLookup, + prerenderer, + }); + let testRealmHttpServer = testRealmServer.listen(parseInt(serverURL.port)); + trackServer(testRealmHttpServer); + await testRealmServer.start(); + + return { + realms: createdRealms, + realmAdapters, + testRealmServer, + testRealmHttpServer, + matrixClient, + }; +} + +// Spins up one RealmServer per realm. Use for cross-realm behavior that doesn't +// require a shared server (authorization, permissions, etc.). +type PermissionedRealmsFixtureRealm = { + realm: Realm; + realmPath: string; + realmHttpServer: Server; + realmAdapter: RealmAdapter; +}; + +type InternalPermissionedRealmsSetupOptions = { + realms: { + realmURL: string; + permissions: RealmPermissions; + fileSystem?: Record; + }[]; + prerenderer?: Prerenderer; +}; + +async function startPermissionedRealmsFixture( + dbAdapter: PgAdapter, + publisher: QueuePublisher, + runner: QueueRunner, + { realms: realmConfigs, prerenderer }: InternalPermissionedRealmsSetupOptions, +): Promise<{ realms: PermissionedRealmsFixtureRealm[] }> { + let realms: PermissionedRealmsFixtureRealm[] = []; + + for (let realmArg of realmConfigs.values()) { + let { + testRealmDir: realmPath, + testRealm: realm, + testRealmHttpServer: realmHttpServer, + testRealmAdapter: realmAdapter, + } = await runTestRealmServer({ + virtualNetwork: await createVirtualNetwork(), + testRealmDir: dirSync().name, + realmsRootPath: dirSync().name, + realmURL: new URL(realmArg.realmURL), + fileSystem: realmArg.fileSystem, + permissions: realmArg.permissions, + matrixURL, + dbAdapter, + publisher, + runner, + prerenderer, + }); + realms.push({ + realm, + realmPath, + realmHttpServer, + realmAdapter, + }); + } + + return { realms }; +} + +async function teardownPermissionedRealmsFixture( + realms: PermissionedRealmsFixtureRealm[], +): Promise { + for (let realm of realms) { + realm.realm.__testOnlyClearCaches(); + await closeServer(realm.realmHttpServer); + } +} + +export function setupPermissionedRealms( + hooks: NestedHooks, + { + mode = 'beforeEach', + realms: realmsArg, + onRealmSetup, + prerenderer, + dbTemplateDatabase, + }: { + mode?: 'beforeEach' | 'before'; + realms: { + realmURL: string; + permissions: RealmPermissions; + fileSystem?: Record; + }[]; + prerenderer?: Prerenderer; + // Internal hook used by cached setup wrappers + dbTemplateDatabase?: TestDatabaseTemplateProvider; + onRealmSetup?: (args: { + dbAdapter: PgAdapter; + realms: PermissionedRealmsFixtureRealm[]; + }) => void; + }, +) { + // We want 2 different realm users to test authorization between them - these + // names are selected because they are already available in the test + // environment (via register-realm-users.ts) + let realms: PermissionedRealmsFixtureRealm[] = []; + let _dbAdapter: PgAdapter; + setupDB(hooks, { + templateDatabase: dbTemplateDatabase, + [mode]: async ( + dbAdapter: PgAdapter, + publisher: QueuePublisher, + runner: QueueRunner, + ) => { + _dbAdapter = dbAdapter; + ({ realms } = await startPermissionedRealmsFixture( + dbAdapter, + publisher, + runner, + { + realms: realmsArg, + prerenderer, + }, + )); + onRealmSetup?.({ + dbAdapter: _dbAdapter!, + realms, + }); + }, + }); + + hooks[mode === 'beforeEach' ? 'afterEach' : 'after'](async function () { + await teardownPermissionedRealmsFixture(realms); + realms = []; + }); +} + +export async function insertUser( + dbAdapter: PgAdapter, + matrixUserId: string, + stripeCustomerId: string, + stripeCustomerEmail: string | null, +): Promise { + let { valueExpressions, nameExpressions } = asExpressions({ + matrix_user_id: matrixUserId, + stripe_customer_id: stripeCustomerId, + stripe_customer_email: stripeCustomerEmail, + }); + let result = await query( + dbAdapter, + insert('users', nameExpressions, valueExpressions), + ); + + return { + id: result[0].id, + matrixUserId: result[0].matrix_user_id, + stripeCustomerId: result[0].stripe_customer_id, + stripeCustomerEmail: result[0].stripe_customer_email, + sessionRoomId: result[0].session_room_id ?? null, + } as User; +} + +export async function ensureTestUser( + dbAdapter: PgAdapter, + matrixUserId: string, +) { + await dbAdapter.execute( + `INSERT INTO users (matrix_user_id) VALUES ($1) ON CONFLICT (matrix_user_id) DO NOTHING`, + { bind: [matrixUserId] }, + ); +} + +export async function insertPlan( + dbAdapter: PgAdapter, + name: string, + monthlyPrice: number, + creditsIncluded: number, + stripePlanId: string, +): Promise { + let { valueExpressions, nameExpressions } = asExpressions({ + name, + monthly_price: monthlyPrice, + credits_included: creditsIncluded, + stripe_plan_id: stripePlanId, + }); + let result = await query( + dbAdapter, + insert('plans', nameExpressions, valueExpressions), + ); + return { + id: result[0].id, + name: result[0].name, + monthlyPrice: parseFloat(result[0].monthly_price as string), + creditsIncluded: result[0].credits_included, + stripePlanId: result[0].stripe_plan_id, + } as Plan; +} + +export async function fetchSubscriptionsByUserId( + dbAdapter: PgAdapter, + userId: string, +): Promise { + let results = (await query(dbAdapter, [ + `SELECT * FROM subscriptions WHERE user_id = `, + param(userId), + ])) as { + id: string; + user_id: string; + plan_id: string; + started_at: number; + ended_at: number; + status: string; + stripe_subscription_id: string; + }[]; + + return results.map((result) => ({ + id: result.id, + userId: result.user_id, + planId: result.plan_id, + startedAt: result.started_at, + endedAt: result.ended_at, + status: result.status, + stripeSubscriptionId: result.stripe_subscription_id, + })); +} + +export function mtimes( + path: string, + realmURL: URL, +): { [path: string]: number } { + const mtimes: { [path: string]: number } = {}; + let paths = new RealmPaths(realmURL); + + function traverseDir(currentPath: string) { + const entries = readdirSync(currentPath, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = join(currentPath, entry.name); + if (entry.isDirectory()) { + traverseDir(fullPath); + } else if (entry.isFile()) { + const stats = statSync(fullPath); + mtimes[paths.fileURL(fullPath.substring(path.length)).href] = unixTime( + stats.mtime.getTime(), + ); + } + } + } + traverseDir(path); + return mtimes; +} + +export async function insertJob( + dbAdapter: PgAdapter, + params: { + job_type: string; + args?: Record; + concurrency_group?: string | null; + timeout?: number; + status?: string; + finished_at?: string | null; + result?: Record | null; + priority?: number; + }, +): Promise> { + let { valueExpressions, nameExpressions } = asExpressions({ + job_type: params.job_type, + args: params.args ?? {}, + concurrency_group: params.concurrency_group ?? null, + timeout: params.timeout ?? 240, + status: params.status ?? 'unfulfilled', + finished_at: params.finished_at ?? null, + result: params.result ?? null, + priority: params.priority ?? 0, + }); + let result = await query( + dbAdapter, + insert('jobs', nameExpressions, valueExpressions), + ); + return { + id: result[0].id, + job_type: result[0].job_type, + args: result[0].args, + concurrency_group: result[0].concurrency_group, + timeout: result[0].timeout, + status: result[0].status, + finished_at: result[0].finished_at, + result: result[0].result, + priority: result[0].priority, + }; +} + +export function setupMatrixRoom( + hooks: NestedHooks, + getRealmSetup: () => { + testRealm: Realm; + testRealmHttpServer: Server; + request: { post(path: string): Test }; + serverRequest?: SuperTest; + dir: DirResult; + dbAdapter: PgAdapter; + }, +) { + let matrixClient = new MatrixClient({ + matrixURL: realmServerTestMatrix.url, + username: 'node-test_realm', + seed: realmSecretSeed, + }); + + let testAuthRoomId: string | undefined; + + hooks.beforeEach(async function () { + await matrixClient.login(); + + let realmSetup = getRealmSetup(); + let openIdToken = await matrixClient.getOpenIdToken(); + if (!openIdToken) { + throw new Error('matrixClient did not return an OpenID token'); + } + + let response = await (realmSetup.serverRequest ?? realmSetup.request) + .post('/_server-session') + .send(JSON.stringify(openIdToken)) + .set('Accept', 'application/json') + .set('Content-Type', 'application/json'); + + let jwt = response.header['authorization']; + if (!jwt) { + throw new Error('Realm server did not send Authorization header'); + } + + let payload = JSON.parse( + Buffer.from(jwt.split('.')[1], 'base64').toString('utf8'), + ) as { sessionRoom: string }; + console.log('Session room', payload.sessionRoom); + + let { joined_rooms: rooms } = await matrixClient.getJoinedRooms(); + + if (!rooms.includes(payload.sessionRoom)) { + await matrixClient.joinRoom(payload.sessionRoom); + } + + testAuthRoomId = payload.sessionRoom; + }); + + return { + matrixClient, + getMessagesSince: async function (since: number) { + let allMessages = await matrixClient.roomMessages(testAuthRoomId!); + // Allow same-ms clock values between the test process and matrix so we don't + // miss events that are emitted immediately after we record the start time. + let messagesAfterSentinel = allMessages.filter( + (m) => m.origin_server_ts >= since, + ); + + return messagesAfterSentinel; + }, + }; +} + +export async function waitForRealmEvent( + getMessagesSince: (since: number) => Promise, + since: number, + options: { + predicate?: (event: RealmEvent) => boolean; + timeout?: number; + timeoutMessage?: string; + } = {}, +): Promise { + let { predicate = () => true, timeout, timeoutMessage } = options; + + let event = await waitUntil( + async () => { + let findMatchingEvent = (messages: MatrixEvent[]) => + messages.find((event): event is RealmEvent => { + if (event.type !== APP_BOXEL_REALM_EVENT_TYPE) { + return false; + } + return predicate(event as RealmEvent); + }); + + let matrixMessages = await getMessagesSince(since); + let matchingEvent = findMatchingEvent(matrixMessages); + + if (!matchingEvent) { + let skewedSince = Math.max(0, since - REALM_EVENT_TS_SKEW_BUFFER_MS); + if (skewedSince !== since) { + let skewedMessages = await getMessagesSince(skewedSince); + matchingEvent = findMatchingEvent(skewedMessages); + } + } + + if (matchingEvent) { + return matchingEvent; + } + + return undefined; + }, + { + timeout: timeout ?? 5000, + timeoutMessage, + }, + ); + + return event!; +} + +export function findRealmEvent( + events: MatrixEvent[], + eventName: string, + indexType: string, +): RealmEvent | undefined { + return events.find( + (m) => + m.type === APP_BOXEL_REALM_EVENT_TYPE && + m.content.eventName === eventName && + (realmEventIsIndex(m.content) ? m.content.indexType === indexType : true), + ) as RealmEvent | undefined; +} + +function realmEventIsIndex( + event: RealmEventContent, +): event is IncrementalIndexEventContent { + return event.eventName === 'index'; +} + +type InternalPermissionedRealmSetupOptions = { + permissions: RealmPermissions; + realmURL?: URL; + fileSystem?: Record; + subscribeToRealmEvents?: boolean; + prerenderer?: Prerenderer; + published?: boolean; + cardSizeLimitBytes?: number; + fileSizeLimitBytes?: number; +}; + +async function startPermissionedRealmFixture( + dbAdapter: PgAdapter, + publisher: QueuePublisher, + runner: QueueRunner, + { + permissions, + realmURL, + fileSystem, + subscribeToRealmEvents = false, + prerenderer, + published = false, + cardSizeLimitBytes, + fileSizeLimitBytes, + }: InternalPermissionedRealmSetupOptions, +): Promise<{ + testRealmServer: Awaited>; + request: SuperTest; + dir: DirResult; +}> { + let resolvedRealmURL = realmURL ?? testRealmURL; + let dir = dirSync(); + + let testRealmDir; + + if (published) { + let publishedRealmId = uuidv4(); + + testRealmDir = join( + dir.name, + 'realm_server_1', + PUBLISHED_DIRECTORY_NAME, + publishedRealmId, + ); + + await dbAdapter.execute( + `INSERT INTO + published_realms + (id, owner_username, source_realm_url, published_realm_url) + VALUES + ( + '${publishedRealmId}', + '@user:localhost', + 'http://example.localhost/source', + '${resolvedRealmURL.href}' + )`, + ); + } else { + testRealmDir = join(dir.name, 'realm_server_1', 'test'); + } + + ensureDirSync(testRealmDir); + + // If a fileSystem is provided, use it to populate the test realm, otherwise copy the default cards + if (!fileSystem) { + copySync(join(__dirname, '..', 'cards'), testRealmDir); + } + + let virtualNetwork = createVirtualNetwork(); + + let testRealmServer = await runTestRealmServer({ + virtualNetwork, + testRealmDir, + realmsRootPath: join(dir.name, 'realm_server_1'), + realmURL: resolvedRealmURL, + permissions, + dbAdapter, + runner, + publisher, + matrixURL, + fileSystem, + enableFileWatcher: subscribeToRealmEvents, + cardSizeLimitBytes, + fileSizeLimitBytes, + prerenderer, + }); + + let request = supertest(testRealmServer.testRealmHttpServer); + + return { + testRealmServer, + request, + dir, + }; +} + +async function teardownPermissionedRealmFixture( + testRealmServer?: Awaited>, +): Promise { + if (!testRealmServer) { + return; + } + + let cleanupError: unknown; + + try { + testRealmServer.testRealm.unsubscribe(); + } catch (error) { + cleanupError ??= error; + } + + try { + if (!testRealmServer.matrixClient.isLoggedIn()) { + await testRealmServer.matrixClient.login(); + } + } catch (error) { + cleanupError ??= error; + } + + try { + await closeServer(testRealmServer.testRealmHttpServer); + } catch (error) { + cleanupError ??= error; + } + + try { + resetCatalogRealms(); + } catch (error) { + cleanupError ??= error; + } + + if (cleanupError) { + throw cleanupError; + } +} + +export function setupPermissionedRealm( + hooks: NestedHooks, + { + permissions, + realmURL, + fileSystem, + onRealmSetup, + subscribeToRealmEvents = false, + mode = 'beforeEach', + prerenderer, + dbTemplateDatabase, + published = false, + cardSizeLimitBytes, + fileSizeLimitBytes, + }: { + permissions: RealmPermissions; + realmURL?: URL; + fileSystem?: Record; + onRealmSetup?: (args: { + dbAdapter: PgAdapter; + publisher: QueuePublisher; + runner: QueueRunner; + testRealmServer: Awaited>; + testRealm: Realm; + testRealmPath: string; + testRealmHttpServer: Server; + testRealmAdapter: RealmAdapter; + request: SuperTest; + dir: DirResult; + virtualNetwork: VirtualNetwork; + }) => void; + subscribeToRealmEvents?: boolean; + mode?: 'beforeEach' | 'before'; + prerenderer?: Prerenderer; + // Internal hook used by cached setup wrappers + dbTemplateDatabase?: TestDatabaseTemplateProvider; + published?: boolean; + cardSizeLimitBytes?: number; + fileSizeLimitBytes?: number; + }, +) { + let testRealmServer: Awaited>; + + setGracefulCleanup(); + + setupDB(hooks, { + templateDatabase: dbTemplateDatabase, + [mode]: async ( + dbAdapter: PgAdapter, + publisher: QueuePublisher, + runner: QueueRunner, + ) => { + let { + testRealmServer: server, + request, + dir, + } = await startPermissionedRealmFixture(dbAdapter, publisher, runner, { + realmURL, + fileSystem, + permissions, + subscribeToRealmEvents, + prerenderer, + published, + cardSizeLimitBytes, + fileSizeLimitBytes, + }); + testRealmServer = server; + + onRealmSetup?.({ + dbAdapter, + publisher, + runner, + testRealmServer, + testRealm: testRealmServer.testRealm, + testRealmPath: testRealmServer.testRealmDir, + testRealmHttpServer: testRealmServer.testRealmHttpServer, + testRealmAdapter: testRealmServer.testRealmAdapter, + virtualNetwork: testRealmServer.virtualNetwork, + request, + dir, + }); + }, + }); + + hooks[mode === 'beforeEach' ? 'afterEach' : 'after'](async function () { + await teardownPermissionedRealmFixture(testRealmServer); + }); +} + +type SetupPermissionedRealmCachedOptions = Omit< + Parameters[1], + 'dbTemplateDatabase' +>; + +function permissionedRealmTemplateCacheKey( + options: SetupPermissionedRealmCachedOptions, +): string { + let resolvedRealmURL = options.realmURL ?? testRealmURL; + return hashCacheKeyPayload({ + version: 1, + type: 'permissioned-realm', + realmURL: resolvedRealmURL.href, + permissions: options.permissions, + fileSystem: options.fileSystem ?? null, + subscribeToRealmEvents: Boolean(options.subscribeToRealmEvents), + published: Boolean(options.published), + cardSizeLimitBytes: options.cardSizeLimitBytes ?? null, + fileSizeLimitBytes: options.fileSizeLimitBytes ?? null, + prerenderer: prerendererCacheKeyPart(options.prerenderer), + }); +} + +async function buildPermissionedRealmTemplate( + cacheKey: string, + options: SetupPermissionedRealmCachedOptions, +): Promise { + let templateDatabaseName = templateDatabaseNameForCacheKey(cacheKey); + let builderDatabaseName = builderDatabaseNameForCacheKey(cacheKey); + + let dbAdapter: PgAdapter | undefined; + let publisher: QueuePublisher | undefined; + let runner: QueueRunner | undefined; + let fixture: + | Awaited> + | undefined; + + await dropDatabase(templateDatabaseName); + await dropDatabase(builderDatabaseName); + + try { + dbAdapter = await createTestPgAdapter({ + databaseName: builderDatabaseName, + templateDatabase: migratedTestDatabaseTemplate, + }); + publisher = new PgQueuePublisher(dbAdapter); + runner = new PgQueueRunner({ + adapter: dbAdapter, + workerId: 'template-worker', + }); + + fixture = await startPermissionedRealmFixture( + dbAdapter, + publisher, + runner, + { + realmURL: options.realmURL, + fileSystem: options.fileSystem, + permissions: options.permissions, + subscribeToRealmEvents: options.subscribeToRealmEvents, + prerenderer: options.prerenderer, + published: options.published, + cardSizeLimitBytes: options.cardSizeLimitBytes, + fileSizeLimitBytes: options.fileSizeLimitBytes, + }, + ); + + await waitForQueueIdle(builderDatabaseName); + await teardownPermissionedRealmFixture(fixture.testRealmServer); + fixture = undefined; + + await publisher.destroy(); + publisher = undefined; + await runner.destroy(); + runner = undefined; + await dbAdapter.close(); + dbAdapter = undefined; + + await createTemplateSnapshot(builderDatabaseName, templateDatabaseName); + } finally { + if (fixture) { + try { + await teardownPermissionedRealmFixture(fixture.testRealmServer); + } catch { + // best-effort cleanup + } + } + if (publisher) { + try { + await publisher.destroy(); + } catch { + // best-effort cleanup + } + } + if (runner) { + try { + await runner.destroy(); + } catch { + // best-effort cleanup + } + } + if (dbAdapter && !dbAdapter.isClosed) { + try { + await dbAdapter.close(); + } catch { + // best-effort cleanup + } + } + try { + await dropDatabase(builderDatabaseName); + } catch { + // best-effort cleanup + } + } +} + +async function acquirePermissionedRealmTemplate( + options: SetupPermissionedRealmCachedOptions, +): Promise<{ cacheKey: string; templateDatabaseName: string }> { + let cacheKey = permissionedRealmTemplateCacheKey(options); + let templateDatabaseName = templateDatabaseNameForCacheKey(cacheKey); + let existing = permissionedRealmTemplateCache.get(cacheKey); + if (existing) { + await existing.ready; + return { cacheKey, templateDatabaseName }; + } + + let entry: CachedPermissionedRealmTemplateEntry = { + ready: Promise.resolve(), + }; + entry.ready = buildPermissionedRealmTemplate(cacheKey, options).catch( + async (error) => { + permissionedRealmTemplateCache.delete(cacheKey); + try { + await dropDatabase(templateDatabaseName); + } catch { + // best-effort cleanup + } + throw error; + }, + ); + permissionedRealmTemplateCache.set(cacheKey, entry); + await entry.ready; + return { cacheKey, templateDatabaseName }; +} + +export function setupPermissionedRealmCached( + hooks: NestedHooks, + options: SetupPermissionedRealmCachedOptions, +) { + let acquiredTemplateDatabase: string | undefined; + + hooks.before(async function () { + let { templateDatabaseName } = + await acquirePermissionedRealmTemplate(options); + acquiredTemplateDatabase = templateDatabaseName; + }); + + setupPermissionedRealm(hooks, { + ...options, + dbTemplateDatabase: () => acquiredTemplateDatabase, + }); +} + +type SetupPermissionedRealmsCachedOptions = Omit< + Parameters[1], + 'dbTemplateDatabase' +>; + +function permissionedRealmsTemplateCacheKey( + options: SetupPermissionedRealmsCachedOptions, +): string { + return hashCacheKeyPayload({ + version: 1, + type: 'permissioned-realms', + realms: options.realms, + prerenderer: prerendererCacheKeyPart(options.prerenderer), + }); +} + +async function buildPermissionedRealmsTemplate( + cacheKey: string, + options: SetupPermissionedRealmsCachedOptions, +): Promise { + let templateDatabaseName = templateDatabaseNameForCacheKey(cacheKey); + let builderDatabaseName = builderDatabaseNameForCacheKey(cacheKey); + + let dbAdapter: PgAdapter | undefined; + let publisher: QueuePublisher | undefined; + let runner: QueueRunner | undefined; + let fixture: + | Awaited> + | undefined; + + await dropDatabase(templateDatabaseName); + await dropDatabase(builderDatabaseName); + + try { + dbAdapter = await createTestPgAdapter({ + databaseName: builderDatabaseName, + templateDatabase: migratedTestDatabaseTemplate, + }); + publisher = new PgQueuePublisher(dbAdapter); + runner = new PgQueueRunner({ + adapter: dbAdapter, + workerId: 'template-worker', + }); + + fixture = await startPermissionedRealmsFixture( + dbAdapter, + publisher, + runner, + { + realms: options.realms, + prerenderer: options.prerenderer, + }, + ); + + await waitForQueueIdle(builderDatabaseName); + await teardownPermissionedRealmsFixture(fixture.realms); + fixture = undefined; + + await publisher.destroy(); + publisher = undefined; + await runner.destroy(); + runner = undefined; + await dbAdapter.close(); + dbAdapter = undefined; + + await createTemplateSnapshot(builderDatabaseName, templateDatabaseName); + } finally { + if (fixture) { + try { + await teardownPermissionedRealmsFixture(fixture.realms); + } catch { + // best-effort cleanup + } + } + if (publisher) { + try { + await publisher.destroy(); + } catch { + // best-effort cleanup + } + } + if (runner) { + try { + await runner.destroy(); + } catch { + // best-effort cleanup + } + } + if (dbAdapter && !dbAdapter.isClosed) { + try { + await dbAdapter.close(); + } catch { + // best-effort cleanup + } + } + try { + await dropDatabase(builderDatabaseName); + } catch { + // best-effort cleanup + } + } +} + +async function acquirePermissionedRealmsTemplate( + options: SetupPermissionedRealmsCachedOptions, +): Promise<{ cacheKey: string; templateDatabaseName: string }> { + let cacheKey = permissionedRealmsTemplateCacheKey(options); + let templateDatabaseName = templateDatabaseNameForCacheKey(cacheKey); + let existing = permissionedRealmTemplateCache.get(cacheKey); + if (existing) { + await existing.ready; + return { cacheKey, templateDatabaseName }; + } + + let entry: CachedPermissionedRealmTemplateEntry = { + ready: Promise.resolve(), + }; + entry.ready = buildPermissionedRealmsTemplate(cacheKey, options).catch( + async (error) => { + permissionedRealmTemplateCache.delete(cacheKey); + try { + await dropDatabase(templateDatabaseName); + } catch { + // best-effort cleanup + } + throw error; + }, + ); + permissionedRealmTemplateCache.set(cacheKey, entry); + await entry.ready; + return { cacheKey, templateDatabaseName }; +} + +export function setupPermissionedRealmsCached( + hooks: NestedHooks, + options: SetupPermissionedRealmsCachedOptions, +) { + let acquiredTemplateDatabase: string | undefined; + + hooks.before(async function () { + let { templateDatabaseName } = + await acquirePermissionedRealmsTemplate(options); + acquiredTemplateDatabase = templateDatabaseName; + }); + + setupPermissionedRealms(hooks, { + ...options, + dbTemplateDatabase: () => acquiredTemplateDatabase, + }); +} + +export function createJWT( + realm: Realm, + user: string, + permissions: RealmPermissions['user'] = [], +) { + return realm.createJWT( + { + user, + realm: realm.url, + permissions, + sessionRoom: `test-session-room-for-${user}`, + realmServerURL: realm.realmServerURL, + }, + '7d', + ); +} + +export const cardInfo = { + notes: null, + name: null, + summary: null, + cardThumbnailURL: null, +}; + +export const cardDefinition: Definition['fields'] = { + id: { + type: 'contains', + isComputed: false, + fieldOrCard: { + name: 'ReadOnlyField', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + cardTitle: { + type: 'contains', + isComputed: true, + fieldOrCard: { + name: 'StringField', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + cardDescription: { + type: 'contains', + isComputed: true, + fieldOrCard: { + name: 'StringField', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + cardThumbnailURL: { + type: 'contains', + isComputed: true, + fieldOrCard: { + name: 'MaybeBase64Field', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + cardInfo: { + type: 'contains', + isComputed: false, + fieldOrCard: { + name: 'CardInfoField', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: false, + }, + 'cardInfo.name': { + type: 'contains', + isComputed: false, + fieldOrCard: { + name: 'StringField', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + 'cardInfo.summary': { + type: 'contains', + isComputed: false, + fieldOrCard: { + name: 'StringField', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + 'cardInfo.cardThumbnailURL': { + type: 'contains', + isComputed: false, + fieldOrCard: { + name: 'MaybeBase64Field', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + 'cardInfo.notes': { + type: 'contains', + isComputed: false, + fieldOrCard: { + name: 'MarkdownField', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + 'cardInfo.theme': { + type: 'linksTo', + isComputed: false, + fieldOrCard: { + name: 'Theme', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: false, + }, + 'cardInfo.theme.id': { + type: 'contains', + isComputed: false, + fieldOrCard: { + name: 'ReadOnlyField', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + 'cardInfo.theme.cardTitle': { + type: 'contains', + isComputed: true, + fieldOrCard: { + name: 'StringField', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + 'cardInfo.theme.cardDescription': { + type: 'contains', + isComputed: true, + fieldOrCard: { + name: 'StringField', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + 'cardInfo.theme.cardThumbnailURL': { + type: 'contains', + isComputed: true, + fieldOrCard: { + name: 'MaybeBase64Field', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + 'cardInfo.theme.cardInfo': { + type: 'contains', + isComputed: false, + fieldOrCard: { + name: 'CardInfoField', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: false, + }, + 'cardInfo.theme.cardInfo.name': { + type: 'contains', + isComputed: false, + fieldOrCard: { + name: 'StringField', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + 'cardInfo.theme.cardInfo.summary': { + type: 'contains', + isComputed: false, + fieldOrCard: { + name: 'StringField', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + 'cardInfo.theme.cardInfo.cardThumbnailURL': { + type: 'contains', + isComputed: false, + fieldOrCard: { + name: 'MaybeBase64Field', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + 'cardInfo.theme.cardInfo.notes': { + type: 'contains', + isComputed: false, + fieldOrCard: { + name: 'MarkdownField', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + 'cardInfo.theme.cssVariables': { + type: 'contains', + isComputed: false, + fieldOrCard: { + name: 'CSSField', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + 'cardInfo.theme.cssImports': { + type: 'containsMany', + isComputed: false, + fieldOrCard: { + name: 'CssImportField', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + 'cardInfo.theme.cardInfo.theme': { + type: 'linksTo', + isComputed: false, + fieldOrCard: { + name: 'Theme', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: false, + }, + 'cardInfo.theme.cardInfo.theme.id': { + type: 'contains', + isComputed: false, + fieldOrCard: { + name: 'ReadOnlyField', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + 'cardInfo.theme.cardInfo.theme.cardTitle': { + type: 'contains', + isComputed: true, + fieldOrCard: { + name: 'StringField', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + 'cardInfo.theme.cardInfo.theme.cardInfo': { + type: 'contains', + isComputed: false, + fieldOrCard: { + name: 'CardInfoField', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: false, + }, + 'cardInfo.theme.cardInfo.theme.cardInfo.name': { + type: 'contains', + isComputed: false, + fieldOrCard: { + name: 'StringField', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + 'cardInfo.theme.cardInfo.theme.cardInfo.summary': { + type: 'contains', + isComputed: false, + fieldOrCard: { + name: 'StringField', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + 'cardInfo.theme.cardInfo.theme.cardInfo.cardThumbnailURL': { + type: 'contains', + isComputed: false, + fieldOrCard: { + name: 'MaybeBase64Field', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + 'cardInfo.theme.cardInfo.theme.cardInfo.notes': { + type: 'contains', + isComputed: false, + fieldOrCard: { + name: 'MarkdownField', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + 'cardInfo.theme.cardInfo.theme.cardDescription': { + type: 'contains', + isComputed: true, + fieldOrCard: { + name: 'StringField', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + 'cardInfo.theme.cardInfo.theme.cssVariables': { + type: 'contains', + isComputed: false, + fieldOrCard: { + name: 'CSSField', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + 'cardInfo.theme.cardInfo.theme.cssImports': { + type: 'containsMany', + isComputed: false, + fieldOrCard: { + name: 'CssImportField', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + 'cardInfo.theme.cardInfo.theme.cardThumbnailURL': { + type: 'contains', + isComputed: true, + fieldOrCard: { + name: 'MaybeBase64Field', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + 'cardInfo.theme.cardInfo.theme.cardInfo.theme': { + type: 'linksTo', + isComputed: false, + fieldOrCard: { + name: 'Theme', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: false, + }, + 'cardInfo.theme.cardInfo.theme.cardInfo.theme.id': { + type: 'contains', + isComputed: false, + fieldOrCard: { + name: 'ReadOnlyField', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + 'cardInfo.theme.cardInfo.theme.cardInfo.theme.cardTitle': { + type: 'contains', + isComputed: true, + fieldOrCard: { + name: 'StringField', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + 'cardInfo.theme.cardInfo.theme.cardInfo.theme.cardInfo': { + type: 'contains', + isComputed: false, + fieldOrCard: { + name: 'CardInfoField', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: false, + }, + 'cardInfo.theme.cardInfo.theme.cardInfo.theme.cardInfo.name': { + type: 'contains', + isComputed: false, + fieldOrCard: { + name: 'StringField', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + 'cardInfo.theme.cardInfo.theme.cardInfo.theme.cardInfo.summary': { + type: 'contains', + isComputed: false, + fieldOrCard: { + name: 'StringField', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + 'cardInfo.theme.cardInfo.theme.cardInfo.theme.cardInfo.cardThumbnailURL': { + type: 'contains', + isComputed: false, + fieldOrCard: { + name: 'MaybeBase64Field', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + 'cardInfo.theme.cardInfo.theme.cardInfo.theme.cardInfo.notes': { + type: 'contains', + isComputed: false, + fieldOrCard: { + name: 'MarkdownField', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + 'cardInfo.theme.cardInfo.theme.cardInfo.theme.cardDescription': { + type: 'contains', + isComputed: true, + fieldOrCard: { + name: 'StringField', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + 'cardInfo.theme.cardInfo.theme.cardInfo.theme.cssVariables': { + type: 'contains', + isComputed: false, + fieldOrCard: { + name: 'CSSField', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + 'cardInfo.theme.cardInfo.theme.cardInfo.theme.cssImports': { + type: 'containsMany', + isComputed: false, + fieldOrCard: { + name: 'CssImportField', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + 'cardInfo.theme.cardInfo.theme.cardInfo.theme.cardThumbnailURL': { + type: 'contains', + isComputed: true, + fieldOrCard: { + name: 'MaybeBase64Field', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + 'cardInfo.theme.cardInfo.theme.cardInfo.theme.cardInfo.theme': { + type: 'linksTo', + isComputed: false, + fieldOrCard: { + name: 'Theme', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: false, + }, + 'cardInfo.theme.cardInfo.theme.cardInfo.theme.cardInfo.theme.id': { + type: 'contains', + isComputed: false, + fieldOrCard: { + name: 'ReadOnlyField', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + 'cardInfo.theme.cardInfo.theme.cardInfo.theme.cardInfo.theme.cardTitle': { + type: 'contains', + isComputed: true, + fieldOrCard: { + name: 'StringField', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + 'cardInfo.theme.cardInfo.theme.cardInfo.theme.cardInfo.theme.cardInfo': { + type: 'contains', + isComputed: false, + fieldOrCard: { + name: 'CardInfoField', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: false, + }, + 'cardInfo.theme.cardInfo.theme.cardInfo.theme.cardInfo.theme.cardInfo.name': { + type: 'contains', + isComputed: false, + fieldOrCard: { + name: 'StringField', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + 'cardInfo.theme.cardInfo.theme.cardInfo.theme.cardInfo.theme.cardInfo.summary': + { + type: 'contains', + isComputed: false, + fieldOrCard: { + name: 'StringField', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + 'cardInfo.theme.cardInfo.theme.cardInfo.theme.cardInfo.theme.cardInfo.cardThumbnailURL': + { + type: 'contains', + isComputed: false, + fieldOrCard: { + name: 'MaybeBase64Field', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + 'cardInfo.theme.cardInfo.theme.cardInfo.theme.cardInfo.theme.cardDescription': + { + type: 'contains', + isComputed: true, + fieldOrCard: { + name: 'StringField', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + 'cardInfo.theme.cardInfo.theme.cardInfo.theme.cardInfo.theme.cssVariables': { + type: 'contains', + isComputed: false, + fieldOrCard: { + name: 'CSSField', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + 'cardInfo.theme.cardInfo.theme.cardInfo.theme.cardInfo.theme.cssImports': { + type: 'containsMany', + isComputed: false, + fieldOrCard: { + name: 'CssImportField', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + 'cardInfo.theme.cardInfo.theme.cardInfo.theme.cardInfo.theme.cardThumbnailURL': + { + type: 'contains', + isComputed: true, + fieldOrCard: { + name: 'MaybeBase64Field', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: true, + }, + 'cardInfo.theme.cardInfo.theme.cardInfo.theme.cardInfo.theme.cardInfo.theme': + { + type: 'linksTo', + isComputed: false, + fieldOrCard: { + name: 'Theme', + module: 'https://cardstack.com/base/card-api', + }, + isPrimitive: false, + }, +}; diff --git a/packages/realm-server/tests-vitest/helpers/indexing.ts b/packages/realm-server/tests-vitest/helpers/indexing.ts new file mode 100644 index 00000000000..cf504eecf96 --- /dev/null +++ b/packages/realm-server/tests-vitest/helpers/indexing.ts @@ -0,0 +1,202 @@ +import type { MatrixEvent } from 'https://cardstack.com/base/matrix-event'; +import { findRealmEvent, waitUntil } from './index'; +import { APP_BOXEL_REALM_EVENT_TYPE } from '@cardstack/runtime-common/matrix-constants'; +import { trimJsonExtension } from '@cardstack/runtime-common'; +import type { DBAdapter, Expression } from '@cardstack/runtime-common'; +import { every, param, query } from '@cardstack/runtime-common'; +import type { + IncrementalIndexEventContent, + IncrementalIndexInitiationContent, +} from 'https://cardstack.com/base/matrix-event'; +import { validate as uuidValidate } from 'uuid'; + +interface IncrementalIndexEventTestContext { + assert: Assert; + getMessagesSince: (since: number) => Promise; + realm: string; + type?: string; + timeout?: number; +} + +export async function waitForIncrementalIndexEvent( + getMessagesSince: (since: number) => Promise, + since: number, + timeout = 5000, +) { + await waitUntil( + async () => { + let matrixMessages = await getMessagesSince(since); + + return matrixMessages.some( + (m) => + m.type === APP_BOXEL_REALM_EVENT_TYPE && + m.content.eventName === 'index' && + m.content.indexType === 'incremental', + ); + }, + { timeout }, + ); +} + +export async function expectIncrementalIndexEvent( + url: string, // <>.gts OR <>.json OR <>.* OR <>/ + since: number, + opts: IncrementalIndexEventTestContext, +) { + let { assert, getMessagesSince, realm, type, timeout } = opts; + + type = type ?? 'CardDef'; + + let endsWithSlash = url.endsWith('/'); // new card def is being created + let hasExtension = /\.[^/]+$/.test(url); + + if (!hasExtension && !endsWithSlash) { + throw new Error('Invalid file path'); + } + await waitForIncrementalIndexEvent(getMessagesSince, since, timeout); + + let messages = await getMessagesSince(since); + let incrementalIndexInitiationEventContent = findRealmEvent( + messages, + 'index', + 'incremental-index-initiation', + )?.content as IncrementalIndexInitiationContent; + + let incrementalEventContent = findRealmEvent(messages, 'index', 'incremental') + ?.content as IncrementalIndexEventContent; + + let targetUrl = url; + if (endsWithSlash) { + let maybeLocalId = incrementalEventContent.invalidations[0] + .split('/') + .pop(); + // check if the card identifier is a UUID + assert.true(uuidValidate(maybeLocalId!), 'card identifier is a UUID'); + assert.strictEqual( + incrementalEventContent.invalidations[0], + `${realm}${type}/${maybeLocalId}`, + ); + targetUrl = `${realm}${type}/${maybeLocalId}.json`; + } + + // For instances, the updatedFile includes .json extension but invalidations don't + // For source files, both updatedFile and invalidations include the full path with extension + let invalidation = targetUrl.endsWith('.json') + ? trimJsonExtension(targetUrl) + : targetUrl; + + if (!incrementalIndexInitiationEventContent) { + throw new Error('Incremental index initiation event not found'); + } + if (!incrementalEventContent) { + throw new Error('Incremental event content not found'); + } + assert.deepEqual(incrementalIndexInitiationEventContent, { + eventName: 'index', + indexType: 'incremental-index-initiation', + updatedFile: targetUrl, + realmURL: realm, + }); + + let expectedIncrementalContent: any = { + eventName: 'index', + indexType: 'incremental', + invalidations: [invalidation], + realmURL: realm, + }; + + let actualContent = { ...incrementalEventContent }; + delete actualContent.clientRequestId; + + assert.deepEqual(actualContent, expectedIncrementalContent); + return incrementalEventContent; +} + +export async function depsForIndexEntry( + dbAdapter: DBAdapter, + url: string, + type: 'instance' | 'file' = 'instance', +): Promise { + let rows = (await query(dbAdapter, [ + `SELECT deps FROM boxel_index WHERE`, + ...every([ + ['url =', param(url)], + ['type =', param(type)], + ]), + `ORDER BY realm_version DESC LIMIT 1`, + ] as Expression)) as { deps: string[] | string | null }[]; + let rawDeps = rows[0]?.deps ?? []; + if (Array.isArray(rawDeps)) { + return rawDeps; + } + if (typeof rawDeps === 'string') { + return JSON.parse(rawDeps) as string[]; + } + return []; +} + +export async function indexedAtForIndexEntry( + dbAdapter: DBAdapter, + url: string, + type: 'instance' | 'file' = 'instance', +): Promise { + let rows = (await query(dbAdapter, [ + `SELECT indexed_at FROM boxel_index WHERE`, + ...every([ + ['url =', param(url)], + ['type =', param(type)], + ]), + `ORDER BY realm_version DESC LIMIT 1`, + ] as Expression)) as { indexed_at: string | number | null }[]; + let value = rows[0]?.indexed_at ?? null; + if (value == null) { + return null; + } + return String(value); +} + +export async function typeForIndexEntry( + dbAdapter: DBAdapter, + url: string, +): Promise { + let rows = (await query(dbAdapter, [ + `SELECT type FROM boxel_index WHERE`, + ...every([['url =', param(url)]]), + `ORDER BY realm_version DESC LIMIT 1`, + ] as Expression)) as { type: string }[]; + return rows[0]?.type ?? null; +} + +export async function errorDocForIndexEntry( + dbAdapter: DBAdapter, + url: string, + type: 'instance' | 'file' = 'instance', +): Promise<{ hasError: boolean; errorDoc: unknown | null } | null> { + let rows = (await query(dbAdapter, [ + `SELECT has_error, error_doc FROM boxel_index WHERE`, + ...every([ + ['url =', param(url)], + ['type =', param(type)], + ]), + `ORDER BY realm_version DESC LIMIT 1`, + ] as Expression)) as { + has_error: boolean | null; + error_doc: unknown | string | null; + }[]; + let row = rows[0]; + if (!row) { + return null; + } + let errorDoc = row.error_doc; + if (typeof errorDoc === 'string') { + try { + errorDoc = JSON.parse(errorDoc); + } catch (_err) { + // Keep the original string when DB driver already returns serialized JSON. + } + } + return { + hasError: Boolean(row.has_error), + errorDoc: errorDoc ?? null, + }; +} diff --git a/packages/realm-server/tests-vitest/helpers/prerender-page-patches.ts b/packages/realm-server/tests-vitest/helpers/prerender-page-patches.ts new file mode 100644 index 00000000000..5269754828d --- /dev/null +++ b/packages/realm-server/tests-vitest/helpers/prerender-page-patches.ts @@ -0,0 +1,216 @@ +import { Realm as RuntimeRealm } from '@cardstack/runtime-common'; +import { PagePool } from '../../prerender/page-pool'; + +export function installRealmServerAssertOwnRealmServerBypassPatch(): { + restore: () => Promise; +} { + // Chrome patch setup: + // We need a browser-side patch because prerender tests can run the host app + // against dynamic realm origins (for example 127.0.0.1:4450), while the host's + // RealmServerService.assertOwnRealmServer check assumes a single configured + // realm server origin. Query-field search calls through that assertion before + // it performs federated search, so without this patch search can bail out too + // early and we never get to exercise query-load tracking behavior. + let originalGetPage = PagePool.prototype.getPage; + let patchedPages = new Map Promise>(); + + // Intercept page acquisition so we can inject one browser-runtime patch + // before route transitions/captures run. + PagePool.prototype.getPage = async function (this: PagePool, realm: string) { + let pageInfo = await originalGetPage.call(this, realm); + let page = pageInfo.page as any; + let originalEvaluate = page?.evaluate?.bind(page); + + if (originalEvaluate && !patchedPages.has(page)) { + patchedPages.set(page, originalEvaluate); + // Per-page guard. A single page can be reused by the page pool; we only + // need to install our patch once for that page instance. + let injected = false; + page.evaluate = async (...args: any[]) => { + if (!injected) { + injected = true; + await originalEvaluate(() => { + // Global guard in page context in case evaluate wrappers are + // re-entered or patched multiple times. + if ((globalThis as any).__boxelAssertOwnRealmServerPatched) { + return; + } + // Resolve the Ember module registry from whichever loader shape + // exists in this runtime build. + let entries = + (window as any).requirejs?.entries ?? + (window as any).require?.entries ?? + (window as any)._eak_seen; + // Find the compiled realm-server service module and patch only the + // one assertion method we need to bypass. + let realmServerModuleName = + entries && + Object.keys(entries).find((name) => + name.endsWith('/services/realm-server'), + ); + if (!realmServerModuleName) { + return; + } + let realmServerModule = (window as any).require( + realmServerModuleName, + ); + let RealmServerClass = realmServerModule?.default; + if (!RealmServerClass?.prototype) { + return; + } + // Save original behavior so restore() can put it back and avoid + // cross-test contamination. + (globalThis as any).__boxelOriginalAssertOwnRealmServer = + RealmServerClass.prototype.assertOwnRealmServer; + // Patch objective: + // Allow query-field search requests to proceed for dynamic test + // realm origins. We are not changing search logic itself; this only + // removes the single-origin assertion gate for this test runtime. + RealmServerClass.prototype.assertOwnRealmServer = function () { + return; + }; + (globalThis as any).__boxelAssertOwnRealmServerPatched = true; + }); + } + // Delegate every evaluate call back to original behavior after patching. + return originalEvaluate(...args); + }; + } + + return { ...pageInfo, page }; + }; + + return { + restore: async () => { + for (let [page, originalEvaluate] of patchedPages) { + try { + // Restore the original evaluate first to avoid leaving wrapped + // evaluate functions behind on pooled pages that survive this test. + page.evaluate = originalEvaluate; + await originalEvaluate(() => { + // Cleanup mirrors setup above: locate the same service module and + // restore the original assertOwnRealmServer implementation. + let entries = + (window as any).requirejs?.entries ?? + (window as any).require?.entries ?? + (window as any)._eak_seen; + let realmServerModuleName = + entries && + Object.keys(entries).find((name) => + name.endsWith('/services/realm-server'), + ); + let originalAssertOwnRealmServer = (globalThis as any) + .__boxelOriginalAssertOwnRealmServer; + if (realmServerModuleName && originalAssertOwnRealmServer) { + let realmServerModule = (window as any).require( + realmServerModuleName, + ); + let RealmServerClass = realmServerModule?.default; + if (RealmServerClass?.prototype) { + RealmServerClass.prototype.assertOwnRealmServer = + originalAssertOwnRealmServer; + } + } + // Remove page globals used by this patch to keep runtime clean. + delete (globalThis as any).__boxelOriginalAssertOwnRealmServer; + delete (globalThis as any).__boxelAssertOwnRealmServerPatched; + }); + } catch { + // best effort cleanup: page may already be gone + } + } + patchedPages.clear(); + // Always restore Node-side monkeypatch as well. + PagePool.prototype.getPage = originalGetPage; + }, + }; +} + +export function installDelayedRuntimeRealmSearchPatch(delayMs: number): { + getRequestCount: () => number; + restore: () => void; +} { + // Server-side deterministic delay: + // This makes query timing explicit/reproducible so tests can assert that + // prerender waited for query resolution instead of "winning a race" by chance. + let originalSearch = RuntimeRealm.prototype.search; + let delayedSearchRequestCount = 0; + + RuntimeRealm.prototype.search = async function ( + this: RuntimeRealm, + query: Parameters[0], + ): Promise>> { + // Exposed to tests as a stable signal that fallback search actually ran. + delayedSearchRequestCount++; + await new Promise((resolve) => setTimeout(resolve, delayMs)); + return await originalSearch.call(this, query); + }; + + return { + getRequestCount: () => delayedSearchRequestCount, + restore: () => { + RuntimeRealm.prototype.search = originalSearch; + }, + }; +} + +export function installSearchRequestObserverPatch(): { + getRequests: () => Array<{ + url: string; + method: string; + hasAuthorization: boolean; + }>; + restore: () => void; +} { + let originalGetPage = PagePool.prototype.getPage; + let observedRequests: Array<{ + url: string; + method: string; + hasAuthorization: boolean; + }> = []; + let pageRequestListeners = new Map void>(); + + PagePool.prototype.getPage = async function (this: PagePool, realm: string) { + let pageInfo = await originalGetPage.call(this, realm); + let page = pageInfo.page as any; + if (page && !pageRequestListeners.has(page)) { + let listener = (request: any) => { + let url = request.url?.(); + if ( + !url || + (!url.endsWith('/_federated-search') && !url.endsWith('/_search')) + ) { + return; + } + let headers = + (request.headers?.() as Record | undefined) ?? {}; + observedRequests.push({ + url, + method: request.method?.() ?? 'UNKNOWN', + hasAuthorization: Boolean( + headers.authorization ?? headers.Authorization, + ), + }); + }; + pageRequestListeners.set(page, listener); + page.on?.('request', listener); + } + return { ...pageInfo, page }; + }; + + return { + getRequests: () => [...observedRequests], + restore: () => { + for (let [page, listener] of pageRequestListeners) { + try { + page.off?.('request', listener); + } catch { + // best-effort cleanup + } + } + pageRequestListeners.clear(); + observedRequests = []; + PagePool.prototype.getPage = originalGetPage; + }, + }; +} diff --git a/packages/realm-server/tests-vitest/helpers/prettier-config.ts b/packages/realm-server/tests-vitest/helpers/prettier-config.ts new file mode 100644 index 00000000000..036b70dbe5a --- /dev/null +++ b/packages/realm-server/tests-vitest/helpers/prettier-config.ts @@ -0,0 +1,33 @@ +// Prettier configuration for test environment +export const TEST_PRETTIER_CONFIG = { + singleQuote: true, + plugins: ['prettier-plugin-ember-template-tag'], + parser: 'glimmer' as const, + printWidth: 80, + tabWidth: 2, + useTabs: false, + semi: true, + trailingComma: 'all' as const, + bracketSpacing: true, + arrowParens: 'avoid' as const, +}; + +export const PRETTIER_PARSERS = { + '.gts': 'glimmer', + '.ts': 'typescript', + '.js': 'babel', +} as const; + +export function inferPrettierParser(filename: string): string { + const extension = filename.substring(filename.lastIndexOf('.')); + return ( + PRETTIER_PARSERS[extension as keyof typeof PRETTIER_PARSERS] || 'glimmer' + ); +} + +export function createPrettierConfig(overrides: Record = {}) { + return { + ...TEST_PRETTIER_CONFIG, + ...overrides, + }; +} diff --git a/packages/realm-server/tests-vitest/helpers/prettier-test-utils.ts b/packages/realm-server/tests-vitest/helpers/prettier-test-utils.ts new file mode 100644 index 00000000000..0a626ec09b0 --- /dev/null +++ b/packages/realm-server/tests-vitest/helpers/prettier-test-utils.ts @@ -0,0 +1,437 @@ +// Test utilities for prettier formatting tests +import { performance } from 'perf_hooks'; + +interface FormattingTestCase { + name: string; + input: string; + expected: string; + cardDescription: string; +} + +/** + * Compares two code strings, ignoring minor whitespace differences + */ +export function normalizeCodeForComparison(code: string): string { + return code + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .join('\n'); +} + +/** + * Performance benchmark interface + */ +interface PerformanceBenchmark { + name: string; + duration: number; + iterations: number; + averageTime: number; + maxTime: number; + minTime: number; + result?: any; +} + +/** + * Output comparison result + */ +interface ComparisonResult { + matches: boolean; + differences: string[]; + similarity: number; +} + +/** + * Performance benchmark utility + */ +export async function benchmarkOperation( + name: string, + operation: () => Promise | T, + iterations: number = 100, +): Promise { + const times: number[] = []; + + for (let i = 0; i < iterations; i++) { + const startTime = performance.now(); + await operation(); + const endTime = performance.now(); + times.push(endTime - startTime); + } + + const duration = times.reduce((sum, time) => sum + time, 0); + const averageTime = duration / iterations; + const maxTime = Math.max(...times); + const minTime = Math.min(...times); + + return { + name, + duration, + iterations, + averageTime, + maxTime, + minTime, + }; +} + +/** + * Compare formatted output with expected result + */ +export function compareFormattedOutput( + actual: string, + expected: string, +): ComparisonResult { + const actualLines = actual.trim().split('\n'); + const expectedLines = expected.trim().split('\n'); + const differences: string[] = []; + + if (actualLines.length !== expectedLines.length) { + differences.push( + `Line count mismatch: actual ${actualLines.length}, expected ${expectedLines.length}`, + ); + } + + const maxLines = Math.max(actualLines.length, expectedLines.length); + let matchingLines = 0; + + for (let i = 0; i < maxLines; i++) { + const actualLine = actualLines[i] || ''; + const expectedLine = expectedLines[i] || ''; + + if (actualLine === expectedLine) { + matchingLines++; + } else { + differences.push( + `Line ${i + 1}: \n Expected: "${expectedLine}"\n Actual: "${actualLine}"`, + ); + } + } + + const similarity = maxLines > 0 ? matchingLines / maxLines : 0; + + return { + matches: differences.length === 0, + differences, + similarity, + }; +} + +/** + * Create performance assertion helper + */ +export function createPerformanceAssertion(maxAverageTime: number) { + return (benchmark: PerformanceBenchmark, assert: any) => { + assert.ok( + benchmark.averageTime <= maxAverageTime, + `Performance benchmark '${benchmark.name}' exceeded maximum average time. ` + + `Expected: <= ${maxAverageTime}ms, Actual: ${benchmark.averageTime.toFixed( + 2, + )}ms ` + + `(min: ${benchmark.minTime.toFixed(2)}ms, max: ${benchmark.maxTime.toFixed( + 2, + )}ms)`, + ); + }; +} + +/** + * Backward compatibility test result + */ +export interface BackwardCompatibilityResult { + tests: Array<{ + name: string; + passed: boolean; + error?: string; + }>; + allPassed: boolean; +} + +/** + * Formatted output comparison result + */ +export interface FormattedOutputComparison { + isMatch: boolean; + input: string; + expected: string; + actual: string; + normalizedMatch?: boolean; +} + +/** + * Error test case for testing error handling + */ +export interface ErrorTestCase { + name: string; + input: string; + expectedError: string; + cardDescription: string; +} + +/** + * Main test utilities class for infrastructure + */ +export class PrettierTestUtils { + /** + * Benchmark an operation and return performance metrics + */ + async benchmarkOperation( + operation: () => Promise, + operationName: string, + iterations: number = 1, + ): Promise { + const times: number[] = []; + let lastResult: T; + + for (let i = 0; i < iterations; i++) { + const startTime = performance.now(); + lastResult = await operation(); + const endTime = performance.now(); + times.push(endTime - startTime); + } + + const duration = times.reduce((sum, time) => sum + time, 0); + const averageTime = duration / iterations; + const maxTime = Math.max(...times); + const minTime = Math.min(...times); + + return { + name: operationName, + duration, + iterations, + averageTime, + maxTime, + minTime, + result: lastResult!, + }; + } + + /** + * Compare formatted output with expected result + */ + compareFormattedOutput( + input: string, + expected: string, + actual: string, + ): FormattedOutputComparison { + const exactMatch = actual === expected; + + if (exactMatch) { + return { + isMatch: true, + input, + expected, + actual, + }; + } + + // Try normalized comparison + const normalizedActual = normalizeCodeForComparison(actual); + const normalizedExpected = normalizeCodeForComparison(expected); + const normalizedMatch = normalizedActual === normalizedExpected; + + return { + isMatch: normalizedMatch, + input, + expected, + actual, + normalizedMatch, + }; + } + + /** + * Create concurrent test data for performance testing + */ + createConcurrentTestData(count: number): FormattingTestCase[] { + const testCases: FormattingTestCase[] = []; + + for (let i = 0; i < count; i++) { + testCases.push({ + name: `concurrent-test-${i}`, + input: `import{CardDef}from'somewhere${i}';export class Test${i} extends CardDef{@field name=contains(StringField);}`, + expected: `import { CardDef } from 'somewhere${i}';\n\nexport class Test${i} extends CardDef {\n @field name = contains(StringField);\n}`, + cardDescription: `Concurrent test case ${i}`, + }); + } + + return testCases; + } + + /** + * Create error test cases for testing error handling + */ + createErrorTestCases(): ErrorTestCase[] { + return [ + { + name: 'invalid-syntax', + input: + 'import { CardDef } from "somewhere";\nexport class Test extends CardDef {\n @field }{ invalid\n}', + expectedError: 'SyntaxError', + cardDescription: 'Test handling of invalid syntax', + }, + { + name: 'malformed-template', + input: '', + expectedError: 'TemplateError', + cardDescription: 'Test handling of malformed template', + }, + { + name: 'missing-imports', + input: + 'export class Test extends CardDef {\n @field name = contains(StringField);\n}', + expectedError: 'ReferenceError', + cardDescription: 'Test handling of missing imports', + }, + ]; + } +} + +/** + * Error simulation helper for testing error handling scenarios + */ +export function createErrorTestCases() { + return { + syntaxError: { + name: 'Syntax Error', + source: `import { CardDef } from 'https://cardstack.com/base/card-api'; +export class MyCard extends CardDef { + @field name = contains(StringField); + // Malformed syntax that prettier cannot parse + @field }{ invalid +}`, + expectedBehavior: 'Should fall back to ESLint-only fixes', + }, + + configError: { + name: 'Config Error', + source: `import { CardDef } from 'https://cardstack.com/base/card-api'; +export class MyCard extends CardDef { + @field name = contains(StringField); +}`, + expectedBehavior: 'Should handle prettier config errors gracefully', + }, + + pluginError: { + name: 'Plugin Error', + source: ``, + expectedBehavior: 'Should handle missing plugin gracefully', + }, + + largeFile: { + name: 'Large File', + source: `${'// Large comment line\n'.repeat(5000)} +import { CardDef } from 'https://cardstack.com/base/card-api'; +export class MyCard extends CardDef { + @field name = contains(StringField); +}`, + expectedBehavior: 'Should handle large files within reasonable time', + }, + }; +} + +/** + * Create test data for concurrent request testing + */ +export function createConcurrentTestData(count: number = 5): Array<{ + source: string; + filename: string; + cardDescription: string; + expectedOutput: string; +}> { + const testData = []; + + for (let i = 0; i < count; i++) { + const source = `import { CardDef } from 'https://cardstack.com/base/card-api'; +export class MyCard${i} extends CardDef { + @field name${i} = contains(StringField); +}`; + + const expectedOutput = `import StringField from 'https://cardstack.com/base/string'; +import { CardDef, field, contains } from 'https://cardstack.com/base/card-api'; + +export class MyCard${i} extends CardDef { + @field name${i} = contains(StringField); +}`; + + testData.push({ + source, + filename: `test-${i}.gts`, + cardDescription: `Concurrent test ${i}`, + expectedOutput, + }); + } + + return testData; +} + +/** + * Create a large test file for performance testing + */ +export function createLargeFileTestCase(lineCount: number): string { + const imports = [ + "import { CardDef } from 'https://cardstack.com/base/card-api';", + "import { field, contains } from 'https://cardstack.com/base/card-api';", + "import StringField from 'https://cardstack.com/base/string';", + "import { tracked } from '@glimmer/tracking';", + "import { action } from '@ember/object';", + "import { fn } from '@ember/helper';", + "import { on } from '@ember/modifier';", + ]; + + const classTemplate = ` +export class Card{classNumber} extends CardDef { + @field name = contains(StringField); + @field title = contains(StringField); + @field description = contains(StringField); + + @tracked isVisible = true; + @tracked isExpanded = false; + + get displayName() { + return this.name || 'Unnamed Card {classNumber}'; + } + + @action + toggleVisibility() { + this.isVisible = !this.isVisible; + } + + @action + toggleExpansion() { + this.isExpanded = !this.isExpanded; + } + + +}`; + + const lines = []; + + // Add imports + lines.push(...imports); + lines.push(''); + + // Calculate how many classes we need to reach target line count + const linesPerClass = classTemplate.split('\n').length; + const classCount = Math.ceil( + (lineCount - imports.length - 1) / linesPerClass, + ); + + // Add classes + for (let i = 1; i <= classCount; i++) { + lines.push(classTemplate.replace(/{classNumber}/g, i.toString())); + lines.push(''); + } + + return lines.join('\n'); +} diff --git a/packages/realm-server/tests-vitest/indexing-event-sink.test.ts b/packages/realm-server/tests-vitest/indexing-event-sink.test.ts new file mode 100644 index 00000000000..beca626e762 --- /dev/null +++ b/packages/realm-server/tests-vitest/indexing-event-sink.test.ts @@ -0,0 +1,123 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect } from "vitest"; +import { IndexingEventSink } from '../indexing-event-sink'; +describe("indexing-event-sink-test.ts", function () { + it('tracks active indexing from start through file visits to finish', function () { + let sink = new IndexingEventSink(); + expect(sink.getSnapshot()).toEqual({ active: [], history: [] }); + sink.handleEvent({ + type: 'indexing-started', + realmURL: 'http://example.com/realm/', + jobId: 1, + jobType: 'from-scratch', + totalFiles: 3, + files: [ + 'http://example.com/realm/a.gts', + 'http://example.com/realm/b.json', + 'http://example.com/realm/c.gts', + ], + }); + let { active, history } = sink.getSnapshot(); + expect(active.length).toBe(1); + expect(history.length).toBe(0); + expect(active[0].realmURL).toBe('http://example.com/realm/'); + expect(active[0].totalFiles).toBe(3); + expect(active[0].filesCompleted).toBe(0); + expect(active[0].status).toBe('indexing'); + sink.handleEvent({ + type: 'file-visited', + realmURL: 'http://example.com/realm/', + jobId: 1, + url: 'http://example.com/realm/a.gts', + filesCompleted: 1, + totalFiles: 3, + }); + ({ active } = sink.getSnapshot()); + expect(active[0].filesCompleted).toBe(1); + expect(active[0].completedFiles).toEqual([ + 'http://example.com/realm/a.gts', + ]); + sink.handleEvent({ + type: 'file-visited', + realmURL: 'http://example.com/realm/', + jobId: 1, + url: 'http://example.com/realm/b.json', + filesCompleted: 2, + totalFiles: 3, + }); + sink.handleEvent({ + type: 'file-visited', + realmURL: 'http://example.com/realm/', + jobId: 1, + url: 'http://example.com/realm/c.gts', + filesCompleted: 3, + totalFiles: 3, + }); + ({ active } = sink.getSnapshot()); + expect(active[0].filesCompleted).toBe(3); + sink.handleEvent({ + type: 'indexing-finished', + realmURL: 'http://example.com/realm/', + jobId: 1, + stats: { + instancesIndexed: 1, + filesIndexed: 2, + instanceErrors: 0, + fileErrors: 0, + totalIndexEntries: 3, + }, + }); + ({ active, history } = sink.getSnapshot()); + expect(active.length).toBe(0); + expect(history.length).toBe(1); + expect(history[0].realmURL).toBe('http://example.com/realm/'); + expect(history[0].status).toBe('finished'); + expect(history[0].stats).toEqual({ + instancesIndexed: 1, + filesIndexed: 2, + instanceErrors: 0, + fileErrors: 0, + totalIndexEntries: 3, + }); + }); + it('tracks multiple realms concurrently', function () { + let sink = new IndexingEventSink(); + sink.handleEvent({ + type: 'indexing-started', + realmURL: 'http://example.com/realm-a/', + jobId: 1, + jobType: 'from-scratch', + totalFiles: 10, + files: [], + }); + sink.handleEvent({ + type: 'indexing-started', + realmURL: 'http://example.com/realm-b/', + jobId: 2, + jobType: 'incremental', + totalFiles: 2, + files: [], + }); + expect(sink.getActiveIndexing().length).toBe(2); + sink.handleEvent({ + type: 'indexing-finished', + realmURL: 'http://example.com/realm-b/', + jobId: 2, + }); + expect(sink.getActiveIndexing().length).toBe(1); + expect(sink.getActiveIndexing()[0].realmURL).toBe('http://example.com/realm-a/'); + expect(sink.getHistory().length).toBe(1); + }); + it('ignores file-visited for unknown realm', function () { + let sink = new IndexingEventSink(); + sink.handleEvent({ + type: 'file-visited', + realmURL: 'http://example.com/unknown/', + jobId: 99, + url: 'http://example.com/unknown/x.json', + filesCompleted: 1, + totalFiles: 1, + }); + expect(sink.getActiveIndexing().length).toBe(0); + }); +}); diff --git a/packages/realm-server/tests-vitest/indexing.test.ts b/packages/realm-server/tests-vitest/indexing.test.ts new file mode 100644 index 00000000000..96b6358f2cb --- /dev/null +++ b/packages/realm-server/tests-vitest/indexing.test.ts @@ -0,0 +1,3000 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import { internalKeyFor, SupportedMimeType, Deferred, IndexWriter, userInitiatedPriority, } from '@cardstack/runtime-common'; +import type { DBAdapter, DefinitionLookup, LooseSingleCardDocument, Realm, RealmPermissions, RealmAdapter, } from '@cardstack/runtime-common'; +import type { IndexedInstance, QueuePublisher, QueueRunner, } from '@cardstack/runtime-common'; +import type { runTestRealmServer } from './helpers'; +import { cleanWhiteSpace, waitUntil, cardInfo, setupPermissionedRealmCached, setupPermissionedRealmsCached, } from './helpers'; +import { depsForIndexEntry, errorDocForIndexEntry, indexedAtForIndexEntry, typeForIndexEntry, } from './helpers/indexing'; +import stripScopedCSSAttributes from '@cardstack/runtime-common/helpers/strip-scoped-css-attributes'; +import type { PgAdapter } from '@cardstack/postgres'; +function trimCardContainer(text: string) { + return cleanWhiteSpace(text) + .replace(/=""/g, '') + .replace(/
\s?[]*? (.*?) <\/div>/g, '$1'); +} +let testDbAdapter: DBAdapter; +const testRealm = new URL('http://127.0.0.1:4445/test/'); +type TestRealmServerResult = Awaited>; +function makeTestRealmFileSystem(): Record { + return { + 'person.gts': ` + import { contains, field, CardDef, Component } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + import NumberField from "https://cardstack.com/base/number"; + + export class Person extends CardDef { + @field firstName = contains(StringField); + @field hourlyRate = contains(NumberField); + static isolated = class Isolated extends Component { + + } + static embedded = class Embedded extends Component { + + } + static fitted = class Fitted extends Component { + + } + } + `, + 'pet-person.gts': ` + import { contains, linksTo, field, CardDef, Component, StringField } from "https://cardstack.com/base/card-api"; + import { Pet } from "./pet"; + + export class PetPerson extends CardDef { + @field firstName = contains(StringField); + @field pet = linksTo(() => Pet); + @field nickName = contains(StringField, { + computeVia: function (this: Person) { + if (this.pet?.firstName) { + return this.pet.firstName + "'s buddy"; + } + return 'buddy'; + }, + }); + } + `, + 'pet.gts': ` + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Pet extends CardDef { + @field firstName = contains(StringField); + } + `, + 'fancy-person.gts': ` + import { contains, field, Component } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + import { Person } from "./person"; + + export class FancyPerson extends Person { + @field favoriteColor = contains(StringField); + + static embedded = class Embedded extends Component { + + } + } + `, + 'post.gts': ` + import { contains, field, linksTo, CardDef, Component } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + import { Person } from "./person"; + + export class Post extends CardDef { + static displayName = 'Post'; + @field author = linksTo(Person); + @field message = contains(StringField); + static isolated = class Isolated extends Component { + + } + } + `, + 'boom.gts': ` + import { contains, field, CardDef, Component } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Boom extends CardDef { + @field firstName = contains(StringField); + static isolated = class Isolated extends Component { + + get boom() { + throw new Error('intentional error'); + } + } + } + `, + 'boom2.gts': ` + import { contains, field, CardDef, Component } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Boom extends CardDef { + @field firstName = contains(StringField); + boom = () => {}; + static isolated = class Isolated extends Component { + + } + } + `, + 'atom-boom.gts': ` + import { contains, field, CardDef, Component } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Boom extends CardDef { + @field firstName = contains(StringField); + static atom = class Atom extends Component { + + get boom() { + throw new Error('intentional error'); + } + } + } + `, + 'embedded-boom.gts': ` + import { contains, field, CardDef, Component } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Boom extends CardDef { + @field firstName = contains(StringField); + static embedded = class Embedded extends Component { + + get boom() { + throw new Error('intentional error'); + } + } + } + `, + 'fitted-boom.gts': ` + import { contains, field, CardDef, Component } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Boom extends CardDef { + @field firstName = contains(StringField); + static fitted = class Fitted extends Component { + + get boom() { + throw new Error('intentional error'); + } + } + } + `, + 'mango.json': { + data: { + attributes: { + firstName: 'Mango', + }, + meta: { + adoptsFrom: { + module: './person', + name: 'Person', + }, + }, + }, + }, + 'vangogh.json': { + data: { + attributes: { + firstName: 'Van Gogh', + hourlyRate: 50, + }, + meta: { + adoptsFrom: { + module: './person', + name: 'Person', + }, + }, + }, + }, + 'hassan.json': { + data: { + attributes: { + firstName: 'Hassan', + }, + relationships: { + pet: { links: { self: './ringo' } }, + }, + meta: { + adoptsFrom: { + module: './pet-person', + name: 'PetPerson', + }, + }, + }, + }, + 'ringo.json': { + data: { + attributes: { + firstName: 'Ringo', + }, + meta: { + adoptsFrom: { + module: './pet', + name: 'Pet', + }, + }, + }, + }, + 'post-1.json': { + data: { + attributes: { + message: 'Who wants to fetch?!', + }, + relationships: { + author: { + links: { + self: './vangogh', + }, + }, + }, + meta: { + adoptsFrom: { + module: './post', + name: 'Post', + }, + }, + }, + }, + 'bad-link.json': { + data: { + attributes: { + message: 'I have a bad link', + }, + relationships: { + author: { + links: { + self: 'http://localhost:9000/this-is-a-link-to-nowhere', + }, + }, + }, + meta: { + adoptsFrom: { + module: './post', + name: 'Post', + }, + }, + }, + }, + 'boom.json': { + data: { + attributes: { + firstName: 'Boom!', + }, + meta: { + adoptsFrom: { + module: './boom', + name: 'Boom', + }, + }, + }, + }, + 'boom2.json': { + data: { + attributes: { + firstName: 'Boom!', + }, + meta: { + adoptsFrom: { + module: './boom2', + name: 'Boom', + }, + }, + }, + }, + 'atom-boom.json': { + data: { + attributes: { + firstName: 'Boom!', + }, + meta: { + adoptsFrom: { + module: './atom-boom', + name: 'Boom', + }, + }, + }, + }, + 'embedded-boom.json': { + data: { + attributes: { + firstName: 'Boom!', + }, + meta: { + adoptsFrom: { + module: './embedded-boom', + name: 'Boom', + }, + }, + }, + }, + 'fitted-boom.json': { + data: { + attributes: { + firstName: 'Boom!', + }, + meta: { + adoptsFrom: { + module: './fitted-boom', + name: 'Boom', + }, + }, + }, + }, + 'empty.json': { + data: { + attributes: {}, + meta: { + adoptsFrom: { + module: 'https://cardstack.com/base/card-api', + name: 'CardDef', + }, + }, + }, + }, + 'address.gts': ` + import { contains, field, FieldDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Address extends FieldDef { + @field street = contains(StringField); + @field city = contains(StringField); + } + `, + 'order-page.gts': ` + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import { Address } from "./address"; + + export class OrderPage extends CardDef { + @field shippingAddress = contains(Address); + } + `, + 'fieldof-address.json': { + data: { + attributes: { + street: '123 Main St', + city: 'Anytown', + }, + meta: { + adoptsFrom: { + type: 'fieldOf', + field: 'shippingAddress', + card: { + module: './order-page', + name: 'OrderPage', + }, + }, + }, + }, + }, + 'filedef-mismatch.gts': ` + import { + FileDef as BaseFileDef, + FileContentMismatchError, + } from "https://cardstack.com/base/file-api"; + + export class FileDef extends BaseFileDef { + static async extractAttributes() { + throw new FileContentMismatchError('content mismatch'); + } + } + `, + 'random-file.txt': 'hello', + 'random-file.mismatch': 'mismatch content', + 'random-image.png': 'i am an image', + '🎉hello.txt': 'emoji filename content', + '.DS_Store': 'In macOS, .DS_Store is a file that stores custom attributes of its containing folder', + }; +} +describe("indexing-test.ts", function () { + describe('indexing (read only)', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let realm: Realm; + async function getInstance(realm: Realm, url: URL): Promise { + let maybeInstance = await realm.realmIndexQueryEngine.instance(url); + if (maybeInstance?.type === 'instance-error') { + return undefined; + } + return maybeInstance as IndexedInstance | undefined; + } + setupPermissionedRealmCached(hooks, { + mode: 'before', + realmURL: testRealm, + permissions: { + '*': ['read'], + }, + fileSystem: makeTestRealmFileSystem(), + onRealmSetup({ dbAdapter, testRealm }) { + testDbAdapter = dbAdapter; + realm = testRealm; + }, + }); + it('realm is full indexed at boot', async function () { + let jobs = await testDbAdapter.execute('select * from jobs'); + expect(jobs.length).toBe(1); + let [job] = jobs; + expect(job.job_type).toBe('from-scratch-index'); + expect(job.concurrency_group).toBe(`indexing:${testRealm}`); + expect(job.status).toBe('resolved'); + expect(job.finished_at).toBeTruthy(); + }); + it('can store card pre-rendered html in the index', async function () { + let entry = await realm.realmIndexQueryEngine.instance(new URL(`${testRealm}mango`)); + if (entry?.type === 'instance') { + expect(trimCardContainer(stripScopedCSSAttributes(entry!.isolatedHtml!))).toBe(cleanWhiteSpace(`

Mango $

`)); + expect(trimCardContainer(stripScopedCSSAttributes(entry!.embeddedHtml![`${testRealm}person/Person`]))).toBe(cleanWhiteSpace(`

Embedded Card Person: Mango

`)); + let cleanedHead = cleanWhiteSpace(entry.headHtml!); + expect(entry.headHtml).toBeTruthy(); + expect(cleanedHead.includes('')).toBeTruthy(); + expect(trimCardContainer(stripScopedCSSAttributes(entry!.fittedHtml![`${testRealm}person/Person`]))).toBe(cleanWhiteSpace(`<h1> Fitted Card Person: Mango </h1>`)); + } + else { + expect(false).toBeTruthy(); + } + }); + it('can store error doc in the index when atom view throws error', async function () { + let entry = await realm.realmIndexQueryEngine.cardDocument(new URL(`${testRealm}atom-boom`)); + if (entry?.type === 'error') { + expect(entry.error.errorDetail.message).toBe('intentional error'); + expect(entry.error.errorDetail.deps?.includes(`${testRealm}atom-boom`)).toBeTruthy(); + } + else { + expect(false).toBeTruthy(); + } + }); + it('can store error doc in the index when embedded view throws error', async function () { + let entry = await realm.realmIndexQueryEngine.cardDocument(new URL(`${testRealm}embedded-boom`)); + if (entry?.type === 'error') { + expect(entry.error.errorDetail.message).toBe('intentional error'); + expect(entry.error.errorDetail.deps?.includes(`${testRealm}embedded-boom`)).toBeTruthy(); + } + else { + expect(false).toBeTruthy(); + } + }); + it('can store error doc in the index when fitted view throws error', async function () { + let entry = await realm.realmIndexQueryEngine.cardDocument(new URL(`${testRealm}fitted-boom`)); + if (entry?.type === 'error') { + expect(entry.error.errorDetail.message).toBe('intentional error'); + expect(entry.error.errorDetail.deps?.includes(`${testRealm}fitted-boom`)).toBeTruthy(); + } + else { + expect(false).toBeTruthy(); + } + }); + it('rendering a card that has a template error does not affect indexing other instances', async function () { + { + let entry = await realm.realmIndexQueryEngine.cardDocument(new URL(`${testRealm}boom`)); + if (entry?.type === 'error') { + expect(entry.error.errorDetail.message).toBe('intentional error'); + expect(entry.error.errorDetail.deps?.includes(`${testRealm}boom`)).toBeTruthy(); + } + else { + expect(false).toBeTruthy(); + } + } + { + let entry = await realm.realmIndexQueryEngine.cardDocument(new URL(`${testRealm}boom2`)); + if (entry?.type === 'error') { + expect(/Attempted to resolve a modifier in a strict mode template, but that value was not in scope: did-insert/.test(entry.error.errorDetail.message)).toBeTruthy(); + expect(entry.error.errorDetail.deps?.includes(`${testRealm}boom2`)).toBeTruthy(); + } + else { + expect(false).toBeTruthy(); + } + } + { + let entry = await realm.realmIndexQueryEngine.cardDocument(new URL(`${testRealm}vangogh`)); + if (entry?.type === 'doc') { + expect(entry.doc.data.attributes?.firstName).toEqual('Van Gogh'); + let item = await realm.realmIndexQueryEngine.instance(new URL(`${testRealm}vangogh`)); + if (item?.type === 'instance') { + expect(trimCardContainer(stripScopedCSSAttributes(item.isolatedHtml!))).toBe(cleanWhiteSpace(`<h1> Van Gogh $50</h1>`)); + expect(trimCardContainer(stripScopedCSSAttributes(item.embeddedHtml![`${testRealm}person/Person`]!))).toBe(cleanWhiteSpace(`<h1> Embedded Card Person: Van Gogh </h1>`)); + expect(trimCardContainer(stripScopedCSSAttributes(item.fittedHtml![`${testRealm}person/Person`]!))).toBe(cleanWhiteSpace(`<h1> Fitted Card Person: Van Gogh </h1>`)); + } + else { + expect(false).toBeTruthy(); + } + } + else { + expect(false).toBeTruthy(); + } + } + }); + it('can make an error doc for a card that has a link to a URL that is not a card', async function () { + let entry = await realm.realmIndexQueryEngine.cardDocument(new URL(`${testRealm}bad-link`)); + if (entry?.type === 'error') { + expect(entry.error.errorDetail.message).toBe('unable to fetch http://localhost:9000/this-is-a-link-to-nowhere: fetch failed'); + let actualDeps = (entry.error.errorDetail.deps ?? []).map((d) => d.endsWith('.json') ? d.slice(0, -5) : d); + expect(actualDeps.includes(`${testRealm}post`)).toBeTruthy(); + expect(actualDeps.includes(`http://localhost:9000/this-is-a-link-to-nowhere`)).toBeTruthy(); + } + else { + expect(false).toBeTruthy(); + } + }); + // Note this particular test should only be a server test as the nature of + // the TestAdapter in the host tests will trigger the linked card to be + // already loaded when in fact in the real world it is not. + it('it can index a card with a contains computed that consumes a linksTo field', async function () { + const hassanId = `${testRealm}hassan`; + let queryEngine = realm.realmIndexQueryEngine; + let hassan = await queryEngine.cardDocument(new URL(hassanId)); + if (hassan?.type === 'doc') { + expect(hassan.doc.data.attributes).toEqual({ + cardTitle: 'Untitled Card', + nickName: "Ringo's buddy", + firstName: 'Hassan', + cardDescription: null, + cardThumbnailURL: null, + cardInfo, + }); + expect(hassan.doc.data.relationships).toEqual({ + pet: { + links: { + self: './ringo', + }, + }, + 'cardInfo.theme': { + links: { + self: null, + }, + }, + }); + } + else { + expect(false).toBeTruthy(); + } + let hassanEntry = await getInstance(realm, new URL(`${testRealm}hassan`)); + if (hassanEntry) { + expect(hassanEntry.searchDoc).toEqual({ + id: hassanId, + pet: { + id: `${testRealm}ringo`, + cardTitle: 'Untitled Card', + firstName: 'Ringo', + cardInfo: { + theme: null, + }, + }, + nickName: "Ringo's buddy", + _cardType: 'PetPerson', + firstName: 'Hassan', + cardTitle: 'Untitled Card', + cardInfo: { + theme: null, + }, + }); + } + else { + expect(false).toBeTruthy(); + } + }); + it('it can index a card whose adoptsFrom is a fieldOf CodeRef', async function () { + const cardId = `${testRealm}fieldof-address`; + let entry = await getInstance(realm, new URL(cardId)); + if (entry) { + expect(entry.searchDoc?.street).toBe('123 Main St'); + expect(entry.searchDoc?.city).toBe('Anytown'); + } + else { + expect(false).toBeTruthy(); + } + }); + it('sets resource_created_at for files and instances', async function () { + let entry = await realm.realmIndexQueryEngine.file(new URL(`${testRealm}fancy-person.gts`)); + expect(entry?.resourceCreatedAt).toBeTruthy(); + let instance = (await realm.realmIndexQueryEngine.instance(new URL(`${testRealm}mango`))) as { + resourceCreatedAt: number; + }; + expect(instance!.resourceCreatedAt).toBeTruthy(); + }); + it('sets urls containing encoded CSS for deps for an instance', async function () { + await realm.write('fancy.json', JSON.stringify({ + data: { + attributes: { + firstName: 'Fancy', + }, + meta: { + adoptsFrom: { + module: './fancy-person', + name: 'FancyPerson', + }, + }, + }, + })); + let entry = await realm.realmIndexQueryEngine.instance(new URL(`${testRealm}fancy`)); + expect(entry?.type).toBe('instance'); + let deps = entry?.type === 'instance' ? (entry.deps ?? []) : []; + let assertCssDependency = (deps: string[], pattern: RegExp, fileName: string) => { + expect(!!deps.find((dep) => pattern.test(dep))).toBe(true); + }; + let dependencies = [ + { + pattern: /fancy-person\.gts.*\.glimmer-scoped\.css$/, + fileName: 'fancy-person.gts', + }, + { + pattern: /\/person\.gts.*\.glimmer-scoped\.css$/, + fileName: 'person.gts', + }, + { + pattern: /cardstack.com\/base\/default-templates\/embedded\.gts.*\.glimmer-scoped\.css$/, + fileName: 'default-templates/embedded.gts', + }, + { + pattern: /cardstack.com\/base\/default-templates\/isolated-and-edit\.gts.*\.glimmer-scoped\.css$/, + fileName: 'default-templates/isolated-and-edit.gts', + }, + { + pattern: /cardstack.com\/base\/default-templates\/missing-template\.gts.*\.glimmer-scoped\.css$/, + fileName: 'default-templates/missing-template.gts', + }, + { + pattern: /cardstack.com\/base\/default-templates\/field-edit\.gts.*\.glimmer-scoped\.css$/, + fileName: 'default-templates/field-edit.gts', + }, + { + pattern: /cardstack.com\/base\/links-to-many-component.gts.*\.glimmer-scoped\.css$/, + fileName: 'links-to-many-component.gts', + }, + { + pattern: /cardstack.com\/base\/links-to-editor.gts.*\.glimmer-scoped\.css$/, + fileName: 'links-to-editor.gts', + }, + { + pattern: /cardstack.com\/base\/contains-many-component.gts.*\.glimmer-scoped\.css$/, + fileName: 'contains-many-component.gts', + }, + { + pattern: /cardstack.com\/base\/field-component.gts.*\.glimmer-scoped\.css$/, + fileName: 'field-component.gts', + }, + ]; + dependencies.forEach(({ pattern, fileName }) => { + assertCssDependency(deps, pattern, fileName); + }); + }); + it('will not invalidate non-json/non-executable files', async function () { + let deletedEntries = (await testDbAdapter.execute(`SELECT url FROM boxel_index WHERE is_deleted = TRUE`)) as { + url: string; + }[]; + let deletedEntryUrls = deletedEntries.map((row) => row.url); + ['random-file.txt', 'random-image.png', '.DS_Store'].forEach((file) => { + expect(deletedEntryUrls.includes(file)).toBeFalsy(); + }); + }); + it('indexes non-card files as file entries', async function () { + let rows = (await testDbAdapter.execute(`SELECT url, type, last_modified FROM boxel_index WHERE url = '${testRealm}random-file.txt'`)) as { + url: string; + type: string; + last_modified: string | null; + }[]; + expect(rows.length).toBe(1); + expect(rows[0].type).toBe('file'); + expect(rows[0].last_modified).toBeTruthy(); + }); + it('indexes files with emoji in filename', async function () { + let entry = await realm.realmIndexQueryEngine.file(new URL(`${testRealm}%F0%9F%8E%89hello.txt`)); + expect(entry).toBeTruthy(); + expect(entry?.type).toBe('file'); + expect(entry?.searchDoc?.name).toBe('🎉hello.txt'); + expect(entry?.searchDoc?.contentType).toBe('text/plain'); + expect(entry?.searchDoc?.contentHash).toBeTruthy(); + expect(typeof entry?.searchDoc?.contentSize).toBe('number'); + }); + it('indexes executable files as file entries too', async function () { + let entry = await realm.realmIndexQueryEngine.file(new URL(`${testRealm}person.gts`)); + expect(entry).toBeTruthy(); + expect(entry?.searchDoc?.name).toBe('person.gts'); + expect(entry?.searchDoc?.contentType).toBe('text/typescript+glimmer'); + }); + it('indexes card json resources as file entries too', async function () { + let entry = await realm.realmIndexQueryEngine.file(new URL(`${testRealm}mango.json`)); + expect(entry).toBeTruthy(); + expect(entry?.searchDoc?.name).toBe('mango.json'); + expect(entry?.searchDoc?.contentHash).toBeTruthy(); + expect(typeof entry?.searchDoc?.contentSize).toBe('number'); + }); + it('keeps instance entries when indexing card json files as file entries', async function () { + let instanceEntry = await getInstance(realm, new URL(`${testRealm}mango`)); + let fileEntry = await realm.realmIndexQueryEngine.file(new URL(`${testRealm}mango.json`)); + expect(instanceEntry).toBeTruthy(); + expect(fileEntry).toBeTruthy(); + expect(instanceEntry?.canonicalURL).toBe(fileEntry?.canonicalURL); + expect(instanceEntry?.type).toBe('instance'); + expect(fileEntry?.type).toBe('file'); + }); + it('file extractor populates search_doc', async function () { + let rows = (await testDbAdapter.execute(`SELECT search_doc FROM boxel_index WHERE url = '${testRealm}random-file.txt'`)) as { + search_doc: Record<string, unknown> | string | null; + }[]; + let raw = rows[0]?.search_doc; + let searchDoc = typeof raw === 'string' ? JSON.parse(raw) : (raw ?? {}); + expect(searchDoc.name).toBe('random-file.txt'); + expect(searchDoc.contentType).toBe('text/plain'); + expect(searchDoc.contentHash).toBeTruthy(); + expect(typeof searchDoc.contentSize).toBe('number'); + }); + it('file extractor mismatch falls back to base extractor', async function () { + let rows = (await testDbAdapter.execute(`SELECT search_doc, deps FROM boxel_index WHERE url = '${testRealm}random-file.mismatch'`)) as { + search_doc: Record<string, unknown> | string | null; + deps: string[] | string | null; + }[]; + let rawDoc = rows[0]?.search_doc; + let searchDoc = typeof rawDoc === 'string' ? JSON.parse(rawDoc) : (rawDoc ?? {}); + expect(searchDoc.name).toBe('random-file.mismatch'); + expect(searchDoc.contentHash).toBeTruthy(); + expect(typeof searchDoc.contentSize).toBe('number'); + let rawDeps = rows[0]?.deps ?? []; + let deps = Array.isArray(rawDeps) + ? rawDeps + : typeof rawDeps === 'string' + ? JSON.parse(rawDeps) + : []; + expect(deps.includes(`${testRealm}filedef-mismatch`)).toBeTruthy(); + expect(deps.includes('https://cardstack.com/base/file-api')).toBeTruthy(); + }); + it('serves FileMeta from index entries', async function () { + // Mutate the index row so we can validate that the response must come from the index, + // not from filesystem metadata. + await testDbAdapter.execute(`UPDATE boxel_index SET search_doc = '{"name":"from-index.txt","contentType":"application/x-index-test"}'::jsonb, pristine_doc = '{"id":"${testRealm}random-file.txt","type":"file-meta","attributes":{"name":"from-pristine.txt","contentType":"application/x-pristine","custom":"present"},"meta":{"adoptsFrom":{"module":"https://cardstack.com/base/file-api","name":"FileDef"}}}'::jsonb WHERE url = '${testRealm}random-file.txt'`); + let response = await fetch(`${testRealm}random-file.txt`, { + headers: { Accept: SupportedMimeType.FileMeta }, + }); + expect(response.status).toBe(200); + let doc = (await response.json()) as LooseSingleCardDocument; + expect(doc.data.id).toBe(`${testRealm}random-file.txt`); + expect(doc.data.type).toBe('file-meta'); + expect(doc.data.attributes?.name).toBe('from-pristine.txt'); + expect(doc.data.attributes?.contentType).toBe('application/x-pristine'); + expect(doc.data.attributes?.custom).toBe('present'); + expect(doc.data.meta?.adoptsFrom).toEqual({ + module: 'https://cardstack.com/base/text-file-def', + name: 'TextFileDef', + }); + expect(doc.data.attributes?.lastModified).toBeTruthy(); + }); + it('file meta adoptsFrom prefers index types', async function () { + let fileDefModule = new URL('filedef-mismatch', testRealm).href; + let fileDefKey = internalKeyFor({ module: fileDefModule, name: 'FileDef' }, undefined); + await testDbAdapter.execute(`UPDATE boxel_index SET types = '${JSON.stringify([ + fileDefKey, + ])}'::jsonb, pristine_doc = NULL WHERE url = '${testRealm}random-file.txt'`); + let response = await fetch(`${testRealm}random-file.txt`, { + headers: { Accept: SupportedMimeType.FileMeta }, + }); + expect(response.status).toBe(200); + let doc = (await response.json()) as LooseSingleCardDocument; + let adoptsFrom = doc.data.meta?.adoptsFrom as { + module?: string; + name?: string; + } | undefined; + expect(adoptsFrom?.module).toBe(fileDefModule); + expect(adoptsFrom?.name).toBe('FileDef'); + }); + describe('permissioned realm', function () { + let testRealm1URL = 'http://127.0.0.1:4447/test/'; + let testRealm2URL = 'http://127.0.0.1:4448/test/'; + let permissionedDbAdapter: PgAdapter; + function setupRealms(hooks: NestedHooks, permissions: { + consumer: RealmPermissions; + provider: RealmPermissions; + }) { + setupPermissionedRealmsCached(hooks, { + mode: 'before', + // provider + realms: [ + { + realmURL: testRealm1URL, + permissions: permissions.provider, + fileSystem: { + 'article.gts': ` + import { contains, field, CardDef, Component } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + export class Article extends CardDef { + @field title = contains(StringField); + } + `, + }, + }, + // consumer + { + realmURL: testRealm2URL, + permissions: permissions.consumer, + fileSystem: { + 'website.gts': ` + import { contains, field, CardDef, linksTo } from "https://cardstack.com/base/card-api"; + import { Article } from "${testRealm1URL}article" // importing from another realm; + export class Website extends CardDef { + @field linkedArticle = linksTo(Article); + }`, + 'website-1.json': { + data: { + attributes: {}, + meta: { + adoptsFrom: { + module: './website', + name: 'Website', + }, + }, + }, + }, + }, + }, + ], + onRealmSetup({ dbAdapter }) { + permissionedDbAdapter = dbAdapter; + }, + }); + } + describe('readable realm', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupRealms(hooks, { + provider: { + ['@node-test_realm:localhost']: ['read'], + }, + consumer: { + '*': ['read', 'write'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + }); + it('indexes a card from another realm when it has permission to read', async function () { + let rows = (await permissionedDbAdapter.execute(`SELECT type, has_error + FROM boxel_index + WHERE realm_url = $1 + AND (is_deleted = FALSE OR is_deleted IS NULL)`, { bind: [testRealm2URL] })) as { + type: string; + has_error: boolean | null; + }[]; + let fileRows = rows.filter((row) => row.type === 'file'); + let instanceRows = rows.filter((row) => row.type === 'instance'); + expect(rows.every((row) => !row.has_error)).toBe(true); + expect(fileRows.length).toBe(2); + expect(instanceRows.length).toBe(1); + expect(rows.length).toBe(3); + }); + }); + describe('un-readable realm', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupRealms(hooks, { + provider: { + nobody: ['read', 'write'], // Consumer's matrix user not authorized to read from provider + }, + consumer: { + '*': ['read', 'write'], + }, + }); + it('surfaces instance errors when lacking permission to read from another realm', async function () { + // Error during indexing will be: "Authorization error: Insufficient + // permissions to perform this action" + let rows = (await permissionedDbAdapter.execute(`SELECT type, has_error + FROM boxel_index + WHERE realm_url = $1 + AND (is_deleted = FALSE OR is_deleted IS NULL)`, { bind: [testRealm2URL] })) as { + type: string; + has_error: boolean | null; + }[]; + let instanceRows = rows.filter((row) => row.type === 'instance'); + let erroredInstanceRows = instanceRows.filter((row) => Boolean(row.has_error)); + expect(erroredInstanceRows.length).toBe(1); + expect(instanceRows.length - erroredInstanceRows.length).toBe(0); + }); + }); + }); + }); + describe('indexing (mutating)', function () { + function hasErrorDetail(error: { + message?: string; + additionalErrors?: { + message?: string; + }[] | null; + }, needle: string): boolean { + let additionalErrors = Array.isArray(error.additionalErrors) + ? error.additionalErrors + : []; + return (String(error.message ?? '').includes(needle) || + additionalErrors.some((additionalError) => String(additionalError.message ?? '').includes(needle))); + } + describe('batch and incremental operations', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let realm: Realm; + let queuePublisher: QueuePublisher; + let queueRunner: QueueRunner; + let testRealmServer: TestRealmServerResult | undefined; + setupPermissionedRealmCached(hooks, { + mode: 'beforeEach', + realmURL: testRealm, + permissions: { + '*': ['read'], + }, + fileSystem: makeTestRealmFileSystem(), + onRealmSetup({ dbAdapter, publisher, runner, testRealmServer: server, testRealm: r, }) { + testDbAdapter = dbAdapter; + queuePublisher = publisher; + queueRunner = runner; + testRealmServer = server; + realm = r; + }, + }); + async function startIndexingGroupBlocker() { + let started = new Deferred<void>(); + let release = new Deferred<void>(); + queueRunner.register('blocking-indexing-group', async () => { + started.fulfill(); + await release.promise; + return null; + }); + let blocker = await queuePublisher.publish<void>({ + jobType: 'blocking-indexing-group', + concurrencyGroup: `indexing:${realm.url}`, + timeout: 30, + args: null, + }); + await started.promise; + return { blocker, release }; + } + it('batch invalidation resolves alias-like seeds via file_alias matching', async function () { + let batch = await new IndexWriter(testDbAdapter).createBatch(new URL(realm.url)); + await batch.invalidate([new URL(`${testRealm}mango`)]); + expect(batch.invalidations.includes(`${testRealm}mango.json`)).toBeTruthy(); + let jsonSeedBatch = await new IndexWriter(testDbAdapter).createBatch(new URL(realm.url)); + await jsonSeedBatch.invalidate([new URL(`${testRealm}mango.json`)]); + expect(jsonSeedBatch.invalidations.includes(`${testRealm}mango.json`)).toBeTruthy(); + }); + it('batch invalidation resolves alias-like seeds from staged working rows', async function () { + let stagedOnlyURL = new URL(`${testRealm}staged-only.json`); + let stagedAliasURL = new URL(`${testRealm}staged-only`); + let stagingBatch = await new IndexWriter(testDbAdapter).createBatch(new URL(realm.url)); + await stagingBatch.updateEntry(stagedOnlyURL, { + type: 'file', + deps: new Set<string>(), + lastModified: Date.now(), + resourceCreatedAt: Date.now(), + }); + let invalidationBatch = await new IndexWriter(testDbAdapter).createBatch(new URL(realm.url)); + await invalidationBatch.invalidate([stagedAliasURL]); + expect(invalidationBatch.invalidations.includes(stagedOnlyURL.href)).toBeTruthy(); + }); + it('batch invalidation tombstones all rows that share a matching file_alias', async function () { + let batch = await new IndexWriter(testDbAdapter).createBatch(new URL(realm.url)); + await batch.invalidate([new URL(`${testRealm}mango`)]); + await batch.done(); + let rows = (await testDbAdapter.execute(`SELECT type, is_deleted + FROM boxel_index + WHERE realm_url = $1 + AND url = $2 + AND type IN ('instance', 'file') + ORDER BY type`, { + bind: [realm.url, `${testRealm}mango.json`], + })) as { + type: 'instance' | 'file'; + is_deleted: boolean; + }[]; + expect(rows.map((row) => row.type)).toEqual(['file', 'instance']); + expect(rows.every((row) => row.is_deleted === true)).toBe(true); + }); + it('can incrementally index updated instance', async function () { + await realm.write('mango.json', JSON.stringify({ + data: { + attributes: { + firstName: 'Mang-Mang', + }, + meta: { + adoptsFrom: { + module: './person.gts', + name: 'Person', + }, + }, + }, + } as LooseSingleCardDocument)); + let { data: result } = await realm.realmIndexQueryEngine.searchCards({ + filter: { + on: { module: `${testRealm}person`, name: 'Person' }, + eq: { firstName: 'Mang-Mang' }, + }, + }); + expect(result.length).toBe(1); + expect(realm.realmIndexUpdater.stats.instancesIndexed).toBe(1); + }); + it('burst incremental updates coalesce into one pending canonical job payload', async function () { + let { blocker, release } = await startIndexingGroupBlocker(); + try { + let update1 = realm.realmIndexUpdater.update([new URL(`${testRealm}mango`)], { + clientRequestId: 'burst-1', + }); + let update2 = realm.realmIndexUpdater.update([new URL(`${testRealm}vangogh`), new URL(`${testRealm}post-1`)], { clientRequestId: 'burst-2' }); + let row = (await waitUntil(async () => { + let rows = (await testDbAdapter.execute(`SELECT id, priority, args + FROM jobs + WHERE job_type = 'incremental-index' + AND concurrency_group = $1 + AND status = 'unfulfilled'`, { bind: [`indexing:${realm.url}`] })) as { + id: number; + priority: number; + args: { + changes: { + url: string; + operation: 'update' | 'delete'; + }[]; + }; + }[]; + return rows.length === 1 ? rows[0] : undefined; + }, { + timeout: 3000, + interval: 50, + timeoutMessage: 'expected exactly one pending incremental canonical job', + })) as { + id: number; + priority: number; + args: { + changes: { + url: string; + operation: 'update' | 'delete'; + }[]; + }; + }; + let urls = row.args.changes.map((change) => change.url).sort(); + expect(urls).toEqual([`${testRealm}mango`, `${testRealm}post-1`, `${testRealm}vangogh`]); + expect(row.priority).toBe(userInitiatedPriority); + release.fulfill(); + await Promise.all([blocker.done, update1, update2]); + } + finally { + release.fulfill(); + } + }); + it('mixed incremental operations coalesce with delete dominance in pending canonical payload', async function () { + let { blocker, release } = await startIndexingGroupBlocker(); + try { + let update = realm.realmIndexUpdater.update([new URL(`${testRealm}mango`)], { + clientRequestId: 'mixed-update', + }); + let remove = realm.realmIndexUpdater.update([new URL(`${testRealm}mango`)], { + delete: true, + clientRequestId: 'mixed-delete', + }); + let row = (await waitUntil(async () => { + let rows = (await testDbAdapter.execute(`SELECT args + FROM jobs + WHERE job_type = 'incremental-index' + AND concurrency_group = $1 + AND status = 'unfulfilled'`, { bind: [`indexing:${realm.url}`] })) as { + args: { + changes: { + url: string; + operation: 'update' | 'delete'; + }[]; + }; + }[]; + return rows.length === 1 ? rows[0] : undefined; + }, { + timeout: 3000, + interval: 50, + timeoutMessage: 'expected one pending incremental job during mixed-op burst', + })) as { + args: { + changes: { + url: string; + operation: 'update' | 'delete'; + }[]; + }; + }; + let operationByUrl = new Map(row.args.changes.map((change) => [change.url, change.operation])); + expect(operationByUrl.get(`${testRealm}mango`)).toBe('delete'); + release.fulfill(); + await Promise.all([blocker.done, update, remove]); + } + finally { + release.fulfill(); + } + }); + it('pending incremental followed by full index keeps separate pending jobs by type', async function () { + let { blocker, release } = await startIndexingGroupBlocker(); + try { + let incremental = realm.realmIndexUpdater.update([new URL(`${testRealm}mango`)], { clientRequestId: 'mixed-types-incremental' }); + let full = realm.realmIndexUpdater.fullIndex(); + let rows = (await waitUntil(async () => { + let rows = (await testDbAdapter.execute(`SELECT job_type + FROM jobs + WHERE concurrency_group = $1 + AND status = 'unfulfilled' + AND job_type IN ('incremental-index', 'from-scratch-index')`, { bind: [`indexing:${realm.url}`] })) as { + job_type: string; + }[]; + return rows.length === 2 ? rows : undefined; + }, { + timeout: 3000, + interval: 50, + timeoutMessage: 'expected separate pending incremental/from-scratch jobs', + })) as { + job_type: string; + }[]; + expect(rows.map((row) => row.job_type).sort()).toEqual(['from-scratch-index', 'incremental-index']); + release.fulfill(); + await Promise.all([blocker.done, incremental, full]); + } + finally { + release.fulfill(); + } + }); + it('realm.indexing waits for all queued indexing operations', async function () { + let { blocker, release } = await startIndexingGroupBlocker(); + try { + let incremental = realm.realmIndexUpdater.update([new URL(`${testRealm}mango`)], { clientRequestId: 'indexing-race-incremental' }); + let indexingDuringIncremental = realm.indexing(); + let full = realm.realmIndexUpdater.fullIndex(); + let indexingAfterFull = realm.indexing(); + let indexingDuringIncrementalResolved = false; + let indexingAfterFullResolved = false; + indexingDuringIncremental?.then(() => { + indexingDuringIncrementalResolved = true; + }); + indexingAfterFull?.then(() => { + indexingAfterFullResolved = true; + }); + expect(indexingDuringIncremental).toBeTruthy(); + expect(indexingAfterFull).toBeTruthy(); + release.fulfill(); + await Promise.all([ + blocker.done, + incremental, + full, + indexingDuringIncremental, + indexingAfterFull, + ]); + expect(indexingDuringIncrementalResolved).toBe(true); + expect(indexingAfterFullResolved).toBe(true); + } + finally { + release.fulfill(); + } + }); + it('burst full-index requests dedupe to one pending canonical from-scratch job', async function () { + let { blocker, release } = await startIndexingGroupBlocker(); + try { + let full1 = realm.realmIndexUpdater.fullIndex(); + let full2 = realm.realmIndexUpdater.fullIndex(); + let row = (await waitUntil(async () => { + let rows = (await testDbAdapter.execute(`SELECT id, job_type + FROM jobs + WHERE concurrency_group = $1 + AND status = 'unfulfilled' + AND job_type = 'from-scratch-index'`, { bind: [`indexing:${realm.url}`] })) as { + id: number; + job_type: string; + }[]; + return rows.length === 1 ? rows[0] : undefined; + }, { + timeout: 3000, + interval: 50, + timeoutMessage: 'expected one pending canonical from-scratch job during full-index burst', + })) as { + id: number; + job_type: string; + }; + expect(row.job_type).toBe('from-scratch-index'); + release.fulfill(); + await Promise.all([blocker.done, full1, full2]); + } + finally { + release.fulfill(); + } + }); + it('can recover from a card error after error is removed from card source', async function () { + // introduce errors into 2 cards and observe that invalidation doesn't + // blindly invalidate all cards are in an error state + await realm.write('pet.gts', ` + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + export class Pet extends CardDef { + @field firstName = contains(StringField); + } + throw new Error('boom!'); + `); + await realm.write('person.gts', ` + // syntax error + export class Intentionally Thrown Error {} + `); + let { data: result } = await realm.realmIndexQueryEngine.searchCards({ + filter: { + type: { module: `${testRealm}person`, name: 'Person' }, + }, + }); + expect(result).toEqual([]); + await realm.write('person.gts', ` + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Person extends CardDef { + @field firstName = contains(StringField); + } + `); + result = (await realm.realmIndexQueryEngine.searchCards({ + filter: { + type: { module: `${testRealm}person`, name: 'Person' }, + }, + })).data; + expect(result.length).toBe(2); + }); + it('expands file deps using module cache for file defs', async function () { + await realm.write('filedef-helper.gts', ` + export function buildName(name: string) { + return name.toUpperCase(); + } + `); + await realm.write('filedef-mismatch.gts', ` + import { FileDef as BaseFileDef } from "https://cardstack.com/base/file-api"; + import { buildName } from "./filedef-helper"; + + export class FileDef extends BaseFileDef { + static async extractAttributes(url: string) { + let name = new URL(url).pathname.split('/').pop() ?? url; + return { name: buildName(name) }; + } + } + `); + let visibility = await realm.visibility(); + expect(visibility).toBe('public'); + let fileDefAlias = `${testRealm}filedef-mismatch`; + let helperUrl = `${testRealm}filedef-helper`; + let definitionLookup = (testRealmServer?.testRealmServer as any) + ?.definitionLookup as DefinitionLookup | undefined; + if (definitionLookup) { + await definitionLookup.lookupDefinition({ + module: fileDefAlias, + name: 'FileDef', + }); + } + else { + expect(false).toBeTruthy(); + } + let moduleRows = (await testDbAdapter.execute(`SELECT url, file_alias, deps, cache_scope, auth_user_id, resolved_realm_url + FROM modules + WHERE url = $1 OR file_alias = $1`, { + bind: [fileDefAlias], + coerceTypes: { deps: 'JSON' }, + })) as { + url: string; + file_alias: string | null; + deps: string[] | string | null; + cache_scope: string | null; + auth_user_id: string | null; + resolved_realm_url: string | null; + }[]; + expect(moduleRows.length > 0).toBeTruthy(); + expect(moduleRows[0]?.url).toBe(`${fileDefAlias}.gts`); + expect(moduleRows[0]?.file_alias).toBe(fileDefAlias); + let moduleDeps = moduleRows[0]?.deps; + expect(Array.isArray(moduleDeps)).toBeTruthy(); + expect(moduleDeps?.includes(helperUrl)).toBeTruthy(); + expect(moduleRows[0]?.cache_scope).toBe('public'); + expect(moduleRows[0]?.auth_user_id).toBe(''); + expect(moduleRows[0]?.resolved_realm_url).toBe(`${testRealm}`); + let moduleQueryRows = (await testDbAdapter.execute(`SELECT url FROM modules + WHERE resolved_realm_url = $1 + AND cache_scope = $2 + AND auth_user_id = $3 + AND (url = $4 OR file_alias = $4)`, { + bind: [`${testRealm}`, 'public', '', fileDefAlias], + })) as { + url: string; + }[]; + expect(moduleQueryRows.length > 0).toBeTruthy(); + if (definitionLookup) { + let moduleEntries = await definitionLookup.getModuleCacheEntries({ + moduleUrls: [fileDefAlias], + cacheScope: 'public', + authUserId: '', + resolvedRealmURL: `${testRealm}`, + }); + expect(moduleEntries[fileDefAlias]).toBeTruthy(); + } + else { + expect(false).toBeTruthy(); + } + await realm.write('random-file.mismatch', 'mismatch content updated'); + let rows = (await testDbAdapter.execute(`SELECT deps FROM boxel_index WHERE url = '${testRealm}random-file.mismatch' AND type = 'file'`)) as { + deps: string[] | string | null; + }[]; + let rawDeps = rows[0]?.deps ?? []; + let deps = Array.isArray(rawDeps) + ? rawDeps + : typeof rawDeps === 'string' + ? JSON.parse(rawDeps) + : []; + expect(deps.includes(`${testRealm}filedef-mismatch`)).toBeTruthy(); + expect(deps.includes(`${testRealm}filedef-helper`)).toBeTruthy(); + }); + it('propagates module errors to dependent instances and recovers after missing modules are added', async function () { + await testDbAdapter.execute('DELETE FROM modules'); + await realm.write('deep-card.json', JSON.stringify({ + data: { + attributes: { + middle: { + leaf: { + value: 'Root', + }, + }, + }, + meta: { + adoptsFrom: { + module: './deep-card', + name: 'DeepCard', + }, + }, + }, + } as LooseSingleCardDocument)); + let brokenInstance = await realm.realmIndexQueryEngine.instance(new URL(`${testRealm}deep-card`)); + expect(brokenInstance?.type).toBe('instance-error'); + if (brokenInstance?.type === 'instance-error') { + expect(brokenInstance.error.deps?.includes(`${testRealm}deep-card`)).toBeTruthy(); + } + else { + expect(false).toBeTruthy(); + } + await realm.write('deep-card.gts', ` + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import { MiddleField } from "./middle-field"; + + export class DeepCard extends CardDef { + @field middle = contains(MiddleField); + } + `); + brokenInstance = await realm.realmIndexQueryEngine.instance(new URL(`${testRealm}deep-card`)); + expect(brokenInstance?.type).toBe('instance-error'); + if (brokenInstance?.type === 'instance-error') { + let additionalErrors = Array.isArray(brokenInstance.error.additionalErrors) + ? brokenInstance.error.additionalErrors + : []; + expect(additionalErrors.some((error: { + message?: string; + }) => String(error.message ?? '').includes('middle-field'))).toBeTruthy(); + } + else { + expect(false).toBeTruthy(); + } + try { + await realm.realmIndexQueryEngine.searchCards({ + filter: { + on: { module: `${testRealm}deep-card`, name: 'DeepCard' }, + eq: { 'middle.leaf.value': 'Root' }, + }, + }); + } + catch (_error) { + // definition lookup errors are expected while dependencies are missing + } + let definitionLookup = (testRealmServer?.testRealmServer as any) + ?.definitionLookup as DefinitionLookup | undefined; + if (!definitionLookup) { + expect(false).toBeTruthy(); + } + else { + let deepModuleEntry = await definitionLookup.getModuleCacheEntry(`${testRealm}deep-card`); + expect(deepModuleEntry?.error?.type).toBe('module-error'); + if (deepModuleEntry?.error?.error) { + let additionalErrors = Array.isArray(deepModuleEntry.error.error.additionalErrors) + ? deepModuleEntry.error.error.additionalErrors + : []; + expect(additionalErrors.some((error: { + message?: string; + }) => String(error.message ?? '').includes('middle-field'))).toBeTruthy(); + } + else { + expect(false).toBeTruthy(); + } + await realm.write('middle-field.gts', ` + import { contains, field, FieldDef } from "https://cardstack.com/base/card-api"; + import { LeafField } from "./leaf-field"; + + export class MiddleField extends FieldDef { + @field leaf = contains(LeafField); + } + `); + try { + await definitionLookup.lookupDefinition({ + module: `${testRealm}middle-field`, + name: 'MiddleField', + }); + } + catch (_error) { + // expected while dependencies are missing + } + brokenInstance = await realm.realmIndexQueryEngine.instance(new URL(`${testRealm}deep-card`)); + expect(brokenInstance?.type).toBe('instance-error'); + if (brokenInstance?.type === 'instance-error') { + let additionalErrors = Array.isArray(brokenInstance.error.additionalErrors) + ? brokenInstance.error.additionalErrors + : []; + expect(additionalErrors.some((error: { + message?: string; + }) => String(error.message ?? '').includes('leaf-field'))).toBeTruthy(); + } + else { + expect(false).toBeTruthy(); + } + try { + await realm.realmIndexQueryEngine.searchCards({ + filter: { + on: { module: `${testRealm}deep-card`, name: 'DeepCard' }, + eq: { 'middle.leaf.value': 'Root' }, + }, + }); + } + catch (_error) { + // definition lookup errors are expected while dependencies are missing + } + deepModuleEntry = await definitionLookup.getModuleCacheEntry(`${testRealm}deep-card`); + if (deepModuleEntry?.error?.error) { + let additionalErrors = Array.isArray(deepModuleEntry.error.error.additionalErrors) + ? deepModuleEntry.error.error.additionalErrors + : []; + expect(additionalErrors.some((error: { + message?: string; + }) => String(error.message ?? '').includes('leaf-field'))).toBeTruthy(); + } + else { + expect(false).toBeTruthy(); + } + let middleModuleEntry = await definitionLookup.getModuleCacheEntry(`${testRealm}middle-field`); + expect(middleModuleEntry?.error?.type).toBe('module-error'); + if (middleModuleEntry?.error?.error) { + let additionalErrors = Array.isArray(middleModuleEntry.error.error.additionalErrors) + ? middleModuleEntry.error.error.additionalErrors + : []; + expect(additionalErrors.some((error: { + message?: string; + }) => String(error.message ?? '').includes('leaf-field'))).toBeTruthy(); + } + else { + expect(false).toBeTruthy(); + } + } + await realm.write('leaf-field.gts', ` + import { contains, field, FieldDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class LeafField extends FieldDef { + @field value = contains(StringField); + } + `); + let healedInstance = await realm.realmIndexQueryEngine.instance(new URL(`${testRealm}deep-card`)); + expect(healedInstance?.type).toBe('instance'); + let rows = (await testDbAdapter.execute(`SELECT error_doc IS NULL AS is_sql_null + FROM boxel_index + WHERE realm_url = '${testRealm}' + AND ( + url = '${testRealm}deep-card.json' + OR file_alias = '${testRealm}deep-card' + ) + AND type = 'instance'`)) as { + is_sql_null: boolean; + }[]; + expect(rows.length).toBe(1); + expect(rows[0].is_sql_null).toBe(true); + }); + }); + describe('additive writes', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let realm: Realm; + let testRealmServer: TestRealmServerResult | undefined; + async function depsFor(url: string, type: 'instance' | 'file' = 'instance'): Promise<string[]> { + return depsForIndexEntry(testDbAdapter, url, type); + } + async function indexedAtFor(url: string, type: 'instance' | 'file' = 'instance'): Promise<string | null> { + return indexedAtForIndexEntry(testDbAdapter, url, type); + } + setupPermissionedRealmCached(hooks, { + mode: 'before', + realmURL: testRealm, + permissions: { + '*': ['read'], + }, + fileSystem: makeTestRealmFileSystem(), + onRealmSetup({ dbAdapter, testRealmServer: server, testRealm: r }) { + testDbAdapter = dbAdapter; + testRealmServer = server; + realm = r; + }, + }); + it('propagates module cache errors through intermediate modules to instances', async function () { + await realm.write('module-b.gts', ` + export const value = (() => { + throw new Error('module-b exploded'); + })(); + `); + await realm.write('module-a.gts', ` + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + import { value } from "./module-b"; + + export class ModuleCard extends CardDef { + static moduleBValue = value; + @field title = contains(StringField); + } + `); + await realm.write('module-a.json', JSON.stringify({ + data: { + attributes: { + title: 'Hello', + }, + meta: { + adoptsFrom: { + module: './module-a', + name: 'ModuleCard', + }, + }, + }, + } as LooseSingleCardDocument)); + let definitionLookup = (testRealmServer?.testRealmServer as any) + ?.definitionLookup as DefinitionLookup | undefined; + if (definitionLookup) { + let moduleBEntry = await definitionLookup.getModuleCacheEntry(`${testRealm}module-b`); + expect(moduleBEntry?.error?.type).toBe('module-error'); + if (moduleBEntry?.error?.error) { + expect(String(moduleBEntry.error.error.message ?? '').includes('module-b exploded')).toBeTruthy(); + } + else { + expect(false).toBeTruthy(); + } + let moduleAEntry = await definitionLookup.getModuleCacheEntry(`${testRealm}module-a`); + expect(moduleAEntry?.error?.type).toBe('module-error'); + if (moduleAEntry?.error?.error) { + let additionalErrors = Array.isArray(moduleAEntry.error.error.additionalErrors) + ? moduleAEntry.error.error.additionalErrors + : []; + let hasModuleBDetail = String(moduleAEntry.error.error.message ?? '').includes('module-b exploded') || + additionalErrors.some((error: { + message?: string; + }) => String(error.message ?? '').includes('module-b exploded')); + expect(hasModuleBDetail).toBeTruthy(); + } + else { + expect(false).toBeTruthy(); + } + } + else { + expect(false).toBeTruthy(); + } + let instanceEntry = await realm.realmIndexQueryEngine.instance(new URL(`${testRealm}module-a`)); + expect(instanceEntry?.type).toBe('instance-error'); + if (instanceEntry?.type === 'instance-error') { + let additionalErrors = Array.isArray(instanceEntry.error.additionalErrors) + ? instanceEntry.error.additionalErrors + : []; + let hasModuleBDetail = String(instanceEntry.error.message ?? '').includes('module-b exploded') || + additionalErrors.some((error: { + message?: string; + }) => String(error.message ?? '').includes('module-b exploded')); + expect(hasModuleBDetail).toBeTruthy(); + } + else { + expect(false).toBeTruthy(); + } + }); + it('collects deep relationship deps from rendered links including field-def linksToMany', async function () { + await realm.write('person-rel.gts', ` + import { CardDef, Component, contains, field, linksTo } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class PersonRel extends CardDef { + @field name = contains(StringField); + @field next = linksTo(() => PersonRel); + + static atom = class Atom extends Component<typeof this> { + <template> + <p><@fields.name /></p> + </template> + } + static embedded = class Embedded extends Component<typeof this> { + <template> + <p><@fields.name /></p> + </template> + } + static isolated = class Isolated extends Component<typeof this> { + <template> + <p><@fields.name /></p> + </template> + } + static fitted = class Fitted extends Component<typeof this> { + <template> + <p><@fields.name /></p> + <@fields.next /> + </template> + } + } + `); + await realm.write('connection-field.gts', ` + import { Component, FieldDef, field, linksTo, linksToMany } from "https://cardstack.com/base/card-api"; + import { PersonRel } from "./person-rel"; + + export class ConnectionField extends FieldDef { + @field bestFriend = linksTo(() => PersonRel); + @field teammates = linksToMany(() => PersonRel); + @field hiddenFriend = linksTo(() => PersonRel); + + static atom = class Atom extends Component<typeof this> { + <template> + <@fields.bestFriend /> + <@fields.teammates /> + </template> + } + static isolated = class Isolated extends Component<typeof this> { + <template> + <@fields.bestFriend /> + <@fields.teammates /> + </template> + } + static embedded = class Embedded extends Component<typeof this> { + <template> + <@fields.bestFriend /> + <@fields.teammates /> + </template> + } + static fitted = class Fitted extends Component<typeof this> { + <template> + <@fields.bestFriend /> + <@fields.teammates /> + </template> + } + } + `); + await realm.write('relationship-consumer.gts', ` + import { CardDef, Component, contains, field } from "https://cardstack.com/base/card-api"; + import { ConnectionField } from "./connection-field"; + + export class RelationshipConsumer extends CardDef { + @field connection = contains(ConnectionField); + + static isolated = class Isolated extends Component<typeof this> { + <template> + <@fields.connection /> + </template> + } + } + `); + let personType = { + module: './person-rel', + name: 'PersonRel', + }; + await realm.write('deep-1.json', JSON.stringify({ + data: { + attributes: { name: 'Deep One' }, + meta: { adoptsFrom: personType }, + }, + } as LooseSingleCardDocument)); + await realm.write('hidden-deep.json', JSON.stringify({ + data: { + attributes: { name: 'Hidden Deep' }, + meta: { adoptsFrom: personType }, + }, + } as LooseSingleCardDocument)); + await realm.write('friend-a.json', JSON.stringify({ + data: { + attributes: { name: 'Friend A' }, + relationships: { + next: { links: { self: './deep-1' } }, + }, + meta: { adoptsFrom: personType }, + }, + } as LooseSingleCardDocument)); + await realm.write('friend-b.json', JSON.stringify({ + data: { + attributes: { name: 'Friend B' }, + meta: { adoptsFrom: personType }, + }, + } as LooseSingleCardDocument)); + await realm.write('friend-c.json', JSON.stringify({ + data: { + attributes: { name: 'Friend C' }, + meta: { adoptsFrom: personType }, + }, + } as LooseSingleCardDocument)); + await realm.write('hidden-friend.json', JSON.stringify({ + data: { + attributes: { name: 'Hidden Friend' }, + relationships: { + next: { links: { self: './hidden-deep' } }, + }, + meta: { adoptsFrom: personType }, + }, + } as LooseSingleCardDocument)); + await realm.write('consumer-relationship.json', JSON.stringify({ + data: { + attributes: { + connection: {}, + }, + relationships: { + 'connection.bestFriend': { links: { self: './friend-a' } }, + 'connection.teammates.0': { links: { self: './friend-b' } }, + 'connection.teammates.1': { links: { self: './friend-c' } }, + 'connection.hiddenFriend': { + links: { self: './hidden-friend' }, + }, + }, + meta: { + adoptsFrom: { + module: './relationship-consumer', + name: 'RelationshipConsumer', + }, + }, + }, + } as LooseSingleCardDocument)); + let deps = await depsFor(`${testRealm}consumer-relationship.json`); + let entryType = await typeForIndexEntry(testDbAdapter, `${testRealm}consumer-relationship.json`); + expect(true).toBeTruthy(); + expect(true).toBeTruthy(); + expect(deps.includes(`${testRealm}friend-a.json`)).toBeTruthy(); + expect(deps.includes(`${testRealm}friend-b.json`)).toBeTruthy(); + expect(deps.includes(`${testRealm}friend-c.json`)).toBeTruthy(); + expect(deps.includes(`${testRealm}deep-1.json`)).toBeTruthy(); + expect(deps.includes(`${testRealm}hidden-friend.json`)).toBeFalsy(); + expect(deps.includes(`${testRealm}hidden-deep.json`)).toBeFalsy(); + expect(deps.includes(`${testRealm}friend-a`)).toBeFalsy(); + let beforeLinksToInvalidation = await indexedAtFor(`${testRealm}consumer-relationship.json`); + await realm.write('friend-a.json', JSON.stringify({ + data: { + attributes: { name: 'Friend A Updated' }, + relationships: { + next: { links: { self: './deep-1' } }, + }, + meta: { adoptsFrom: personType }, + }, + } as LooseSingleCardDocument)); + let afterLinksToInvalidation = await indexedAtFor(`${testRealm}consumer-relationship.json`); + expect(afterLinksToInvalidation).not.toBe(beforeLinksToInvalidation); + let beforeLinksToManyInvalidation = afterLinksToInvalidation; + await realm.write('friend-b.json', JSON.stringify({ + data: { + attributes: { name: 'Friend B Updated' }, + meta: { adoptsFrom: personType }, + }, + } as LooseSingleCardDocument)); + let afterLinksToManyInvalidation = await indexedAtFor(`${testRealm}consumer-relationship.json`); + expect(afterLinksToManyInvalidation).not.toBe(beforeLinksToManyInvalidation); + }); + // remove this once we have a query based relationship invalidation strategy + it('does not capture deps from query-backed relationships', async function () { + await realm.write('query-rel-target.gts', ` + import { CardDef, Component, contains, field } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class QueryRelTarget extends CardDef { + @field cardTitle = contains(StringField); + + static embedded = class Embedded extends Component<typeof this> { + <template> + <span><@fields.cardTitle /></span> + </template> + } + } + `); + await realm.write('query-rel-consumer.gts', ` + import { CardDef, Component, contains, field, linksTo, linksToMany } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class QueryRelConsumer extends CardDef { + @field cardTitle = contains(StringField); + @field favorite = linksTo(() => CardDef, { + query: { + filter: { + eq: { + cardTitle: 'target', + }, + }, + }, + }); + @field matches = linksToMany(() => CardDef, { + query: { + filter: { + eq: { + cardTitle: 'target', + }, + }, + page: { + size: 10, + number: 0, + }, + }, + }); + + static isolated = class Isolated extends Component<typeof this> { + <template> + <@fields.favorite /> + <@fields.matches /> + </template> + } + } + `); + await realm.write('query-rel-target-1.json', JSON.stringify({ + data: { + attributes: { cardTitle: 'target' }, + meta: { + adoptsFrom: { + module: './query-rel-target', + name: 'QueryRelTarget', + }, + }, + }, + } as LooseSingleCardDocument)); + await realm.write('query-rel-consumer-1.json', JSON.stringify({ + data: { + attributes: { cardTitle: 'consumer' }, + meta: { + adoptsFrom: { + module: './query-rel-consumer', + name: 'QueryRelConsumer', + }, + }, + }, + } as LooseSingleCardDocument)); + let queryConsumerDoc = await realm.realmIndexQueryEngine.cardDocument(new URL(`${testRealm}query-rel-consumer-1`), { loadLinks: true }); + if (queryConsumerDoc?.type === 'doc') { + let relationships = queryConsumerDoc.doc.data.relationships ?? {}; + let favorite = relationships.favorite as { + links?: Record<string, string | null>; + data?: { + type: string; + id: string; + } | null; + } | undefined; + let matches = relationships.matches as { + links?: Record<string, string | null>; + data?: { + type: string; + id: string; + }[]; + } | undefined; + expect(typeof favorite?.links?.search).toBe('string'); + expect(favorite?.data).toEqual({ + type: 'card', + id: `${testRealm}query-rel-target-1`, + }); + expect(typeof matches?.links?.search).toBe('string'); + expect(matches?.data).toEqual([ + { + type: 'card', + id: `${testRealm}query-rel-target-1`, + }, + ]); + } + else { + expect(false).toBeTruthy(); + } + let deps = await depsFor(`${testRealm}query-rel-consumer-1.json`); + expect(deps.length > 0).toBe(true); + expect(deps.includes(`${testRealm}query-rel-target-1.json`)).toBeFalsy(); + expect(deps.includes(`${testRealm}query-rel-target`)).toBeFalsy(); + }); + it('retains deps that are consumed in both query and non-query contexts', async function () { + await realm.write('query-rel-overlap-target.gts', ` + import { CardDef, Component, contains, field } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class QueryRelOverlapTarget extends CardDef { + @field cardTitle = contains(StringField); + + static embedded = class Embedded extends Component<typeof this> { + <template> + <span><@fields.cardTitle /></span> + </template> + } + } + `); + await realm.write('query-rel-overlap-consumer.gts', ` + import { CardDef, Component, field, linksTo, linksToMany } from "https://cardstack.com/base/card-api"; + + export class QueryRelOverlapConsumer extends CardDef { + @field direct = linksTo(() => CardDef); + @field matches = linksToMany(() => CardDef, { + query: { + filter: { + eq: { + cardTitle: 'overlap-target', + }, + }, + page: { + size: 10, + number: 0, + }, + }, + }); + + static isolated = class Isolated extends Component<typeof this> { + <template> + <@fields.direct /> + <@fields.matches /> + </template> + } + } + `); + await realm.write('query-rel-overlap-target-1.json', JSON.stringify({ + data: { + attributes: { cardTitle: 'overlap-target' }, + meta: { + adoptsFrom: { + module: './query-rel-overlap-target', + name: 'QueryRelOverlapTarget', + }, + }, + }, + } as LooseSingleCardDocument)); + await realm.write('query-rel-overlap-consumer-1.json', JSON.stringify({ + data: { + relationships: { + direct: { links: { self: './query-rel-overlap-target-1' } }, + }, + meta: { + adoptsFrom: { + module: './query-rel-overlap-consumer', + name: 'QueryRelOverlapConsumer', + }, + }, + }, + } as LooseSingleCardDocument)); + let overlapConsumerDoc = await realm.realmIndexQueryEngine.cardDocument(new URL(`${testRealm}query-rel-overlap-consumer-1`), { loadLinks: true }); + if (overlapConsumerDoc?.type === 'doc') { + let relationships = overlapConsumerDoc.doc.data.relationships ?? {}; + let direct = relationships.direct as { + data?: { + type: string; + id: string; + } | null; + } | undefined; + let matches = relationships.matches as { + links?: Record<string, string | null>; + data?: { + type: string; + id: string; + }[]; + } | undefined; + expect(direct?.data).toEqual({ + type: 'card', + id: `${testRealm}query-rel-overlap-target-1`, + }); + expect(typeof matches?.links?.search).toBe('string'); + expect(matches?.data).toEqual([ + { + type: 'card', + id: `${testRealm}query-rel-overlap-target-1`, + }, + ]); + } + else { + expect(false).toBeTruthy(); + } + let deps = await depsFor(`${testRealm}query-rel-overlap-consumer-1.json`); + expect(deps.includes(`${testRealm}query-rel-overlap-target-1.json`)).toBeTruthy(); + expect(deps.includes(`${testRealm}query-rel-overlap-target`)).toBeTruthy(); + }); + it('collects glimmer scoped CSS deps from first-degree and second-degree relationship instances', async function () { + await realm.write('second-rel.gts', ` + import { CardDef, Component, contains, field } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class SecondRel extends CardDef { + @field name = contains(StringField); + + static atom = class Atom extends Component<typeof this> { + <template> + <span class="second-name"><@fields.name /></span> + <style scoped> + .second-name { + color: teal; + } + </style> + </template> + } + static embedded = class Embedded extends Component<typeof this> { + <template> + <span class="second-name"><@fields.name /></span> + <style scoped> + .second-name { + color: teal; + } + </style> + </template> + } + static isolated = class Isolated extends Component<typeof this> { + <template> + <span class="second-name"><@fields.name /></span> + <style scoped> + .second-name { + color: teal; + } + </style> + </template> + } + static fitted = class Fitted extends Component<typeof this> { + <template> + <span class="second-name"><@fields.name /></span> + <style scoped> + .second-name { + color: teal; + } + </style> + </template> + } + } + `); + await realm.write('first-rel.gts', ` + import { CardDef, Component, contains, field, linksTo } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + import { SecondRel } from "./second-rel"; + + export class FirstRel extends CardDef { + @field name = contains(StringField); + @field next = linksTo(() => SecondRel); + + static atom = class Atom extends Component<typeof this> { + <template> + <span class="first-name"><@fields.name /></span> + <@fields.next /> + <style scoped> + .first-name { + color: olive; + } + </style> + </template> + } + static embedded = class Embedded extends Component<typeof this> { + <template> + <span class="first-name"><@fields.name /></span> + <@fields.next /> + <style scoped> + .first-name { + color: olive; + } + </style> + </template> + } + static isolated = class Isolated extends Component<typeof this> { + <template> + <span class="first-name"><@fields.name /></span> + <@fields.next /> + <style scoped> + .first-name { + color: olive; + } + </style> + </template> + } + static fitted = class Fitted extends Component<typeof this> { + <template> + <span class="first-name"><@fields.name /></span> + <@fields.next /> + <style scoped> + .first-name { + color: olive; + } + </style> + </template> + } + } + `); + await realm.write('css-relationship-consumer.gts', ` + import { CardDef, Component, field, linksTo } from "https://cardstack.com/base/card-api"; + import { FirstRel } from "./first-rel"; + + export class CssRelationshipConsumer extends CardDef { + @field first = linksTo(() => FirstRel); + + static isolated = class Isolated extends Component<typeof this> { + <template> + <@fields.first /> + </template> + } + } + `); + await realm.write('second-rel-1.json', JSON.stringify({ + data: { + attributes: { name: 'Second One' }, + meta: { + adoptsFrom: { + module: './second-rel', + name: 'SecondRel', + }, + }, + }, + } as LooseSingleCardDocument)); + await realm.write('first-rel-1.json', JSON.stringify({ + data: { + attributes: { name: 'First One' }, + relationships: { + next: { links: { self: './second-rel-1' } }, + }, + meta: { + adoptsFrom: { + module: './first-rel', + name: 'FirstRel', + }, + }, + }, + } as LooseSingleCardDocument)); + await realm.write('css-relationship-consumer-1.json', JSON.stringify({ + data: { + relationships: { + first: { links: { self: './first-rel-1' } }, + }, + meta: { + adoptsFrom: { + module: './css-relationship-consumer', + name: 'CssRelationshipConsumer', + }, + }, + }, + } as LooseSingleCardDocument)); + let deps = await depsFor(`${testRealm}css-relationship-consumer-1.json`); + expect(deps.includes(`${testRealm}first-rel-1.json`)).toBeTruthy(); + expect(deps.includes(`${testRealm}second-rel-1.json`)).toBeTruthy(); + let assertCssDependency = (depList: string[], pattern: RegExp, fileName: string) => { + expect(depList.some((dep) => pattern.test(dep))).toBe(true); + }; + assertCssDependency(deps, /first-rel\.gts.*\.glimmer-scoped\.css$/, 'first-rel.gts'); + assertCssDependency(deps, /second-rel\.gts.*\.glimmer-scoped\.css$/, 'second-rel.gts'); + }); + it('handles relationship cycles in deps and invalidation', async function () { + await realm.write('loop-card.gts', ` + import { CardDef, Component, contains, field, linksTo } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class LoopCard extends CardDef { + @field name = contains(StringField); + @field next = linksTo(() => LoopCard); + + static atom = class Atom extends Component<typeof this> { + <template> + <p><@fields.name /></p> + </template> + } + static embedded = class Embedded extends Component<typeof this> { + <template> + <p><@fields.name /></p> + <p>next <@fields.next @format='atom'/></p> + </template> + } + static fitted = class Fitted extends Component<typeof this> { + <template> + <p><@fields.name /></p> + <p>next <@fields.next @format='atom'/></p> + </template> + } + static isolated = class Isolated extends Component<typeof this> { + <template> + <p><@fields.name /></p> + <@fields.next /> + </template> + } + } + `); + await realm.write('loop-consumer.gts', ` + import { CardDef, Component, field, linksTo } from "https://cardstack.com/base/card-api"; + import { LoopCard } from "./loop-card"; + + export class LoopConsumer extends CardDef { + @field root = linksTo(() => LoopCard); + + static isolated = class Isolated extends Component<typeof this> { + <template> + <@fields.root /> + </template> + } + } + `); + await realm.write('loop-a.json', JSON.stringify({ + data: { + attributes: { name: 'Loop A' }, + relationships: { + next: { links: { self: './loop-b' } }, + }, + meta: { + adoptsFrom: { + module: './loop-card', + name: 'LoopCard', + }, + }, + }, + } as LooseSingleCardDocument)); + await realm.write('loop-b.json', JSON.stringify({ + data: { + attributes: { name: 'Loop B' }, + relationships: { + next: { links: { self: './loop-a' } }, + }, + meta: { + adoptsFrom: { + module: './loop-card', + name: 'LoopCard', + }, + }, + }, + } as LooseSingleCardDocument)); + await realm.write('loop-consumer.json', JSON.stringify({ + data: { + relationships: { + root: { links: { self: './loop-a' } }, + }, + meta: { + adoptsFrom: { + module: './loop-consumer', + name: 'LoopConsumer', + }, + }, + }, + } as LooseSingleCardDocument)); + let deps = await depsFor(`${testRealm}loop-consumer.json`); + expect(deps.includes(`${testRealm}loop-a.json`)).toBeTruthy(); + expect(deps.includes(`${testRealm}loop-b.json`)).toBeTruthy(); + let beforeIndexedAt = await indexedAtFor(`${testRealm}loop-consumer.json`); + await realm.write('loop-b.json', JSON.stringify({ + data: { + attributes: { name: 'Loop B Updated' }, + relationships: { + next: { links: { self: './loop-a' } }, + }, + meta: { + adoptsFrom: { + module: './loop-card', + name: 'LoopCard', + }, + }, + }, + } as LooseSingleCardDocument)); + let afterIndexedAt = await indexedAtFor(`${testRealm}loop-consumer.json`); + expect(afterIndexedAt).not.toBe(beforeIndexedAt); + let loopA = await realm.realmIndexQueryEngine.instance(new URL(`${testRealm}loop-a`)); + expect(loopA?.type).toBe('instance'); + let loopB = await realm.realmIndexQueryEngine.instance(new URL(`${testRealm}loop-b`)); + expect(loopB?.type).toBe('instance'); + let loopConsumer = await realm.realmIndexQueryEngine.instance(new URL(`${testRealm}loop-consumer`)); + expect(loopConsumer?.type).toBe('instance'); + }); + it('repairs relationship consumers when an errored relationship target is fixed', async function () { + await realm.write('relationship-parent.gts', ` + import { CardDef, Component, field, linksTo } from "https://cardstack.com/base/card-api"; + + export class RelationshipParent extends CardDef { + @field child = linksTo(() => CardDef); + + static atom = class Atom extends Component<typeof this> { + <template> + <@fields.child /> + </template> + } + static isolated = class Isolated extends Component<typeof this> { + <template> + <@fields.child /> + </template> + } + static embedded = class Embedded extends Component<typeof this> { + <template> + <@fields.child /> + </template> + } + static fitted = class Fitted extends Component<typeof this> { + <template> + <@fields.child /> + </template> + } + } + `); + await realm.write('relationship-grandparent.gts', ` + import { CardDef, Component, field, linksTo } from "https://cardstack.com/base/card-api"; + + export class RelationshipGrandParent extends CardDef { + @field parent = linksTo(() => CardDef); + + static atom = class Atom extends Component<typeof this> { + <template> + <@fields.parent /> + </template> + } + static isolated = class Isolated extends Component<typeof this> { + <template> + <@fields.parent /> + </template> + } + static embedded = class Embedded extends Component<typeof this> { + <template> + <@fields.parent /> + </template> + } + static fitted = class Fitted extends Component<typeof this> { + <template> + <@fields.parent /> + </template> + } + } + `); + await realm.write('child-error.json', JSON.stringify({ + data: { + attributes: { + title: 'Broken Child', + }, + meta: { + adoptsFrom: { + module: './missing-child', + name: 'MissingChild', + }, + }, + }, + } as LooseSingleCardDocument)); + await realm.write('parent-rel.json', JSON.stringify({ + data: { + relationships: { + child: { links: { self: './child-error' } }, + }, + meta: { + adoptsFrom: { + module: './relationship-parent', + name: 'RelationshipParent', + }, + }, + }, + } as LooseSingleCardDocument)); + await realm.write('grandparent-rel.json', JSON.stringify({ + data: { + relationships: { + parent: { links: { self: './parent-rel' } }, + }, + meta: { + adoptsFrom: { + module: './relationship-grandparent', + name: 'RelationshipGrandParent', + }, + }, + }, + } as LooseSingleCardDocument)); + let parentBefore = await realm.realmIndexQueryEngine.instance(new URL(`${testRealm}parent-rel`)); + expect(parentBefore?.type).toBe('instance-error'); + let grandParentBefore = await realm.realmIndexQueryEngine.instance(new URL(`${testRealm}grandparent-rel`)); + expect(grandParentBefore?.type).toBe('instance-error'); + if (grandParentBefore?.type === 'instance-error') { + expect(hasErrorDetail(grandParentBefore.error, 'missing-child')).toBeTruthy(); + } + else { + expect(false).toBeTruthy(); + } + await realm.write('missing-child.gts', ` + import { CardDef, contains, field } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class MissingChild extends CardDef { + @field title = contains(StringField); + } + `); + let parentAfter = await realm.realmIndexQueryEngine.instance(new URL(`${testRealm}parent-rel`)); + expect(parentAfter?.type).toBe('instance'); + let grandParentAfter = await realm.realmIndexQueryEngine.instance(new URL(`${testRealm}grandparent-rel`)); + expect(grandParentAfter?.type).toBe('instance'); + let parentDeps = await depsFor(`${testRealm}parent-rel.json`); + expect(parentDeps.includes(`${testRealm}child-error.json`)).toBeTruthy(); + let grandParentDeps = await depsFor(`${testRealm}grandparent-rel.json`); + expect(grandParentDeps.includes(`${testRealm}parent-rel.json`)).toBeTruthy(); + expect(grandParentDeps.includes(`${testRealm}child-error.json`)).toBeTruthy(); + }); + }); + describe('error recovery and deletion', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let realm: Realm; + let adapter: RealmAdapter; + async function depsFor(url: string, type: 'instance' | 'file' = 'instance'): Promise<string[]> { + return depsForIndexEntry(testDbAdapter, url, type); + } + async function indexedAtFor(url: string, type: 'instance' | 'file' = 'instance'): Promise<string | null> { + return indexedAtForIndexEntry(testDbAdapter, url, type); + } + setupPermissionedRealmCached(hooks, { + mode: 'beforeEach', + realmURL: testRealm, + permissions: { + '*': ['read'], + }, + fileSystem: makeTestRealmFileSystem(), + onRealmSetup({ dbAdapter, testRealm: r, testRealmAdapter }) { + testDbAdapter = dbAdapter; + realm = r; + adapter = testRealmAdapter; + }, + }); + it('repairs relationship consumers when an errored second-degree FileDef target is fixed', async function () { + await realm.write('filedef-mismatch.gts', ` + import { FileDef as BaseFileDef } from "https://cardstack.com/base/file-api"; + import { MissingChild } from "./missing-child"; + + export class FileDef extends BaseFileDef { + static missingChild = MissingChild; + } + `); + await realm.write('relationship-file-parent.gts', ` + import { CardDef, Component, field, linksTo, linksToMany } from "https://cardstack.com/base/card-api"; + import { FileDef } from "https://cardstack.com/base/file-api"; + + export class RelationshipFileParent extends CardDef { + @field attachment = linksTo(() => FileDef); + @field attachments = linksToMany(() => FileDef); + + static isolated = class Isolated extends Component<typeof this> { + <template> + <@fields.attachment /> + <@fields.attachments /> + </template> + } + static embedded = class Embedded extends Component<typeof this> { + <template> + <@fields.attachment /> + <@fields.attachments /> + </template> + } + static fitted = class Fitted extends Component<typeof this> { + <template> + <@fields.attachment /> + <@fields.attachments /> + </template> + } + } + `); + await realm.write('relationship-file-grandparent.gts', ` + import { CardDef, Component, field, linksTo } from "https://cardstack.com/base/card-api"; + + export class RelationshipFileGrandParent extends CardDef { + @field parent = linksTo(() => CardDef); + + static isolated = class Isolated extends Component<typeof this> { + <template> + <@fields.parent /> + </template> + } + static embedded = class Embedded extends Component<typeof this> { + <template> + <@fields.parent /> + </template> + } + static fitted = class Fitted extends Component<typeof this> { + <template> + <@fields.parent /> + </template> + } + } + `); + await realm.write('parent-file-rel.json', JSON.stringify({ + data: { + relationships: { + attachment: { links: { self: './random-file.mismatch' } }, + 'attachments.0': { + links: { self: './random-file.mismatch' }, + }, + }, + meta: { + adoptsFrom: { + module: './relationship-file-parent', + name: 'RelationshipFileParent', + }, + }, + }, + } as LooseSingleCardDocument)); + await realm.write('grandparent-file-rel.json', JSON.stringify({ + data: { + relationships: { + parent: { links: { self: './parent-file-rel' } }, + }, + meta: { + adoptsFrom: { + module: './relationship-file-grandparent', + name: 'RelationshipFileGrandParent', + }, + }, + }, + } as LooseSingleCardDocument)); + let fileTargetBeforeType = await typeForIndexEntry(testDbAdapter, `${testRealm}random-file.mismatch`); + expect(fileTargetBeforeType).toBe('file'); + let fileTargetBeforeError = await errorDocForIndexEntry(testDbAdapter, `${testRealm}random-file.mismatch`, 'file'); + expect(Boolean(fileTargetBeforeError?.hasError)).toBe(true); + let fileTargetHasExpectedErrorDetail = hasErrorDetail((fileTargetBeforeError?.errorDoc ?? {}) as { + message?: string; + additionalErrors?: { + message?: string; + }[] | null; + }, 'Received HTTP 404 from server') || + hasErrorDetail((fileTargetBeforeError?.errorDoc ?? {}) as { + message?: string; + additionalErrors?: { + message?: string; + }[] | null; + }, 'missing-child'); + expect(fileTargetHasExpectedErrorDetail).toBeTruthy(); + let parentBefore = await realm.realmIndexQueryEngine.instance(new URL(`${testRealm}parent-file-rel`)); + expect(parentBefore?.type).toBe('instance-error'); + let grandParentBefore = await realm.realmIndexQueryEngine.instance(new URL(`${testRealm}grandparent-file-rel`)); + expect(grandParentBefore?.type).toBe('instance-error'); + if (grandParentBefore?.type === 'instance-error') { + let delegatedHasExpectedErrorDetail = hasErrorDetail(grandParentBefore.error, 'Received HTTP 404 from server') || hasErrorDetail(grandParentBefore.error, 'missing-child'); + expect(delegatedHasExpectedErrorDetail).toBeTruthy(); + } + else { + expect(false).toBeTruthy(); + } + await realm.write('filedef-mismatch.gts', ` + import { + FileDef as BaseFileDef, + FileContentMismatchError, + } from "https://cardstack.com/base/file-api"; + + export class FileDef extends BaseFileDef { + static async extractAttributes() { + throw new FileContentMismatchError('content mismatch'); + } + } + `); + let fileTargetAfterError = await errorDocForIndexEntry(testDbAdapter, `${testRealm}random-file.mismatch`, 'file'); + expect(Boolean(fileTargetAfterError?.hasError)).toBe(false); + let parentAfter = await realm.realmIndexQueryEngine.instance(new URL(`${testRealm}parent-file-rel`)); + expect(parentAfter?.type).toBe('instance'); + let grandParentAfter = await realm.realmIndexQueryEngine.instance(new URL(`${testRealm}grandparent-file-rel`)); + expect(grandParentAfter?.type).toBe('instance'); + let parentDeps = await depsFor(`${testRealm}parent-file-rel.json`); + expect(parentDeps.includes(`${testRealm}random-file.mismatch`)).toBeTruthy(); + expect(parentDeps.includes(`${testRealm}random-file.mismatch`)).toBeTruthy(); + let grandParentDeps = await depsFor(`${testRealm}grandparent-file-rel.json`); + expect(grandParentDeps.includes(`${testRealm}parent-file-rel.json`)).toBeTruthy(); + expect(grandParentDeps.includes(`${testRealm}random-file.mismatch`)).toBeTruthy(); + expect(grandParentDeps.includes(`${testRealm}random-file.mismatch`)).toBeTruthy(); + }); + it('tracks and invalidates FileDef relationship deps for linksTo and linksToMany', async function () { + await realm.write('file-relationship-consumer.gts', ` + import { CardDef, Component, field, linksTo, linksToMany } from "https://cardstack.com/base/card-api"; + import { FileDef } from "https://cardstack.com/base/file-api"; + + export class FileRelationshipConsumer extends CardDef { + @field primaryFile = linksTo(() => FileDef); + @field attachments = linksToMany(() => FileDef); + + static isolated = class Isolated extends Component<typeof this> { + <template> + <@fields.primaryFile /> + <@fields.attachments /> + </template> + } + static embedded = class Embedded extends Component<typeof this> { + <template> + <@fields.primaryFile /> + <@fields.attachments /> + </template> + } + } + `); + await realm.write('primary-note.txt', 'primary note v1'); + await realm.write('attachment-a.txt', 'attachment a v1'); + await realm.write('attachment-b.txt', 'attachment b v1'); + await realm.write('file-relationship-consumer.json', JSON.stringify({ + data: { + relationships: { + primaryFile: { links: { self: './primary-note.txt' } }, + 'attachments.0': { links: { self: './attachment-a.txt' } }, + 'attachments.1': { links: { self: './attachment-b.txt' } }, + }, + meta: { + adoptsFrom: { + module: './file-relationship-consumer', + name: 'FileRelationshipConsumer', + }, + }, + }, + } as LooseSingleCardDocument)); + let deps = await depsFor(`${testRealm}file-relationship-consumer.json`); + expect(deps.includes(`${testRealm}primary-note.txt`)).toBeTruthy(); + expect(deps.includes(`${testRealm}attachment-a.txt`)).toBeTruthy(); + expect(deps.includes(`${testRealm}attachment-b.txt`)).toBeTruthy(); + let beforeLinksToInvalidation = await indexedAtFor(`${testRealm}file-relationship-consumer.json`); + await realm.write('primary-note.txt', 'primary note v2'); + let afterLinksToInvalidation = await indexedAtFor(`${testRealm}file-relationship-consumer.json`); + expect(afterLinksToInvalidation).not.toBe(beforeLinksToInvalidation); + let beforeLinksToManyInvalidation = afterLinksToInvalidation; + await realm.write('attachment-a.txt', 'attachment a v2'); + let afterLinksToManyInvalidation = await indexedAtFor(`${testRealm}file-relationship-consumer.json`); + expect(afterLinksToManyInvalidation).not.toBe(beforeLinksToManyInvalidation); + }); + it('can incrementally index deleted instance', async function () { + await realm.delete('mango.json'); + let { data: result } = await realm.realmIndexQueryEngine.searchCards({ + filter: { + on: { module: `${testRealm}person`, name: 'Person' }, + eq: { firstName: 'Mango' }, + }, + }); + expect(result.length).toBe(0); + expect(realm.realmIndexUpdater.stats.instancesIndexed).toBe(0); + expect(realm.realmIndexUpdater.stats.instanceErrors).toBe(0); + }); + it('can incrementally index instance that depends on updated card source', async function () { + await realm.write('post.gts', ` + import { contains, linksTo, field, CardDef, Component } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + import { Person } from "./person"; + + export class Post extends CardDef { + @field author = linksTo(Person); + @field message = contains(StringField); + @field nickName = contains(StringField, { + computeVia: function() { + return this.author.firstName + '-poo'; + } + }) + static isolated = class Isolated extends Component<typeof this> { + <template> + <h1><@fields.message/></h1> + <h2><@fields.author/></h2> + </template> + } + } + `); + let { data: result } = await realm.realmIndexQueryEngine.searchCards({ + filter: { + on: { module: `${testRealm}post`, name: 'Post' }, + eq: { nickName: 'Van Gogh-poo' }, + }, + }); + expect(result.length).toBe(1); + }); + it('can recover from a module sequence error', async function () { + await realm.write('pet.gts', ` + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + import { Name } from "./name"; + + export class Pet extends CardDef { + @field name = contains(Name); + } + `); + await realm.write('pet-apple.json', JSON.stringify({ + data: { + attributes: { + name: { + firstName: 'Apple', + lastName: 'Tangle', + }, + }, + meta: { + adoptsFrom: { + module: './pet', + name: 'Pet', + }, + }, + }, + })); + let response = await fetch(`${testRealm}pet-apple`, { + headers: { Accept: SupportedMimeType.CardJson }, + }); + expect(response.status).toBe(500); + let errorDoc = await response.json(); + expect(errorDoc.errors?.[0]?.id).toBe(`${testRealm}pet-apple`); + await realm.write('name.gts', ` + import { contains, field, FieldDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Name extends FieldDef { + @field firstName = contains(StringField); + @field lastName = contains(StringField); + } + `); + response = await fetch(`${testRealm}pet-apple`, { + headers: { Accept: SupportedMimeType.CardJson }, + }); + expect(response.status).toBe(200); + let doc = await response.json(); + expect(doc.data?.attributes?.name?.firstName).toBe('Apple'); + }); + it('can successfully create instance after module sequence error is resolved', async function () { + await realm.write('pet.gts', ` + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import { Name } from "./name"; + + export class Pet extends CardDef { + @field name = contains(Name); + } + `); + await realm.write('pet-ember.json', JSON.stringify({ + data: { + attributes: { + name: { + firstName: 'Ember', + lastName: 'Glow', + }, + }, + meta: { + adoptsFrom: { + module: './pet', + name: 'Pet', + }, + }, + }, + })); + let response = await fetch(`${testRealm}pet-ember`, { + headers: { Accept: SupportedMimeType.CardJson }, + }); + expect(response.status).toBe(500); + await realm.write('name.gts', ` + import { contains, field, FieldDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Name extends FieldDef { + @field firstName = contains(StringField); + @field lastName = contains(StringField); + } + `); + await realm.write('pet-puffin.json', JSON.stringify({ + data: { + attributes: { + name: { + firstName: 'Puffin', + lastName: 'Light', + }, + }, + meta: { + adoptsFrom: { + module: './pet', + name: 'Pet', + }, + }, + }, + })); + let createdResponse = await fetch(`${testRealm}pet-puffin`, { + headers: { Accept: SupportedMimeType.CardJson }, + }); + expect(createdResponse.status).toBe(200); + let fetchedDoc = (await createdResponse.json()) as LooseSingleCardDocument; + expect(fetchedDoc.data?.attributes?.name?.lastName).toBe('Light'); + }); + it('can incrementally index instance that depends on updated card source consumed by other card sources', async function () { + await realm.write('person.gts', ` + import { contains, field, Component, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Person extends CardDef { + @field firstName = contains(StringField); + @field nickName = contains(StringField, { + computeVia: function() { + return this.firstName + '-poo'; + } + }) + static embedded = class Embedded extends Component<typeof this> { + <template><@fields.firstName/> (<@fields.nickName/>)</template> + } + static fitted = class Fitted extends Component<typeof this> { + <template><@fields.firstName/> (<@fields.nickName/>)</template> + } + } + `); + let { data: result } = await realm.realmIndexQueryEngine.searchCards({ + filter: { + on: { module: `${testRealm}post`, name: 'Post' }, + eq: { 'author.nickName': 'Van Gogh-poo' }, + }, + }); + expect(result.length).toBe(1); + }); + it('can incrementally index instance that depends on deleted card source', async function () { + await realm.delete('post.gts'); + { + let { data: result } = await realm.realmIndexQueryEngine.searchCards({ + filter: { + type: { module: `${testRealm}post`, name: 'Post' }, + }, + }); + expect(result).toEqual([]); + } + let actual = await realm.realmIndexQueryEngine.cardDocument(new URL(`${testRealm}post-1`)); + if (actual?.type === 'error') { + expect(actual.error.errorDetail.stack).toBeTruthy(); + delete actual.error.errorDetail.stack; + expect(actual.error.errorDetail.id).toBe(`${testRealm}post`); + expect(actual.error.errorDetail.isCardError).toBe(true); + expect(actual.error.errorDetail.additionalErrors).toBe(null); + expect(actual.error.errorDetail.message).toBe(`missing file ${testRealm}post`); + expect(actual.error.errorDetail.status).toBe(404); + expect(actual.error.errorDetail.title).toBe('Link Not Found'); + expect(actual.error.errorDetail.deps?.includes(`${testRealm}post`)).toBeTruthy(); + } + else { + expect(false).toBeTruthy(); + } + // when the definitions is created again, the instance should mend its broken link + await realm.write('post.gts', ` + import { contains, linksTo, field, CardDef, Component } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + import { Person } from "./person"; + + export class Post extends CardDef { + @field author = linksTo(Person); + @field message = contains(StringField); + @field nickName = contains(StringField, { + computeVia: function() { + return this.author?.firstName + '-poo'; + } + }) + } + `); + { + let { data: result } = await realm.realmIndexQueryEngine.searchCards({ + filter: { + on: { module: `${testRealm}post`, name: 'Post' }, + eq: { nickName: 'Van Gogh-poo' }, + }, + }); + expect(result.length).toBe(1); + } + }); + it('can write several module files at once', async function () { + let mapOfWrites = new Map(); + mapOfWrites.set('place.gts', ` + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + export class Place extends CardDef { + @field name = contains(StringField); + } + `); + mapOfWrites.set('country.gts', ` + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + export class Country extends CardDef { + @field name = contains(StringField); + } + `); + mapOfWrites.set('notes.txt', 'Hello from writeMany'); + let result = await realm.writeMany(mapOfWrites); + expect(result.length).toBe(3); + expect(result[0].path).toBe('place.gts'); + expect(result[1].path).toBe('country.gts'); + expect(result[2].path).toBe('notes.txt'); + let place = await realm.realmIndexQueryEngine.file(new URL(`${testRealm}place.gts`)); + expect(place).toBeTruthy(); + let country = await realm.realmIndexQueryEngine.file(new URL(`${testRealm}country.gts`)); + expect(country).toBeTruthy(); + let fileEntry = await realm.realmIndexQueryEngine.file(new URL(`${testRealm}notes.txt`)); + expect(fileEntry).toBeTruthy(); + expect(realm.realmIndexUpdater.stats.filesIndexed).toBe(3); + }); + it('can write instances and module files and files at once', async function () { + let mapOfWrites = new Map(); + mapOfWrites.set('city.gts', ` + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + export class City extends CardDef { + @field name = contains(StringField); + } + `); + mapOfWrites.set('city.json', JSON.stringify({ + data: { + type: 'card', + attributes: { name: 'Paris' }, + meta: { + adoptsFrom: { + module: './city', + name: 'City', + }, + }, + }, + })); + mapOfWrites.set('notes.txt', 'Hello from mixed writeMany'); + let result = await realm.writeMany(mapOfWrites); + expect(result.length).toBe(3); + expect(result[0].path).toBe('city.gts'); + expect(result[1].path).toBe('city.json'); + expect(result[2].path).toBe('notes.txt'); + let moduleFile = await realm.realmIndexQueryEngine.file(new URL(`${testRealm}city.gts`)); + expect(moduleFile).toBeTruthy(); + let instance = await realm.realmIndexQueryEngine.instance(new URL(`${testRealm}city`)); + expect(instance).toBeTruthy(); + let fileEntry = await realm.realmIndexQueryEngine.file(new URL(`${testRealm}notes.txt`)); + expect(fileEntry).toBeTruthy(); + let instanceFileEntry = await realm.realmIndexQueryEngine.file(new URL(`${testRealm}city.json`)); + expect(instanceFileEntry).toBeTruthy(); + expect({ + filesIndexed: realm.realmIndexUpdater.stats.filesIndexed, + fileErrors: realm.realmIndexUpdater.stats.fileErrors, + instancesIndexed: realm.realmIndexUpdater.stats.instancesIndexed, + instanceErrors: realm.realmIndexUpdater.stats.instanceErrors, + }).toEqual({ + filesIndexed: 2, + fileErrors: 0, + instancesIndexed: 1, + instanceErrors: 0, + }); + }); + it('can tombstone deleted files when running fromScratch indexing', async function () { + await realm.write('test-file.json', JSON.stringify({ + data: { + attributes: { + firstName: 'Test Person', + }, + meta: { + adoptsFrom: { + module: './person', + name: 'Person', + }, + }, + }, + })); + let testFile = await realm.realmIndexQueryEngine.instance(new URL(`${testRealm}test-file`)); + expect(testFile?.type).toBe('instance'); + await adapter.remove('test-file.json'); // incremental doesn't get triggered (like in development) here bcos there is no filewatcher enabled + realm.__testOnlyClearCaches(); + let fileExists = await adapter.exists('test-file.json'); + expect(fileExists).toBe(false); + await realm.realmIndexUpdater.fullIndex(); + let deletedEntries = (await testDbAdapter.execute(`SELECT * FROM boxel_index where is_deleted = true and type = 'instance'`)) as { + url: string; + is_deleted: boolean; + }[]; + expect(deletedEntries.some((entry) => entry.url === `${testRealm}test-file.json`)).toBeTruthy(); + expect(deletedEntries.every((entry) => entry.is_deleted)).toBe(true); + // Verify the file is no longer retrievable through the query engine + let deletedFile = await realm.realmIndexQueryEngine.instance(new URL(`${testRealm}test-file`)); + expect(deletedFile).toBe(undefined); + }); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/module-syntax.test.ts b/packages/realm-server/tests-vitest/module-syntax.test.ts new file mode 100644 index 00000000000..ffa69c033d3 --- /dev/null +++ b/packages/realm-server/tests-vitest/module-syntax.test.ts @@ -0,0 +1,1131 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect } from "vitest"; +import { ModuleSyntax, gjsToPlaceholderJS, placeholderJSToGJS, } from '@cardstack/runtime-common/module-syntax'; +import { extractModuleDependencyKeys, moduleDependencyKey, } from '@cardstack/runtime-common/cache/module-cache-invalidation'; +import { baseCardRef, baseFieldRef, RealmPaths, } from '@cardstack/runtime-common'; +import { testRealm } from './helpers'; +import '@cardstack/runtime-common/helpers/code-equality-assertion'; +describe("module-syntax-test.ts", function () { + describe('module-syntax', function () { + function addField(src: string, addFieldAtIndex?: number) { + let mod = new ModuleSyntax(src, new URL(`${testRealm}dir/person.gts`)); + mod.addField({ + cardBeingModified: { + module: `${testRealm}dir/person.gts`, + name: 'Person', + }, + fieldName: 'age', + fieldRef: { + module: 'https://cardstack.com/base/number', + name: 'default', + }, + fieldType: 'contains', + fieldDefinitionType: 'field', + addFieldAtIndex, + incomingRelativeTo: undefined, + outgoingRelativeTo: undefined, + outgoingRealmURL: undefined, + }); + return mod; + } + it('can get the code for a card', async function () { + let src = ` + import { contains, field, Component, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Person extends CardDef { + @field firstName = contains(StringField); + static embedded = class Embedded extends Component<typeof this> { + <template><h1><@fields.firstName/></h1></template> + } + } + `; + let mod = new ModuleSyntax(src, new URL(testRealm)); + expect(mod.code()).toEqual(src); + }); + it('can add a field to a card', async function () { + let mod = addField(` + import { contains, field, Component, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + export class Person extends CardDef { + @field firstName = contains(StringField); + static embedded = class Embedded extends Component<typeof this> { + <template><h1><@fields.firstName/></h1></template> + } + } + `); + expect(mod.code()).toEqual(` + import NumberField from "https://cardstack.com/base/number"; + import { contains, field, Component, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + export class Person extends CardDef { + @field firstName = contains(StringField); + @field age = contains(NumberField); + static embedded = class Embedded extends Component<typeof this> { + <template><h1><@fields.firstName/></h1></template> + } + } + `); + expect(mod.code()).toBe(` + import NumberField from "https://cardstack.com/base/number"; + import { contains, field, Component, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + export class Person extends CardDef { + @field firstName = contains(StringField); + @field age = contains(NumberField); + static embedded = class Embedded extends Component<typeof this> { + <template><h1><@fields.firstName/></h1></template> + } + } + `); + let card = mod.possibleCardsOrFields.find((c) => c.exportName === 'Person'); + let field = card!.possibleFields.get('age'); + expect(field).toBeTruthy(); + expect(field?.card).toEqual({ + type: 'external', + module: 'https://cardstack.com/base/number', + name: 'default', + }); + expect(field?.type).toEqual({ + type: 'external', + module: 'https://cardstack.com/base/card-api', + name: 'contains', + }); + expect(field?.decorator).toEqual({ + type: 'external', + module: 'https://cardstack.com/base/card-api', + name: 'field', + }); + // add another field which will assert that the field path is correct since + // the new field must go after this field + mod.addField({ + cardBeingModified: { + module: `${testRealm}dir/person.gts`, + name: 'Person', + }, + fieldName: 'lastName', + fieldRef: { + module: 'https://cardstack.com/base/string', + name: 'default', + }, + fieldType: 'contains', + fieldDefinitionType: 'field', + incomingRelativeTo: undefined, + outgoingRelativeTo: undefined, + outgoingRealmURL: undefined, + }); + expect(mod.code()).toEqual(` + import NumberField from "https://cardstack.com/base/number"; + import { contains, field, Component, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Person extends CardDef { + @field firstName = contains(StringField); + @field age = contains(NumberField); + @field lastName = contains(StringField); + static embedded = class Embedded extends Component<typeof this> { + <template><h1><@fields.firstName/></h1></template> + } + } + `); + }); + it('added field respects indentation of previous field', async function () { + // 4 space indent + let mod = addField(` + import { contains, field, Component, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + export class Person extends CardDef { + @field firstName = contains(StringField); + static embedded = class Embedded extends Component<typeof this> { + <template><h1><@fields.firstName/></h1></template> + } + } + `); + expect(mod.code()).toBe(` + import NumberField from "https://cardstack.com/base/number"; + import { contains, field, Component, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + export class Person extends CardDef { + @field firstName = contains(StringField); + @field age = contains(NumberField); + static embedded = class Embedded extends Component<typeof this> { + <template><h1><@fields.firstName/></h1></template> + } + } + `); + }); + it('added field respects indentation of previous class member', async function () { + // 2 space indent + let mod = addField(` + import { contains, field, Component, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + export class Person extends CardDef { + static embedded = class Embedded extends Component<typeof this> { + <template><h1><@fields.firstName/></h1></template> + } + } + `); + expect(mod.code()).toBe(` + import NumberField from "https://cardstack.com/base/number"; + import { contains, field, Component, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + export class Person extends CardDef { + @field age = contains(NumberField); + static embedded = class Embedded extends Component<typeof this> { + <template><h1><@fields.firstName/></h1></template> + } + } + `); + }); + it(`added field defaults to a 2 space indent if it's the only class member`, async function () { + let mod = addField(` + import { contains, field, Component, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + export class Person extends CardDef { + } + `); + expect(mod.code()).toBe(` + import NumberField from "https://cardstack.com/base/number"; + import { contains, field, Component, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + export class Person extends CardDef { + @field age = contains(NumberField); + } + `); + mod = addField(` + import { contains, field, Component, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + export class Person extends CardDef {} + `); + expect(mod.code()).toBe(` + import NumberField from "https://cardstack.com/base/number"; + import { contains, field, Component, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + export class Person extends CardDef { + @field age = contains(NumberField); + } + `); + mod = addField(` + import { contains, field, Component, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + export class Person extends CardDef {} + `); + expect(mod.code()).toBe(` + import NumberField from "https://cardstack.com/base/number"; + import { contains, field, Component, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + export class Person extends CardDef { + @field age = contains(NumberField); + } + `); + }); + it(`added field respects the indentation of the next field when adding field at specific position`, async function () { + // 4 space indent + let mod = addField(` + import { contains, field, Component, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + export class Person extends CardDef { + @field firstName = contains(StringField); + static embedded = class Embedded extends Component<typeof this> { + <template><h1><@fields.firstName/></h1></template> + } + } + `, 0); + expect(mod.code()).toBe(` + import NumberField from "https://cardstack.com/base/number"; + import { contains, field, Component, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + export class Person extends CardDef { + @field age = contains(NumberField); + @field firstName = contains(StringField); + static embedded = class Embedded extends Component<typeof this> { + <template><h1><@fields.firstName/></h1></template> + } + } + `); + }); + it('can add a base-card field to a card', async function () { + let src = ` + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + export class Pet extends CardDef { + @field petName = contains(StringField); + } + `; + let mod = new ModuleSyntax(src, new URL(`${testRealm}dir/pet.gts`)); + mod.addField({ + cardBeingModified: { module: `${testRealm}dir/pet`, name: 'Pet' }, // Card we want to add to + fieldName: 'card', + fieldRef: baseCardRef, + fieldType: 'linksTo', + fieldDefinitionType: 'card', + incomingRelativeTo: undefined, + outgoingRelativeTo: new URL('http://localhost:4202/node-test/pet'), // outgoing card + outgoingRealmURL: undefined, + }); + expect(mod.code()).toEqual(` + import { contains, field, CardDef, linksTo } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + export class Pet extends CardDef { + @field petName = contains(StringField); + @field card = linksTo(CardDef); + } + `); + }); + it('can add a base-field field to a card', async function () { + let src = ` + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + export class Pet extends CardDef { + @field petName = contains(StringField); + } + `; + let mod = new ModuleSyntax(src, new URL(`${testRealm}dir/pet.gts`)); + mod.addField({ + cardBeingModified: { module: `${testRealm}dir/pet`, name: 'Pet' }, // Card we want to add to + fieldName: 'field', + fieldRef: baseFieldRef, + fieldType: 'contains', + fieldDefinitionType: 'field', + incomingRelativeTo: undefined, + outgoingRelativeTo: new URL('http://localhost:4202/node-test/pet'), // outgoing card + outgoingRealmURL: undefined, + }); + expect(mod.code()).toEqual(` + import { contains, field, CardDef, FieldDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + export class Pet extends CardDef { + @field petName = contains(StringField); + @field field = contains(FieldDef); + } + `); + }); + it('can add a field to a card when the module url is relative', async function () { + let src = ` + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + export class Pet extends CardDef { + @field petName = contains(StringField); + } + `; + let mod = new ModuleSyntax(src, new URL(`${testRealm}dir/pet.gts`)); + mod.addField({ + cardBeingModified: { module: `${testRealm}dir/pet`, name: 'Pet' }, // Card we want to add to + fieldName: 'bestFriend', + fieldRef: { + module: '../person', + name: 'Person', + }, + fieldType: 'linksTo', + fieldDefinitionType: 'card', + incomingRelativeTo: new URL(`http://localhost:4202/node-test/spec/1`), // hypothethical spec that lives at this id + outgoingRelativeTo: new URL('http://localhost:4202/node-test/pet'), // outgoing card + outgoingRealmURL: new URL('http://localhost:4202/node-test/'), // the realm that the spec lives in + }); + expect(mod.code()).toEqual(` + import { Person as PersonCard } from "./person"; + import { contains, field, CardDef, linksTo } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + export class Pet extends CardDef { + @field petName = contains(StringField); + @field bestFriend = linksTo(PersonCard); + } + `); + }); + it('can add a field to a card when the module url is from another realm', async function () { + let src = ` + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + export class Pet extends CardDef { + @field petName = contains(StringField); + } + `; + let mod = new ModuleSyntax(src, new URL(`${testRealm}dir/pet.gts`)); + mod.addField({ + cardBeingModified: { module: `${testRealm}dir/pet`, name: 'Pet' }, // card we want to add to + fieldName: 'bestFriend', + fieldRef: { + module: '../person', // the other realm (will be from the /test realm not the /node-test) + name: 'Person', + }, + fieldType: 'linksTo', + fieldDefinitionType: 'card', + incomingRelativeTo: new URL(`http://localhost:4202/test/spec/1`), // hypothethical spec that lives at this id + outgoingRelativeTo: new URL('http://localhost:4202/node-test/pet'), // outgoing card + outgoingRealmURL: new URL('http://localhost:4202/node-test/'), // the realm that thel spec lives in + }); + expect(mod.code()).toEqual(` + import { Person as PersonCard } from "http://localhost:4202/test/person"; + import { contains, field, CardDef, linksTo } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + export class Pet extends CardDef { + @field petName = contains(StringField); + @field bestFriend = linksTo(PersonCard); + } + `); + }); + it("can add a field to a card that doesn't have any fields", async function () { + let src = ` + import { CardDef } from "https://cardstack.com/base/card-api"; + + export class Person extends CardDef { } + `; + let mod = new ModuleSyntax(src, new URL(`${testRealm}dir/person`)); + mod.addField({ + cardBeingModified: { module: `${testRealm}dir/person`, name: 'Person' }, + fieldName: 'firstName', + fieldRef: { + module: 'https://cardstack.com/base/string', + name: 'default', + }, + fieldType: 'contains', + fieldDefinitionType: 'field', + incomingRelativeTo: undefined, + outgoingRelativeTo: undefined, + outgoingRealmURL: undefined, + }); + expect(mod.code()).toEqual(` + import StringField from "https://cardstack.com/base/string"; + import { CardDef, field, contains } from "https://cardstack.com/base/card-api"; + + export class Person extends CardDef { + @field firstName = contains(StringField); + } + `); + }); + it('can add a field to an interior card that is the field of card that is exported', async function () { + let src = ` + import { contains, field, Component, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + class Details extends CardDef { + @field favoriteColor = contains(StringField); + } + + export class Person extends CardDef { + @field firstName = contains(StringField); + @field details = contains(Details); + static embedded = class Embedded extends Component<typeof this> { + <template><h1><@fields.firstName/></h1></template> + } + } + `; + let mod = new ModuleSyntax(src, new URL(`${testRealm}dir/person`)); + mod.addField({ + cardBeingModified: { + type: 'fieldOf', + field: 'details', + card: { module: `${testRealm}dir/person`, name: 'Person' }, + }, + fieldName: 'age', + fieldDefinitionType: 'field', + fieldRef: { + module: 'https://cardstack.com/base/number', + name: 'default', + }, + fieldType: 'contains', + incomingRelativeTo: undefined, + outgoingRelativeTo: undefined, + outgoingRealmURL: undefined, + }); + expect(mod.code()).toEqual(` + import NumberField from "https://cardstack.com/base/number"; + import { contains, field, Component, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + class Details extends CardDef { + @field favoriteColor = contains(StringField); + @field age = contains(NumberField); + } + + export class Person extends CardDef { + @field firstName = contains(StringField); + @field details = contains(Details); + static embedded = class Embedded extends Component<typeof this> { + <template><h1><@fields.firstName/></h1></template> + } + } + `); + }); + it('can add a field to an interior card that is the ancestor of card that is exported', async function () { + let src = ` + import { contains, field, Component, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + class Person extends CardDef { + @field firstName = contains(StringField); + static embedded = class Embedded extends Component<typeof this> { + <template><h1><@fields.firstName/></h1></template> + } + } + + export class FancyPerson extends Person { + @field favoriteColor = contains(StringField); + } + `; + let mod = new ModuleSyntax(src, new URL(`${testRealm}dir/person`)); + mod.addField({ + cardBeingModified: { + type: 'ancestorOf', + card: { module: `${testRealm}dir/person`, name: 'FancyPerson' }, + }, + fieldName: 'age', + fieldDefinitionType: 'field', + fieldRef: { + module: 'https://cardstack.com/base/number', + name: 'default', + }, + fieldType: 'contains', + incomingRelativeTo: undefined, + outgoingRelativeTo: undefined, + outgoingRealmURL: undefined, + }); + expect(mod.code()).toEqual(` + import NumberField from "https://cardstack.com/base/number"; + import { contains, field, Component, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + class Person extends CardDef { + @field firstName = contains(StringField); + @field age = contains(NumberField); + static embedded = class Embedded extends Component<typeof this> { + <template><h1><@fields.firstName/></h1></template> + } + } + + export class FancyPerson extends Person { + @field favoriteColor = contains(StringField); + } + `); + }); + it('can add a field to an interior card within a module that also has non card declarations', async function () { + let src = ` + import { contains, field, Component, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Foo {} + + class Details extends CardDef { + @field favoriteColor = contains(StringField); + } + + export class Person extends CardDef { + @field firstName = contains(StringField); + @field details = contains(Details); + static embedded = class Embedded extends Component<typeof this> { + <template><h1><@fields.firstName/></h1></template> + } + } + `; + let mod = new ModuleSyntax(src, new URL(`${testRealm}dir/person`)); + mod.addField({ + cardBeingModified: { + type: 'fieldOf', + field: 'details', + card: { module: `${testRealm}dir/person`, name: 'Person' }, + }, + fieldName: 'age', + fieldDefinitionType: 'field', + fieldRef: { + module: 'https://cardstack.com/base/number', + name: 'default', + }, + fieldType: 'contains', + incomingRelativeTo: undefined, + outgoingRelativeTo: undefined, + outgoingRealmURL: undefined, + }); + expect(mod.code()).toEqual(` + import NumberField from "https://cardstack.com/base/number"; + import { contains, field, Component, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Foo {} + + class Details extends CardDef { + @field favoriteColor = contains(StringField); + @field age = contains(NumberField); + } + + export class Person extends CardDef { + @field firstName = contains(StringField); + @field details = contains(Details); + static embedded = class Embedded extends Component<typeof this> { + <template><h1><@fields.firstName/></h1></template> + } + } + `); + }); + it('can add a containsMany field', async function () { + let src = ` + import { contains, field, Component, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Person extends CardDef { + @field firstName = contains(StringField); + static embedded = class Embedded extends Component<typeof this> { + <template><h1><@fields.firstName/></h1></template> + } + } + `; + let mod = new ModuleSyntax(src, new URL(`${testRealm}dir/person`)); + mod.addField({ + cardBeingModified: { module: `${testRealm}dir/person`, name: 'Person' }, + fieldName: 'aliases', + fieldDefinitionType: 'field', + fieldRef: { + module: 'https://cardstack.com/base/string', + name: 'default', + }, + fieldType: 'containsMany', + incomingRelativeTo: undefined, + outgoingRelativeTo: undefined, + outgoingRealmURL: undefined, + }); + expect(mod.code()).toEqual(` + import { contains, field, Component, CardDef, containsMany } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Person extends CardDef { + @field firstName = contains(StringField); + @field aliases = containsMany(StringField); + static embedded = class Embedded extends Component<typeof this> { + <template><h1><@fields.firstName/></h1></template> + } + } + `); + let card = mod.possibleCardsOrFields.find((c) => c.exportName === 'Person'); + let field = card!.possibleFields.get('aliases'); + expect(field).toBeTruthy(); + expect(field?.type).toEqual({ + type: 'external', + module: 'https://cardstack.com/base/card-api', + name: 'containsMany', + }); + }); + it('can add a linksTo field', async function () { + let src = ` + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + export class Person extends CardDef { + @field firstName = contains(StringField); + } + `; + let mod = new ModuleSyntax(src, new URL(`${testRealm}dir/person`)); + mod.addField({ + cardBeingModified: { module: `${testRealm}dir/person`, name: 'Person' }, + fieldName: 'pet', + fieldRef: { + module: `${testRealm}dir/pet`, + name: 'Pet', + }, + fieldDefinitionType: 'card', + fieldType: 'linksTo', + incomingRelativeTo: undefined, + outgoingRelativeTo: undefined, + outgoingRealmURL: undefined, + }); + expect(mod.code()).toEqual(` + import { Pet as PetCard } from "${testRealm}dir/pet"; + import { contains, field, CardDef, linksTo } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + export class Person extends CardDef { + @field firstName = contains(StringField); + @field pet = linksTo(PetCard); + } + `); + let card = mod.possibleCardsOrFields.find((c) => c.exportName === 'Person'); + let field = card!.possibleFields.get('pet'); + expect(field).toBeTruthy(); + expect(field?.type).toEqual({ + type: 'external', + module: 'https://cardstack.com/base/card-api', + name: 'linksTo', + }); + }); + it('can add a linksTo field with the same type as its enclosing card', async function () { + let src = ` + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Person extends CardDef { + @field firstName = contains(StringField); + } + `; + let mod = new ModuleSyntax(src, new URL(`${testRealm}dir/person`)); + mod.addField({ + cardBeingModified: { module: `${testRealm}dir/person`, name: 'Person' }, + fieldName: 'friend', + fieldRef: { + module: `${testRealm}dir/person`, + name: 'Person', + }, + fieldType: 'linksTo', + fieldDefinitionType: 'card', + incomingRelativeTo: undefined, + outgoingRelativeTo: undefined, + outgoingRealmURL: undefined, + }); + expect(mod.code()).toEqual(` + import { contains, field, CardDef, linksTo } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Person extends CardDef { + @field firstName = contains(StringField); + @field friend = linksTo(() => Person); + } + `); + let card = mod.possibleCardsOrFields.find((c) => c.exportName === 'Person'); + let field = card!.possibleFields.get('friend'); + expect(field).toBeTruthy(); + expect(field?.type).toEqual({ + type: 'external', + module: 'https://cardstack.com/base/card-api', + name: 'linksTo', + }); + }); + it('can add a contains field with a computed value', async function () { + let src = ` + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Person extends CardDef { + @field firstName = contains(StringField); + @field lastName = contains(StringField); + } + `; + let mod = new ModuleSyntax(src, new URL(`${testRealm}dir/person`)); + mod.addField({ + cardBeingModified: { module: `${testRealm}dir/person`, name: 'Person' }, + fieldName: 'fullName', + fieldType: 'contains', + fieldDefinitionType: 'field', + fieldRef: { + module: 'https://cardstack.com/base/string', + name: 'default', + }, + incomingRelativeTo: undefined, + outgoingRelativeTo: undefined, + outgoingRealmURL: undefined, + computedFieldFunctionSourceCode: ` + function() { + return [this.firstName, this.lastName].filter(Boolean).join(' '); + }`, + }); + expect(mod.code()).toEqual(` + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Person extends CardDef { + @field firstName = contains(StringField); + @field lastName = contains(StringField); + @field fullName = contains(StringField, { + computeVia: function () { + return [this.firstName, this.lastName].filter(Boolean).join(' '); + }, + }); + } + `); + }); + it('can handle field card declaration collisions when adding field', async function () { + let src = ` + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + const NumberField = "don't collide with me"; + + export class Person extends CardDef { + @field firstName = contains(StringField); + } + `; + let mod = new ModuleSyntax(src, new URL(`${testRealm}dir/person`)); + mod.addField({ + cardBeingModified: { module: `${testRealm}dir/person`, name: 'Person' }, + fieldName: 'age', + fieldRef: { + module: 'https://cardstack.com/base/number', + name: 'default', + }, + fieldType: 'contains', + fieldDefinitionType: 'field', + incomingRelativeTo: undefined, + outgoingRelativeTo: undefined, + outgoingRealmURL: undefined, + }); + expect(mod.code()).toEqual(` + import NumberField0 from "https://cardstack.com/base/number"; + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + const NumberField = "don't collide with me"; + + export class Person extends CardDef { + @field firstName = contains(StringField); + @field age = contains(NumberField0); + } + `); + }); + // At this level, we can only see this specific module. we'll need the + // upstream caller to perform a field existence check on the card + // definition to ensure this field does not already exist in the adoption chain + it('throws when adding a field with a name the card already has', async function () { + let src = ` + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Person extends CardDef { + @field firstName = contains(StringField); + } + `; + let mod = new ModuleSyntax(src, new URL(`${testRealm}dir/person`)); + try { + mod.addField({ + cardBeingModified: { + module: `${testRealm}dir/person`, + name: 'Person', + }, + fieldName: 'firstName', + fieldRef: { + module: 'https://cardstack.com/base/string', + name: 'default', + }, + fieldType: 'contains', + fieldDefinitionType: 'field', + incomingRelativeTo: undefined, + outgoingRelativeTo: undefined, + outgoingRealmURL: undefined, + }); + throw new Error('expected error was not thrown'); + } + catch (err: any) { + expect(err.message.match(/field "firstName" already exists/)).toBeTruthy(); + } + }); + it('can remove a field from a card', async function () { + let src = ` + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Person extends CardDef { + @field firstName = contains(StringField); + @field lastName = contains(StringField); + } + `; + let mod = new ModuleSyntax(src, new URL(`${testRealm}dir/person`)); + mod.removeField({ module: `${testRealm}dir/person`, name: 'Person' }, 'firstName'); + expect(mod.code()).toEqual(` + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Person extends CardDef { + @field lastName = contains(StringField); + } + `); + expect(mod.code().trim()).toBe(` + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Person extends CardDef { + @field lastName = contains(StringField); + } + `.trim()); + let card = mod.possibleCardsOrFields.find((c) => c.exportName === 'Person'); + let field = card!.possibleFields.get('firstName'); + expect(field).toBe(undefined); + }); + it('can use remove & add a field to achieve edit in place', async function () { + let src = ` + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Person extends CardDef { + @field firstName = contains(StringField); + @field lastName = contains(StringField); + @field artistName = contains(StringField); + @field streetName = contains(StringField); + } + `; + let mod = new ModuleSyntax(src, new URL(`${testRealm}dir/person`)); + let addFieldAtIndex = mod.removeField({ module: `${testRealm}dir/person`, name: 'Person' }, 'artistName'); + mod.addField({ + cardBeingModified: { module: `${testRealm}dir/person`, name: 'Person' }, + fieldName: 'artistNames', + fieldRef: { + module: 'https://cardstack.com/base/string', + name: 'default', + }, + fieldType: 'containsMany', + fieldDefinitionType: 'field', + addFieldAtIndex, + incomingRelativeTo: undefined, + outgoingRelativeTo: undefined, + outgoingRealmURL: undefined, + }); + expect(mod.code()).toEqual(` + import { contains, field, CardDef, containsMany } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Person extends CardDef { + @field firstName = contains(StringField); + @field lastName = contains(StringField); + @field artistNames = containsMany(StringField); + @field streetName = contains(StringField); + } + `); + }); + it('can use remove & add a field to achieve edit in place - when field is at the beginning', async function () { + let src = ` + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Person extends CardDef { + @field firstName = contains(StringField); + @field lastName = contains(StringField); + @field artistName = contains(StringField); + @field streetName = contains(StringField); + } + `; + let mod = new ModuleSyntax(src, new URL(`${testRealm}dir/person`)); + let addFieldAtIndex = mod.removeField({ module: `${testRealm}dir/person`, name: 'Person' }, 'firstName'); + mod.addField({ + cardBeingModified: { module: `${testRealm}dir/person`, name: 'Person' }, + fieldName: 'firstNameAdjusted', + fieldRef: { + module: 'https://cardstack.com/base/string', + name: 'default', + }, + fieldType: 'contains', + fieldDefinitionType: 'field', + addFieldAtIndex, + incomingRelativeTo: undefined, + outgoingRelativeTo: undefined, + outgoingRealmURL: undefined, + }); + expect(mod.code()).toEqual(` + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Person extends CardDef { + @field firstNameAdjusted = contains(StringField); + @field lastName = contains(StringField); + @field artistName = contains(StringField); + @field streetName = contains(StringField); + } + `); + }); + it('can use remove & add a field to achieve edit in place - when field is at the end', async function () { + let src = ` + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Person extends CardDef { + @field firstName = contains(StringField); + @field lastName = contains(StringField); + @field artistName = contains(StringField); + @field streetName = contains(StringField); + } + `; + let mod = new ModuleSyntax(src, new URL(`${testRealm}dir/person`)); + let addFieldAtIndex = mod.removeField({ module: `${testRealm}dir/person`, name: 'Person' }, 'streetName'); + mod.addField({ + cardBeingModified: { module: `${testRealm}dir/person`, name: 'Person' }, + fieldName: 'streetNameAdjusted', + fieldRef: { + module: 'https://cardstack.com/base/string', + name: 'default', + }, + fieldType: 'contains', + fieldDefinitionType: 'field', + addFieldAtIndex, + incomingRelativeTo: undefined, + outgoingRelativeTo: undefined, + outgoingRealmURL: undefined, + }); + expect(mod.code()).toEqual(` + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Person extends CardDef { + @field firstName = contains(StringField); + @field lastName = contains(StringField); + @field artistName = contains(StringField); + @field streetNameAdjusted = contains(StringField); + } + `); + }); + it('can remove the last field from a card', async function () { + let src = ` + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Person extends CardDef { + @field firstName = contains(StringField); + } + `; + let mod = new ModuleSyntax(src, new URL(`${testRealm}dir/person`)); + mod.removeField({ module: `${testRealm}dir/person`, name: 'Person' }, 'firstName'); + expect(mod.code()).toEqual(` + import { CardDef } from "https://cardstack.com/base/card-api"; + export class Person extends CardDef { } + `); + }); + it('can remove a linksTo field with the same type as its enclosing card', async function () { + let src = ` + import { contains, field, CardDef, linksTo } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Friend extends CardDef { + @field firstName = contains(StringField); + @field friend = linksTo(() => Friend); + } + `; + let mod = new ModuleSyntax(src, new URL(`${testRealm}dir/person`)); + mod.removeField({ module: `${testRealm}dir/person`, name: 'Friend' }, 'friend'); + expect(mod.code()).toEqual(` + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Friend extends CardDef { + @field firstName = contains(StringField); + } + `); + let card = mod.possibleCardsOrFields.find((c) => c.exportName === 'Friend'); + let field = card!.possibleFields.get('friend'); + expect(field).toBe(undefined); + }); + it('can remove the field of an interior card that is the ancestor of a card that is exported', async function () { + let src = ` + import { contains, field, Component, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + class Person extends CardDef { + @field firstName = contains(StringField); + @field lastName = contains(StringField); + } + + export class FancyPerson extends Person { + @field favoriteColor = contains(StringField); + } + `; + let mod = new ModuleSyntax(src, new URL(`${testRealm}dir/person`)); + mod.removeField({ + type: 'ancestorOf', + card: { module: `${testRealm}dir/person`, name: 'FancyPerson' }, + }, 'firstName'); + expect(mod.code()).toEqual(` + import { contains, field, Component, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + class Person extends CardDef { + @field lastName = contains(StringField); + } + + export class FancyPerson extends Person { + @field favoriteColor = contains(StringField); + } + `); + }); + it('can remove the field of an interior card that is the field of a card that is exported', async function () { + let src = ` + import { contains, field, Component, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + class Details extends CardDef { + @field nickName = contains(StringField); + @field favoriteColor = contains(StringField); + } + + export class Person extends CardDef { + @field firstName = contains(StringField); + @field lastName = contains(StringField); + @field details = contains(Details); + } + `; + let mod = new ModuleSyntax(src, new URL(`${testRealm}dir/person`)); + mod.removeField({ + type: 'fieldOf', + field: 'details', + card: { module: `${testRealm}dir/person`, name: 'Person' }, + }, 'nickName'); + expect(mod.code()).toEqual(` + import { contains, field, Component, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + class Details extends CardDef { + @field favoriteColor = contains(StringField); + } + + export class Person extends CardDef { + @field firstName = contains(StringField); + @field lastName = contains(StringField); + @field details = contains(Details); + } + `); + }); + it('throws when field to remove does not actually exist', async function () { + let src = ` + import { contains, field, Component, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Person extends CardDef { + @field firstName = contains(StringField); + } + `; + let mod = new ModuleSyntax(src, new URL(`${testRealm}dir/person`)); + try { + mod.removeField({ module: `${testRealm}dir/person`, name: 'Person' }, 'foo'); + throw new Error('expected error was not thrown'); + } + catch (err: any) { + expect(err.message.match(/field "foo" does not exist/)).toBeTruthy(); + } + }); + }); + describe('module-cache-invalidation', function () { + it('extracts dependency keys from import and export specifiers via AST parsing', function () { + let source = ` + import alpha from './alpha'; + export { beta } from './beta.js'; + export * from './gamma.gjs'; + const ignored = "import './not-real'"; + const ignoredTemplate = \`export * from "./also-not-real"\`; + await import('./delta.ts'); + await import(dynamicPath); + import "https://example.com/not-in-realm"; + `; + let paths = new RealmPaths(new URL(testRealm)); + let deps = extractModuleDependencyKeys(source, 'dir/main.gts', testRealm, paths); + expect([...deps].sort()).toEqual([ + moduleDependencyKey('dir/alpha'), + moduleDependencyKey('dir/beta.js'), + moduleDependencyKey('dir/delta.ts'), + moduleDependencyKey('dir/gamma.gjs'), + ].sort()); + }); + }); + describe('gjs-to-placeholder', function () { + const examples = [ + `<template>Greetings ")]</template>`, + '<template>${{stuff}}</template>', + `<template> + Hello + </template>`, + ]; + for (let example of examples) { + it('round-trip gjs placeholders', async function () { + expect(placeholderJSToGJS(gjsToPlaceholderJS(example))).toBe(example); + }); + } + it('preserves line numbers', function () { + let src = ` + console.log( + <template> + Hi + </template> + ); + console.log('after'); + `; + expect(gjsToPlaceholderJS(src).split('\n')[6]).toBe(src.split('\n')[6]); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/node-realm.test.ts b/packages/realm-server/tests-vitest/node-realm.test.ts new file mode 100644 index 00000000000..f9a4714f999 --- /dev/null +++ b/packages/realm-server/tests-vitest/node-realm.test.ts @@ -0,0 +1,91 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import type { PgAdapter } from '@cardstack/postgres'; +import { fetchSessionRoom, insertPermissions, upsertSessionRoom, } from '@cardstack/runtime-common'; +import type { MatrixClient } from '@cardstack/runtime-common/matrix-client'; +import type { RealmEventContent } from 'https://cardstack.com/base/matrix-event'; +import { NodeAdapter } from '../node-realm'; +import { insertUser, setupDB } from './helpers'; +describe("node-realm-test.ts", function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let dbAdapter: PgAdapter; + const realmURL = new URL('http://127.0.0.1:4444/test/'); + const staleRoomId = '!room-alice:localhost'; + setupDB(hooks, { + beforeEach: async (_dbAdapter) => { + dbAdapter = _dbAdapter; + }, + }); + async function insertSessionForAlice() { + await insertUser(dbAdapter, '@alice:localhost', 'cus_alice', 'alice@example.com'); + await upsertSessionRoom(dbAdapter, '@alice:localhost', staleRoomId); + await insertPermissions(dbAdapter, realmURL, { + '@alice:localhost': ['read'], + }); + } + function makeEvent(): RealmEventContent { + return { + eventName: 'index', + indexType: 'incremental', + invalidations: [`${realmURL.href}card`], + clientRequestId: null, + realmURL: realmURL.href, + }; + } + it('clears a stale session room when the realm server is no longer in it', async function () { + await insertSessionForAlice(); + let sendEventCalls = 0; + let matrixClient = { + login: async () => undefined, + getUserId: () => '@realm_server:localhost', + sendEvent: async () => { + sendEventCalls++; + throw new Error(`Unable to send room event 'app.boxel.realm-event' to room ${staleRoomId}: status 403 - {"errcode":"M_FORBIDDEN","error":"User @realm_server:localhost not in room ${staleRoomId}"}`); + }, + } as unknown as MatrixClient; + let adapter = new NodeAdapter('/tmp'); + await adapter.broadcastRealmEvent(makeEvent(), realmURL.href, matrixClient, dbAdapter); + expect(sendEventCalls).toBe(1); + expect(await fetchSessionRoom(dbAdapter, '@alice:localhost')).toBe(null); + }); + it('keeps the session room when the send failure is unrelated', async function () { + await insertSessionForAlice(); + let matrixClient = { + login: async () => undefined, + getUserId: () => '@realm_server:localhost', + sendEvent: async () => { + throw new Error(`Unable to send room event 'app.boxel.realm-event' to room ${staleRoomId}: status 500 - {"errcode":"M_UNKNOWN","error":"boom"}`); + }, + } as unknown as MatrixClient; + let adapter = new NodeAdapter('/tmp'); + await adapter.broadcastRealmEvent(makeEvent(), realmURL.href, matrixClient, dbAdapter); + expect(await fetchSessionRoom(dbAdapter, '@alice:localhost')).toBe(staleRoomId); + }); + it('does not reject when clearing a stale session room fails', async function () { + await insertSessionForAlice(); + let originalExecute = dbAdapter.execute.bind(dbAdapter); + let failCleanup = false; + dbAdapter.execute = (async (...args: Parameters<typeof dbAdapter.execute>) => { + if (failCleanup) { + throw new Error('boom'); + } + return await originalExecute(...args); + }) as typeof dbAdapter.execute; + let matrixClient = { + login: async () => undefined, + getUserId: () => '@realm_server:localhost', + sendEvent: async () => { + failCleanup = true; + throw new Error(`Unable to send room event 'app.boxel.realm-event' to room ${staleRoomId}: status 403 - {"errcode":"M_FORBIDDEN","error":"User @realm_server:localhost not in room ${staleRoomId}"}`); + }, + } as unknown as MatrixClient; + let adapter = new NodeAdapter('/tmp'); + await adapter.broadcastRealmEvent(makeEvent(), realmURL.href, matrixClient, dbAdapter); + expect(true).toBe(true); + }); +}); diff --git a/packages/realm-server/tests-vitest/permissions/permission-checker.test.ts b/packages/realm-server/tests-vitest/permissions/permission-checker.test.ts new file mode 100644 index 00000000000..2e958bd65e7 --- /dev/null +++ b/packages/realm-server/tests-vitest/permissions/permission-checker.test.ts @@ -0,0 +1,89 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect } from "vitest"; +import type { MatrixClient } from '@cardstack/runtime-common/matrix-client'; +import RealmPermissionChecker from '@cardstack/runtime-common/realm-permission-checker'; +let matrixUserProfile: { + displayname: string; +} | undefined = undefined; +let mockMatrixClient = { + async getProfile(_userId) { + return matrixUserProfile; + }, +} as MatrixClient; +describe("permissions/permission-checker-test.ts", function () { + describe('world-readable realm', function () { + let permissionsChecker = new RealmPermissionChecker({ + '*': ['read'], + }, mockMatrixClient); + it('anyone can read but not write', async function () { + expect(await permissionsChecker.can('anyone', 'read')).toBeTruthy(); + expect(await permissionsChecker.can('anyone', 'write')).toBeFalsy(); + expect(await permissionsChecker.for('anyone')).toEqual(['read']); + }); + }); + describe('world-writable realm', function () { + let permissionsChecker = new RealmPermissionChecker({ + '*': ['read', 'write'], + }, mockMatrixClient); + it('anyone can read and write', async function () { + expect(await permissionsChecker.can('anyone', 'read')).toBeTruthy(); + expect(await permissionsChecker.can('anyone', 'write')).toBeTruthy(); + expect(await permissionsChecker.for('anyone')).toEqual([ + 'read', + 'write', + ]); + }); + }); + describe('users-readable realm', function () { + let permissionsChecker = new RealmPermissionChecker({ + users: ['read'], + '@matic:boxel-ai': ['read', 'write'], + }, mockMatrixClient); + it('matrix user can read but not write', async function () { + expect(await permissionsChecker.can('@matic:boxel-ai', 'read')).toBeTruthy(); + expect(await permissionsChecker.can('@matic:boxel-ai', 'write')).toBeTruthy(); + expect(await permissionsChecker.for('@matic:boxel-ai')).toEqual([ + 'read', + 'write', + ]); + matrixUserProfile = { displayname: 'Not Matic' }; + expect(await permissionsChecker.can('@not-matic:boxel-ai', 'read')).toBeTruthy(); + expect(await permissionsChecker.can('@not-matic:boxel-ai', 'write')).toBeFalsy(); + expect(await permissionsChecker.for('@not-matic:boxel-ai')).toEqual([ + 'read', + ]); + }); + it('non-matrix user can not read and write', async function () { + expect(await permissionsChecker.can('@matic:boxel-ai', 'read')).toBeTruthy(); + expect(await permissionsChecker.can('@matic:boxel-ai', 'write')).toBeTruthy(); + expect(await permissionsChecker.for('@matic:boxel-ai')).toEqual([ + 'read', + 'write', + ]); + matrixUserProfile = undefined; + expect(await permissionsChecker.can('anyone', 'read')).toBeFalsy(); + expect(await permissionsChecker.can('anyone', 'write')).toBeFalsy(); + expect(await permissionsChecker.for('anyone')).toEqual([]); + }); + }); + describe('user permissioned realm', function () { + let permissionsChecker = new RealmPermissionChecker({ + '*': ['read'], + '@matic:boxel-ai': ['read', 'write', 'realm-owner'], + }, mockMatrixClient); + it('user with permission can do permitted actions', async function () { + expect(await permissionsChecker.can('@matic:boxel-ai', 'read')).toBeTruthy(); + expect(await permissionsChecker.can('anyone', 'read')).toBeTruthy(); + expect(await permissionsChecker.can('@matic:boxel-ai', 'write')).toBeTruthy(); + expect(await permissionsChecker.can('anyone', 'write')).toBeFalsy(); + expect(await permissionsChecker.can('@matic:boxel-ai', 'realm-owner')).toBeTruthy(); + expect(await permissionsChecker.can('anyone', 'realm-owner')).toBeFalsy(); + expect(await permissionsChecker.for('@matic:boxel-ai')).toEqual([ + 'read', + 'write', + 'realm-owner', + ]); + expect(await permissionsChecker.for('anyone')).toEqual(['read']); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/prerender-manager.test.ts b/packages/realm-server/tests-vitest/prerender-manager.test.ts new file mode 100644 index 00000000000..099c6b3d2d3 --- /dev/null +++ b/packages/realm-server/tests-vitest/prerender-manager.test.ts @@ -0,0 +1,1148 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import type { SuperTest, Test } from 'supertest'; +import supertest from 'supertest'; +import Koa from 'koa'; +import Router from '@koa/router'; +import type { Server } from 'http'; +import { createServer } from 'http'; +import { buildPrerenderManagerApp } from '../prerender/manager-app'; +import { PRERENDER_SERVER_DRAINING_STATUS_CODE, PRERENDER_SERVER_STATUS_DRAINING, PRERENDER_SERVER_STATUS_HEADER, } from '../prerender/prerender-constants'; +import { toAffinityKey } from '../prerender/affinity'; +import { Deferred } from '@cardstack/runtime-common'; +import { testCreatePrerenderAuth } from './helpers'; +describe("prerender-manager-test.ts", function () { + describe('Prerender manager', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let previousMultiplex: string | undefined; + let previousHeartbeatTimeout: string | undefined; + let previousDiscoveryWait: string | undefined; + let previousDiscoveryPoll: string | undefined; + let mockPrerenderA: ReturnType<typeof makeMockPrerender> | undefined; + let mockPrerenderB: ReturnType<typeof makeMockPrerender> | undefined; + let serverUrlA: string | undefined; + let serverUrlB: string | undefined; + hooks.beforeEach(function () { + previousMultiplex = process.env.PRERENDER_MULTIPLEX; + previousHeartbeatTimeout = process.env.PRERENDER_HEARTBEAT_TIMEOUT_MS; + previousDiscoveryWait = process.env.PRERENDER_SERVER_DISCOVERY_WAIT_MS; + previousDiscoveryPoll = process.env.PRERENDER_SERVER_DISCOVERY_POLL_MS; + // create two mock prerender servers available for tests + mockPrerenderA = makeMockPrerender(); + mockPrerenderB = makeMockPrerender(); + serverUrlA = `http://127.0.0.1:${(mockPrerenderA.server.address() as any).port}`; + serverUrlB = `http://127.0.0.1:${(mockPrerenderB.server.address() as any).port}`; + }); + hooks.afterEach(async function () { + if (previousMultiplex === undefined) { + delete process.env.PRERENDER_MULTIPLEX; + } + else { + process.env.PRERENDER_MULTIPLEX = previousMultiplex; + } + if (previousHeartbeatTimeout === undefined) { + delete process.env.PRERENDER_HEARTBEAT_TIMEOUT_MS; + } + else { + process.env.PRERENDER_HEARTBEAT_TIMEOUT_MS = previousHeartbeatTimeout; + } + if (previousDiscoveryWait === undefined) { + delete process.env.PRERENDER_SERVER_DISCOVERY_WAIT_MS; + } + else { + process.env.PRERENDER_SERVER_DISCOVERY_WAIT_MS = previousDiscoveryWait; + } + if (previousDiscoveryPoll === undefined) { + delete process.env.PRERENDER_SERVER_DISCOVERY_POLL_MS; + } + else { + process.env.PRERENDER_SERVER_DISCOVERY_POLL_MS = previousDiscoveryPoll; + } + // ensure mock servers are stopped + if (mockPrerenderA) { + await mockPrerenderA.stop(); + mockPrerenderA = undefined; + } + if (mockPrerenderB) { + await mockPrerenderB.stop(); + mockPrerenderB = undefined; + } + serverUrlA = undefined; + serverUrlB = undefined; + }); + it('health', async function () { + let { app } = buildPrerenderManagerApp(); + let request: SuperTest<Test> = supertest(app.callback()); + let headResponse = await request.head('/'); + expect(headResponse.status).toBe(200); + let getResponse = await request.get('/'); + expect(getResponse.status).toBe(200); + expect(getResponse.headers['content-type']).toBe('application/vnd.api+json'); + expect(getResponse.body.data.type).toBe('prerender-manager-health'); + expect(getResponse.body.data.id).toBe('health'); + expect(getResponse.body.data.attributes.ready).toBe(false); + expect(Array.isArray(getResponse.body.included)).toBeTruthy(); + expect(getResponse.body.included.length).toBe(0); + }); + it('health includes active servers with affinities and last used times', async function () { + process.env.PRERENDER_MULTIPLEX = '2'; + let { app } = buildPrerenderManagerApp(); + let request: SuperTest<Test> = supertest(app.callback()); + // Register two servers + await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { capacity: 2, url: serverUrlA }, + }, + }); + await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { capacity: 2, url: serverUrlB }, + }, + }); + // Make a prerender request to assign a realm to a server + let realm = 'https://realm.example/R'; + let body = makeBody(realm, `${realm}/1`); + let proxyResponse = await request.post('/prerender-card').send(body); + expect(proxyResponse.status).toBe(201); + let assignedServer = proxyResponse.headers['x-boxel-prerender-target']; + // Get healthcheck + let healthResponse = await request.get('/'); + expect(healthResponse.status).toBe(200); + expect(healthResponse.headers['content-type']).toBe('application/vnd.api+json'); + let { data, included } = healthResponse.body; + expect(data.type).toBe('prerender-manager-health'); + expect(data.attributes.ready).toBe(true); + // Verify included servers + expect(Array.isArray(included)).toBeTruthy(); + expect(included.length).toBe(2); + // Find the server that was assigned the realm + let assignedServerData = included.find((s: any) => s.id === assignedServer); + expect(assignedServerData).toBeTruthy(); + expect(assignedServerData.type).toBe('prerender-server'); + expect(assignedServerData.attributes.url).toBe(assignedServer); + expect(assignedServerData.attributes.capacity).toBe(2); + expect(assignedServerData.attributes.registeredAt).toBeTruthy(); + expect(assignedServerData.attributes.lastSeenAt).toBeTruthy(); + // Verify affinities array + expect(Array.isArray(assignedServerData.attributes.affinities)).toBeTruthy(); + expect(assignedServerData.attributes.affinities.length).toBe(1); + expect(assignedServerData.attributes.affinities[0].affinityType).toBe('realm'); + expect(assignedServerData.attributes.affinities[0].affinityValue).toBe(realm); + expect(assignedServerData.attributes.affinities[0].key).toBe(realmAffinityKey(realm)); + expect(assignedServerData.attributes.affinities[0].lastUsed).toBeTruthy(); + expect(assignedServerData.attributes.status).toBe('active'); + expect(Array.isArray(assignedServerData.attributes.warmedAffinities)).toBeTruthy(); + // Verify the other server has no affinities + let otherServerUrl = assignedServer === serverUrlA ? serverUrlB : serverUrlA; + let otherServerData = included.find((s: any) => s.id === otherServerUrl); + expect(otherServerData).toBeTruthy(); + expect(otherServerData.attributes.affinities.length).toBe(0); + }); + it('proxies card prerender requests', async function () { + let { app } = buildPrerenderManagerApp(); + let request: SuperTest<Test> = supertest(app.callback()); + await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { capacity: 2, url: serverUrlA }, + }, + }); + let realm = 'https://realm.example/C'; + let cardURL = `${realm}/1`; + let proxyResponse = await request + .post('/prerender-card') + .send(makeBody(realm, cardURL)); + expect(proxyResponse.status).toBe(201); + expect(proxyResponse.headers['x-boxel-prerender-target']).toBe(serverUrlA); + expect(proxyResponse.body?.data?.type).toBe('prerender-result'); + expect(proxyResponse.body?.data?.attributes?.ok).toBe(true); + }); + it('proxies module prerender requests', async function () { + let { app } = buildPrerenderManagerApp(); + let request: SuperTest<Test> = supertest(app.callback()); + // Register a single server + await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { capacity: 2, url: serverUrlA }, + }, + }); + let realm = 'https://realm.example/M'; + let moduleURL = `${realm}/module.gts`; + let proxyResponse = await request + .post('/prerender-module') + .send(makeModuleBody(realm, moduleURL)); + expect(proxyResponse.status).toBe(201); + expect(proxyResponse.headers['x-boxel-prerender-target']).toBe(serverUrlA); + expect(proxyResponse.body?.data?.type).toBe('prerender-module-result'); + expect(proxyResponse.body?.data?.attributes?.id).toBe(moduleURL); + }); + it('proxies run-command requests', async function () { + let { app } = buildPrerenderManagerApp(); + let request: SuperTest<Test> = supertest(app.callback()); + // Register a single server + await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { capacity: 2, url: serverUrlA }, + }, + }); + let realm = 'https://realm.example/CMD'; + let command = `${realm}/commands/say-hello/SayHelloCommand`; + let proxyResponse = await request + .post('/run-command') + .send(makeCommandBody(realm, command)); + expect(proxyResponse.status).toBe(201); + expect(proxyResponse.headers['x-boxel-prerender-target']).toBe(serverUrlA); + expect(proxyResponse.body?.data?.type).toBe('command-result'); + expect(proxyResponse.body?.data?.id).toBe(command); + }); + it('heartbeat: url required; heartbeat updates warmed affinities and status', async function () { + let { app } = buildPrerenderManagerApp(); + let request: SuperTest<Test> = supertest(app.callback()); + let registrationResponse = await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { + capacity: 2, + url: serverUrlA, + status: 'active', + warmedAffinities: [ + realmAffinityKey('https://realm.example/warmed'), + ], + }, + }, + }); + expect(registrationResponse.status).toBe(204); + expect(registrationResponse.headers['x-prerender-server-id']).toBe(serverUrlA); + let missingInferenceResponse = await request + .post('/prerender-servers') + .send({}); + expect(missingInferenceResponse.status).toBe(400); + }); + it('proxy: sticky routing with multiplex=1', async function () { + process.env.PRERENDER_MULTIPLEX = '1'; + let { app } = buildPrerenderManagerApp(); + let request: SuperTest<Test> = supertest(app.callback()); + // register both + await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { capacity: 2, url: serverUrlA }, + }, + }); + await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { capacity: 2, url: serverUrlB }, + }, + }); + // sticky to first chosen server for realm R when multiplex=1 + let body = makeBody('https://realm.example/R', 'https://realm.example/R/1'); + let firstProxyResponse = await request.post('/prerender-card').send(body); + expect(firstProxyResponse.status).toBe(201); + let firstTarget = firstProxyResponse.headers['x-boxel-prerender-target']; + expect([serverUrlA, serverUrlB].includes(firstTarget)).toBeTruthy(); + let secondProxyResponse = await request + .post('/prerender-card') + .send(body); + expect(secondProxyResponse.status).toBe(201); + expect(secondProxyResponse.headers['x-boxel-prerender-target']).toBe(firstTarget); + }); + it('proxy: rotation with multiplex>1, capacity and pressure', async function () { + process.env.PRERENDER_MULTIPLEX = '2'; + let { app } = buildPrerenderManagerApp(); + let request: SuperTest<Test> = supertest(app.callback()); + // register both + await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { capacity: 2, url: serverUrlA }, + }, + }); + await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { capacity: 2, url: serverUrlB }, + }, + }); + let body = makeBody('https://realm.example/R', 'https://realm.example/R/1'); + let firstProxyResponse = await request.post('/prerender-card').send(body); + let firstTarget = firstProxyResponse.headers['x-boxel-prerender-target']; + let secondProxyResponse = await request + .post('/prerender-card') + .send(body); + let secondTarget = secondProxyResponse.headers['x-boxel-prerender-target']; + expect(firstTarget).not.toBe(undefined); + expect(secondTarget).not.toBe(undefined); + expect(firstTarget).not.toBe(secondTarget); + // capacity: distribute different realms across servers first + let realm2RequestBody = makeBody('https://realm.example/R2', 'https://realm.example/R2/1'); + let realm2ProxyResponse = await request + .post('/prerender-card') + .send(realm2RequestBody); + let realm2Target = realm2ProxyResponse.headers['x-boxel-prerender-target']; + expect([serverUrlA, serverUrlB].includes(realm2Target)).toBeTruthy(); + // now pressure: third realm + let realm3RequestBody = makeBody('https://realm.example/R3', 'https://realm.example/R3/1'); + let realm3ProxyResponse = await request + .post('/prerender-card') + .send(realm3RequestBody); + let realm3Target = realm3ProxyResponse.headers['x-boxel-prerender-target']; + expect([serverUrlA, serverUrlB].includes(realm3Target)).toBeTruthy(); + }); + it('affinity disposal removes server from affinity mapping', async function () { + process.env.PRERENDER_MULTIPLEX = '2'; + let { app } = buildPrerenderManagerApp(); + let request: SuperTest<Test> = supertest(app.callback()); + await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { capacity: 2, url: serverUrlA }, + }, + }); + await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { capacity: 2, url: serverUrlB }, + }, + }); + let realm = 'https://realm.example/R'; + let body = makeBody(realm, `${realm}/1`); + let affinityKey = realmAffinityKey(realm); + let firstProxyResponse = await request.post('/prerender-card').send(body); + expect(firstProxyResponse.status).toBe(201); + let firstTarget = firstProxyResponse.headers['x-boxel-prerender-target']; + expect(firstTarget).toBeTruthy(); + let missingUrlDisposalResponse = await request.delete(`/prerender-servers/affinities/${encodeURIComponent(affinityKey)}`); + expect(missingUrlDisposalResponse.status).toBe(400); + // simulate prerender server notifying disposal + let disposalResponse = await request + .delete(`/prerender-servers/affinities/${encodeURIComponent(affinityKey)}`) + .query({ url: firstTarget as string }); + expect(disposalResponse.status).toBe(204); + // next request should succeed; mapping for that realm should no longer be required + let secondProxyResponse = await request + .post('/prerender-card') + .send(body); + expect(secondProxyResponse.status).toBe(201); + // should target one of the registered servers + expect([serverUrlA, serverUrlB].includes(secondProxyResponse.headers['x-boxel-prerender-target'])).toBeTruthy(); + }); + it('affinity disposal selects least recently used idle server when multiplex=1', async function () { + process.env.PRERENDER_MULTIPLEX = '1'; + let { app } = buildPrerenderManagerApp(); + let request: SuperTest<Test> = supertest(app.callback()); + await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { capacity: 1, url: serverUrlA }, + }, + }); + await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { capacity: 1, url: serverUrlB }, + }, + }); + let realm = 'https://realm.example/R'; + let body = makeBody(realm, `${realm}/1`); + let affinityKey = realmAffinityKey(realm); + let firstProxyResponse = await request.post('/prerender-card').send(body); + expect(firstProxyResponse.status).toBe(201); + let firstTarget = firstProxyResponse.headers['x-boxel-prerender-target']; + expect(firstTarget).toBeTruthy(); + let disposalResponse = await request + .delete(`/prerender-servers/affinities/${encodeURIComponent(affinityKey)}`) + .query({ url: firstTarget as string }); + expect(disposalResponse.status).toBe(204); + let otherTarget = firstTarget === serverUrlA ? serverUrlB : serverUrlA; + let secondProxyResponse = await request + .post('/prerender-card') + .send(body); + expect(secondProxyResponse.status).toBe(201); + expect(secondProxyResponse.headers['x-boxel-prerender-target']).toBe(otherTarget); + }); + it('unregister removes server from routing', async function () { + process.env.PRERENDER_MULTIPLEX = '2'; + let { app } = buildPrerenderManagerApp(); + let request: SuperTest<Test> = supertest(app.callback()); + await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { capacity: 2, url: serverUrlA }, + }, + }); + await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { capacity: 2, url: serverUrlB }, + }, + }); + // unregister server A + let missingUrlUnregisterResponse = await request.delete('/prerender-servers'); + expect(missingUrlUnregisterResponse.status).toBe(400); + let unregisterResponse = await request + .delete('/prerender-servers') + .query({ url: serverUrlA as string }); + expect(unregisterResponse.status).toBe(204); + // new affinity should not target A anymore + let realm2RequestBody = makeBody('https://realm.example/R2', 'https://realm.example/R2/1'); + let proxyResponse = await request + .post('/prerender-card') + .send(realm2RequestBody); + expect(proxyResponse.status).toBe(201); + expect(proxyResponse.headers['x-boxel-prerender-target']).not.toBe(serverUrlA); + }); + it('unreachable server is removed by health sweep', async function () { + process.env.PRERENDER_MULTIPLEX = '2'; + let { app, sweepServers } = buildPrerenderManagerApp(); + let request: SuperTest<Test> = supertest(app.callback()); + await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { capacity: 2, url: serverUrlA }, + }, + }); + await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { capacity: 2, url: serverUrlB }, + }, + }); + // Verify both can be targets + let body = makeBody('https://realm.example/R', 'https://realm.example/R/1'); + let firstProxyResponse = await request.post('/prerender-card').send(body); + expect(firstProxyResponse.status).toBe(201); + // Stop server A to make it unreachable + await mockPrerenderA!.stop(); + // Run health sweep to evict unreachable + await sweepServers(); + // New realm must not target serverUrlA anymore + let realm2RequestBody = makeBody('https://realm.example/R2', 'https://realm.example/R2/1'); + let proxyResponse = await request + .post('/prerender-card') + .send(realm2RequestBody); + expect(proxyResponse.status).toBe(201); + expect(proxyResponse.headers['x-boxel-prerender-target']).not.toBe(serverUrlA); + }); + it('stale heartbeat removes server from routing', async function () { + process.env.PRERENDER_MULTIPLEX = '2'; + process.env.PRERENDER_HEARTBEAT_TIMEOUT_MS = '1'; + let { app, sweepServers, registry } = buildPrerenderManagerApp(); + let request: SuperTest<Test> = supertest(app.callback()); + await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { capacity: 2, url: serverUrlA }, + }, + }); + await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { capacity: 2, url: serverUrlB }, + }, + }); + // age server A heartbeat + let infoA = registry.servers.get(serverUrlA as string); + expect(infoA).toBeTruthy(); + if (infoA) { + infoA.lastSeenAt = Date.now() - 10000; + } + await sweepServers(); + let realm2RequestBody = makeBody('https://realm.example/R2', 'https://realm.example/R2/1'); + let proxyResponse = await request + .post('/prerender-card') + .send(realm2RequestBody); + expect(proxyResponse.status).toBe(201); + expect(proxyResponse.headers['x-boxel-prerender-target']).not.toBe(serverUrlA); + }); + it('manager retries another server when one is draining', async function () { + process.env.PRERENDER_MULTIPLEX = '2'; + let { app } = buildPrerenderManagerApp(); + let request: SuperTest<Test> = supertest(app.callback()); + mockPrerenderA?.setResponder(async (ctxt) => { + ctxt.status = PRERENDER_SERVER_DRAINING_STATUS_CODE; + ctxt.set(PRERENDER_SERVER_STATUS_HEADER, PRERENDER_SERVER_STATUS_DRAINING); + ctxt.body = 'draining'; + }); + await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { capacity: 2, url: serverUrlA }, + }, + }); + await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { capacity: 2, url: serverUrlB }, + }, + }); + let body = makeBody('https://realm.example/retry', 'https://realm.example/retry/1'); + let response = await request.post('/prerender-card').send(body); + expect(response.status).toBe(201); + expect(response.headers['x-boxel-prerender-target']).toBe(serverUrlB); + }); + it('manager prefers warmed affinity when available', async function () { + process.env.PRERENDER_MULTIPLEX = '2'; + let { app } = buildPrerenderManagerApp(); + let request: SuperTest<Test> = supertest(app.callback()); + let realm = 'https://realm.example/warmed'; + await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { + capacity: 2, + url: serverUrlA, + warmedAffinities: [realmAffinityKey(realm)], + }, + }, + }); + await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { capacity: 2, url: serverUrlB }, + }, + }); + let response = await request + .post('/prerender-card') + .send(makeBody(realm, `${realm}/1`)); + expect(response.status).toBe(201); + expect(response.headers['x-boxel-prerender-target']).toBe(serverUrlA); + }); + it('does not treat warmed user affinity as warmed realm affinity for the same value', async function () { + process.env.PRERENDER_MULTIPLEX = '2'; + let { app } = buildPrerenderManagerApp(); + let request: SuperTest<Test> = supertest(app.callback()); + let sharedValue = 'https://affinity.example/shared'; + await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { + capacity: 2, + url: serverUrlA, + warmedAffinities: [userAffinityKey(sharedValue)], + }, + }, + }); + await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { + capacity: 2, + url: serverUrlB, + warmedAffinities: [realmAffinityKey(sharedValue)], + }, + }, + }); + let response = await request + .post('/prerender-card') + .send(makeBody(sharedValue, `${sharedValue}/1`)); + expect(response.status).toBe(201); + expect(response.headers['x-boxel-prerender-target']).toBe(serverUrlB); + }); + it('run-command prefers user-warmed server', async function () { + process.env.PRERENDER_MULTIPLEX = '2'; + let { app } = buildPrerenderManagerApp(); + let request: SuperTest<Test> = supertest(app.callback()); + let realm = 'https://realm.example/commands/'; + let runAs = '@alice:localhost'; + let command = `${realm}commands/say-hello/SayHelloCommand`; + await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { + capacity: 2, + url: serverUrlA, + warmedAffinities: [realmAffinityKey(realm)], + }, + }, + }); + await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { + capacity: 2, + url: serverUrlB, + warmedAffinities: [userAffinityKey(runAs)], + }, + }, + }); + let response = await request + .post('/run-command') + .send(makeCommandBody(realm, command, runAs)); + expect(response.status).toBe(201); + expect(response.headers['x-boxel-prerender-target']).toBe(serverUrlB); + }); + it('pressure mode skips unusable LRU server and falls back to healthy', async function () { + process.env.PRERENDER_MULTIPLEX = '2'; + let { app, registry, chooseServerForAffinity } = buildPrerenderManagerApp(); + let request: SuperTest<Test> = supertest(app.callback()); + await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { capacity: 2, url: serverUrlA }, + }, + }); + await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { capacity: 2, url: serverUrlB }, + }, + }); + // seed LRU realm with both servers so pressure mode has multiple candidates + let lruRealm = 'https://realm.example/lru'; + let lruAffinityKey = realmAffinityKey(lruRealm); + registry.affinities.set(lruAffinityKey, [ + serverUrlA as string, + serverUrlB as string, + ]); + registry.lastAccessByAffinity.set(lruAffinityKey, Date.now() - 1000); + registry.servers.get(serverUrlA!)!.activeAffinities.add(lruAffinityKey); + registry.servers.get(serverUrlB!)!.activeAffinities.add(lruAffinityKey); + // make A unusable + let infoA = registry.servers.get(serverUrlA!); + if (infoA) { + infoA.status = 'draining'; + } + // simulate full capacity to bypass earlier capacity selection + registry.servers.get(serverUrlA!)!.activeAffinities.add('fillA'); + registry.servers.get(serverUrlB!)!.activeAffinities.add('fillB'); + // choose for new affinity should drop A and pick B from LRU set + let realm = 'https://realm.example/new'; + let target = chooseServerForAffinity('realm', realm); + expect(target).toBe(serverUrlB); + expect(registry.servers.get(serverUrlA!)?.activeAffinities.has(lruAffinityKey)).toBe(false); + let lruMapping = registry.affinities.get(lruAffinityKey) || []; + expect(lruMapping.includes(serverUrlA as string)).toBe(false); + if (lruMapping.length === 0) { + expect(lruMapping).toEqual([]); + } + else { + expect(lruMapping).toEqual([serverUrlB]); + } + }); + it('cleanup keeps activeAffinities in sync so capacity is restored after draining', async function () { + process.env.PRERENDER_MULTIPLEX = '1'; + let { app, registry } = buildPrerenderManagerApp(); + let request: SuperTest<Test> = supertest(app.callback()); + await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { capacity: 1, url: serverUrlA }, + }, + }); + // initial assignment + let realm1 = 'https://realm.example/one'; + let res1 = await request + .post('/prerender-card') + .send(makeBody(realm1, `${realm1}/1`)); + expect(res1.status).toBe(201); + expect(registry.servers.get(serverUrlA!)?.activeAffinities.size).toBe(1); + // mark draining and trigger cleanup via a new request (will 503) + let info = registry.servers.get(serverUrlA!); + expect(info).toBeTruthy(); + if (info) { + info.status = 'draining'; + } + let realm2 = 'https://realm.example/two'; + let res2 = await request + .post('/prerender-card') + .send(makeBody(realm2, `${realm2}/1`)); + expect(res2.status).toBe(503); + expect(registry.servers.get(serverUrlA!)?.activeAffinities.size).toBe(0); + // back to active; capacity should allow new assignment + if (info) { + info.status = 'active'; + info.lastSeenAt = Date.now(); + } + let realm3 = 'https://realm.example/three'; + let res3 = await request + .post('/prerender-card') + .send(makeBody(realm3, `${realm3}/1`)); + expect(res3.status).toBe(201); + expect(registry.servers + .get(serverUrlA!) + ?.activeAffinities.has(realmAffinityKey(realm3)) as boolean).toBe(true); + }); + it('pressure mode assignment updates activeAffinities for capacity accounting', async function () { + process.env.PRERENDER_MULTIPLEX = '1'; + let { app, registry, chooseServerForAffinity } = buildPrerenderManagerApp(); + let request: SuperTest<Test> = supertest(app.callback()); + await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { capacity: 2, url: serverUrlA }, + }, + }); + let lruRealm = 'https://realm.example/lru'; + let lruAffinityKey = realmAffinityKey(lruRealm); + let first = chooseServerForAffinity('realm', lruRealm); + expect(first).toBe(serverUrlA); + registry.lastAccessByAffinity.set(lruAffinityKey, Date.now() - 1000); + let newRealm = 'https://realm.example/new-capacity'; + let target = chooseServerForAffinity('realm', newRealm, { + exclude: [serverUrlA as string], + }); + expect(target).toBe(serverUrlA); + expect(registry.servers + .get(serverUrlA!) + ?.activeAffinities.has(realmAffinityKey(newRealm)) as boolean).toBe(true); + }); + it('returns draining response if all targets are draining', async function () { + process.env.PRERENDER_MULTIPLEX = '2'; + let { app } = buildPrerenderManagerApp(); + let request: SuperTest<Test> = supertest(app.callback()); + // Both responders return draining + mockPrerenderA?.setResponder(async (ctxt) => { + ctxt.status = PRERENDER_SERVER_DRAINING_STATUS_CODE; + ctxt.set(PRERENDER_SERVER_STATUS_HEADER, PRERENDER_SERVER_STATUS_DRAINING); + }); + mockPrerenderB?.setResponder(async (ctxt) => { + ctxt.status = PRERENDER_SERVER_DRAINING_STATUS_CODE; + ctxt.set(PRERENDER_SERVER_STATUS_HEADER, PRERENDER_SERVER_STATUS_DRAINING); + }); + await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { capacity: 2, url: serverUrlA }, + }, + }); + await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { capacity: 2, url: serverUrlB }, + }, + }); + let realm = 'https://realm.example/draining'; + let res = await request + .post('/prerender-card') + .send(makeBody(realm, `${realm}/1`)); + expect(res.status).toBe(PRERENDER_SERVER_DRAINING_STATUS_CODE); + expect(res.headers[PRERENDER_SERVER_STATUS_HEADER.toLowerCase()]).toBe(PRERENDER_SERVER_STATUS_DRAINING); + expect(res.body?.errors?.[0]?.message).toBe('All prerender servers draining'); + }); + it('returns draining immediately when manager is draining (no proxy)', async function () { + let draining = true; + let { app } = buildPrerenderManagerApp({ isDraining: () => draining }); + let request: SuperTest<Test> = supertest(app.callback()); + let res = await request + .post('/prerender-card') + .send(makeBody('https://realm.example/drain-manager', 'https://realm.example/drain-manager/1')); + expect(res.status).toBe(PRERENDER_SERVER_DRAINING_STATUS_CODE); + expect(res.headers[PRERENDER_SERVER_STATUS_HEADER.toLowerCase()]).toBe(PRERENDER_SERVER_STATUS_DRAINING); + expect(res.body?.errors?.[0]?.message).toBe('Prerender manager draining'); + }); + it('returns draining when manager starts draining during an in-flight proxy', async function () { + let draining = false; + let { app } = buildPrerenderManagerApp({ isDraining: () => draining }); + let request: SuperTest<Test> = supertest(app.callback()); + let blocker = new Deferred<void>(); + let hits = 0; + mockPrerenderA?.setResponder(async (ctxt) => { + hits++; + await new Promise((resolve) => setTimeout(resolve, 100)); + ctxt.status = 201; + ctxt.set('Content-Type', 'application/vnd.api+json'); + ctxt.body = JSON.stringify({ data: { attributes: { ok: true } } }); + blocker.fulfill(); + }); + await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { capacity: 1, url: serverUrlA }, + }, + }); + let resPromise = request + .post('/prerender-card') + .send(makeBody('https://realm.example/drain-midflight', 'https://realm.example/drain-midflight/1')); + // start draining shortly after proxying begins + await new Promise((resolve) => setTimeout(resolve, 10)); + draining = true; + let res = await resPromise; + expect(res.status).toBe(PRERENDER_SERVER_DRAINING_STATUS_CODE); + expect(res.headers[PRERENDER_SERVER_STATUS_HEADER.toLowerCase()]).toBe(PRERENDER_SERVER_STATUS_DRAINING); + expect(hits >= 0).toBeTruthy(); + }); + it('recovers when a prerender server disappears without draining', async function () { + let { app, registry } = buildPrerenderManagerApp(); + let request: SuperTest<Test> = supertest(app.callback()); + await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { capacity: 1, url: serverUrlA }, + }, + }); + // stop the server before proxying so fetch will fail + await mockPrerenderA?.stop(); + let res = await request + .post('/prerender-card') + .send(makeBody('https://realm.example/lost-server', 'https://realm.example/lost-server/1')); + expect(res.status).toBe(503); + expect(res.body?.errors?.[0]?.message).toBe('No servers'); + expect(registry.servers.size).toBe(0); + }); + it('retries another server when the first returns 500', async function () { + let { app, registry } = buildPrerenderManagerApp(); + let request: SuperTest<Test> = supertest(app.callback()); + await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { capacity: 1, url: serverUrlA }, + }, + }); + await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { capacity: 1, url: serverUrlB }, + }, + }); + mockPrerenderA?.setResponder((ctxt) => { + ctxt.status = 500; + ctxt.body = JSON.stringify({ + errors: [{ status: 500, message: 'Protocol error (Target closed)' }], + }); + }); + mockPrerenderB?.setResponder((ctxt) => { + ctxt.status = 201; + ctxt.set('Content-Type', 'application/vnd.api+json'); + ctxt.body = JSON.stringify({ data: { attributes: { ok: true } } }); + }); + let res = await request + .post('/prerender-card') + .send(makeBody('https://realm.example/server-error', 'https://realm.example/server-error/1')); + expect(res.status).toBe(201); + expect(res.headers['x-boxel-prerender-target']).toBe(serverUrlB); + expect(registry.servers.has(serverUrlA!)).toBe(false); + }); + it('maintenance reset clears realm assignments', async function () { + let { app, registry } = buildPrerenderManagerApp(); + let request: SuperTest<Test> = supertest(app.callback()); + await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { capacity: 1, url: serverUrlA }, + }, + }); + let realm = 'https://realm.example/reset'; + let affinityKey = realmAffinityKey(realm); + await request.post('/prerender-card').send(makeBody(realm, `${realm}/1`)); + expect(registry.servers + .get(serverUrlA!) + ?.activeAffinities.has(affinityKey) as boolean).toBe(true); + let resetRes = await request.post('/prerender-maintenance/reset'); + expect(resetRes.status).toBe(204); + expect(registry.servers + .get(serverUrlA!) + ?.activeAffinities.has(affinityKey) as boolean).toBe(false); + expect(registry.affinities.has(affinityKey)).toBe(false); + }); + it('pressure mode evicts LRU realm when all servers at capacity', async function () { + process.env.PRERENDER_MULTIPLEX = '1'; + let { app, registry } = buildPrerenderManagerApp(); + let request: SuperTest<Test> = supertest(app.callback()); + await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { capacity: 1, url: serverUrlA }, + }, + }); + let realm1 = 'https://realm.example/experiments/'; + let realm2 = 'https://realm.example/new'; + let res1 = await request + .post('/prerender-card') + .send(makeBody(realm1, `${realm1}1`)); + expect(res1.status).toBe(201); + let res2 = await request + .post('/prerender-card') + .send(makeBody(realm2, `${realm2}/1`)); + expect(res2.status).toBe(201); + expect(registry.affinities.has(realmAffinityKey(realm1))).toBe(false); + expect(registry.servers + .get(serverUrlA!) + ?.activeAffinities.has(realmAffinityKey(realm2)) as boolean).toBe(true); + }); + it('heartbeat clears stale active affinities when warmedAffinities are empty', async function () { + process.env.PRERENDER_MULTIPLEX = '1'; + let { app, registry, chooseServerForAffinity } = buildPrerenderManagerApp(); + let request: SuperTest<Test> = supertest(app.callback()); + await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { capacity: 1, url: serverUrlA }, + }, + }); + let staleRealm = 'https://realm.example/stale'; + let staleAffinityKey = realmAffinityKey(staleRealm); + registry.servers.get(serverUrlA!)?.activeAffinities.add(staleAffinityKey); + registry.affinities.set(staleAffinityKey, [serverUrlA as string]); + await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { + capacity: 1, + url: serverUrlA, + warmedAffinities: [], + }, + }, + }); + expect(registry.servers.get(serverUrlA!)?.activeAffinities.size).toBe(0); + expect(registry.affinities.has(staleAffinityKey)).toBe(false); + let newRealm = 'https://realm.example/newafterclear'; + let target = chooseServerForAffinity('realm', newRealm); + expect(target).toBe(serverUrlA); + }); + it('returns 503 when no prerender servers are registered', async function () { + let { app } = buildPrerenderManagerApp(); + let request: SuperTest<Test> = supertest(app.callback()); + let res = await request + .post('/prerender-card') + .send(makeBody('https://realm.example/none', 'https://realm.example/none/1')); + expect(res.status).toBe(503); + expect(res.body?.errors?.[0]?.message).toBe('No servers'); + }); + it('waits for discovery when registry empty before returning 503', async function () { + process.env.PRERENDER_SERVER_DISCOVERY_WAIT_MS = '500'; + process.env.PRERENDER_SERVER_DISCOVERY_POLL_MS = '25'; + let { app } = buildPrerenderManagerApp(); + let request: SuperTest<Test> = supertest(app.callback()); + // schedule heartbeat registration shortly after request starts + let registration = new Promise<void>((resolve) => { + setTimeout(() => { + request + .post('/prerender-servers') + .send({ + data: { + type: 'prerender-server', + attributes: { capacity: 2, url: serverUrlA }, + }, + }) + .then(() => resolve()) + .catch(() => resolve()); + }, 100); + }); + let res = await request + .post('/prerender-card') + .send(makeBody('https://realm.example/discovery', 'https://realm.example/discovery/1')); + await registration; + expect(res.status).toBe(201); + expect(res.headers['x-boxel-prerender-target']).toBe(serverUrlA); + }); + it('returns draining immediately when manager is draining', async function () { + let draining = true; + let { app } = buildPrerenderManagerApp({ + isDraining: () => draining, + }); + let request: SuperTest<Test> = supertest(app.callback()); + let hits = 0; + mockPrerenderA?.setResponder((ctxt) => { + hits++; + ctxt.status = 201; + ctxt.set('Content-Type', 'application/vnd.api+json'); + ctxt.body = JSON.stringify({ data: { attributes: { ok: true } } }); + }); + await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { capacity: 2, url: serverUrlA }, + }, + }); + let res = await request + .post('/prerender-card') + .send(makeBody('https://realm.example/draining', 'https://realm.example/draining/1')); + expect(res.status).toBe(PRERENDER_SERVER_DRAINING_STATUS_CODE); + expect(res.headers[PRERENDER_SERVER_STATUS_HEADER.toLowerCase()]).toBe(PRERENDER_SERVER_STATUS_DRAINING); + expect(hits).toBe(0); + }); + }); +}); +function makeMockPrerender(): { + app: Koa; + router: Router; + server: Server; + stop: () => Promise<void>; + setResponder: (responder: (ctxt: Koa.Context, body: any, type: 'card' | 'module' | 'command') => Promise<void> | void) => void; +} { + let app = new Koa(); + let router = new Router(); + router.get('/', (ctxt) => { + ctxt.status = 200; + ctxt.body = 'OK'; + }); + let responder: (ctxt: Koa.Context, body: any, type: 'card' | 'module' | 'command') => Promise<void> | void = defaultResponder; + async function readBody(ctxt: Koa.Context) { + return await new Promise<string>((resolve) => { + let buf: Buffer[] = []; + ctxt.req.on('data', (c) => buf.push(c)); + ctxt.req.on('end', () => resolve(Buffer.concat(buf).toString('utf8'))); + }); + } + function defaultResponder(ctxt: Koa.Context, body: any, type: 'card' | 'module' | 'command') { + ctxt.status = 201; + ctxt.set('Content-Type', 'application/vnd.api+json'); + if (type === 'card') { + ctxt.body = JSON.stringify({ + data: { + type: 'prerender-result', + id: body?.data?.attributes?.url || 'x', + attributes: { ok: true }, + }, + meta: { + timing: { launchMs: 0, renderMs: 0, totalMs: 0 }, + pool: { + pageId: 'p', + affinityType: body?.data?.attributes?.affinityType ?? 'realm', + affinityValue: body?.data?.attributes?.affinityValue ?? 'unknown', + reused: false, + evicted: false, + }, + }, + }); + } + else if (type === 'module') { + ctxt.body = JSON.stringify({ + data: { + type: 'prerender-module-result', + id: body?.data?.attributes?.url || 'x', + attributes: { + id: body?.data?.attributes?.url || 'x', + status: 'ready', + isShimmed: false, + nonce: '1', + lastModified: 0, + createdAt: 0, + deps: [], + definitions: {}, + }, + }, + meta: { + timing: { launchMs: 0, renderMs: 0, totalMs: 0 }, + pool: { + pageId: 'p', + affinityType: body?.data?.attributes?.affinityType ?? 'realm', + affinityValue: body?.data?.attributes?.affinityValue ?? 'unknown', + reused: false, + evicted: false, + }, + }, + }); + } + else { + ctxt.body = JSON.stringify({ + data: { + type: 'command-result', + id: body?.data?.attributes?.command || 'command', + attributes: { + status: 'ready', + cardResultString: null, + }, + }, + meta: { + timing: { launchMs: 0, renderMs: 0, totalMs: 0 }, + pool: { + pageId: 'p', + affinityType: body?.data?.attributes?.affinityType ?? 'realm', + affinityValue: body?.data?.attributes?.affinityValue ?? 'unknown', + reused: false, + evicted: false, + }, + }, + }); + } + } + router.post('/prerender-card', async (ctxt) => { + let raw = await readBody(ctxt); + let body = raw ? JSON.parse(raw) : {}; + await responder(ctxt, body, 'card'); + }); + router.post('/prerender-module', async (ctxt) => { + let raw = await readBody(ctxt); + let body = raw ? JSON.parse(raw) : {}; + await responder(ctxt, body, 'module'); + }); + router.post('/run-command', async (ctxt) => { + let raw = await readBody(ctxt); + let body = raw ? JSON.parse(raw) : {}; + await responder(ctxt, body, 'command'); + }); + app.use(router.routes()); + let server = createServer(app.callback()).listen(0); + let stopped = false; + return { + app, + router, + server, + stop: () => new Promise((resolve) => { + if (stopped) + return resolve(); + stopped = true; + server.close(() => resolve()); + }), + setResponder: (r) => { + responder = r; + }, + }; +} +function makeBody(realm: string, url: string) { + let auth = makeAuth(realm); + return { + data: { + type: 'prerender-request', + attributes: { + affinityType: 'realm', + affinityValue: realm, + url, + auth, + realm, + }, + }, + }; +} +function realmAffinityKey(realm: string) { + return toAffinityKey({ affinityType: 'realm', affinityValue: realm }); +} +function userAffinityKey(userId: string) { + return toAffinityKey({ affinityType: 'user', affinityValue: userId }); +} +function makeModuleBody(realm: string, url: string) { + let auth = makeAuth(realm); + return { + data: { + type: 'prerender-module-request', + attributes: { + affinityType: 'realm', + affinityValue: realm, + url, + auth, + realm, + }, + }, + }; +} +function makeCommandBody(realm: string, command: string, runAs = '@user:localhost') { + let auth = makeAuth(realm); + return { + data: { + type: 'command-request', + attributes: { + affinityType: 'user', + affinityValue: runAs, + realm, + auth, + command, + }, + }, + }; +} +function makeAuth(realm: string) { + return testCreatePrerenderAuth('@user:localhost', { + [realm]: ['read', 'write', 'realm-owner'], + }); +} diff --git a/packages/realm-server/tests-vitest/prerender-proxy.test.ts b/packages/realm-server/tests-vitest/prerender-proxy.test.ts new file mode 100644 index 00000000000..00698413559 --- /dev/null +++ b/packages/realm-server/tests-vitest/prerender-proxy.test.ts @@ -0,0 +1,320 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect } from "vitest"; +import Koa from 'koa'; +import Router from '@koa/router'; +import supertest from 'supertest'; +import type { DBAdapter, Prerenderer } from '@cardstack/runtime-common'; +import type { RenderRouteOptions } from '@cardstack/runtime-common'; +import handlePrerenderProxy from '../handlers/handle-prerender-proxy'; +import { jwtMiddleware } from '../middleware'; +import { createJWT } from '../utils/jwt'; +import { realmSecretSeed } from './helpers'; +import { buildCreatePrerenderAuth } from '../prerender/auth'; +import { verifyJWT } from '../jwt'; +describe("prerender-proxy-test.ts", function () { + describe('prerender proxy', function () { + let createPrerenderAuth = buildCreatePrerenderAuth(realmSecretSeed); + function makeDbAdapter(rows: any[]): DBAdapter { + return { + kind: 'pg', + isClosed: false, + async execute() { + return rows; + }, + async close() { }, + async getColumnNames() { + return []; + }, + }; + } + function makePrerenderer() { + let renderCalls: Array<{ + kind: 'card' | 'module' | 'file-extract' | 'file-render' | 'command'; + args: { + affinityType?: 'realm' | 'user'; + affinityValue?: string; + realm?: string; + userId?: string; + url?: string; + auth: string; + command?: string; + commandInput?: Record<string, unknown> | null; + renderOptions?: RenderRouteOptions; + }; + }> = []; + let prerenderer: Prerenderer = { + async prerenderCard(args) { + renderCalls.push({ kind: 'card', args }); + return { + serialized: null, + searchDoc: { url: args.url, cardTitle: 'through proxy' }, + displayNames: ['Proxy Card'], + deps: [], + types: [], + isolatedHTML: `<div>${args.url}</div>`, + headHTML: null, + atomHTML: null, + embeddedHTML: {}, + fittedHTML: {}, + iconHTML: null, + }; + }, + async prerenderModule(args) { + renderCalls.push({ kind: 'module', args }); + return { + id: args.url, + status: 'ready', + nonce: 'nonce', + isShimmed: false, + lastModified: Date.now(), + createdAt: Date.now(), + deps: [], + definitions: {}, + }; + }, + async prerenderFileExtract(args) { + renderCalls.push({ kind: 'file-extract', args }); + return { + id: args.url, + nonce: 'nonce', + status: 'ready', + searchDoc: { url: args.url, title: 'through proxy' }, + deps: [], + }; + }, + async prerenderFileRender(args) { + renderCalls.push({ kind: 'file-render', args }); + return { + isolatedHTML: null, + headHTML: null, + atomHTML: null, + embeddedHTML: null, + fittedHTML: null, + iconHTML: null, + }; + }, + async runCommand(args) { + renderCalls.push({ kind: 'command', args }); + return { + status: 'ready', + cardResultString: null, + }; + }, + }; + return { prerenderer, renderCalls }; + } + it('proxies prerender requests to the configured prerenderer', async function () { + let { prerenderer, renderCalls } = makePrerenderer(); + let dbAdapter = makeDbAdapter([ + { + username: '@someone:localhost', + read: true, + write: true, + realm_owner: false, + }, + ]); + let app = new Koa(); + let router = new Router(); + router.post('/_prerender-card', jwtMiddleware(realmSecretSeed), handlePrerenderProxy({ + kind: 'card', + prerenderer, + dbAdapter, + createPrerenderAuth, + })); + app.use(router.routes()); + let token = createJWT({ user: '@someone:localhost', sessionRoom: '!room:localhost' }, realmSecretSeed); + let cardURL = 'http://example/card'; + let realm = 'http://example/'; + let payload = { + data: { + attributes: { realm, url: cardURL }, + }, + }; + let response = await supertest(app.callback()) + .post('/_prerender-card') + .set('Authorization', `Bearer ${token}`) + .send(payload) + .expect(201); + expect(response.body).toEqual({ + data: { + type: 'prerender-result', + id: cardURL, + attributes: { + serialized: null, + searchDoc: { url: cardURL, cardTitle: 'through proxy' }, + displayNames: ['Proxy Card'], + deps: [], + types: [], + isolatedHTML: `<div>${cardURL}</div>`, + headHTML: null, + atomHTML: null, + embeddedHTML: {}, + fittedHTML: {}, + iconHTML: null, + }, + }, + }); + expect(renderCalls.length).toEqual(1); + expect(renderCalls[0]?.kind).toBe('card'); + expect(renderCalls[0]?.args).toEqual({ + affinityType: 'realm', + affinityValue: realm, + realm, + url: cardURL, + auth: renderCalls[0]?.args.auth, + renderOptions: undefined, + }); + let sessions = JSON.parse(renderCalls[0]!.args.auth); + let tokenClaims = verifyJWT(sessions[realm], realmSecretSeed); + expect(tokenClaims.user).toBe('@someone:localhost'); + expect(tokenClaims.permissions).toEqual(['read', 'write']); + expect(tokenClaims.realm).toBe(realm); + }); + it('returns an error when no upstream is configured', async function () { + let app = new Koa(); + let router = new Router(); + router.post('/_prerender-card', jwtMiddleware(realmSecretSeed), handlePrerenderProxy({ + kind: 'card', + prerenderer: undefined, + dbAdapter: makeDbAdapter([]), + createPrerenderAuth, + })); + app.use(router.routes()); + let token = createJWT({ user: '@someone:localhost', sessionRoom: '!room:localhost' }, realmSecretSeed); + let res = await supertest(app.callback()) + .post('/_prerender-card') + .set('Authorization', `Bearer ${token}`) + .send({ data: { attributes: {} } }) + .expect(500); + expect(res.text.includes('Prerender proxy is not configured')).toBeTruthy(); + }); + it('returns unauthorized when no token is provided', async function () { + let { prerenderer } = makePrerenderer(); + let app = new Koa(); + let router = new Router(); + router.post('/_prerender-card', jwtMiddleware(realmSecretSeed), handlePrerenderProxy({ + kind: 'card', + prerenderer, + dbAdapter: makeDbAdapter([]), + createPrerenderAuth, + })); + app.use(router.routes()); + let res = await supertest(app.callback()) + .post('/_prerender-card') + .send({ + data: { + attributes: { + realm: 'http://localhost:4201/base/', + url: 'http://localhost:4201/base/some-card', + }, + }, + }) + .expect(401); + expect(res.body.errors).toEqual(['Missing Authorization header']); + }); + it('returns forbidden when user has no realm permissions', async function () { + let { prerenderer, renderCalls } = makePrerenderer(); + let app = new Koa(); + let router = new Router(); + router.post('/_prerender-card', jwtMiddleware(realmSecretSeed), handlePrerenderProxy({ + kind: 'card', + prerenderer, + dbAdapter: makeDbAdapter([]), // no permissions + createPrerenderAuth, + })); + app.use(router.routes()); + let token = createJWT({ user: '@someone:localhost', sessionRoom: '!room:localhost' }, realmSecretSeed); + let res = await supertest(app.callback()) + .post('/_prerender-card') + .set('Authorization', `Bearer ${token}`) + .send({ + data: { + attributes: { + realm: 'http://localhost:4201/base/', + url: 'http://localhost:4201/base/some-card', + }, + }, + }); + expect(res.status).toBe(403); + expect(renderCalls).toEqual([]); + }); + it('proxies to prerender server card and module endpoints', async function () { + let { prerenderer, renderCalls } = makePrerenderer(); + let realm = 'http://example.test/'; + let dbAdapter = makeDbAdapter([ + { + username: '@someone:localhost', + read: true, + write: true, + realm_owner: false, + }, + ]); + let app = new Koa(); + let router = new Router(); + router.post('/_prerender-card', jwtMiddleware(realmSecretSeed), handlePrerenderProxy({ + kind: 'card', + prerenderer, + dbAdapter, + createPrerenderAuth, + })); + router.post('/_prerender-module', jwtMiddleware(realmSecretSeed), handlePrerenderProxy({ + kind: 'module', + prerenderer, + dbAdapter, + createPrerenderAuth, + })); + app.use(router.routes()); + let token = createJWT({ user: '@someone:localhost', sessionRoom: '!room:localhost' }, realmSecretSeed); + let cardUrl = `${realm}card`; + let cardResponse = await supertest(app.callback()) + .post('/_prerender-card') + .set('Authorization', `Bearer ${token}`) + .send({ + data: { attributes: { realm, url: cardUrl } }, + }) + .expect(201); + expect(cardResponse.body.data.type).toBe('prerender-result'); + expect(cardResponse.body.data.id).toBe(cardUrl); + expect(cardResponse.body.data.attributes.displayNames).toEqual([ + 'Proxy Card', + ]); + let moduleUrl = `${realm}module.gts`; + let moduleResponse = await supertest(app.callback()) + .post('/_prerender-module') + .set('Authorization', `Bearer ${token}`) + .send({ + data: { attributes: { realm, url: moduleUrl } }, + }) + .expect(201); + expect(moduleResponse.body.data.type).toBe('prerender-module-result'); + expect(moduleResponse.body.data.id).toBe(moduleUrl); + expect(moduleResponse.body.data.attributes.status).toBe('ready'); + expect(renderCalls.map(({ kind, args }) => { + let sessions = JSON.parse(args.auth); + let claims = verifyJWT(sessions[realm], realmSecretSeed); + return { + kind, + realm: args.realm, + url: args.url, + permissions: { [claims.realm]: claims.permissions }, + userId: claims.user, + }; + })).toEqual([ + { + kind: 'card', + realm, + url: cardUrl, + permissions: { [realm]: ['read', 'write'] }, + userId: '@someone:localhost', + }, + { + kind: 'module', + realm, + url: moduleUrl, + permissions: { [realm]: ['read', 'write'] }, + userId: '@someone:localhost', + }, + ]); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/prerender-server.test.ts b/packages/realm-server/tests-vitest/prerender-server.test.ts new file mode 100644 index 00000000000..6f7645ae2bb --- /dev/null +++ b/packages/realm-server/tests-vitest/prerender-server.test.ts @@ -0,0 +1,475 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import type { SuperTest, Test } from 'supertest'; +import supertest from 'supertest'; +import { setupPermissionedRealmCached, testCreatePrerenderAuth, } from './helpers'; +import { buildPrerenderApp } from '../prerender/prerender-app'; +import type { Prerenderer } from '../prerender'; +import { baseCardRef } from '@cardstack/runtime-common'; +import { PRERENDER_SERVER_DRAINING_STATUS_CODE, PRERENDER_SERVER_STATUS_DRAINING, PRERENDER_SERVER_STATUS_HEADER, } from '../prerender/prerender-constants'; +import { toAffinityKey } from '../prerender/affinity'; +import { Deferred } from '@cardstack/runtime-common'; +describe("prerender-server-test.ts", function () { + describe('Prerender server', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let request: SuperTest<Test>; + let prerenderer: Prerenderer; + const testUserId = '@jade:localhost'; + let draining = false; + let realmURL = new URL('http://127.0.0.1:4444/test/'); + setupPermissionedRealmCached(hooks, { + mode: 'before', + permissions: { [testUserId]: ['read', 'write', 'realm-owner'] }, + realmURL, + fileSystem: { + 'pet.gts': ` + import { CardDef, field, contains, StringField } from 'https://cardstack.com/base/card-api'; + import { Component } from 'https://cardstack.com/base/card-api'; + export class Pet extends CardDef { + static displayName = 'Pet'; + @field name = contains(StringField); + static embedded = <template>{{@fields.name}} is a good pet</template> + } + `, + '1.json': { + data: { + attributes: { name: 'Maple' }, + meta: { + adoptsFrom: { module: './pet', name: 'Pet' }, + }, + }, + }, + 'command-runner-test.gts': ` + import { Command } from '@cardstack/runtime-common'; + import { + CardDef, + field, + contains, + StringField, + } from 'https://cardstack.com/base/card-api'; + + export class CommandResult extends CardDef { + static displayName = 'CommandResult'; + @field message = contains(StringField); + } + + export class SayHelloCommand extends Command< + undefined, + typeof CommandResult + > { + static displayName = 'SayHelloCommand'; + async getInputType() { + return undefined; + } + protected async run(): Promise<CommandResult> { + return new CommandResult({ message: 'hello from command' }); + } + } + + export class SayGoodbyeCommand extends Command< + undefined, + typeof CommandResult + > { + static displayName = 'SayGoodbyeCommand'; + async getInputType() { + return undefined; + } + protected async run(): Promise<CommandResult> { + return new CommandResult({ message: 'goodbye from command' }); + } + } + + export class ThrowErrorCommand extends Command< + undefined, + typeof CommandResult + > { + static displayName = 'ThrowErrorCommand'; + async getInputType() { + return undefined; + } + protected async run(): Promise<CommandResult> { + throw new Error('command exploded'); + } + } + `, + }, + }); + hooks.before(function () { + draining = false; + let built = buildPrerenderApp({ + serverURL: 'http://127.0.0.1:4221', + isDraining: () => draining, + }); + prerenderer = built.prerenderer; + request = supertest(built.app.callback()); + }); + hooks.after(async function () { + await prerenderer.stop(); + }); + it('liveness', async function () { + let res = await request.get('/').set('Accept', 'application/json'); + expect(res.status).toBe(200); + expect(res.body).toEqual({ ready: true }); + }); + it('it handles prerender request', async function () { + let url = `${realmURL.href}1`; + let permissions = { + [realmURL.href]: ['read', 'write', 'realm-owner'] as ('read' | 'write' | 'realm-owner')[], + }; + let auth = testCreatePrerenderAuth(testUserId, permissions); + let res = await request + .post('/prerender-card') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .send({ + data: { + type: 'prerender-request', + attributes: { + url, + auth, + realm: realmURL.href, + affinityType: 'realm', + affinityValue: realmURL.href, + }, + }, + }); + expect(res.status).toBe(201); + expect(res.body.data.type).toBe('prerender-result'); + expect(res.body.data.id).toBe(url); + expect(res.body.data.attributes.displayNames).toEqual(['Pet', 'Card']); + expect(res.body.data.attributes.searchDoc?.name).toBe('Maple'); + expect(res.body.data.attributes.searchDoc?._cardType).toBe('Pet'); + expect(/Maple/.test(res.body.data.attributes.isolatedHTML ?? '')).toBeTruthy(); + // spot check a few deps, as the whole list is overwhelming... + expect(res.body.data.attributes.deps?.includes(baseCardRef.module)).toBeTruthy(); + expect(res.body.data.attributes.deps?.includes(`${realmURL.href}pet`)).toBeTruthy(); + expect((res.body.data.attributes.deps as string[]).find((d) => d.match(/^https:\/\/cardstack.com\/base\/card-api\.gts\..*glimmer-scoped\.css$/))).toBeTruthy(); + expect(res.body.meta?.timing?.totalMs >= 0).toBeTruthy(); + expect(res.body.meta?.pool?.pageId).toBeTruthy(); + expect(res.body.meta?.pool?.evicted).toBe(false); + expect(res.body.meta?.pool?.timedOut).toBe(false); + expect(res.body.meta?.pool?.affinityType).toBe('realm'); + expect(res.body.meta?.pool?.affinityValue).toBe(realmURL.href); + }); + it('it handles module prerender request', async function () { + let url = `${realmURL.href}pet.gts`; + let permissions = { + [realmURL.href]: ['read', 'write', 'realm-owner'] as ('read' | 'write' | 'realm-owner')[], + }; + let auth = testCreatePrerenderAuth(testUserId, permissions); + let res = await request + .post('/prerender-module') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .send({ + data: { + type: 'prerender-module-request', + attributes: { + url, + auth, + realm: realmURL.href, + affinityType: 'realm', + affinityValue: realmURL.href, + }, + }, + }); + expect(res.status).toBe(201); + expect(res.body.data.type).toBe('prerender-module-result'); + expect(res.body.data.id).toBe(url); + expect(res.body.data.attributes.status).toBe('ready'); + expect(res.body.data.attributes.isShimmed).toBe(false); + expect(Object.keys(res.body.data.attributes.definitions || {}).length > 0).toBe(true); + expect(res.body.meta?.timing?.totalMs >= 0).toBeTruthy(); + expect(res.body.meta?.pool?.pageId).toBeTruthy(); + }); + describe('run-command', function () { + it('it handles run-command request', async function () { + let permissions = { + [realmURL.href]: ['read', 'write', 'realm-owner'] as ('read' | 'write' | 'realm-owner')[], + }; + let auth = testCreatePrerenderAuth(testUserId, permissions); + let command = `${realmURL.href}command-runner-test/SayHelloCommand`; + let res = await request + .post('/run-command') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .send({ + data: { + type: 'command-request', + attributes: { + realm: realmURL.href, + auth, + command, + affinityType: 'user', + affinityValue: testUserId, + }, + }, + }); + expect(res.status).toBe(201); + expect(res.body.data.type).toBe('command-result'); + expect(res.body.data.id).toBe(command); + expect(res.body.data.attributes.status).toBe('ready'); + expect(res.body.data.attributes.error).toBeFalsy(); + let cardResultString = res.body.data.attributes.cardResultString; + expect(typeof cardResultString).toBe('string'); + expect(res.body.data.attributes.cardResult).toBeFalsy(); + expect(cardResultString.length > 0).toBeTruthy(); + expect(cardResultString.includes('hello from command')).toBeTruthy(); + expect(res.body.meta?.timing?.totalMs >= 0).toBeTruthy(); + expect(res.body.meta?.pool?.pageId).toBeTruthy(); + }); + it('it captures run-command error state', async function () { + let permissions = { + [realmURL.href]: ['read', 'write', 'realm-owner'] as ('read' | 'write' | 'realm-owner')[], + }; + let auth = testCreatePrerenderAuth(testUserId, permissions); + let command = `${realmURL.href}command-runner-test/ThrowErrorCommand`; + let res = await request + .post('/run-command') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .send({ + data: { + type: 'command-request', + attributes: { + realm: realmURL.href, + auth, + command, + affinityType: 'user', + affinityValue: testUserId, + }, + }, + }); + expect(res.status).toBe(201); + expect(res.body.data.type).toBe('command-result'); + expect(res.body.data.attributes.status).toBe('error'); + expect((res.body.data.attributes.error as string).includes('command exploded')).toBeTruthy(); + expect(res.body.data.attributes.cardResultString).toBeFalsy(); + expect(res.body.data.attributes.cardResult).toBeFalsy(); + expect(res.body.meta?.timing?.totalMs >= 0).toBeTruthy(); + expect(res.body.meta?.pool?.pageId).toBeTruthy(); + }); + it('concurrent commands each return their own correct result', async function () { + let permissions = { + [realmURL.href]: ['read', 'write', 'realm-owner'] as ('read' | 'write' | 'realm-owner')[], + }; + let auth = testCreatePrerenderAuth(testUserId, permissions); + let helloCommand = `${realmURL.href}command-runner-test/SayHelloCommand`; + let goodbyeCommand = `${realmURL.href}command-runner-test/SayGoodbyeCommand`; + let [resultA, resultB, resultC] = await Promise.all([ + prerenderer.runCommand({ + userId: '@user-a:localhost', + auth, + command: helloCommand, + opts: { simulateTimeoutMs: 500 }, + }), + prerenderer.runCommand({ + userId: '@user-b:localhost', + auth, + command: goodbyeCommand, + opts: { simulateTimeoutMs: 500 }, + }), + prerenderer.runCommand({ + userId: '@user-c:localhost', + auth, + command: helloCommand, + opts: { simulateTimeoutMs: 500 }, + }), + ]); + expect(resultA.response.status).toBe('ready'); + expect(resultB.response.status).toBe('ready'); + expect(resultC.response.status).toBe('ready'); + expect(resultA.response.cardResultString?.includes('hello from command')).toBeTruthy(); + expect(resultB.response.cardResultString?.includes('goodbye from command')).toBeTruthy(); + expect(resultC.response.cardResultString?.includes('hello from command')).toBeTruthy(); + expect(resultA.response.error).toBeFalsy(); + expect(resultB.response.error).toBeFalsy(); + expect(resultC.response.error).toBeFalsy(); + }); + it('it returns unusable status when command times out', async function () { + let permissions = { + [realmURL.href]: ['read', 'write', 'realm-owner'] as ('read' | 'write' | 'realm-owner')[], + }; + let auth = testCreatePrerenderAuth(testUserId, permissions); + let command = `${realmURL.href}command-runner-test/SayHelloCommand`; + let result = await prerenderer.runCommand({ + userId: testUserId, + auth, + command, + opts: { timeoutMs: 1, simulateTimeoutMs: 25 }, + }); + expect(result.response.status).toBe('unusable'); + expect(result.response.error?.includes('Render timed-out')).toBeTruthy(); + expect(result.pool.timedOut).toBe(true); + }); + }); + it('reports draining status when shutting down', async function () { + draining = true; + const permissions: Record<string, ('read' | 'write' | 'realm-owner')[]> = { [realmURL.href]: ['read', 'write', 'realm-owner'] }; + let auth = testCreatePrerenderAuth(testUserId, permissions); + let res = await request + .post('/prerender-card') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .send({ + data: { + type: 'prerender-request', + attributes: { + url: `${realmURL.href}drain`, + auth, + realm: realmURL.href, + affinityType: 'realm', + affinityValue: realmURL.href, + }, + }, + }); + expect(res.status).toBe(PRERENDER_SERVER_DRAINING_STATUS_CODE); + expect(res.headers[PRERENDER_SERVER_STATUS_HEADER.toLowerCase()]).toBe(PRERENDER_SERVER_STATUS_DRAINING); + draining = false; + }); + it('HEAD reflects draining state', async function () { + draining = true; + let res = await request.head('/').set('Accept', 'application/json'); + expect(res.status).toBe(PRERENDER_SERVER_DRAINING_STATUS_CODE); + expect(res.headers[PRERENDER_SERVER_STATUS_HEADER.toLowerCase()]).toBe(PRERENDER_SERVER_STATUS_DRAINING); + draining = false; + }); + it('tracks warmed affinities for heartbeat', async function () { + let beforeWarm = prerenderer.getWarmAffinities(); + let url = `${realmURL.href}2`; + const permissions: Record<string, ('read' | 'write' | 'realm-owner')[]> = { [realmURL.href]: ['read', 'write', 'realm-owner'] }; + let auth = testCreatePrerenderAuth(testUserId, permissions); + await request + .post('/prerender-card') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .send({ + data: { + type: 'prerender-request', + attributes: { + url, + auth, + realm: realmURL.href, + affinityType: 'realm', + affinityValue: realmURL.href, + }, + }, + }); + expect(prerenderer.getWarmAffinities().includes(toAffinityKey({ + affinityType: 'realm', + affinityValue: realmURL.href, + }))).toBe(true); + expect(prerenderer.getWarmAffinities().length >= beforeWarm.length).toBe(true); + }); + it('responds draining immediately when shutdown begins during an in-flight prerender', async function () { + let localDraining = false; + let drainingDeferred = new Deferred<void>(); + let built = buildPrerenderApp({ + serverURL: 'http://127.0.0.1:4222', + isDraining: () => localDraining, + drainingPromise: drainingDeferred.promise, + }); + let localRequest = supertest(built.app.callback()); + let execDeferred = new Deferred<void>(); + let stubResponse = { + response: { ok: true }, + timings: { launchMs: 0, renderMs: 0 }, + pool: { + pageId: 'p', + affinityType: 'realm', + affinityValue: realmURL.href, + reused: false, + evicted: false, + timedOut: false, + }, + }; + let originalPrerender = (built.prerenderer as any).prerenderCard; + (built.prerenderer as any).prerenderCard = async () => { + await execDeferred.promise; + return stubResponse; + }; + let permissions: Record<string, ('read' | 'write' | 'realm-owner')[]> = { + [realmURL.href]: ['read', 'write', 'realm-owner'], + }; + let auth = testCreatePrerenderAuth(testUserId, permissions); + let resPromise = localRequest + .post('/prerender-card') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .send({ + data: { + type: 'prerender-request', + attributes: { + url: `${realmURL.href}drain-midflight`, + auth, + realm: realmURL.href, + affinityType: 'realm', + affinityValue: realmURL.href, + }, + }, + }); + // Allow handler to start by yielding once inside execute + await Promise.resolve(); + // simulate shutdown signal while prerender is in progress (after handler start) + localDraining = true; + drainingDeferred.fulfill(); + let res = await resPromise; + expect(res.status).toBe(PRERENDER_SERVER_DRAINING_STATUS_CODE); + expect(res.headers[PRERENDER_SERVER_STATUS_HEADER.toLowerCase()]).toBe(PRERENDER_SERVER_STATUS_DRAINING); + // clean up + execDeferred.fulfill(); + (built.prerenderer as any).prerenderCard = originalPrerender; + await built.prerenderer.stop(); + }); + it('draining race does not leak unhandled rejection from execute', async function () { + let unhandled = 0; + let onUnhandled = () => unhandled++; + process.on('unhandledRejection', onUnhandled); + try { + let built = buildPrerenderApp({ + serverURL: 'http://127.0.0.1:4223', + isDraining: () => true, + drainingPromise: Promise.resolve(), + }); + let localRequest = supertest(built.app.callback()); + let originalPrerender = (built.prerenderer as any).prerenderCard; + (built.prerenderer as any).prerenderCard = async () => { + throw new Error('boom'); + }; + let permissions: Record<string, ('read' | 'write' | 'realm-owner')[]> = { [realmURL.href]: ['read', 'write', 'realm-owner'] }; + let auth = testCreatePrerenderAuth(testUserId, permissions); + let res = await localRequest + .post('/prerender-card') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .send({ + data: { + type: 'prerender-request', + attributes: { + url: `${realmURL.href}drain-unhandled`, + auth, + realm: realmURL.href, + affinityType: 'realm', + affinityValue: realmURL.href, + }, + }, + }); + expect(res.status).toBe(PRERENDER_SERVER_DRAINING_STATUS_CODE); + expect(res.headers[PRERENDER_SERVER_STATUS_HEADER.toLowerCase()]).toBe(PRERENDER_SERVER_STATUS_DRAINING); + // allow promise rejection to settle + await Promise.resolve(); + expect(unhandled).toBe(0); + (built.prerenderer as any).prerenderCard = originalPrerender; + await built.prerenderer.stop(); + } + finally { + process.off('unhandledRejection', onUnhandled); + } + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/prerendering.test.ts b/packages/realm-server/tests-vitest/prerendering.test.ts new file mode 100644 index 00000000000..3c5906605ae --- /dev/null +++ b/packages/realm-server/tests-vitest/prerendering.test.ts @@ -0,0 +1,3999 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import type { RealmPermissions, RealmAdapter, RenderResponse, ModuleRenderResponse, FileExtractResponse, RenderRouteOptions, } from '@cardstack/runtime-common'; +import { baseRealm, type Realm as RuntimeRealm, } from '@cardstack/runtime-common'; +import type { Prerenderer } from '../prerender/index'; +import { PagePool } from '../prerender/page-pool'; +import { RenderRunner } from '../prerender/render-runner'; +import { setupPermissionedRealmsCached, cleanWhiteSpace, testCreatePrerenderAuth, getPrerendererForTesting, } from './helpers'; +import '@cardstack/runtime-common/helpers/code-equality-assertion'; +import { baseCardRef, trimExecutableExtension, } from '@cardstack/runtime-common'; +import { installDelayedRuntimeRealmSearchPatch, installRealmServerAssertOwnRealmServerBypassPatch, installSearchRequestObserverPatch, } from './helpers/prerender-page-patches'; +class TestSemaphore { + #available: number; + #queue: Array<(release: () => void) => void> = []; + constructor(max: number) { + this.#available = Math.max(1, max); + } + async acquire(): Promise<() => void> { + if (this.#available > 0) { + this.#available--; + return this.#release; + } + return await new Promise<() => void>((resolve) => { + this.#queue.push(resolve); + }); + } + #release = () => { + let next = this.#queue.shift(); + if (next) { + next(this.#release); + return; + } + this.#available++; + }; +} +function makeStubPagePool(maxPages: number, renderSemaphore?: { + acquire(): Promise<() => void>; +}, createContextDelay?: (contextNumber: number) => Promise<void>, options?: { + disableStandbyRefill?: boolean; + standbyTimeoutMs?: number; + closeContextDelay?: (id: string) => Promise<void>; + onContextCreated?: (id: string) => void; + onContextClosed?: (id: string) => void; +}) { + function makeStorage(): Storage { + let values: Record<string, string> = {}; + return { + getItem(key: string) { + return values[key] ?? null; + }, + setItem(key: string, value: string) { + values[key] = value; + }, + removeItem(key: string) { + delete values[key]; + }, + clear() { + values = {}; + }, + key(index: number) { + return Object.keys(values)[index] ?? null; + }, + get length() { + return Object.keys(values).length; + }, + } as Storage; + } + let contextCounter = 0; + let contextsCreated: string[] = []; + let contextsClosed: string[] = []; + let browser = { + async createBrowserContext() { + let counter = ++contextCounter; + if (createContextDelay) { + await createContextDelay(counter); + } + let id = `ctx-${counter}`; + contextsCreated.push(id); + options?.onContextCreated?.(id); + let localStorage = makeStorage(); + let context = { + async newPage() { + return { + async goto(_url: string, _opts?: any) { + return; + }, + async waitForFunction(_fn: any) { + return true; + }, + async evaluate(fn: (...args: any[]) => any, ...args: any[]) { + let originalLocalStorage = (globalThis as any).localStorage; + (globalThis as any).localStorage = localStorage; + try { + return await fn(...args); + } + finally { + (globalThis as any).localStorage = originalLocalStorage; + } + }, + browserContext() { + return context; + }, + removeAllListeners() { + return; + }, + on() { + return; + }, + } as any; + }, + async close() { + if (options?.closeContextDelay) { + await options.closeContextDelay(id); + } + contextsClosed.push(id); + options?.onContextClosed?.(id); + return; + }, + } as any; + return context; + }, + }; + let browserManager = { + async getBrowser() { + return browser as any; + }, + async cleanupUserDataDirs() { + return; + }, + }; + let pool = new PagePool({ + maxPages, + serverURL: 'http://localhost', + browserManager: browserManager as any, + boxelHostURL: 'http://localhost:4200', + standbyTimeoutMs: options?.standbyTimeoutMs ?? 500, + renderSemaphore, + disableStandbyRefill: options?.disableStandbyRefill, + }); + return { pool, contextsCreated, contextsClosed }; +} +describe("prerendering-test.ts", function () { + describe('prerender - mutating tests', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let realmURL = 'http://127.0.0.1:4450/test/'; + let prerenderServerURL = new URL(realmURL).origin; + let testUserId = '@user1:localhost'; + let permissions: RealmPermissions = { + [realmURL]: ['read', 'write', 'realm-owner'], + }; + let prerenderer: Prerenderer; + let realmAdapter: RealmAdapter; + let realm: RuntimeRealm; + let auth = () => { + let sessions = JSON.parse(testCreatePrerenderAuth(testUserId, permissions)) as Record<string, string>; + let token = sessions[realmURL]; + if (token) { + sessions[new URL(realmURL).origin + '/'] = token; + } + return JSON.stringify(sessions); + }; + hooks.before(async () => { + prerenderer = getPrerendererForTesting({ + maxPages: 2, + serverURL: prerenderServerURL, + }); + }); + hooks.after(async () => { + await prerenderer.stop(); + }); + hooks.afterEach(async () => { + await prerenderer.disposeAffinity({ + affinityType: 'realm', + affinityValue: realmURL, + }); + }); + setupPermissionedRealmsCached(hooks, { + realms: [ + { + realmURL, + permissions: { + '*': ['read'], + [testUserId]: ['read', 'write', 'realm-owner'], + }, + fileSystem: { + 'person.gts': ` + import { CardDef, field, contains, StringField, Component } from 'https://cardstack.com/base/card-api'; + export class Person extends CardDef { + static displayName = "Person"; + @field name = contains(StringField); + static isolated = class extends Component<typeof this> { + <template>{{@model.name}}</template> + } + } + `, + '1.json': { + data: { + attributes: { + name: 'Maple', + }, + meta: { + adoptsFrom: { + module: './person', + name: 'Person', + }, + }, + }, + }, + }, + }, + ], + onRealmSetup({ realms: setupRealms }) { + ({ realm, realmAdapter } = setupRealms[0]); + permissions = { + [realmURL]: ['read', 'write', 'realm-owner'], + }; + }, + }); + it('reuses pooled page and picks up updated instance', async function () { + const cardURL = `${realmURL}1`; + let first = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: realmURL, + realm: realmURL, + url: cardURL, + auth: auth(), + }); + expect(first.pool.reused).toBe(false); + expect(first.pool.evicted).toBe(false); + expect(first.response.serialized?.data.attributes?.name).toBe('Maple'); + await realmAdapter.write('1.json', JSON.stringify({ + data: { + attributes: { + name: 'Juniper', + }, + meta: { + adoptsFrom: { + module: './person', + name: 'Person', + }, + }, + }, + }, null, 2)); + await realm.realmIndexUpdater.fullIndex(); + realm.__testOnlyClearCaches(); + let second = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: realmURL, + realm: realmURL, + url: cardURL, + auth: auth(), + }); + expect(second.pool.reused).toBe(true); + expect(second.pool.evicted).toBe(false); + expect(second.pool.pageId).toBe(first.pool.pageId); + expect(second.response.serialized?.data.attributes?.name).toBe('Juniper'); + }); + it('module prerender reuses pooled page after updates', async function () { + const moduleURL = `${realmURL}person.gts`; + let first = await prerenderer.prerenderModule({ + affinityType: 'realm', + affinityValue: realmURL, + realm: realmURL, + url: moduleURL, + auth: auth(), + }); + expect(first.pool.reused).toBe(false); + await realmAdapter.write('person.gts', ` + import { CardDef, field, contains, StringField, Component } from 'https://cardstack.com/base/card-api'; + export class Person extends CardDef { + static displayName = "Updated Person"; + @field name = contains(StringField); + static isolated = class extends Component<typeof this> { + <template>{{@model.name}}</template> + } + } + `); + realm.__testOnlyClearCaches(); // out write bypasses the index so we need to manually flush the realm cache + let second = await prerenderer.prerenderModule({ + affinityType: 'realm', + affinityValue: realmURL, + realm: realmURL, + url: moduleURL, + auth: auth(), + renderOptions: { clearCache: true }, + }); + expect(second.pool.reused).toBe(true); + expect(first.pool.pageId).toBe(second.pool.pageId); + let key = `${trimExecutableExtension(new URL(moduleURL)).href}/Person`; + let entry = second.response.definitions[key]; + expect(entry).toBeTruthy(); + expect(entry?.type).toBe('definition'); + if (entry?.type === 'definition') { + expect(entry.definition.displayName).toBe('Updated Person'); + } + else { + expect(false).toBeTruthy(); + } + }); + it('module prerender surfaces syntax errors', async function () { + const modulePath = 'person.gts'; + const moduleURL = `${realmURL}${modulePath}`; + await realmAdapter.write(modulePath, 'export const Broken = ;'); + realm.__testOnlyClearCaches(); + let result = await prerenderer.prerenderModule({ + affinityType: 'realm', + affinityValue: realmURL, + realm: realmURL, + url: moduleURL, + auth: auth(), + }); + expect(result.response.status).toBe('error'); + expect(result.response.error?.error.status).toBe(406); + expect(result.pool.evicted).toBe(false); + }); + it('file extract surfaces broken FileDef module error without remote prerender timeout', async function () { + await realmAdapter.write('filedef-mismatch.gts', ` + import { FileDef as BaseFileDef } from "https://cardstack.com/base/file-api"; + import { MissingChild } from "./missing-child"; + + export class FileDef extends BaseFileDef { + static missingChild = MissingChild; + } + `); + await realmAdapter.write('broken-file.mismatch', 'broken mismatch file'); + realm.__testOnlyClearCaches(); + let result = await prerenderer.prerenderFileExtract({ + affinityType: 'realm', + affinityValue: realmURL, + realm: realmURL, + url: `${realmURL}broken-file.mismatch`, + auth: auth(), + renderOptions: { + fileExtract: true, + fileDefCodeRef: { + module: `${realmURL}filedef-mismatch`, + name: 'FileDef', + }, + }, + }); + expect(result.response.status).toBe('error'); + expect(result.response.error).toBeTruthy(); + expect(result.response.error?.error.message?.includes('Received HTTP 404 from server')).toBeTruthy(); + let messageIncludesTimeoutMarker = Boolean(result.response.error?.error.message?.includes('Prerender request to')); + expect(messageIncludesTimeoutMarker).toBe(false); + expect(result.pool.timedOut).toBe(false); + }); + }); + function defineNonMutatingRunnerTests() { + describe('runner behavior', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let realmURL = 'http://127.0.0.1:4455/test/'; + let prerenderServerURL = new URL(realmURL).origin; + let testUserId = '@user1:localhost'; + let permissions: RealmPermissions = { + [realmURL]: ['read', 'write', 'realm-owner'], + }; + let prerenderer: Prerenderer; + let auth = () => { + let sessions = JSON.parse(testCreatePrerenderAuth(testUserId, permissions)) as Record<string, string>; + let token = sessions[realmURL]; + if (token) { + sessions[new URL(realmURL).origin + '/'] = token; + } + return JSON.stringify(sessions); + }; + hooks.before(async () => { + prerenderer = getPrerendererForTesting({ + maxPages: 2, + serverURL: prerenderServerURL, + }); + }); + hooks.after(async () => { + await prerenderer.stop(); + }); + hooks.beforeEach(async () => { + await prerenderer.disposeAffinity({ + affinityType: 'realm', + affinityValue: realmURL, + }); + }); + setupPermissionedRealmsCached(hooks, { + mode: 'before', + realms: [ + { + realmURL, + permissions: { + '*': ['read'], + [testUserId]: ['read', 'write', 'realm-owner'], + }, + fileSystem: { + 'person.gts': ` + import { CardDef, field, contains, StringField, Component } from 'https://cardstack.com/base/card-api'; + export class Person extends CardDef { + static displayName = "Person"; + @field name = contains(StringField); + static isolated = class extends Component<typeof this> { + <template>{{@model.name}}</template> + } + } + `, + '1.json': { + data: { + attributes: { + name: 'Maple', + }, + meta: { + adoptsFrom: { + module: './person', + name: 'Person', + }, + }, + }, + }, + 'no-icon.gts': ` + import { CardDef, field, contains, StringField, Component } from 'https://cardstack.com/base/card-api'; + export class NoIcon extends CardDef { + static displayName = "No Icon"; + static icon = class extends Component<typeof this> { + <template></template> + } + @field name = contains(StringField); + static isolated = class extends Component<typeof this> { + <template>{{@model.name}}</template> + } + } + `, + 'no-icon.json': { + data: { + attributes: { + name: 'Missing Icon', + }, + meta: { + adoptsFrom: { + module: './no-icon', + name: 'NoIcon', + }, + }, + }, + }, + 'bad-icon-import.gts': ` + import { CardDef, field, contains, StringField, Component } from 'https://cardstack.com/base/card-api'; + export class BadIconImport extends CardDef { + static displayName = "Bad Icon Import"; + static icon = undefined as any; + @field name = contains(StringField); + static isolated = class extends Component<typeof this> { + <template>{{@model.name}}</template> + } + } + `, + 'bad-icon-import.json': { + data: { + attributes: { + name: 'Bad Icon', + }, + meta: { + adoptsFrom: { + module: './bad-icon-import', + name: 'BadIconImport', + }, + }, + }, + }, + 'broken.gts': 'export const Broken = ;', + 'broken.json': { + data: { + meta: { + adoptsFrom: { + module: './broken', + name: 'Broken', + }, + }, + }, + }, + 'rejects.gts': ` + import { CardDef, Component } from 'https://cardstack.com/base/card-api'; + export class Rejects extends CardDef { + static isolated = class extends Component<typeof this> { + constructor(...args) { + super(...args); + Promise.reject(new Error('reject boom')); + } + <template>oops</template> + } + } + `, + 'rejects.json': { + data: { + meta: { + adoptsFrom: { + module: './rejects', + name: 'Rejects', + }, + }, + }, + }, + 'rsvp-rejects.gts': ` + import { CardDef, Component } from 'https://cardstack.com/base/card-api'; + import * as RSVP from 'rsvp'; + export class RsvpRejects extends CardDef { + static isolated = class extends Component<typeof this> { + constructor(...args) { + super(...args); + RSVP.reject(new Error('rsvp boom')); + } + <template>oops</template> + } + } + `, + 'rsvp-rejects.json': { + data: { + meta: { + adoptsFrom: { + module: './rsvp-rejects', + name: 'RsvpRejects', + }, + }, + }, + }, + 'throws.gts': ` + import { CardDef, Component } from 'https://cardstack.com/base/card-api'; + export class Throws extends CardDef { + static isolated = class extends Component<typeof this> { + get explode() { + throw new Error('boom'); + } + <template>{{this.explode}}</template> + } + } + `, + 'throws.json': { + data: { + meta: { + adoptsFrom: { + module: './throws', + name: 'Throws', + }, + }, + }, + }, + 'console-error.gts': ` + import { CardDef, Component } from 'https://cardstack.com/base/card-api'; + export class ConsoleError extends CardDef { + static isolated = class extends Component<typeof this> { + get explode() { + console.error('console boom'); + throw new Error('boom'); + } + <template>{{this.explode}}</template> + } + } + `, + 'console-error.json': { + data: { + meta: { + adoptsFrom: { + module: './console-error', + name: 'ConsoleError', + }, + }, + }, + }, + 'console-no-error.gts': ` + import { CardDef, Component } from 'https://cardstack.com/base/card-api'; + export class ConsoleNoError extends CardDef { + static isolated = class extends Component<typeof this> { + constructor(...args) { + super(...args); + console.error('console boom'); + } + <template>ok</template> + } + } + `, + 'console-no-error.json': { + data: { + meta: { + adoptsFrom: { + module: './console-no-error', + name: 'ConsoleNoError', + }, + }, + }, + }, + 'directory-query.gts': ` + import { CardDef, field, contains, linksTo, linksToMany, StringField, Component, queryableValue } from 'https://cardstack.com/base/card-api'; + + export class Person extends CardDef { + static displayName = 'Person'; + @field name = contains(StringField); + @field team = contains(StringField); + @field managerName = contains(StringField); + @field manager = linksTo(() => Person); + @field reports = linksToMany(() => Person, { + query: { + filter: { + eq: { + managerName: '$this.name', + }, + }, + page: { + size: 10, + number: 0, + }, + }, + }); + + // Keep person-instance indexing deterministic for this test: + // this avoids firing query fields when Person cards are + // prerendered in isolation during indexing. + static isolated = class extends Component<typeof this> { + <template> + <span data-test-person-name>{{@model.name}}</span> + <span data-test-person-team>{{@model.team}}</span> + </template> + }; + } + + export class Directory extends CardDef { + static displayName = 'Directory'; + @field teamFilter = contains(StringField); + @field staff = linksToMany(() => Person, { + query: { + filter: { + eq: { + team: '$this.teamFilter', + }, + }, + page: { + size: 10, + number: 0, + }, + }, + }); + + static [queryableValue](value: Directory | null) { + if (!value) { + return null; + } + return { + teamFilter: value.teamFilter, + staff: (value.staff ?? []).map((person) => ({ + name: person.name, + manager: person.manager + ? { + name: person.manager.name, + } + : null, + reports: (person.reports ?? []).map((report) => ({ + name: report.name, + manager: report.manager + ? { + name: report.manager.name, + } + : null, + })), + })), + }; + } + + static isolated = class extends Component<typeof this> { + <template> + <div data-test-directory-team>{{@model.teamFilter}}</div> + <div id="heroGridPlane" data-test-hero-grid-plane> + {{#each @model.staff as |person|}} + <div class="hero-mini-card" data-test-hero-mini-card> + <div data-test-staff-name>{{person.name}}</div> + <div data-test-staff-manager> + {{#if person.manager}} + {{person.manager.name}} + {{/if}} + </div> + <ul data-test-staff-reports> + {{#each person.reports as |report|}} + <li class="hero-mini-card" data-test-staff-report data-test-hero-mini-card> + {{report.name}} + <span data-test-staff-report-manager> + {{#if report.manager}} + {{report.manager.name}} + {{/if}} + </span> + </li> + {{/each}} + </ul> + </div> + {{/each}} + </div> + </template> + }; + } + `, + 'directory-ops.json': { + data: { + attributes: { + teamFilter: 'Ops', + }, + relationships: { + staff: { + links: { self: null }, + data: [], + meta: { + errors: [ + { + realm: 'https://unreachable-realm.example.com/', + type: 'fetch-error', + message: 'Could not reach remote realm', + status: 502, + }, + ], + }, + }, + }, + meta: { + adoptsFrom: { + module: './directory-query', + name: 'Directory', + }, + }, + }, + }, + 'person-alice.json': { + data: { + attributes: { + name: 'Alice', + team: 'Leadership', + managerName: '', + }, + meta: { + adoptsFrom: { + module: './directory-query', + name: 'Person', + }, + }, + }, + }, + 'person-bob.json': { + data: { + attributes: { + name: 'Bob', + team: 'Ops', + managerName: 'Alice', + }, + relationships: { + manager: { + links: { + self: './person-alice', + }, + }, + }, + meta: { + adoptsFrom: { + module: './directory-query', + name: 'Person', + }, + }, + }, + }, + 'person-carol.json': { + data: { + attributes: { + name: 'Carol', + team: 'Ops', + managerName: 'Alice', + }, + relationships: { + manager: { + links: { + self: './person-alice', + }, + }, + }, + meta: { + adoptsFrom: { + module: './directory-query', + name: 'Person', + }, + }, + }, + }, + 'person-dave.json': { + data: { + attributes: { + name: 'Dave', + team: 'Ops', + managerName: 'Bob', + }, + relationships: { + manager: { + links: { + self: './person-bob', + }, + }, + }, + meta: { + adoptsFrom: { + module: './directory-query', + name: 'Person', + }, + }, + }, + }, + 'person-eve.json': { + data: { + attributes: { + name: 'Eve', + team: 'Sales', + managerName: 'Bob', + }, + relationships: { + manager: { + links: { + self: './person-bob', + }, + }, + }, + meta: { + adoptsFrom: { + module: './directory-query', + name: 'Person', + }, + }, + }, + }, + 'notes.txt': 'Hello from file extract', + }, + }, + ], + onRealmSetup() { + permissions = { + [realmURL]: ['read', 'write', 'realm-owner'], + }; + }, + }); + it('prerenderModule returns module metadata', async function () { + const moduleURL = `${realmURL}person.gts`; + let result = await prerenderer.prerenderModule({ + affinityType: 'realm', + affinityValue: realmURL, + realm: realmURL, + url: moduleURL, + auth: auth(), + }); + expect(result.pool.reused).toBe(false); + expect(result.response.status).toBe('ready'); + let key = `${trimExecutableExtension(new URL(moduleURL)).href}/Person`; + let entry = result.response.definitions[key]; + expect(entry).toBeTruthy(); + expect(entry?.type).toBe('definition'); + if (entry?.type === 'definition') { + expect(entry.definition.displayName).toBeTruthy(); + } + else { + expect(false).toBeTruthy(); + } + }); + it('card prerender hoists module transpile errors', async function () { + let brokenCard = `${realmURL}broken.json`; + let result = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: realmURL, + realm: realmURL, + url: brokenCard, + auth: auth(), + }); + expect(result.response.error).toBeTruthy(); + expect(result.response.error?.error.status).toBe(406); + expect(result.response.error?.error.message).toBe(`Parse Error at broken.gts:1:23: 1:24 (${realmURL}broken)`); + expect(result.response.error?.error.stack?.includes('at transpileJS')).toBeTruthy(); + let additionalErrors = result.response.error?.error.additionalErrors; + if (additionalErrors !== null) { + expect(Array.isArray(additionalErrors)).toBeTruthy(); + expect(additionalErrors?.every((entry) => entry?.title === 'Console error' || + entry?.title === 'Console assert')).toBeTruthy(); + } + let deps = result.response.error?.error.deps ?? []; + expect(deps.some((dep) => dep.includes(`${realmURL}broken`))).toBeTruthy(); + }); + it('card prerender surfaces actionable error for bad icon import', async function () { + let cardURL = `${realmURL}bad-icon-import.json`; + let result = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: realmURL, + realm: realmURL, + url: cardURL, + auth: auth(), + }); + expect(result.response.error).toBeTruthy(); + expect(result.response.error?.error.status).toBe(500); + expect(result.response.error?.error.message?.includes('static icon of BadIconImport is undefined')).toBeTruthy(); + }); + it('card prerender surfaces empty render container', async function () { + let cardURL = `${realmURL}no-icon.json`; + let result = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: realmURL, + realm: realmURL, + url: cardURL, + auth: auth(), + }); + expect(result.response.error).toBeTruthy(); + expect(result.response.error?.error.title).toBe('Invalid render response'); + expect(result.response.error?.error.message?.includes('[data-prerender] has no child element to capture')).toBeTruthy(); + let errorDeps = result.response.error?.error.deps; + expect(errorDeps).not.toBe(null); + let deps = errorDeps ?? []; + expect(Array.isArray(deps)).toBe(true); + expect([`${realmURL}no-icon`, `${realmURL}no-icon.json`].some((dep) => deps.includes(dep))).toBe(true); + }); + it('card prerender surfaces runtime render errors without timing out', async function () { + let cardURL = `${realmURL}throws.json`; + let result = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: realmURL, + realm: realmURL, + url: cardURL, + auth: auth(), + }); + expect(result.response.error).toBeTruthy(); + expect(result.response.error?.error.status).toBe(500); + expect(result.response.error?.error.message?.includes('boom')).toBeTruthy(); + expect(result.pool.timedOut).toBe(false); + expect(result.pool.evicted).toBe(true); + }); + it('card prerender includes console errors when render fails', async function () { + let cardURL = `${realmURL}console-error.json`; + let result = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: realmURL, + realm: realmURL, + url: cardURL, + auth: auth(), + }); + expect(result.response.error).toBeTruthy(); + let additionalErrors = result.response.error?.error.additionalErrors; + expect(Array.isArray(additionalErrors)).toBeTruthy(); + expect(additionalErrors?.some((error) => typeof error?.message === 'string' && + error.message.includes('console boom'))).toBeTruthy(); + }); + it('card prerender ignores console errors on success', async function () { + let cardURL = `${realmURL}console-no-error.json`; + let result = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: realmURL, + realm: realmURL, + url: cardURL, + auth: auth(), + }); + expect(result.response.error).toBeFalsy(); + }); + it('card prerender surfaces unhandled promise rejection without timing out', async function () { + let cardURL = `${realmURL}rejects.json`; + let result = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: realmURL, + realm: realmURL, + url: cardURL, + auth: auth(), + }); + expect(result.response.error).toBeTruthy(); + expect(result.response.error?.error.status).toBe(500); + expect(result.response.error?.error.message?.includes('reject boom')).toBeTruthy(); + expect(result.pool.timedOut).toBe(false); + expect(result.pool.evicted).toBe(true); + }); + it('card prerender surfaces RSVP rejection without timing out', async function () { + let cardURL = `${realmURL}rsvp-rejects.json`; + let result = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: realmURL, + realm: realmURL, + url: cardURL, + auth: auth(), + }); + expect(result.response.error).toBeTruthy(); + expect(result.response.error?.error.status).toBe(500); + expect(result.response.error?.error.message?.includes('rsvp boom')).toBeTruthy(); + expect(result.pool.timedOut).toBe(false); + expect(result.pool.evicted).toBe(true); + }); + it('card prerender surfaces errors thrown before the render model hook', async function () { + let originalGetPage = PagePool.prototype.getPage; + try { + PagePool.prototype.getPage = async function (this: PagePool, realm: string) { + let pageInfo = await originalGetPage.call(this, realm); + let page = pageInfo.page as any; + let originalEvaluate = page?.evaluate?.bind(page); + if (originalEvaluate) { + let injected = false; + page.evaluate = async (...args: any[]) => { + if (!injected) { + injected = true; + await originalEvaluate(() => { + let entries = (window as any).requirejs?.entries ?? + (window as any).require?.entries ?? + (window as any)._eak_seen; + let renderModuleName = entries && + Object.keys(entries).find((name) => name.endsWith('/routes/render')); + if (!renderModuleName) { + throw new Error('render route module not found for injection'); + } + let renderRouteModule = (window as any).require(renderModuleName); + let RenderRouteClass = renderRouteModule?.default; + if (!RenderRouteClass?.prototype) { + throw new Error('render route class not found for injection'); + } + let originalBeforeModel = RenderRouteClass.prototype.beforeModel; + RenderRouteClass.prototype.beforeModel = async function (...bmArgs: any[]) { + if (originalBeforeModel) { + await originalBeforeModel.apply(this, bmArgs as any); + } + RenderRouteClass.prototype.beforeModel = + originalBeforeModel; + throw new Error('boom before model'); + }; + }); + } + return originalEvaluate(...args); + }; + } + return { ...pageInfo, page }; + }; + let result = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: realmURL, + realm: realmURL, + url: `${realmURL}1.json`, + auth: auth(), + }); + expect(result.response.error).toBeTruthy(); + expect(result.response.error?.error.message?.includes('boom before model')).toBeTruthy(); + expect(result.pool.evicted).toBe(true); + expect((result.response.error as any)?.evict).toBe(true); + expect(result.pool.timedOut).toBe(false); + let errorDeps = result.response.error?.error.deps; + expect(errorDeps).not.toBe(null); + let deps = errorDeps ?? []; + expect(Array.isArray(deps)).toBe(true); + expect([`${realmURL}1.json`, `${realmURL}1`].some((dep) => deps.includes(dep))).toBe(true); + } + finally { + PagePool.prototype.getPage = originalGetPage; + } + }); + it('card prerender waits for query fallback search and nested relationship loads', async function () { + const cardURL = `${realmURL}directory-ops`; + let realmServerPatch = installRealmServerAssertOwnRealmServerBypassPatch(); + let delayedSearchPatch = installDelayedRuntimeRealmSearchPatch(150); + try { + let result = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: realmURL, + realm: realmURL, + url: cardURL, + auth: auth(), + }); + expect(result.response.error).toBeFalsy(); + expect(delayedSearchPatch.getRequestCount() > 0).toBe(true); + let isolatedHTML = cleanWhiteSpace(result.response.isolatedHTML ?? ''); + expect(/data-test-staff-name[^>]*>\s*Bob\s*</.test(isolatedHTML)).toBeTruthy(); + expect(/data-test-staff-manager[^>]*>\s*Alice\s*</.test(isolatedHTML)).toBeTruthy(); + expect(/data-test-staff-report[^>]*>\s*Eve/.test(isolatedHTML)).toBeTruthy(); + expect(/data-test-staff-report-manager[^>]*>\s*Bob\s*</.test(isolatedHTML)).toBeTruthy(); + expect(/id="heroGridPlane"/.test(isolatedHTML)).toBeTruthy(); + let heroMiniCards = isolatedHTML.match(/class="hero-mini-card"/g) ?? []; + expect(heroMiniCards.length >= 3).toBeTruthy(); + let staff = result.response.searchDoc?.staff as Array<Record<string, any>> | undefined; + expect(Array.isArray(staff)).toBeTruthy(); + let bob = staff?.find((entry) => entry?.name === 'Bob'); + expect(bob).toBeTruthy(); + expect(bob?.manager?.name).toBe('Alice'); + let bobReports = bob?.reports as Array<Record<string, any>> | undefined; + expect(Array.isArray(bobReports)).toBeTruthy(); + let hasEveWithManager = bobReports?.some((report) => report?.name === 'Eve' && report?.manager?.name === 'Bob'); + expect(Boolean(hasEveWithManager)).toBe(true); + } + finally { + await realmServerPatch.restore(); + delayedSearchPatch.restore(); + } + }); + it('module prerender evicts pooled page on timeout', async function () { + const moduleURL = `${realmURL}person.gts`; + let first = await prerenderer.prerenderModule({ + affinityType: 'realm', + affinityValue: realmURL, + realm: realmURL, + url: moduleURL, + auth: auth(), + }); + expect(first.pool.reused).toBe(false); + expect(first.pool.evicted).toBe(false); + let timedOut = await prerenderer.prerenderModule({ + affinityType: 'realm', + affinityValue: realmURL, + realm: realmURL, + url: moduleURL, + auth: auth(), + opts: { timeoutMs: 1, simulateTimeoutMs: 25 }, + }); + expect(timedOut.response.status).toBe('error'); + expect(timedOut.response.error?.error.title).toBe('Render timeout'); + expect(timedOut.response.error?.error.status).toBe(504); + expect(timedOut.pool.timedOut).toBe(true); + expect(timedOut.pool.evicted).toBe(true); + expect(timedOut.pool.pageId).not.toBe('unknown'); + let afterTimeout = await prerenderer.prerenderModule({ + affinityType: 'realm', + affinityValue: realmURL, + realm: realmURL, + url: moduleURL, + auth: auth(), + }); + expect(afterTimeout.pool.reused).toBe(false); + expect(afterTimeout.pool.evicted).toBe(false); + expect(afterTimeout.pool.timedOut).toBe(false); + expect(afterTimeout.response.status).toBe('ready'); + }); + it('file prerender returns extracted metadata', async function () { + const fileURL = `${realmURL}notes.txt`; + let result = await prerenderer.prerenderFileExtract({ + affinityType: 'realm', + affinityValue: realmURL, + realm: realmURL, + url: fileURL, + auth: auth(), + renderOptions: { fileExtract: true }, + }); + expect(result.response.status).toBe('ready'); + expect(result.response.searchDoc?.name).toBe('notes.txt'); + expect(result.response.deps.includes(`${baseRealm.url}file-api`)).toBeTruthy(); + expect(result.response.deps.includes(fileURL)).toBeFalsy(); + }); + }); + } + function defineRuntimeDepsResetTests() { + describe('runtime deps reset', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let realmURL = 'http://127.0.0.1:4457/test/'; + let prerenderServerURL = new URL(realmURL).origin; + let testUserId = '@user1:localhost'; + let permissions: RealmPermissions = { + [realmURL]: ['read', 'write', 'realm-owner'], + }; + let prerenderer: Prerenderer; + let auth = () => { + let sessions = JSON.parse(testCreatePrerenderAuth(testUserId, permissions)) as Record<string, string>; + let token = sessions[realmURL]; + if (token) { + sessions[new URL(realmURL).origin + '/'] = token; + } + return JSON.stringify(sessions); + }; + hooks.before(async () => { + prerenderer = getPrerendererForTesting({ + maxPages: 2, + serverURL: prerenderServerURL, + }); + }); + hooks.after(async () => { + await prerenderer.stop(); + }); + hooks.beforeEach(async () => { + await prerenderer.disposeAffinity({ + affinityType: 'realm', + affinityValue: realmURL, + }); + }); + setupPermissionedRealmsCached(hooks, { + mode: 'before', + realms: [ + { + realmURL, + permissions: { + '*': ['read'], + [testUserId]: ['read', 'write', 'realm-owner'], + }, + fileSystem: { + 'person.gts': ` + import { CardDef, field, contains, StringField, Component } from 'https://cardstack.com/base/card-api'; + export class Person extends CardDef { + static displayName = "Person"; + @field name = contains(StringField); + static isolated = class extends Component<typeof this> { + <template>{{@model.name}}</template> + } + } + `, + 'dep-reset-consumer.gts': ` + import { CardDef, field, linksTo, Component } from 'https://cardstack.com/base/card-api'; + import { Person } from './person'; + export class DepResetConsumer extends CardDef { + static displayName = 'Dep Reset Consumer'; + @field friend = linksTo(() => Person); + static isolated = class extends Component<typeof this> { + <template><@fields.friend/></template> + } + } + `, + 'dep-reset-consumer-a.json': { + data: { + relationships: { + friend: { + links: { + self: './dep-reset-friend-a', + }, + }, + }, + meta: { + adoptsFrom: { + module: './dep-reset-consumer', + name: 'DepResetConsumer', + }, + }, + }, + }, + 'dep-reset-consumer-b.json': { + data: { + relationships: { + friend: { + links: { + self: './dep-reset-friend-b', + }, + }, + }, + meta: { + adoptsFrom: { + module: './dep-reset-consumer', + name: 'DepResetConsumer', + }, + }, + }, + }, + 'dep-reset-friend-a.json': { + data: { + attributes: { + name: 'Friend A', + }, + meta: { + adoptsFrom: { + module: './person', + name: 'Person', + }, + }, + }, + }, + 'dep-reset-friend-b.json': { + data: { + attributes: { + name: 'Friend B', + }, + meta: { + adoptsFrom: { + module: './person', + name: 'Person', + }, + }, + }, + }, + }, + }, + ], + onRealmSetup() { + permissions = { + [realmURL]: ['read', 'write', 'realm-owner'], + }; + }, + }); + it('resets runtime deps between consecutive prerenders', async function () { + let first = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: realmURL, + realm: realmURL, + url: `${realmURL}dep-reset-consumer-a`, + auth: auth(), + }); + let firstDeps = first.response.deps ?? []; + expect(firstDeps.includes(`${realmURL}dep-reset-friend-a.json`)).toBe(true); + let second = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: realmURL, + realm: realmURL, + url: `${realmURL}dep-reset-consumer-b`, + auth: auth(), + }); + let secondDeps = second.response.deps ?? []; + expect(secondDeps.includes(`${realmURL}dep-reset-friend-b.json`)).toBe(true); + expect(secondDeps.includes(`${realmURL}dep-reset-friend-a.json`)).toBe(false); + }); + }); + } + function defineLivePrerenderedSearchFallbackTests() { + describe('live prerendered search fallback', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let realmURL = 'http://127.0.0.1:4456/test/'; + let prerenderServerURL = new URL(realmURL).origin; + let testUserId = '@user1:localhost'; + let permissions: RealmPermissions = { + [realmURL]: ['read', 'write', 'realm-owner'], + }; + let prerenderer: Prerenderer; + let dbAdapter: any; + let auth = () => { + let sessions = JSON.parse(testCreatePrerenderAuth(testUserId, permissions)) as Record<string, string>; + let token = sessions[realmURL]; + if (token) { + sessions[new URL(realmURL).origin + '/'] = token; + } + return JSON.stringify(sessions); + }; + hooks.before(async () => { + prerenderer = getPrerendererForTesting({ + maxPages: 2, + serverURL: prerenderServerURL, + }); + }); + hooks.after(async () => { + await prerenderer.stop(); + }); + hooks.beforeEach(async () => { + await prerenderer.disposeAffinity({ + affinityType: 'realm', + affinityValue: realmURL, + }); + }); + async function overrideIndexedIsolatedHTML(url: string, html: string) { + let alternate = url.endsWith('.json') + ? url.replace(/\.json$/, '') + : `${url}.json`; + await dbAdapter.execute(`UPDATE boxel_index SET isolated_html = $1 WHERE url = $2 OR url = $3`, { bind: [html, url, alternate] }); + } + setupPermissionedRealmsCached(hooks, { + mode: 'before', + realms: [ + { + realmURL, + permissions: { + '*': ['read'], + [testUserId]: ['read', 'write', 'realm-owner'], + }, + fileSystem: { + 'prerendered-search-live.gts': ` + import { CardDef, Component, field, contains, StringField, linksTo } from 'https://cardstack.com/base/card-api'; + + export class LiveSearchResult extends CardDef { + static displayName = 'Live Search Result'; + @field cardTitle = contains(StringField); + + static fitted = class extends Component<typeof this> { + <template> + <div class="live-search-css-sentinel" data-test-live-card-value>{{@model.cardTitle}}</div> + <style scoped> + .live-search-css-sentinel { + border-top: 4px solid rgb(1, 2, 3); + } + </style> + </template> + }; + + static embedded = this.fitted; + static isolated = this.fitted; + } + + export class LiveSearchInner extends CardDef { + static displayName = 'Live Search Inner'; + static isolated = class extends Component<typeof this> { + get realmHref() { + let id = this.args.model?.id; + if (!id) { + return ''; + } + return new URL('.', id).href; + } + + get query() { + return { + filter: { + type: { + module: new URL('./prerendered-search-live', import.meta.url).href, + name: 'LiveSearchResult', + }, + }, + page: { + size: 10, + number: 0, + }, + }; + } + + get realms() { + return [new URL('./', import.meta.url).href]; + } + + <template> + <div data-test-live-search-host-ran>Host ran</div> + <div data-test-live-search-realm>{{this.realmHref}}</div> + {{#if @context.prerenderedCardSearchComponent}} + <@context.prerenderedCardSearchComponent + @query={{this.query}} + @format='fitted' + @realms={{this.realms}} + @isLive={{true}} + > + <:loading> + <div data-test-live-search-loading>Loading...</div> + </:loading> + <:response as |cards|> + {{#each cards as |card|}} + <div data-test-live-search-card={{card.url}}> + <card.component /> + </div> + {{/each}} + </:response> + <:meta as |meta|> + <div data-test-live-search-total>{{meta.page.total}}</div> + </:meta> + </@context.prerenderedCardSearchComponent> + {{else}} + <div data-test-live-search-component-missing>missing</div> + {{/if}} + </template> + }; + } + + export class LiveSearchHost extends CardDef { + static displayName = 'Live Search Host'; + @field child = linksTo(() => LiveSearchInner); + + static isolated = class extends Component<typeof this> { + <template> + <@fields.child @format='isolated' /> + </template> + }; + + static embedded = this.isolated; + } + `, + 'live-search-host.json': { + data: { + relationships: { + child: { + links: { + self: './live-search-inner', + }, + }, + }, + meta: { + adoptsFrom: { + module: './prerendered-search-live', + name: 'LiveSearchHost', + }, + }, + }, + }, + 'live-search-inner.json': { + data: { + meta: { + adoptsFrom: { + module: './prerendered-search-live', + name: 'LiveSearchInner', + }, + }, + }, + }, + 'live-search-result-1.json': { + data: { + attributes: { + cardTitle: 'LIVE_RESULT_VALUE', + }, + meta: { + adoptsFrom: { + module: './prerendered-search-live', + name: 'LiveSearchResult', + }, + }, + }, + }, + 'live-file-search-card.gts': ` + import { CardDef, Component, field, contains, StringField, linksTo } from 'https://cardstack.com/base/card-api'; + + export class LiveFileSearchInner extends CardDef { + static displayName = 'Live File Search Inner'; + static isolated = class extends Component<typeof this> { + get realmHref() { + let id = this.args.model?.id; + if (!id) { + return ''; + } + return new URL('.', id).href; + } + + get query() { + return { + filter: { + on: { + module: 'https://cardstack.com/base/file-api', + name: 'FileDef', + }, + eq: { + url: \`\${this.realmHref}live-file.live\`, + }, + }, + page: { + size: 10, + number: 0, + }, + }; + } + + get realms() { + return [new URL('./', import.meta.url).href]; + } + + <template> + <div data-test-live-file-search-host-ran>File Host ran</div> + {{#if @context.prerenderedCardSearchComponent}} + <@context.prerenderedCardSearchComponent + @query={{this.query}} + @format='embedded' + @realms={{this.realms}} + @isLive={{true}} + > + <:response as |cards|> + {{#each cards as |card|}} + <div data-test-live-file-search-card={{card.url}}> + <card.component /> + </div> + {{/each}} + </:response> + </@context.prerenderedCardSearchComponent> + {{else}} + <div data-test-live-file-search-component-missing>missing</div> + {{/if}} + </template> + }; + } + + export class LiveFileSearchHost extends CardDef { + static displayName = 'Live File Search Host'; + @field cardTitle = contains(StringField); + @field child = linksTo(() => LiveFileSearchInner); + + static isolated = class extends Component<typeof this> { + <template> + <@fields.child @format='isolated' /> + </template> + }; + + static embedded = this.isolated; + } + `, + 'live-file-search-host.json': { + data: { + attributes: { + cardTitle: 'Live File Search Host', + }, + relationships: { + child: { + links: { + self: './live-file-search-inner', + }, + }, + }, + meta: { + adoptsFrom: { + module: './live-file-search-card', + name: 'LiveFileSearchHost', + }, + }, + }, + }, + 'live-file-search-inner.json': { + data: { + meta: { + adoptsFrom: { + module: './live-file-search-card', + name: 'LiveFileSearchInner', + }, + }, + }, + }, + 'live-file.live': 'LIVE_FILE_VALUE', + }, + }, + ], + onRealmSetup({ dbAdapter: setupDbAdapter }) { + dbAdapter = setupDbAdapter; + permissions = { + [realmURL]: ['read', 'write', 'realm-owner'], + }; + }, + }); + it('card prerendered search uses live rendered CardDef HTML and keeps unique CSS', async function () { + const cardURL = `${realmURL}live-search-host`; + const sentinel = 'SENTINEL_STALE_CARD_HTML'; + let realmServerPatch = installRealmServerAssertOwnRealmServerBypassPatch(); + let searchRequestObserverPatch = installSearchRequestObserverPatch(); + try { + let indexedRows = await dbAdapter.execute(`SELECT url FROM boxel_index WHERE url LIKE $1 ORDER BY url`, { bind: [`${realmURL}%live-search%`] }); + expect(indexedRows.length > 0).toBeTruthy(); + await overrideIndexedIsolatedHTML(`${realmURL}live-search-result-1`, `<div data-test-stale-card-html>${sentinel}</div>`); + let result = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: realmURL, + realm: realmURL, + url: cardURL, + auth: auth(), + }); + expect(result.response.error).toBeFalsy(); + let isolatedHTML = cleanWhiteSpace(result.response.isolatedHTML ?? ''); + let searchRequests = searchRequestObserverPatch.getRequests(); + expect(searchRequests.length > 0).toBeTruthy(); + expect(isolatedHTML.includes('LIVE_RESULT_VALUE')).toBeTruthy(); + expect(isolatedHTML.includes(sentinel)).toBeFalsy(); + expect(isolatedHTML.includes('live-search-css-sentinel')).toBeTruthy(); + expect(/live-search-css-sentinel[^>]*data-scopedcss-[a-f0-9]{10}-[a-f0-9]{10}/.test(isolatedHTML)).toBeTruthy(); + } + finally { + searchRequestObserverPatch.restore(); + await realmServerPatch.restore(); + } + }); + it('card prerendered search uses live rendered FileDef HTML', async function () { + const cardURL = `${realmURL}live-file-search-host`; + const sentinel = 'SENTINEL_STALE_FILE_HTML'; + let realmServerPatch = installRealmServerAssertOwnRealmServerBypassPatch(); + try { + await overrideIndexedIsolatedHTML(`${realmURL}live-file.live`, `<article data-test-stale-file-html>${sentinel}</article>`); + let result = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: realmURL, + realm: realmURL, + url: cardURL, + auth: auth(), + }); + expect(result.response.error).toBeFalsy(); + let isolatedHTML = cleanWhiteSpace(result.response.isolatedHTML ?? ''); + expect(isolatedHTML.includes('live-file.live')).toBeTruthy(); + expect(isolatedHTML.includes(sentinel)).toBeFalsy(); + expect(isolatedHTML.includes('data-test-live-file-search-card')).toBeTruthy(); + } + finally { + await realmServerPatch.restore(); + } + }); + }); + } + describe('prerender - non-mutating tests', function () { + defineNonMutatingRunnerTests(); + defineRuntimeDepsResetTests(); + defineLivePrerenderedSearchFallbackTests(); + defineNonMutatingStaticTests(); + }); + describe('prerender - permissioned auth failures', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let providerRealmURL = 'http://127.0.0.1:4451/test/'; + let consumerRealmURL = 'http://127.0.0.1:4452/test/'; + let prerenderServerURL = new URL(consumerRealmURL).origin; + let testUserId = '@user1:localhost'; + let permissions: RealmPermissions = {}; + let indexingReady: Promise<void> = Promise.resolve(); + let prerenderer: Prerenderer; + let auth = () => testCreatePrerenderAuth(testUserId, permissions); + hooks.before(async () => { + prerenderer = getPrerendererForTesting({ + maxPages: 2, + serverURL: prerenderServerURL, + }); + }); + hooks.after(async () => { + await prerenderer.stop(); + }); + hooks.beforeEach(async function () { + await indexingReady; + permissions = { + [consumerRealmURL]: ['read', 'write', 'realm-owner'], + }; + }); + hooks.afterEach(async () => { + await Promise.all([ + prerenderer.disposeAffinity({ + affinityType: 'realm', + affinityValue: providerRealmURL, + }), + prerenderer.disposeAffinity({ + affinityType: 'realm', + affinityValue: consumerRealmURL, + }), + ]); + }); + setupPermissionedRealmsCached(hooks, { + mode: 'before', + realms: [ + { + realmURL: providerRealmURL, + permissions: { + // consumer's matrix user is not authorized to read + nobody: ['read', 'write'], + }, + fileSystem: { + 'article.gts': ` + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + export class Article extends CardDef { + @field title = contains(StringField); + } + `, + 'secret.json': { + data: { + attributes: { + cardTitle: 'Top Secret', + }, + meta: { + adoptsFrom: { + module: './article', + name: 'Article', + }, + }, + }, + }, + 'secret.txt': 'Top Secret file', + }, + }, + { + realmURL: consumerRealmURL, + permissions: { + '*': ['read', 'write', 'realm-owner'], + }, + fileSystem: { + 'website.gts': ` + import { contains, field, CardDef, linksTo } from "https://cardstack.com/base/card-api"; + import { Article } from "${providerRealmURL}article" // importing from another realm; + export class Website extends CardDef { + @field linkedArticle = linksTo(Article); + } + `, + 'website-1.json': { + data: { + attributes: {}, + meta: { + adoptsFrom: { + module: './website', + name: 'Website', + }, + }, + }, + }, + 'auth-proxy.gts': ` + import { contains, field, CardDef, linksTo, Component } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + // define a local stand-in type so the consumer realm doesn't need to import provider modules + export class RemoteArticle extends CardDef { + @field title = contains(StringField); + } + export class AuthProxy extends CardDef { + @field linkedArticle = linksTo(RemoteArticle); + @field articleTitle = contains(StringField, { + computeVia(this: AuthProxy) { + return this.linkedArticle?.title; + }, + }); + static isolated = class extends Component<typeof this> { + <template> + <@fields.articleTitle /> + </template> + } + } + `, + 'auth-proxy-1.json': { + data: { + attributes: {}, + relationships: { + linkedArticle: { + links: { + self: `${providerRealmURL}secret`, + }, + }, + }, + meta: { + adoptsFrom: { + module: './auth-proxy', + name: 'AuthProxy', + }, + }, + }, + }, + }, + }, + ], + onRealmSetup({ realms }) { + indexingReady = Promise.all(realms.map(({ realm }) => realm.indexing())).then(() => undefined); + permissions = { + [consumerRealmURL]: ['read', 'write', 'realm-owner'], + }; + }, + }); + it('module prerender surfaces auth error without timing out', async function () { + const moduleURL = `${consumerRealmURL}website.gts`; + let result = await prerenderer.prerenderModule({ + affinityType: 'realm', + affinityValue: consumerRealmURL, + realm: consumerRealmURL, + url: moduleURL, + auth: auth(), + }); + expect(result.response.error).toBeTruthy(); + let status = result.response.error?.error.status; + expect(status).toBe(401); + expect(result.response.error?.error.title).not.toBe('Render timeout'); + expect(result.pool.timedOut).toBe(false); + expect(result.pool.evicted).toBe(false); + }); + it('file prerender surfaces auth error without timing out', async function () { + const fileURL = `${providerRealmURL}secret.txt`; + let result = await prerenderer.prerenderFileExtract({ + affinityType: 'realm', + affinityValue: providerRealmURL, + realm: providerRealmURL, + url: fileURL, + auth: auth(), + renderOptions: { fileExtract: true }, + }); + expect(result.response.error).toBeTruthy(); + let status = result.response.error?.error.status; + expect(status).toBe(401); + expect(result.response.error?.error.title).not.toBe('Render timeout'); + expect(result.pool.timedOut).toBe(false); + expect(result.pool.evicted).toBe(false); + }); + it('card prerender surfaces auth error without timing out', async function () { + const cardURL = `${consumerRealmURL}auth-proxy-1`; + let result = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: consumerRealmURL, + realm: consumerRealmURL, + url: cardURL, + auth: auth(), + }); + expect(result.response.error).toBeTruthy(); + let status = result.response.error?.error.status; + expect(status).toBe(401); + expect(result.response.error?.error.title).not.toBe('Render timeout'); + expect(result.pool.timedOut).toBe(false); + expect(result.pool.evicted).toBe(false); + }); + it('card prerender surfaces auth error from linked fetch', async function () { + const cardURL = `${consumerRealmURL}auth-proxy-1`; + let result = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: consumerRealmURL, + realm: consumerRealmURL, + url: cardURL, + auth: auth(), + }); + expect(result.response.error).toBeTruthy(); + expect(result.response.error?.error.status).toBe(401); + expect(result.response.error?.error.title).not.toBe('Render timeout'); + expect(result.pool.timedOut).toBe(false); + }); + }); + describe('prerender - public query fallback', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let publicRealmURL = 'http://127.0.0.1:4454/test/'; + let prerenderServerURL = new URL(publicRealmURL).origin; + let testUserId = '@user1:localhost'; + let permissions: RealmPermissions = {}; + let prerenderer: Prerenderer; + let auth = () => testCreatePrerenderAuth(testUserId, permissions); + hooks.before(async () => { + prerenderer = getPrerendererForTesting({ + maxPages: 2, + serverURL: prerenderServerURL, + }); + }); + hooks.after(async () => { + await prerenderer.stop(); + }); + hooks.beforeEach(function () { + permissions = { + [publicRealmURL]: ['read', 'write', 'realm-owner'], + }; + }); + hooks.afterEach(async () => { + await prerenderer.disposeAffinity({ + affinityType: 'realm', + affinityValue: publicRealmURL, + }); + }); + let makeQueryDirectoryFileSystem = () => ({ + 'person.gts': ` + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Person extends CardDef { + @field name = contains(StringField); + } + `, + 'person-1.json': { + data: { + attributes: { + name: 'Alpha', + }, + meta: { + adoptsFrom: { + module: './person', + name: 'Person', + }, + }, + }, + }, + 'person-2.json': { + data: { + attributes: { + name: 'Beta', + }, + meta: { + adoptsFrom: { + module: './person', + name: 'Person', + }, + }, + }, + }, + 'query-directory.gts': ` + import { field, CardDef, Component, linksToMany } from "https://cardstack.com/base/card-api"; + import { Person } from "./person"; + + export class QueryDirectory extends CardDef { + @field people = linksToMany(() => Person, { + query: { + filter: { + eq: { + name: "Beta", + }, + }, + }, + }); + + static isolated = class extends Component<typeof this> { + <template> + <ul data-test-directory-people> + {{#each @model.people as |person|}} + <li data-test-directory-person-name>{{person.name}}</li> + {{/each}} + </ul> + </template> + }; + } + `, + 'query-directory-1.json': { + data: { + attributes: {}, + meta: { + adoptsFrom: { + module: './query-directory', + name: 'QueryDirectory', + }, + }, + }, + }, + 'query-directory-proxy.gts': ` + import { field, CardDef, Component, linksTo } from "https://cardstack.com/base/card-api"; + import { QueryDirectory } from "./query-directory"; + + export class QueryDirectoryProxy extends CardDef { + @field directory = linksTo(() => QueryDirectory); + + static isolated = class extends Component<typeof this> { + <template> + <@fields.directory @format="isolated" /> + </template> + }; + } + `, + 'query-directory-proxy-1.json': { + data: { + attributes: {}, + relationships: { + directory: { + links: { + self: './query-directory-1', + }, + }, + }, + meta: { + adoptsFrom: { + module: './query-directory-proxy', + name: 'QueryDirectoryProxy', + }, + }, + }, + }, + }); + setupPermissionedRealmsCached(hooks, { + mode: 'before', + realms: [ + { + realmURL: publicRealmURL, + permissions: { + '*': ['read', 'write', 'realm-owner'], + }, + fileSystem: { + ...makeQueryDirectoryFileSystem(), + }, + }, + ], + onRealmSetup() { + permissions = { + [publicRealmURL]: ['read', 'write', 'realm-owner'], + }; + }, + }); + it('card prerender in a public realm authorizes query fallback search for source-loaded linked cards', async function () { + const cardURL = `${publicRealmURL}query-directory-proxy-1.json`; + let realmServerPatch = installRealmServerAssertOwnRealmServerBypassPatch(); + let delayedSearchPatch = installDelayedRuntimeRealmSearchPatch(150); + try { + let result = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: publicRealmURL, + realm: publicRealmURL, + url: cardURL, + auth: auth(), + }); + expect(result.response.error).toBeFalsy(); + expect(delayedSearchPatch.getRequestCount() > 0).toBe(true); + let isolatedHTML = cleanWhiteSpace(result.response.isolatedHTML ?? ''); + expect(/data-test-directory-person-name[^>]*>\s*Beta\s*</.test(isolatedHTML)).toBeTruthy(); + } + finally { + await realmServerPatch.restore(); + delayedSearchPatch.restore(); + } + }); + }); + describe('prerender - permissioned auth failures (private query fallback)', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let privateRealmURL = 'http://127.0.0.1:4453/test/'; + let prerenderServerURL = new URL(privateRealmURL).origin; + let testUserId = '@user1:localhost'; + let permissions: RealmPermissions = {}; + let prerenderer: Prerenderer; + let auth = () => testCreatePrerenderAuth(testUserId, permissions); + hooks.before(async () => { + prerenderer = getPrerendererForTesting({ + maxPages: 2, + serverURL: prerenderServerURL, + }); + }); + hooks.after(async () => { + await prerenderer.stop(); + }); + hooks.beforeEach(function () { + permissions = { + [privateRealmURL]: ['read', 'write', 'realm-owner'], + }; + }); + hooks.afterEach(async () => { + await prerenderer.disposeAffinity({ + affinityType: 'realm', + affinityValue: privateRealmURL, + }); + }); + let makeQueryDirectoryFileSystem = () => ({ + 'person.gts': ` + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Person extends CardDef { + @field name = contains(StringField); + } + `, + 'person-1.json': { + data: { + attributes: { + name: 'Alpha', + }, + meta: { + adoptsFrom: { + module: './person', + name: 'Person', + }, + }, + }, + }, + 'person-2.json': { + data: { + attributes: { + name: 'Beta', + }, + meta: { + adoptsFrom: { + module: './person', + name: 'Person', + }, + }, + }, + }, + 'query-directory.gts': ` + import { field, CardDef, Component, linksToMany } from "https://cardstack.com/base/card-api"; + import { Person } from "./person"; + + export class QueryDirectory extends CardDef { + @field people = linksToMany(() => Person, { + query: { + filter: { + eq: { + name: "Beta", + }, + }, + }, + }); + + static isolated = class extends Component<typeof this> { + <template> + <ul data-test-directory-people> + {{#each @model.people as |person|}} + <li data-test-directory-person-name>{{person.name}}</li> + {{/each}} + </ul> + </template> + }; + } + `, + 'query-directory-1.json': { + data: { + attributes: {}, + meta: { + adoptsFrom: { + module: './query-directory', + name: 'QueryDirectory', + }, + }, + }, + }, + 'query-directory-proxy.gts': ` + import { field, CardDef, Component, linksTo } from "https://cardstack.com/base/card-api"; + import { QueryDirectory } from "./query-directory"; + + export class QueryDirectoryProxy extends CardDef { + @field directory = linksTo(() => QueryDirectory); + + static isolated = class extends Component<typeof this> { + <template> + <@fields.directory @format="isolated" /> + </template> + }; + } + `, + 'query-directory-proxy-1.json': { + data: { + attributes: {}, + relationships: { + directory: { + links: { + self: './query-directory-1', + }, + }, + }, + meta: { + adoptsFrom: { + module: './query-directory-proxy', + name: 'QueryDirectoryProxy', + }, + }, + }, + }, + }); + setupPermissionedRealmsCached(hooks, { + mode: 'before', + realms: [ + { + realmURL: privateRealmURL, + permissions: { + [testUserId]: ['read', 'write', 'realm-owner'], + }, + fileSystem: { + ...makeQueryDirectoryFileSystem(), + }, + }, + ], + onRealmSetup() { + permissions = { + [privateRealmURL]: ['read', 'write', 'realm-owner'], + }; + }, + }); + it('card prerender in a private realm sends auth header on query fallback federated search', async function () { + const cardURL = `${privateRealmURL}query-directory-proxy-1.json`; + let realmServerPatch = installRealmServerAssertOwnRealmServerBypassPatch(); + let searchRequestObserverPatch = installSearchRequestObserverPatch(); + let delayedSearchPatch = installDelayedRuntimeRealmSearchPatch(150); + try { + let result = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: privateRealmURL, + realm: privateRealmURL, + url: cardURL, + auth: auth(), + }); + expect(result.response.error).toBeFalsy(); + expect(delayedSearchPatch.getRequestCount() > 0).toBe(true); + let isolatedHTML = cleanWhiteSpace(result.response.isolatedHTML ?? ''); + expect(/data-test-directory-person-name[^>]*>\s*Beta\s*</.test(isolatedHTML)).toBeTruthy(); + let searchRequests = searchRequestObserverPatch.getRequests(); + expect(searchRequests.length > 0).toBe(true); + let querySearchRequests = searchRequests.filter((request) => request.method === 'QUERY'); + expect(querySearchRequests.length > 0).toBe(true); + expect(querySearchRequests.every((request) => request.hasAuthorization)).toBe(true); + } + finally { + delayedSearchPatch.restore(); + searchRequestObserverPatch.restore(); + await realmServerPatch.restore(); + } + }); + }); + function defineNonMutatingStaticTests() { + describe('formats and pooling', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let realmURL1 = 'http://127.0.0.1:4447/test/'; + let realmURL2 = 'http://127.0.0.1:4448/test/'; + let realmURL3 = 'http://127.0.0.1:4449/test/'; + let prerenderServerURL = new URL(realmURL1).origin; + let testUserId = '@user1:localhost'; + let permissions: RealmPermissions = {}; + let prerenderer: Prerenderer; + let auth = () => testCreatePrerenderAuth(testUserId, permissions); + const disposeAllRealms = async () => { + await Promise.all([ + prerenderer.disposeAffinity({ + affinityType: 'realm', + affinityValue: realmURL1, + }), + prerenderer.disposeAffinity({ + affinityType: 'realm', + affinityValue: realmURL2, + }), + prerenderer.disposeAffinity({ + affinityType: 'realm', + affinityValue: realmURL3, + }), + ]); + }; + hooks.before(async function () { + prerenderer = getPrerendererForTesting({ + maxPages: 2, + serverURL: prerenderServerURL, + }); + }); + hooks.after(async function () { + await prerenderer.stop(); + }); + setupPermissionedRealmsCached(hooks, { + mode: 'before', + realms: [ + { + realmURL: realmURL1, + permissions: { + [testUserId]: ['read', 'write', 'realm-owner'], + }, + fileSystem: { + 'person.gts': ` + import { CardDef, field, contains, StringField } from 'https://cardstack.com/base/card-api'; + import { Component } from 'https://cardstack.com/base/card-api'; + export class Person extends CardDef { + static displayName = "Person"; + @field name = contains(StringField); + static fitted = <template><@fields.name/></template> + } + `, + '1.json': { + data: { + attributes: { + name: 'Hassan', + }, + meta: { + adoptsFrom: { + module: './person', + name: 'Person', + }, + }, + }, + }, + '2.json': { + data: { + attributes: { + name: 'Mango', + }, + meta: { + adoptsFrom: { + module: './person', + name: 'Person', + }, + }, + }, + }, + }, + }, + { + realmURL: realmURL2, + permissions: { + [testUserId]: ['read', 'write', 'realm-owner'], + }, + fileSystem: { + 'broken-card.gts': ` + import { + `, + 'broken.json': { + data: { + attributes: { + name: 'Broken', + }, + meta: { + adoptsFrom: { + module: './broken-card', + name: 'Broken', + }, + }, + }, + }, + 'cat.gts': ` + import { CardDef, field, contains, linksTo, StringField } from 'https://cardstack.com/base/card-api'; + import { Component } from 'https://cardstack.com/base/card-api'; + import { Person } from '${realmURL1}person'; + export class Cat extends CardDef { + @field name = contains(StringField); + @field owner = linksTo(Person); + static displayName = "Cat"; + static embedded = <template>{{@fields.name}} says Meow. owned by <@fields.owner /></template> + } + `, + 'dog.gts': ` + import { CardDef, field, contains, linksTo, StringField, Component } from 'https://cardstack.com/base/card-api'; + import { Person } from '${realmURL1}person'; + export class Dog extends CardDef { + static displayName = "Dog"; + @field name = contains(StringField); + @field owner = linksTo(Person, { isUsed: true }); + static isolated = class extends Component<typeof this> { + // owner is intentionally not in isolated template, this is included in search doc via isUsed=true + <template>{{@model.name}}</template> + } + } + `, + 'dog-many.gts': ` + import { CardDef, field, contains, linksToMany, StringField, Component } from 'https://cardstack.com/base/card-api'; + import { Person } from '${realmURL1}person'; + export class DogMany extends CardDef { + static displayName = "Dog Many"; + @field name = contains(StringField); + @field owners = linksToMany(Person, { isUsed: true }); + static isolated = class extends Component<typeof this> { + // owners is intentionally not in isolated template, this is included in search doc via isUsed=true + <template>{{@model.name}}</template> + } + } + `, + 'dog-profile.gts': ` + import { CardDef, FieldDef, field, contains, linksTo, linksToMany, StringField, Component } from 'https://cardstack.com/base/card-api'; + import { Person } from '${realmURL1}person'; + + class DogProfileField extends FieldDef { + @field primaryOwner = linksTo(Person, { isUsed: true }); + @field caretakers = linksToMany(Person, { isUsed: true }); + } + + export class DogProfile extends CardDef { + static displayName = "Dog Profile"; + @field name = contains(StringField); + @field profile = contains(DogProfileField); + static isolated = class extends Component<typeof this> { + // profile is intentionally not in isolated template, this is included in search doc via isUsed=true + <template>{{@model.name}}</template> + } + } + `, + 'non-isolated-links-card.gts': ` + import { CardDef, FieldDef, field, contains, linksTo, linksToMany, StringField, Component } from 'https://cardstack.com/base/card-api'; + import { Person } from '${realmURL1}person'; + + class RelationshipField extends FieldDef { + @field lead = linksTo(Person); + @field members = linksToMany(Person); + } + + export class NonIsolatedLinks extends CardDef { + static displayName = 'Non Isolated Links'; + @field name = contains(StringField); + @field owner = linksTo(Person); + @field owners = linksToMany(Person); + @field profile = contains(RelationshipField); + + static isolated = class extends Component<typeof this> { + <template><div data-test-isolated-name>{{@model.name}}</div></template> + }; + + static embedded = class extends Component<typeof this> { + <template> + <div data-test-embedded-owner> + <span data-test-embedded-owner>{{@model.owner.name}}</span> + </div> + <div data-test-embedded-owners> + {{#each @model.owners as |owner|}} + <span data-test-embedded-owner-name>{{owner.name}}</span> + {{/each}} + </div> + <div data-test-embedded-profile-lead> + <span data-test-embedded-profile-lead-name>{{@model.profile.lead.name}}</span> + </div> + <div data-test-embedded-profile-members> + {{#each @model.profile.members as |member|}} + <span data-test-embedded-profile-member-name>{{member.name}}</span> + {{/each}} + </div> + </template> + }; + } + `, + '1.json': { + data: { + attributes: { + name: 'Maple', + }, + relationships: { + owner: { + links: { self: `${realmURL1}1` }, + }, + }, + meta: { + adoptsFrom: { + module: './cat', + name: 'Cat', + }, + }, + }, + }, + 'is-used.json': { + data: { + attributes: { + name: 'Mango', + }, + relationships: { + owner: { + links: { self: `${realmURL1}1` }, + }, + }, + meta: { + adoptsFrom: { + module: './dog', + name: 'Dog', + }, + }, + }, + }, + 'is-used-many.json': { + data: { + attributes: { + name: 'Mango Many', + }, + relationships: { + 'owners.0': { + links: { self: `${realmURL1}1` }, + }, + 'owners.1': { + links: { self: `${realmURL1}2` }, + }, + }, + meta: { + adoptsFrom: { + module: './dog-many', + name: 'DogMany', + }, + }, + }, + }, + 'is-used-field-def.json': { + data: { + attributes: { + name: 'Mango Profile', + profile: {}, + }, + relationships: { + 'profile.primaryOwner': { + links: { self: `${realmURL1}1` }, + }, + 'profile.caretakers.0': { + links: { self: `${realmURL1}1` }, + }, + 'profile.caretakers.1': { + links: { self: `${realmURL1}2` }, + }, + }, + meta: { + adoptsFrom: { + module: './dog-profile', + name: 'DogProfile', + }, + }, + }, + }, + 'non-isolated-links.json': { + data: { + attributes: { + name: 'Mango Non Isolated', + profile: {}, + cardInfo: { + name: null, + summary: null, + cardThumbnailURL: null, + notes: null, + }, + }, + relationships: { + 'cardInfo.theme': { + links: { self: `${realmURL2}non-isolated-theme` }, + }, + owner: { + links: { self: `${realmURL1}1` }, + }, + 'owners.0': { + links: { self: `${realmURL1}1` }, + }, + 'owners.1': { + links: { self: `${realmURL1}2` }, + }, + 'profile.lead': { + links: { self: `${realmURL1}1` }, + }, + 'profile.members.0': { + links: { self: `${realmURL1}1` }, + }, + 'profile.members.1': { + links: { self: `${realmURL1}2` }, + }, + }, + meta: { + adoptsFrom: { + module: './non-isolated-links-card', + name: 'NonIsolatedLinks', + }, + }, + }, + }, + 'non-isolated-theme.json': { + data: { + type: 'card', + attributes: { + markUsage: { + socialMediaProfileIcon: 'https://example.com/non-isolated-social-icon.png', + }, + }, + meta: { + adoptsFrom: { + module: 'https://cardstack.com/base/brand-guide', + name: 'default', + }, + }, + }, + }, + 'missing-link.json': { + data: { + attributes: { + name: 'Missing Owner', + }, + relationships: { + owner: { + links: { self: `${realmURL1}missing-owner` }, + }, + }, + meta: { + adoptsFrom: { + module: './cat', + name: 'Cat', + }, + }, + }, + }, + 'fetch-failed.json': { + data: { + attributes: { + name: 'Missing Owner', + }, + relationships: { + owner: { + links: { + self: 'http://localhost:9000/this-is-a-link-to-nowhere', + }, + }, + }, + meta: { + adoptsFrom: { + module: './cat', + name: 'Cat', + }, + }, + }, + }, + 'intentional-error.gts': ` + import { CardDef, field, contains, StringField } from 'https://cardstack.com/base/card-api'; + import { Component } from 'https://cardstack.com/base/card-api'; + export class IntentionalError extends CardDef { + @field name = contains(StringField); + static displayName = "Intentional Error"; + static isolated = class extends Component { + get message() { + if (this.args.model.name === 'Intentional Error') { + throw new Error('intentional failure during render') + } + return this.args.model.name; + } + <template>{{this.message}}</template> + } + } + `, + '2.json': { + data: { + attributes: { + name: 'Intentional Error', + }, + meta: { + adoptsFrom: { + module: './intentional-error', + name: 'IntentionalError', + }, + }, + }, + }, + 'timer-error-card.gts': ` + import { CardDef, field, contains, StringField } from 'https://cardstack.com/base/card-api'; + import { Component } from 'https://cardstack.com/base/card-api'; + export class TimerError extends CardDef { + @field name = contains(StringField); + static displayName = "Timer Error"; + static isolated = class extends Component { + get message() { + setTimeout(() => {}, 0); + setInterval(() => {}, 5); + throw new Error('timer error during render'); + } + <template>{{this.message}}</template> + } + } + `, + 'timer-error.json': { + data: { + attributes: { + name: 'Timer Error', + }, + meta: { + adoptsFrom: { + module: './timer-error-card', + name: 'TimerError', + }, + }, + }, + }, + 'timer-timeout-card.gts': ` + import { CardDef, field, contains, StringField } from 'https://cardstack.com/base/card-api'; + import { Component } from 'https://cardstack.com/base/card-api'; + setTimeout(() => {}, 0); + setInterval(() => {}, 5); + export class TimerTimeout extends CardDef { + @field name = contains(StringField); + static displayName = "Timer Timeout"; + static isolated = class extends Component { + get message() { + setTimeout(() => {}, 0); + setInterval(() => {}, 5); + return this.args.model.name; + } + <template>{{this.message}}</template> + } + } + `, + 'timer-timeout.json': { + data: { + attributes: { + name: 'Timer Timeout', + }, + meta: { + adoptsFrom: { + module: './timer-timeout-card', + name: 'TimerTimeout', + }, + }, + }, + }, + // A card that fires the boxel-render-error event (handled by the prerender route) + // and then blocks the event loop long enough that Ember health probe times out, + // causing data-prerender-status to be set to 'unusable' by the error handler without + // transitioning to the render-error route (so nothing overwrites our dataset). + 'unusable-error.gts': ` + import { CardDef, field, contains, StringField } from 'https://cardstack.com/base/card-api'; + import { Component } from 'https://cardstack.com/base/card-api'; + export class UnusableError extends CardDef { + @field name = contains(StringField); + static displayName = "Unusable Error"; + static isolated = class extends Component { + get trigger() { + throw new Error('forced unusable for test'); + } + <template>{{this.trigger}}</template> + } + } + `, + '3.json': { + data: { + attributes: { + name: 'Force Unusable', + }, + meta: { + adoptsFrom: { + module: './unusable-error', + name: 'UnusableError', + }, + }, + }, + }, + 'embedded-error.gts': ` + import { CardDef, field, contains, StringField } from 'https://cardstack.com/base/card-api'; + export class EmbeddedError extends CardDef { + @field name = contains(StringField); + static displayName = "Embedded Error"; + static isolated = <template> + <pre data-prerender-error> + { + "type": "error", + "error": { + "id": "embedded-error", + "status": 500, + "title": "Embedded error", + "message": "error flagged from DOM", + "additionalErrors": null + } + } + </pre> +</template> + } + `, + '4.json': { + data: { + attributes: { + name: 'Embedded Error', + }, + meta: { + adoptsFrom: { + module: './embedded-error', + name: 'EmbeddedError', + }, + }, + }, + }, + }, + }, + { + realmURL: realmURL3, + permissions: { + [testUserId]: ['read', 'write', 'realm-owner'], + }, + fileSystem: { + 'dog.gts': ` + import { CardDef, field, contains, StringField } from 'https://cardstack.com/base/card-api'; + import { Component } from 'https://cardstack.com/base/card-api'; + export class Dog extends CardDef { + @field name = contains(StringField); + static displayName = "Dog"; + static embedded = <template>{{@fields.name}} wags tail</template> + } + `, + '1.json': { + data: { + attributes: { + name: 'Taro', + }, + meta: { + adoptsFrom: { + module: './dog', + name: 'Dog', + }, + }, + }, + }, + }, + }, + ], + onRealmSetup: () => { + permissions = { + [realmURL1]: ['read', 'write', 'realm-owner'], + [realmURL2]: ['read', 'write', 'realm-owner'], + [realmURL3]: ['read', 'write', 'realm-owner'], + }; + }, + }); + describe('basics', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + hooks.beforeEach(disposeAllRealms); + let result: RenderResponse; + hooks.before(async () => { + const testCardURL = `${realmURL2}1`; + let { response } = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: realmURL2, + realm: realmURL2, + url: testCardURL, + auth: auth(), + }); + result = response; + }); + it('embedded HTML', function () { + expect(/Maple\s+says\s+Meow/.test(cleanWhiteSpace(result.embeddedHTML![`${realmURL2}cat/Cat`]))).toBeTruthy(); + }); + it('parent embedded HTML', function () { + expect(/data-test-card-thumbnail-placeholder/.test(result.embeddedHTML!['https://cardstack.com/base/card-api/CardDef'])).toBeTruthy(); + }); + it('isolated HTML', function () { + expect(/data-test-field="cardInfo-summary"/.test(result.isolatedHTML!)).toBeTruthy(); + }); + it('atom HTML', function () { + expect(/Untitled Cat/.test(result.atomHTML!)).toBeTruthy(); + }); + it('icon HTML', function () { + expect(result.iconHTML?.startsWith('<svg')).toBeTruthy(); + }); + it('head HTML', function () { + expect(result.headHTML).toBeTruthy(); + let cleanedHead = cleanWhiteSpace(result.headHTML!); + expect(cleanedHead.includes('<title data-test-card-head-title>Untitled Cat')).toBeTruthy(); + expect(cleanedHead.includes('property="og:title" content="Untitled Cat"')).toBeTruthy(); + expect(cleanedHead.includes(`property="og:url" content="${realmURL2}1"`)).toBeTruthy(); + expect(cleanedHead.includes('name="twitter:card" content="summary"')).toBeTruthy(); + }); + it('serialized', function () { + expect(result.serialized?.data.attributes?.name).toBe('Maple'); + }); + it('displayNames', function () { + expect(result.displayNames).toEqual(['Cat', 'Card']); + }); + it('deps', function () { + // spot check a few deps, as the whole list is overwhelming... + expect(result.deps?.includes(baseCardRef.module)).toBeTruthy(); + expect(result.deps?.includes(`${realmURL1}person`)).toBeTruthy(); + expect(result.deps?.includes(`${realmURL2}cat`)).toBeTruthy(); + expect(result.deps?.find((d) => d.match(/^https:\/\/cardstack.com\/base\/card-api\.gts\..*glimmer-scoped\.css$/))).toBeTruthy(); + }); + it('types', function () { + expect(result.types).toEqual([ + `${realmURL2}cat/Cat`, + 'https://cardstack.com/base/card-api/CardDef', + ]); + }); + it('searchDoc', function () { + expect(result.searchDoc?.name).toBe('Maple'); + expect(result.searchDoc?._cardType).toBe('Cat'); + expect(result.searchDoc?.owner.name).toBe('Hassan'); + }); + it('isUsed field includes a field in search doc that is not rendered in template', async function () { + const testCardURL = `${realmURL2}is-used`; + let { response } = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: realmURL2, + realm: realmURL2, + url: testCardURL, + auth: auth(), + }); + expect(/Mango/.test(response.isolatedHTML!)).toBeTruthy(); + expect(/data-test-field="owner"/.test(response.isolatedHTML!)).toBe(false); + expect(response.searchDoc?.owner.name).toBe('Hassan'); + }); + it('isUsed linksToMany field includes links in search doc that are not rendered in template', async function () { + const testCardURL = `${realmURL2}is-used-many`; + let { response } = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: realmURL2, + realm: realmURL2, + url: testCardURL, + auth: auth(), + }); + expect(/Mango Many/.test(response.isolatedHTML!)).toBeTruthy(); + expect(/data-test-field="owners"/.test(response.isolatedHTML!)).toBe(false); + expect(response.searchDoc?.owners?.[0]?.name).toBe('Hassan'); + expect(response.searchDoc?.owners?.[1]?.name).toBe('Mango'); + }); + it('isUsed compound field includes nested linksTo relationship in search doc', async function () { + const testCardURL = `${realmURL2}is-used-field-def`; + let { response } = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: realmURL2, + realm: realmURL2, + url: testCardURL, + auth: auth(), + }); + expect(/Mango Profile/.test(response.isolatedHTML!)).toBeTruthy(); + expect(/data-test-field="profile"/.test(response.isolatedHTML!)).toBe(false); + expect(response.searchDoc?.profile?.primaryOwner?.name).toBe('Hassan'); + }); + it('isUsed compound field includes nested linksToMany relationships in search doc', async function () { + const testCardURL = `${realmURL2}is-used-field-def`; + let { response } = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: realmURL2, + realm: realmURL2, + url: testCardURL, + auth: auth(), + }); + expect(/Mango Profile/.test(response.isolatedHTML!)).toBeTruthy(); + expect(/data-test-field="profile"/.test(response.isolatedHTML!)).toBe(false); + expect(response.searchDoc?.profile?.caretakers?.[0]?.name).toBe('Hassan'); + expect(response.searchDoc?.profile?.caretakers?.[1]?.name).toBe('Mango'); + }); + it('non-isolated formats render linked fields and those links appear in search doc', async function () { + const testCardURL = `${realmURL2}non-isolated-links`; + let { response } = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: realmURL2, + realm: realmURL2, + url: testCardURL, + auth: auth(), + }); + let headHTML = cleanWhiteSpace(response.headHTML ?? ''); + expect(/]*>\s*Hassan\s*]*>\s*Hassan\s*]*>\s*Mango\s*]*>\s*Hassan\s*]*>\s*Hassan\s*]*>\s*Mango\s*= 1).toBeTruthy(); + let intervalMatch = stack.match(/setInterval:\s+(\d+)/); + expect(intervalMatch).toBeTruthy(); + expect(Number(intervalMatch?.[1]) >= 1).toBeTruthy(); + expect(stack.includes('at get message')).toBeTruthy(); + }); + it('timeout includes blocked timer summary in stack', async function () { + const testCardURL = `${realmURL2}timer-timeout`; + await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: realmURL2, + realm: realmURL2, + url: testCardURL, + auth: auth(), + }); + let { response } = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: realmURL2, + realm: realmURL2, + url: testCardURL, + auth: auth(), + opts: { timeoutMs: 1000, simulateTimeoutMs: 2000 }, + }); + expect(response.error?.error.title).toBe('Render timeout'); + let stack = response.error?.error.stack ?? ''; + expect(stack.includes('Timers blocked during prerender')).toBeTruthy(); + expect(/setTimeout:\s+\d+/.test(stack)).toBeTruthy(); + expect(/setInterval:\s+\d+/.test(stack)).toBeTruthy(); + }); + it('missing link surfaces 404 without eviction', async function () { + const testCardURL = `${realmURL2}missing-link`; + let result = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: realmURL2, + realm: realmURL2, + url: testCardURL, + auth: auth(), + }); + let { response } = result; + expect(response.error).toBeTruthy(); + expect(response.error?.error.message).toBe(`missing file ${realmURL1}missing-owner.json`); + expect(response.error?.error.status).toBe(404); + expect(result.pool.evicted).toBe(false); + expect(result.pool.timedOut).toBe(false); + }); + it('fetch failed surfaces error without eviction', async function () { + const testCardURL = `${realmURL2}fetch-failed`; + let result = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: realmURL2, + realm: realmURL2, + url: testCardURL, + auth: auth(), + }); + let { response } = result; + expect(response.error).toBeTruthy(); + expect(response.error?.error.message).toBe(`unable to fetch http://localhost:9000/this-is-a-link-to-nowhere: fetch failed`); + expect(response.error?.error.status).toBe(500); + expect(result.pool.evicted).toBe(false); + expect(result.pool.timedOut).toBe(false); + }); + it('embedded error markup triggers render error', async function () { + const testCardURL = `${realmURL2}4`; + let { response } = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: realmURL2, + realm: realmURL2, + url: testCardURL, + auth: auth(), + }); + expect(response.error).toBeTruthy(); + expect(response.error?.error.id).toBe('embedded-error'); + expect(response.error?.error.message).toBe('error flagged from DOM'); + expect(response.error?.error.title).toBe('Embedded error'); + expect(response.error?.error.status).toBe(500); + }); + it('unusable triggers eviction and short-circuit', async function () { + // Render the card that forces unusable + const unusableURL = `${realmURL2}3`; + let unusable = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: realmURL2, + realm: realmURL2, + url: unusableURL, + auth: auth(), + }); + // We should see an error with evict semantics and short-circuited payloads + expect(unusable.response.error).toBeTruthy(); + expect(unusable.response.error?.error.id).toBe(unusableURL); + expect(unusable.response.error?.error.message).toBe('forced unusable for test'); + expect(unusable.response.error?.error.status).toBe(500); + expect(unusable.response.isolatedHTML).toBe(null); + expect(unusable.response.embeddedHTML).toBe(null); + expect(unusable.response.atomHTML).toBe(null); + expect(unusable.response.iconHTML).toBe(null); + expect({ + serialized: unusable.response.serialized, + searchDoc: unusable.response.searchDoc, + displayNames: unusable.response.displayNames, + types: unusable.response.types, + deps: unusable.response.deps, + }).toEqual({ + serialized: null, + searchDoc: null, + displayNames: null, + types: null, + deps: null, + }); + expect(unusable.pool.evicted).toBe(true); + expect(unusable.pool.timedOut).toBe(false); + expect(unusable.pool.pageId).not.toBe('unknown'); + // After unusable, the realm should be evicted; a subsequent render should not reuse + const healthyURL = `${realmURL2}1`; + let next = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: realmURL2, + realm: realmURL2, + url: healthyURL, + auth: auth(), + }); + expect(next.pool.reused).toBe(false); + expect(next.pool.evicted).toBe(false); + }); + it('prerender surfaces module syntax errors without timing out', async function () { + const cardURL = `${realmURL2}broken`; + let broken = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: realmURL2, + realm: realmURL2, + url: cardURL, + auth: auth(), + }); + expect(broken.response.error).toBeTruthy(); + expect(broken.response.error?.error.status).toBe(406); + expect(broken.pool.timedOut).toBe(false); + }); + }); + describe('affinity pooling', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + hooks.beforeEach(disposeAllRealms); + it('evicts on timeout and does not reuse', async function () { + const testCardURL = `${realmURL2}1`; + // First render to initialize pool + let first = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: realmURL2, + realm: realmURL2, + url: testCardURL, + auth: auth(), + }); + expect(first.pool.reused).toBe(false); + // Now trigger a timeout; this should evict the realm + let timeoutRun = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: realmURL2, + realm: realmURL2, + url: testCardURL, + auth: auth(), + opts: { timeoutMs: 1, simulateTimeoutMs: 5 }, + }); + expect(timeoutRun.response.error?.error.title).toBe('Render timeout'); + expect(timeoutRun.pool.evicted).toBe(true); + expect(timeoutRun.pool.timedOut).toBe(true); + expect(timeoutRun.pool.pageId).not.toBe('unknown'); + // A subsequent render should not reuse the previously pooled page + let afterTimeout = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: realmURL2, + realm: realmURL2, + url: testCardURL, + auth: auth(), + }); + expect(afterTimeout.pool.reused).toBe(false); + expect(afterTimeout.pool.evicted).toBe(false); + expect(afterTimeout.pool.timedOut).toBe(false); + }); + it('reuses the same page within a realm', async function () { + const testCardURL = `${realmURL2}1`; + let first = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: realmURL2, + realm: realmURL2, + url: testCardURL, + auth: auth(), + }); + let second = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: realmURL2, + realm: realmURL2, + url: testCardURL, + auth: auth(), + }); + expect(first.pool.affinityValue).toBe(realmURL2); + expect(second.pool.affinityValue).toBe(realmURL2); + expect(first.pool.pageId).toBe(second.pool.pageId); + expect(first.pool.reused).toBe(false); + expect(second.pool.reused).toBe(true); + expect(first.pool.timedOut).toBe(false); + expect(second.pool.timedOut).toBe(false); + }); + it('reuses the same page within a user affinity', async function () { + const testCardURL = `${realmURL2}1`; + const userAffinityValue = testUserId; + let first = await prerenderer.prerenderCard({ + affinityType: 'user', + affinityValue: userAffinityValue, + realm: realmURL2, + url: testCardURL, + auth: auth(), + }); + let second = await prerenderer.prerenderCard({ + affinityType: 'user', + affinityValue: userAffinityValue, + realm: realmURL2, + url: testCardURL, + auth: auth(), + }); + expect(first.pool.affinityValue).toBe(userAffinityValue); + expect(second.pool.affinityValue).toBe(userAffinityValue); + expect(first.pool.pageId).toBe(second.pool.pageId); + expect(first.pool.reused).toBe(false); + expect(second.pool.reused).toBe(true); + expect(first.pool.timedOut).toBe(false); + expect(second.pool.timedOut).toBe(false); + }); + it('does not reuse across affinity types when affinity values match', async function () { + const testCardURL = `${realmURL2}1`; + const sharedAffinityValue = 'shared-affinity-value'; + let firstRealm = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: sharedAffinityValue, + realm: realmURL2, + url: testCardURL, + auth: auth(), + }); + let firstUser = await prerenderer.prerenderCard({ + affinityType: 'user', + affinityValue: sharedAffinityValue, + realm: realmURL2, + url: testCardURL, + auth: auth(), + }); + let secondRealm = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: sharedAffinityValue, + realm: realmURL2, + url: testCardURL, + auth: auth(), + }); + expect(firstRealm.pool.pageId).not.toBe(firstUser.pool.pageId); + expect(firstUser.pool.reused).toBe(false); + expect(secondRealm.pool.pageId).toBe(firstRealm.pool.pageId); + expect(secondRealm.pool.reused).toBe(true); + }); + it('refreshes prerender session when auth changes for the same realm', async function () { + const testCardURL = `${realmURL2}1`; + let authA = testCreatePrerenderAuth(testUserId, { + [realmURL2]: ['read', 'write', 'realm-owner'], + }); + let authB = testCreatePrerenderAuth(testUserId, { + [realmURL2]: ['read', 'write', 'realm-owner'], + [realmURL1]: ['read', 'write', 'realm-owner'], // introduce a different token set + }); + let first = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: realmURL2, + realm: realmURL2, + url: testCardURL, + auth: authA, + }); + let second = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: realmURL2, + realm: realmURL2, + url: testCardURL, + auth: authB, + }); + expect(first.pool.reused).toBe(false); + expect(second.pool.reused).toBe(false); + expect(first.pool.pageId).not.toBe(second.pool.pageId); + expect(second.response.serialized?.data.attributes?.name).toBe('Maple'); + }); + it('does not reuse across different realms', async function () { + const testCardURL1 = `${realmURL1}1`; + const testCardURL2 = `${realmURL2}1`; + let r1 = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: realmURL1, + realm: realmURL1, + url: testCardURL1, + auth: auth(), + }); + let r2 = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: realmURL2, + realm: realmURL2, + url: testCardURL2, + auth: auth(), + }); + expect(r1.pool.pageId).not.toBe(r2.pool.pageId); + expect(r1.pool.reused).toBe(false); + expect(r2.pool.reused).toBe(false); + }); + it('evicts LRU when capacity reached', async function () { + const cardA = `${realmURL1}1`; + const cardB = `${realmURL2}1`; + const cardC = `${realmURL3}1`; + let firstA = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: realmURL1, + realm: realmURL1, + url: cardA, + auth: auth(), + }); + expect(firstA.pool.reused).toBe(false); + let firstB = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: realmURL2, + realm: realmURL2, + url: cardB, + auth: auth(), + }); + expect(firstB.pool.reused).toBe(false); + // Now adding C should evict the LRU (A), since maxPages=2 + let firstC = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: realmURL3, + realm: realmURL3, + url: cardC, + auth: auth(), + }); + expect(firstC.pool.reused).toBe(false); + // Returning to A should not reuse because it was evicted + let secondA = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: realmURL1, + realm: realmURL1, + url: cardA, + auth: auth(), + }); + expect(secondA.pool.reused).toBe(false); + expect(firstA.pool.pageId).not.toBe(secondA.pool.pageId); + }); + it('serializes prerenders when only one tab is available', async function () { + let prevTabMax = process.env.PRERENDER_AFFINITY_TAB_MAX; + let semaphore = new TestSemaphore(1); + let active = 0; + let maxActive = 0; + let pool: PagePool | undefined; + try { + process.env.PRERENDER_AFFINITY_TAB_MAX = '1'; + ({ pool } = makeStubPagePool(1, semaphore)); + await pool.warmStandbys(); + let run = async (realm: string) => { + let lease = await pool!.getPage(realm); + active++; + maxActive = Math.max(maxActive, active); + await new Promise((resolve) => setTimeout(resolve, 25)); + active--; + lease.release(); + }; + await Promise.all([run('realm-a'), run('realm-b')]); + expect(maxActive).toBe(1); + } + finally { + await pool?.closeAll(); + if (prevTabMax === undefined) { + delete process.env.PRERENDER_AFFINITY_TAB_MAX; + } + else { + process.env.PRERENDER_AFFINITY_TAB_MAX = prevTabMax; + } + } + }); + it('runs prerenders in parallel when multiple tabs are available', async function () { + let prevTabMax = process.env.PRERENDER_AFFINITY_TAB_MAX; + let semaphore = new TestSemaphore(2); + let active = 0; + let maxActive = 0; + let pool: PagePool | undefined; + try { + process.env.PRERENDER_AFFINITY_TAB_MAX = '2'; + ({ pool } = makeStubPagePool(2, semaphore)); + await pool.warmStandbys(); + let run = async (realm: string) => { + let lease = await pool!.getPage(realm); + active++; + maxActive = Math.max(maxActive, active); + await new Promise((resolve) => setTimeout(resolve, 25)); + active--; + lease.release(); + }; + await Promise.all([run('realm-a'), run('realm-a')]); + expect(maxActive).toBe(2); + } + finally { + await pool?.closeAll(); + if (prevTabMax === undefined) { + delete process.env.PRERENDER_AFFINITY_TAB_MAX; + } + else { + process.env.PRERENDER_AFFINITY_TAB_MAX = prevTabMax; + } + } + }); + it('prefers idle tab aligned to realm over standby tabs', async function () { + let { pool } = makeStubPagePool(2); + await pool.warmStandbys(); + let first = await pool.getPage('realm-a'); + first.release(); + await pool.warmStandbys(); + let second = await pool.getPage('realm-a'); + expect(second.pageId).toBe(first.pageId); + expect(second.reused).toBe(true); + second.release(); + await pool.closeAll(); + }); + it('prefers standby over idle tabs from other realms', async function () { + let originalNow = Date.now; + let now = 1000; + (Date as any).now = () => now; + let { pool } = makeStubPagePool(1); + try { + await pool.warmStandbys(); // standby at t=1000 + now = 1100; + let realmALease = await pool.getPage('realm-a'); + realmALease.release(); + now = 2000; + await pool.warmStandbys(); // standby created after idle realm tab + let realmBLease = await pool.getPage('realm-b'); + expect(realmBLease.pageId).not.toBe(realmALease.pageId); + expect(realmBLease.reused).toBe(false); + realmBLease.release(); + } + finally { + await pool.closeAll(); + (Date as any).now = originalNow; + } + }); + it('enforces per-realm tab cap by queueing on an existing tab', async function () { + let prevTabMax = process.env.PRERENDER_AFFINITY_TAB_MAX; + let pool: PagePool | undefined; + let resolved = false; + try { + process.env.PRERENDER_AFFINITY_TAB_MAX = '1'; + ({ pool } = makeStubPagePool(2)); + await pool.warmStandbys(); + let first = await pool.getPage('realm-a'); + let secondPromise = pool.getPage('realm-a').then((lease) => { + resolved = true; + return lease; + }); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(resolved).toBe(false); + first.release(); + let second = await secondPromise; + expect(second.pageId).toBe(first.pageId); + expect(second.reused).toBe(true); + second.release(); + } + finally { + await pool?.closeAll(); + if (prevTabMax === undefined) { + delete process.env.PRERENDER_AFFINITY_TAB_MAX; + } + else { + process.env.PRERENDER_AFFINITY_TAB_MAX = prevTabMax; + } + } + }); + it('queues on the least-pending tab when the realm cap is met', async function () { + let prevTabMax = process.env.PRERENDER_AFFINITY_TAB_MAX; + let pool: PagePool | undefined; + let originalNow = Date.now; + let now = 1000; + (Date as any).now = () => now; + let thirdResolved = false; + let fourthResolved = false; + try { + process.env.PRERENDER_AFFINITY_TAB_MAX = '2'; + ({ pool } = makeStubPagePool(2)); + await pool.warmStandbys(); + let first = await pool.getPage('realm-a'); + now = 1100; + let second = await pool.getPage('realm-a'); + let thirdPromise = pool.getPage('realm-a').then((lease) => { + thirdResolved = true; + return lease; + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + let fourthPromise = pool.getPage('realm-a').then((lease) => { + fourthResolved = true; + return lease; + }); + second.release(); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(fourthResolved).toBe(true); + expect(thirdResolved).toBe(false); + let fourth = await fourthPromise; + expect(fourth.pageId).toBe(second.pageId); + first.release(); + let third = await thirdPromise; + expect(third.pageId).toBe(first.pageId); + fourth.release(); + third.release(); + } + finally { + (Date as any).now = originalNow; + await pool?.closeAll(); + if (prevTabMax === undefined) { + delete process.env.PRERENDER_AFFINITY_TAB_MAX; + } + else { + process.env.PRERENDER_AFFINITY_TAB_MAX = prevTabMax; + } + } + }); + it('queued cross-realm requests realign the tab per request', async function () { + let { pool } = makeStubPagePool(1); + await pool.warmStandbys(); + let first = await pool.getPage('realm-a'); + await pool.warmStandbys(); + let blocker = await pool.getPage('realm-c'); + let order: string[] = []; + let secondPromise = pool.getPage('realm-a').then((lease) => { + order.push('a'); + return lease; + }); + let thirdPromise = pool.getPage('realm-b').then((lease) => { + order.push('b'); + return lease; + }); + first.release(); + let second = await secondPromise; + expect(order).toEqual(['a']); + expect(pool.getWarmAffinities().includes('realm-a')).toBe(true); + second.release(); + blocker.release(); + let third = await thirdPromise; + expect(order).toEqual(['a', 'b']); + expect(pool.getWarmAffinities().includes('realm-b')).toBe(true); + third.release(); + await pool.closeAll(); + }); + it('does not reassign a busy tab with queued work across realms', async function () { + let { pool } = makeStubPagePool(1, undefined, undefined, { + disableStandbyRefill: true, + }); + try { + await pool.warmStandbys(); + let first = await pool.getPage('realm-a'); + let secondPromise = pool.getPage('realm-a'); + let thirdPromise = pool.getPage('realm-b'); + await expect(thirdPromise).rejects.toThrow(/No standby page available for prerender/); + first.release(); + let second = await secondPromise; + expect(second.pageId).toBe(first.pageId); + second.release(); + expect(pool.getWarmAffinities().includes('realm-b')).toBe(false); + } + finally { + await pool.closeAll(); + } + }); + it('queues same-realm request when tab is transitioning', async function () { + let { pool } = makeStubPagePool(1, undefined, undefined, { + disableStandbyRefill: true, + }); + try { + await pool.warmStandbys(); + let first = await pool.getPage('realm-a'); + let crossResolved = false; + let sameResolved = false; + let crossPromise = pool.getPage('realm-b').then((lease) => { + crossResolved = true; + return lease; + }); + let samePromise = pool.getPage('realm-a').then((lease) => { + sameResolved = true; + return lease; + }); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(crossResolved).toBe(false); + expect(sameResolved).toBe(false); + first.release(); + let cross = await crossPromise; + expect(cross.pageId).toBe(first.pageId); + cross.release(); + let same = await samePromise; + expect(same.pageId).toBe(first.pageId); + expect(pool.getWarmAffinities().includes('realm-b')).toBe(false); + same.release(); + } + finally { + await pool.closeAll(); + } + }); + it('does not oversubscribe contexts during async eviction', async function () { + let prevTabMax = process.env.PRERENDER_AFFINITY_TAB_MAX; + let pool: PagePool | undefined; + let active = 0; + let peak = 0; + let resolveClose!: () => void; + let closeGate = new Promise((resolve) => { + resolveClose = resolve; + }); + try { + process.env.PRERENDER_AFFINITY_TAB_MAX = '2'; + ({ pool } = makeStubPagePool(2, undefined, undefined, { + closeContextDelay: async () => closeGate, + onContextCreated() { + active++; + peak = Math.max(peak, active); + }, + onContextClosed() { + active--; + }, + })); + await pool.warmStandbys(); + let first = await pool.getPage('realm-a'); + let second = await pool.getPage('realm-a'); + await pool.disposeAffinity('realm-a', { awaitIdle: false }); + await pool.warmStandbys(); + expect(peak <= 3).toBeTruthy(); + first.release(); + second.release(); + resolveClose(); + } + finally { + if (resolveClose) { + resolveClose(); + } + await pool?.closeAll(); + if (prevTabMax === undefined) { + delete process.env.PRERENDER_AFFINITY_TAB_MAX; + } + else { + process.env.PRERENDER_AFFINITY_TAB_MAX = prevTabMax; + } + } + }); + it('creates spare standby when pool is at capacity', async function () { + let { pool, contextsCreated } = makeStubPagePool(1); + await pool.warmStandbys(); + expect(contextsCreated.length).toBe(1); + let first = await pool.getPage('realm-standby'); + expect(first.reused).toBe(false); + await pool.warmStandbys(); + expect(contextsCreated.length).toBe(2); + first.release(); + await pool.closeAll(); + }); + it('standby pages bind to the first realm they serve', async function () { + let { pool } = makeStubPagePool(2); + await pool.warmStandbys(); // fill initial standbys + let realmAFirst = await pool.getPage('realm-a'); + let realmAFirstId = realmAFirst.pageId; + realmAFirst.release(); + await pool.warmStandbys(); // replenish after consuming standby + let realmBFirst = await pool.getPage('realm-b'); + let realmBFirstId = realmBFirst.pageId; + realmBFirst.release(); + await pool.warmStandbys(); // replenish again to keep standbys warm + let realmASecond = await pool.getPage('realm-a'); + let realmBSecond = await pool.getPage('realm-b'); + expect(realmAFirst.reused).toBe(false); + expect(realmBFirst.reused).toBe(false); + expect(realmASecond.reused).toBe(true); + expect(realmBSecond.reused).toBe(true); + expect(realmASecond.pageId).toBe(realmAFirstId); + expect(realmBSecond.pageId).toBe(realmBFirstId); + expect(realmAFirstId).not.toBe(realmBFirstId); + realmASecond.release(); + realmBSecond.release(); + await pool.closeAll(); + }); + it('each tab uses a separate browser context', async function () { + let { pool } = makeStubPagePool(2); + await pool.warmStandbys(); + let first = await pool.getPage('realm-a'); + let second = await pool.getPage('realm-b'); + expect(first.page.browserContext()).not.toBe(second.page.browserContext()); + await first.page.evaluate((key: string, value: string) => localStorage.setItem(key, value), 'boxel-test-local-storage-key', 'realm-a-value'); + let firstValue = await first.page.evaluate((key: string) => localStorage.getItem(key), 'boxel-test-local-storage-key'); + let secondValue = await second.page.evaluate((key: string) => localStorage.getItem(key), 'boxel-test-local-storage-key'); + expect(firstValue).toBe('realm-a-value'); + expect(secondValue).toBe(null); + first.release(); + second.release(); + await pool.closeAll(); + }); + it('evicts idle realms without touching standbys', async function () { + let { pool, contextsCreated, contextsClosed } = makeStubPagePool(2); + await pool.warmStandbys(); + expect(contextsCreated.length).toBe(2); + let realmLease = await pool.getPage('realm-a'); + await pool.warmStandbys(); // ensure standby pool replenishment settles before idle sweep + realmLease.release(); + let originalNow = Date.now; + try { + let now = Date.now(); + (Date as any).now = () => now + 13 * 60 * 60 * 1000; // 13 hours later + let evicted = await pool.evictIdleAffinities(12 * 60 * 60 * 1000); + expect(evicted).toEqual(['realm-a']); + expect(pool.getWarmAffinities()).toEqual([]); + let closedAtEviction = [...contextsClosed]; + expect(closedAtEviction).toEqual([contextsCreated[0]]); + expect(contextsCreated.length > closedAtEviction.length).toBe(true); + } + finally { + (Date as any).now = originalNow; + await pool.closeAll(); + } + }); + it('idle eviction skips unassigned standbys', async function () { + let { pool, contextsCreated, contextsClosed } = makeStubPagePool(1); + await pool.warmStandbys(); + let createdBeforeSweep = contextsCreated.length; + let evicted = await pool.evictIdleAffinities(1); + let closedAfterSweep = contextsClosed.length; + expect(evicted).toEqual([]); + expect(contextsCreated.length).toBe(createdBeforeSweep); + expect(closedAfterSweep).toBe(0); + await pool.closeAll(); + }); + }); + }); + } + describe('prerender - module retries', function () { + it('module prerender retries with clear cache on retry signature', async function () { + let originalAttempt = RenderRunner.prototype.prerenderModuleAttempt; + let prerenderer: Prerenderer | undefined; + let attempts: Array = []; + let retryRealm = 'https://retry.example/'; + let moduleURL = `${retryRealm}module.gts`; + try { + let attemptCount = 0; + RenderRunner.prototype.prerenderModuleAttempt = async function (args: Parameters[0]) { + let { affinityType, affinityValue, url, renderOptions } = args; + attempts.push(renderOptions); + attemptCount++; + let baseResponse = { + id: url, + nonce: `nonce-${attemptCount}`, + isShimmed: false, + lastModified: 0, + createdAt: 0, + deps: [], + definitions: {}, + }; + let response: ModuleRenderResponse = attemptCount === 1 + ? { + ...baseResponse, + status: 'error', + error: { + type: 'module-error', + error: { + message: `Failed to execute 'removeChild' on 'Node': NotFoundError`, + status: 500, + title: 'boom', + additionalErrors: null, + stack: `Failed to execute 'removeChild' on 'Node': NotFoundError`, + }, + }, + } + : { + ...baseResponse, + status: 'ready', + }; + return { + response, + timings: { launchMs: 0, renderMs: 1 }, + pool: { + pageId: `page-${attemptCount}`, + affinityType, + affinityValue, + reused: attemptCount > 1, + evicted: false, + timedOut: false, + }, + }; + }; + prerenderer = getPrerendererForTesting({ + maxPages: 1, + serverURL: 'http://127.0.0.1:4225', + }); + let result = await prerenderer.prerenderModule({ + affinityType: 'realm', + affinityValue: retryRealm, + realm: retryRealm, + url: moduleURL, + auth: 'test-auth', + }); + expect(attempts.length).toBe(2); + expect(attempts[0]).toBe(undefined); + expect(attempts[1]).toEqual({ clearCache: true }); + expect(result.response.status).toBe('ready'); + } + finally { + RenderRunner.prototype.prerenderModuleAttempt = originalAttempt; + await prerenderer?.stop(); + } + }); + }); + describe('prerender - file retries', function () { + it('file prerender retries with clear cache on retry signature', async function () { + let originalAttempt = RenderRunner.prototype.prerenderFileExtractAttempt; + let prerenderer: Prerenderer | undefined; + let attempts: Array = []; + let retryRealm = 'https://file-retry.example/'; + let fileURL = `${retryRealm}notes.txt`; + try { + let attemptCount = 0; + RenderRunner.prototype.prerenderFileExtractAttempt = async function (args: Parameters[0]) { + let { affinityType, affinityValue, url, renderOptions } = args; + attempts.push(renderOptions); + attemptCount++; + let response: FileExtractResponse = attemptCount === 1 + ? { + id: url, + nonce: `nonce-${attemptCount}`, + status: 'error', + searchDoc: null, + deps: [], + error: { + type: 'file-error', + error: { + message: `Failed to execute 'removeChild' on 'Node': NotFoundError`, + status: 500, + title: 'boom', + additionalErrors: null, + stack: `Failed to execute 'removeChild' on 'Node': NotFoundError`, + }, + }, + } + : { + id: url, + nonce: `nonce-${attemptCount}`, + status: 'ready', + searchDoc: { name: 'notes.txt' }, + deps: [], + }; + return { + response, + timings: { launchMs: 0, renderMs: 1 }, + pool: { + pageId: `page-${attemptCount}`, + affinityType, + affinityValue, + reused: attemptCount > 1, + evicted: false, + timedOut: false, + }, + }; + }; + prerenderer = getPrerendererForTesting({ + maxPages: 1, + serverURL: 'http://127.0.0.1:4225', + }); + let result = await prerenderer.prerenderFileExtract({ + affinityType: 'realm', + affinityValue: retryRealm, + realm: retryRealm, + url: fileURL, + auth: 'test-auth', + renderOptions: { fileExtract: true }, + }); + expect(attempts.length).toBe(2); + expect(attempts[0]).toEqual({ fileExtract: true }); + expect(attempts[1]).toEqual({ fileExtract: true, clearCache: true }); + expect(result.response.status).toBe('ready'); + } + finally { + RenderRunner.prototype.prerenderFileExtractAttempt = originalAttempt; + await prerenderer?.stop(); + } + }); + }); + describe('prerender - card retries', function () { + it('card prerender retries with clear cache on retry signature', async function () { + let originalAttempt = RenderRunner.prototype.prerenderCardAttempt; + let prerenderer: Prerenderer | undefined; + let attempts: Array = []; + let retryRealm = 'https://card-retry.example/'; + let cardURL = `${retryRealm}card`; + try { + let attemptCount = 0; + RenderRunner.prototype.prerenderCardAttempt = async function (args: Parameters[0]) { + let { affinityType, affinityValue, url: attemptUrl, renderOptions, } = args; + attempts.push(renderOptions); + attemptCount++; + let baseResponse: RenderResponse = { + serialized: null, + searchDoc: null, + displayNames: null, + deps: null, + types: null, + iconHTML: null, + isolatedHTML: `${attemptUrl}-render-${attemptCount}`, + headHTML: null, + atomHTML: null, + embeddedHTML: null, + fittedHTML: null, + }; + let response: RenderResponse = attemptCount === 1 + ? { + ...baseResponse, + error: { + type: 'instance-error', + error: { + message: `Failed to execute 'removeChild' on 'Node': NotFoundError`, + status: 500, + title: 'boom', + additionalErrors: null, + stack: `Failed to execute 'removeChild' on 'Node': NotFoundError`, + }, + }, + } + : baseResponse; + return { + response, + timings: { launchMs: 0, renderMs: 1 }, + pool: { + pageId: `page-${attemptCount}`, + affinityType, + affinityValue, + reused: attemptCount > 1, + evicted: false, + timedOut: false, + }, + }; + }; + prerenderer = getPrerendererForTesting({ + maxPages: 1, + serverURL: 'http://127.0.0.1:4225', + }); + let result = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: retryRealm, + realm: retryRealm, + url: cardURL, + auth: 'test-auth', + }); + expect(attempts.length).toBe(2); + expect(attempts[0]).toBe(undefined); + expect(attempts[1]).toEqual({ clearCache: true }); + expect(result.response.error).toBeFalsy(); + expect(result.response.isolatedHTML).toBe(`${cardURL}-render-2`); + } + finally { + RenderRunner.prototype.prerenderCardAttempt = originalAttempt; + await prerenderer?.stop(); + } + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/publish-unpublish-realm.test.ts b/packages/realm-server/tests-vitest/publish-unpublish-realm.test.ts new file mode 100644 index 00000000000..d814dd83741 --- /dev/null +++ b/packages/realm-server/tests-vitest/publish-unpublish-realm.test.ts @@ -0,0 +1,577 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { fileURLToPath } from "url"; +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import type { SuperTest, Test } from 'supertest'; +import supertest from 'supertest'; +import { v4 as uuidv4 } from 'uuid'; +import { existsSync, ensureDirSync, copySync, pathExistsSync, readJsonSync, writeJsonSync, } from 'fs-extra'; +import { join, dirname } from 'path'; +import type { Server } from 'http'; +import { dirSync, type DirResult } from 'tmp'; +import type { Realm, VirtualNetwork } from '@cardstack/runtime-common'; +import { DEFAULT_PERMISSIONS, type QueuePublisher, type QueueRunner, } from '@cardstack/runtime-common'; +import type { PgAdapter } from '@cardstack/postgres'; +import { setupDB, setupPermissionedRealmCached, runTestRealmServer, closeServer, createVirtualNetwork, realmSecretSeed, matrixURL, waitUntil, } from './helpers'; +import { createJWT as createRealmServerJWT } from '../utils/jwt'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const testRealm2URL = 'http://127.0.0.1:4445/test/'; +describe("publish-unpublish-realm-test.ts", function () { + describe('publish and unpublish realm tests', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let testRealmHttpServer: Server; + let testRealm: Realm; + let dbAdapter: PgAdapter; + let publisher: QueuePublisher; + let runner: QueueRunner; + let request: SuperTest; + let testRealmDir: string; + let virtualNetwork: VirtualNetwork; + let ownerUserId = '@mango:localhost'; + let dir: DirResult; + setupPermissionedRealmCached(hooks, { + permissions: { + '*': ['read', 'write'], + }, + onRealmSetup: async () => { }, + }); + hooks.beforeEach(async function () { + dir = dirSync(); + copySync(join(__dirname, 'cards'), dir.name); + }); + async function startRealmServer(dbAdapter: PgAdapter, publisher: QueuePublisher, runner: QueueRunner) { + virtualNetwork = createVirtualNetwork(); + ({ testRealm: testRealm, testRealmHttpServer: testRealmHttpServer } = + await runTestRealmServer({ + virtualNetwork, + testRealmDir, + realmsRootPath: join(dir.name, 'realm_server_3'), + realmURL: new URL(testRealm2URL), + dbAdapter, + publisher, + runner, + matrixURL, + permissions: { + '*': ['read', 'write'], + [ownerUserId]: DEFAULT_PERMISSIONS, + }, + domainsForPublishedRealms: { + boxelSpace: 'localhost', + boxelSite: 'localhost:4445', + }, + })); + request = supertest(testRealmHttpServer); + } + setupDB(hooks, { + beforeEach: async (_dbAdapter, _publisher, _runner) => { + dbAdapter = _dbAdapter; + publisher = _publisher; + runner = _runner; + testRealmDir = join(dir.name, 'realm_server_3', 'test'); + ensureDirSync(testRealmDir); + copySync(join(__dirname, 'cards'), testRealmDir); + await startRealmServer(dbAdapter, publisher, runner); + }, + afterEach: async () => { + await closeServer(testRealmHttpServer); + }, + }); + it('POST /_publish-realm cannot publish a realm that is not publishable', async function () { + let response = await request + .post('/_publish-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send(JSON.stringify({ + sourceRealmURL: testRealm.url, + publishedRealmURL: 'http://testuser.localhost:4445/test-realm/', + })); + expect(response.status).toBe(422); + expect(response.text).toBe(`{"errors":["Realm ${testRealm.url} is not publishable"]}`); + let publishedDir = join(dir.name, 'realm_server_3', '_published'); + expect(pathExistsSync(publishedDir)).toBe(false); + }); + describe('with a publishable source realm', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let sourceRealmUrlString: string; + hooks.beforeEach(async () => { + let endpoint = `test-realm-${uuidv4()}`; + let createSourceRealmResponse = await request + .post('/_create-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send(JSON.stringify({ + data: { + type: 'realm', + attributes: { + name: 'Test Realm', + endpoint, + }, + }, + })); + sourceRealmUrlString = createSourceRealmResponse.body.data.id; + // Make the published realm public so reading _info doesn’t need a token + await dbAdapter.execute(` + INSERT INTO realm_user_permissions (realm_url, username, read, write, realm_owner) + VALUES ('${sourceRealmUrlString}', '*', true, true, true) + `); + }); + it('POST /_publish-realm can publish realm successfully', async function () { + let response = await request + .post('/_publish-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send(JSON.stringify({ + sourceRealmURL: sourceRealmUrlString, + publishedRealmURL: 'http://testuser.localhost:4445/test-realm/', + })); + expect(response.status).toBe(201); + expect(response.body.data.type).toBe('published_realm'); + expect(response.body.data.id).toBeTruthy(); + expect(response.body.data.attributes.sourceRealmURL).toBe(sourceRealmUrlString); + expect(response.body.data.attributes.publishedRealmURL).toBeTruthy(); + expect(response.body.data.attributes.lastPublishedAt).toBeTruthy(); + // Verify that the correct directory within _published was created + let publishedRealmId = response.body.data.id; + let publishedDir = join(dir.name, 'realm_server_3', '_published'); + let publishedRealmPath = join(publishedDir, publishedRealmId); + expect(existsSync(publishedDir)).toBeTruthy(); + expect(existsSync(publishedRealmPath)).toBeTruthy(); + expect(existsSync(join(publishedRealmPath, 'index.json'))).toBeTruthy(); + let publishedRealmConfig = readJsonSync(join(publishedRealmPath, '.realm.json')); + expect(publishedRealmConfig.publishable).toBeFalsy(); + // Verify that boxel_index entries exist for the published realm + let publishedRealmURL = response.body.data.attributes.publishedRealmURL; + let indexResults = await dbAdapter.execute(`SELECT * FROM boxel_index WHERE realm_url = '${publishedRealmURL}'`); + expect(indexResults.length > 0).toBeTruthy(); + expect(indexResults[0].realm_url).toBe(publishedRealmURL); + // Verify that head_html in the published realm references the + // published URL, not the source realm URL (the fullIndex after + // publish re-renders templates so og:url uses the correct URL) + let instanceWithHead = indexResults.find((r) => r.type === 'instance' && r.head_html); + expect(instanceWithHead).toBeTruthy(); + let headHtml = (instanceWithHead as any).head_html as string; + expect(headHtml.includes(publishedRealmURL)).toBeTruthy(); + expect(headHtml.includes(sourceRealmUrlString)).toBeFalsy(); + let catalogResponse = await request + .get('/_catalog-realms') + .set('Accept', 'application/vnd.api+json'); + expect(catalogResponse.status).toBe(200); + let catalogRealmIds = catalogResponse.body.data.map((item: { + id: string; + }) => item.id); + expect(catalogRealmIds.includes(publishedRealmURL)).toBe(false); + let sourceRealmPath = new URL(sourceRealmUrlString).pathname; + let sourceRealmInfoPath = `${sourceRealmPath}_info`; + let sourceRealmInfoResponse = await request + .post(sourceRealmInfoPath) + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', 'application/vnd.api+json'); + expect(sourceRealmInfoResponse.status).toBe(200); + expect(sourceRealmInfoResponse.body.data.attributes.lastPublishedAt).toBeTruthy(); + // For source realm, lastPublishedAt should be an object + let sourceLastPublishedAt = sourceRealmInfoResponse.body.data.attributes.lastPublishedAt; + expect(typeof sourceLastPublishedAt).toBe('object'); + // Verify the object contains the published realm URL + expect(sourceLastPublishedAt[publishedRealmURL]).toBeTruthy(); + // Test that published realm info includes lastPublishedAt as a string + let publishedRealmInfoResponse = await request + .post('/test-realm/_info') + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', 'application/vnd.api+json') + .set('Host', new URL(publishedRealmURL).host) + .set('Authorization', `Bearer ${createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`); + expect(publishedRealmInfoResponse.status).toBe(200); + expect(publishedRealmInfoResponse.body.data.attributes.lastPublishedAt).toBeTruthy(); + // For published realm, lastPublishedAt should be a string + let publishedLastPublishedAt = publishedRealmInfoResponse.body.data.attributes.lastPublishedAt; + expect(typeof publishedLastPublishedAt).toBe('string'); + // Verify the timestamp matches what was returned from the publish response + expect(publishedLastPublishedAt).toBe(response.body.data.attributes.lastPublishedAt); + }); + it('POST /_publish-realm serves cached module entries for published realm URLs', async function () { + let requestedPublishedRealmURL = 'http://localhost:4445/test-realm/'; + let sourceRealmPath = new URL(sourceRealmUrlString).pathname; + let linkedCardModuleResponse = await request + .post(`${sourceRealmPath}linked-card.gts`) + .set('Accept', 'application/vnd.card+source').send(` + import { CardDef } from "https://cardstack.com/base/card-api"; + import { linkedCardTitle } from "./linked-card-title"; + + export const _linkedCardTitle = linkedCardTitle; + + export class LinkedCard extends CardDef {} + `); + expect(linkedCardModuleResponse.status).toBe(204); + let linkedCardDepResponse = await request + .post(`${sourceRealmPath}linked-card-title.ts`) + .set('Accept', 'application/vnd.card+source') + .send(`export const linkedCardTitle = "linked-card-title";`); + expect(linkedCardDepResponse.status).toBe(204); + let linkedCardInstanceResponse = await request + .post(`${sourceRealmPath}linked-card.json`) + .set('Accept', 'application/vnd.card+source') + .send(JSON.stringify({ + data: { + type: 'card', + id: `${sourceRealmUrlString}linked-card`, + attributes: {}, + meta: { + adoptsFrom: { + module: './linked-card', + name: 'LinkedCard', + }, + }, + }, + })); + expect(linkedCardInstanceResponse.status).toBe(204); + let publishResponse = await request + .post('/_publish-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send(JSON.stringify({ + sourceRealmURL: sourceRealmUrlString, + publishedRealmURL: requestedPublishedRealmURL, + })); + expect(publishResponse.status).toBe(201); + let publishedRealmURL = publishResponse.body.data.attributes.publishedRealmURL; + let publishedRealmPath = new URL(publishedRealmURL).pathname; + let publishedRealmHost = new URL(publishedRealmURL).host; + let publishedModuleAlias = `${publishedRealmURL}linked-card`; + let publishedCardResponse = await waitUntil(async () => { + let response = await request + .get(`${publishedRealmPath}linked-card`) + .set('Accept', 'application/vnd.card+json') + .set('Host', publishedRealmHost); + return response.status === 200 ? response : undefined; + }, { + // This can be slow in CI because the first published lookup may + // need to prerender and populate module cache rows. + timeout: 30000, + interval: 200, + timeoutMessage: 'published linked-card card did not become readable', + }); + expect(publishedCardResponse?.status).toBe(200); + let cachedModuleEntry = await waitUntil(async () => { + let rows = (await dbAdapter.execute(`SELECT url, file_alias, deps, resolved_realm_url + FROM modules + WHERE file_alias = $1 + AND resolved_realm_url = $2`, { + bind: [publishedModuleAlias, publishedRealmURL], + coerceTypes: { deps: 'JSON' }, + })) as { + url: string; + file_alias: string | null; + deps: string[] | string | null; + resolved_realm_url: string | null; + }[]; + return rows[0]; + }, { + timeout: 30000, + interval: 200, + timeoutMessage: 'module cache entry for published linked-card was not created', + }); + expect(cachedModuleEntry).toBeTruthy(); + expect(cachedModuleEntry.url).toBe(`${publishedModuleAlias}.gts`); + expect(cachedModuleEntry.file_alias).toBe(publishedModuleAlias); + expect(cachedModuleEntry.resolved_realm_url).toBe(publishedRealmURL); + let moduleDeps = cachedModuleEntry.deps; + expect(Array.isArray(moduleDeps)).toBeTruthy(); + expect(moduleDeps?.includes(`${publishedRealmURL}linked-card-title`)).toBeTruthy(); + }); + it('publishing rewrites hostHome URLs that point to the source realm', async function () { + let sourceRealmURL = new URL(sourceRealmUrlString); + let sourceRealmPath = join(dir.name, 'realm_server_3', ...sourceRealmURL.pathname.split('/').filter(Boolean)); + let sourceRealmConfigPath = join(sourceRealmPath, '.realm.json'); + let sourceRealmConfig = pathExistsSync(sourceRealmConfigPath) + ? readJsonSync(sourceRealmConfigPath) + : {}; + let hostHomePath = 'SiteConfig/custom-home'; + let sourceHostHome = `${sourceRealmUrlString}${hostHomePath}`; + writeJsonSync(sourceRealmConfigPath, { + ...sourceRealmConfig, + publishable: true, + hostHome: sourceHostHome, + }); + let response = await request + .post('/_publish-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send(JSON.stringify({ + sourceRealmURL: sourceRealmUrlString, + publishedRealmURL: 'http://testuser.localhost:4445/test-realm/', + })); + expect(response.status).toBe(201); + let publishedRealmId = response.body.data.id; + let publishedRealmPath = join(dir.name, 'realm_server_3', '_published', publishedRealmId); + let publishedRealmConfig = readJsonSync(join(publishedRealmPath, '.realm.json')); + expect(publishedRealmConfig.hostHome).toBe(`${response.body.data.attributes.publishedRealmURL}${hostHomePath}`); + expect(publishedRealmConfig.publishable).toBeFalsy(); + }); + it('POST /_publish-realm can republish realm with updated timestamp', async function () { + // First publish + let firstResponse = await request + .post('/_publish-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send(JSON.stringify({ + sourceRealmURL: sourceRealmUrlString, + publishedRealmURL: 'http://testuser.localhost:4445/test-realm/', + })); + expect(firstResponse.status).toBe(201); + let firstTimestamp = firstResponse.body.data.attributes.lastPublishedAt; + // Wait a bit to ensure timestamp difference + await new Promise((resolve) => setTimeout(resolve, 10)); + // Republish + let secondResponse = await request + .post('/_publish-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send(JSON.stringify({ + sourceRealmURL: sourceRealmUrlString, + publishedRealmURL: 'http://testuser.localhost:4445/test-realm/', + })); + expect(secondResponse.status).toBe(201); + expect(secondResponse.body.data.id).toBe(firstResponse.body.data.id); + expect(secondResponse.body.data.attributes.publishedRealmURL).toBe(firstResponse.body.data.attributes.publishedRealmURL); + expect(secondResponse.body.data.attributes.lastPublishedAt).not.toEqual(firstTimestamp); + let publishedRealmId = secondResponse.body.data.id; + let publishedDir = join(dir.name, 'realm_server_3', '_published'); + let publishedRealmPath = join(publishedDir, publishedRealmId); + let publishedRealmConfig = readJsonSync(join(publishedRealmPath, '.realm.json')); + expect(publishedRealmConfig.publishable).toBeFalsy(); + }); + it('POST /_unpublish-realm can unpublish realm successfully', async function () { + // First publish a realm + let publishResponse = await request + .post('/_publish-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send(JSON.stringify({ + sourceRealmURL: sourceRealmUrlString, + publishedRealmURL: 'http://testuser.localhost:4445/test-realm/', + })); + expect(publishResponse.status).toBe(201); + let publishedRealmURL = publishResponse.body.data.attributes.publishedRealmURL; + // Verify that boxel_index entries exist before unpublishing + let indexResultsBefore = await dbAdapter.execute(`SELECT * FROM boxel_index WHERE realm_url = '${publishedRealmURL}'`); + expect(indexResultsBefore.length > 0).toBeTruthy(); + // All entries should be marked as deleted (tombstones) + for (let entry of indexResultsBefore) { + expect(entry.is_deleted).toBe(false); + } + // Now unpublish the realm + let unpublishResponse = await request + .post('/_unpublish-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send(JSON.stringify({ + publishedRealmURL: publishedRealmURL, + })); + expect(unpublishResponse.status).toBe(200); + expect(unpublishResponse.body.data.type).toBe('unpublished_realm'); + expect(unpublishResponse.body.data.id).toBe(publishResponse.body.data.id); + expect(unpublishResponse.body.data.attributes.sourceRealmURL).toBe(sourceRealmUrlString); + expect(unpublishResponse.body.data.attributes.publishedRealmURL).toBe(publishedRealmURL); + expect(unpublishResponse.body.data.attributes.lastPublishedAt).toBeTruthy(); + // Verify that the published realm directory was removed + let publishedRealmId = unpublishResponse.body.data.id; + let publishedDir = join(dir.name, 'realm_server_3', '_published'); + let publishedRealmPath = join(publishedDir, publishedRealmId); + expect(existsSync(publishedRealmPath)).toBeFalsy(); + let realmVersion = (await dbAdapter.execute(`SELECT current_version FROM realm_versions WHERE realm_url = '${publishedRealmURL}'`))[0]; + expect(realmVersion.current_version).toBe(3); + // Verify that boxel_index entries are tombstoned (marked as deleted) for the unpublished realm + let indexResultsAfter = await dbAdapter.execute(`SELECT * FROM boxel_index WHERE realm_url = '${publishedRealmURL}' AND realm_version = '${realmVersion.current_version}'`); + expect(indexResultsAfter.length > 0).toBeTruthy(); + // All entries should be marked as deleted (tombstones) + for (let entry of indexResultsAfter) { + expect(entry.is_deleted).toBe(true); + } + // Verify that source realm info no longer includes lastPublishedAt + let sourceRealmInfoResponse = await request + .post('/test/_info') + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', 'application/vnd.api+json'); + expect(sourceRealmInfoResponse.status).toBe(200); + expect(sourceRealmInfoResponse.body.data.attributes.lastPublishedAt).toBe(null); + // Verify that published realm is no longer accessible + let publishedRealmInfoResponse = await request + .post('/test-realm/_info') + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', 'application/vnd.api+json') + .set('Host', new URL(publishedRealmURL).host); + expect(publishedRealmInfoResponse.status).toBe(404); + }); + it('POST /_unpublish-realm returns not found for non-existent published realm', async function () { + let response = await request + .post('/_unpublish-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send(JSON.stringify({ + publishedRealmURL: 'http://testuser.localhost/non-existent-realm/', + })); + expect(response.status).toBe(422); + expect(response.text).toBe('{"errors":["Published realm http://testuser.localhost/non-existent-realm/ not found"]}'); + }); + it('POST /_unpublish-realm returns bad request for missing publishedRealmURL', async function () { + let response = await request + .post('/_unpublish-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send(JSON.stringify({})); + expect(response.status).toBe(400); + expect(response.text).toBe('{"errors":["publishedRealmURL is required"]}'); + }); + it('POST /_unpublish-realm returns forbidden for user without realm-owner permission', async function () { + // First publish a realm as the owner + let publishResponse = await request + .post('/_publish-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send(JSON.stringify({ + sourceRealmURL: sourceRealmUrlString, + publishedRealmURL: 'http://testuser.localhost:4445/test-realm/', + })); + expect(publishResponse.status).toBe(201); + let publishedRealmURL = publishResponse.body.data.attributes.publishedRealmURL; + // Now try to unpublish as a non-owner + let nonOwnerUserId = '@non-realm-owner:localhost'; + let response = await request + .post('/_unpublish-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: nonOwnerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send(JSON.stringify({ + publishedRealmURL: publishedRealmURL, + })); + expect(response.status).toBe(403); + expect(response.text).toBe(`{"errors":["${nonOwnerUserId} does not have enough permission to unpublish this realm"]}`); + }); + it('POST /_unpublish-realm returns bad request for invalid JSON', async function () { + let response = await request + .post('/_unpublish-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send('invalid json'); + expect(response.status).toBe(400); + expect(response.text).toBe('{"errors":["Request body is not valid JSON-API - invalid JSON"]}'); + }); + it('QUERY /_info returns lastPublishedAt as null for unpublished realm', async function () { + let response = await request + .post('/test/_info') + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', 'application/vnd.api+json'); + expect(response.status).toBe(200); + expect(response.body.data.attributes.lastPublishedAt).toBe(null); + }); + it('republishing clears stale modules cache entries for the published realm', async function () { + let publishedRealmURL = 'http://testuser.localhost/test-realm/'; + // First publish + let firstResponse = await request + .post('/_publish-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send(JSON.stringify({ + sourceRealmURL: sourceRealmUrlString, + publishedRealmURL, + })); + expect(firstResponse.status).toBe(201); + // Simulate a stale modules cache entry with an error for the published realm + let moduleUrl = `${publishedRealmURL}my-module`; + await dbAdapter.execute(`INSERT INTO modules (url, file_alias, definitions, deps, error_doc, created_at, resolved_realm_url, cache_scope, auth_user_id) + VALUES ('${moduleUrl}', '${moduleUrl}', '{}', '[]', '${JSON.stringify({ error: { message: 'simulated prerender failure' } })}', ${Date.now()}, '${publishedRealmURL}', 'public', '')`); + // Verify the error entry exists + let modulesBefore = await dbAdapter.execute(`SELECT * FROM modules WHERE resolved_realm_url = '${publishedRealmURL}'`); + expect(modulesBefore.length > 0).toBeTruthy(); + let errorEntry = modulesBefore.find((m: any) => m.error_doc != null); + expect(errorEntry).toBeTruthy(); + // Republish the realm + let secondResponse = await request + .post('/_publish-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send(JSON.stringify({ + sourceRealmURL: sourceRealmUrlString, + publishedRealmURL, + })); + expect(secondResponse.status).toBe(201); + // Verify the stale modules cache entries were cleared + let modulesAfter = await dbAdapter.execute(`SELECT * FROM modules WHERE resolved_realm_url = '${publishedRealmURL}'`); + let errorEntryAfter = modulesAfter.find((m: any) => m.error_doc != null); + expect(errorEntryAfter).toBeFalsy(); + }); + it('POST /_publish-realm does not create duplicate realm instances on republish', async function () { + let publishedRealmURL = 'http://testuser.localhost:4445/test-realm/'; + // First publish + let firstResponse = await request + .post('/_publish-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send(JSON.stringify({ + sourceRealmURL: sourceRealmUrlString, + publishedRealmURL: publishedRealmURL, + })); + expect(firstResponse.status).toBe(201); + let republishResponse = await request + .post('/_publish-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send(JSON.stringify({ + sourceRealmURL: sourceRealmUrlString, + publishedRealmURL: publishedRealmURL, + })); + expect(republishResponse.status).toBe(201); + expect(republishResponse.body.data.id).toBe(firstResponse.body.data.id); + // Now unpublish and verify clean removal + let unpublishResponse = await request + .post('/_unpublish-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send(JSON.stringify({ + publishedRealmURL: publishedRealmURL, + })); + expect(unpublishResponse.status).toBe(200); + // Verify we can republish after unpublish without issues + let republishAfterUnpublishResponse = await request + .post('/_publish-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send(JSON.stringify({ + sourceRealmURL: sourceRealmUrlString, + publishedRealmURL: publishedRealmURL, + })); + expect(republishAfterUnpublishResponse.status).toBe(201); + expect(republishAfterUnpublishResponse.body.data.id).not.toEqual(firstResponse.body.data.id); + }); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/queries.test.ts b/packages/realm-server/tests-vitest/queries.test.ts new file mode 100644 index 00000000000..171aba98411 --- /dev/null +++ b/packages/realm-server/tests-vitest/queries.test.ts @@ -0,0 +1,147 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import { v4 as uuidv4 } from 'uuid'; +import type { PgAdapter } from '@cardstack/postgres'; +import { asExpressions, fetchAllRealmsWithOwners, fetchUserPermissions, insert, insertPermissions, query, } from '@cardstack/runtime-common'; +import { setupDB } from './helpers'; +describe("queries-test.ts", function () { + describe('fetchUserPermissions', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let dbAdapter: PgAdapter; + setupDB(hooks, { + beforeEach: async (_dbAdapter: PgAdapter, _publisher, _runner): Promise => { + dbAdapter = _dbAdapter; + }, + }); + async function insertPublishedRealm({ sourceRealmURL, publishedRealmURL, ownerUsername = '@realm/published-owner', }: { + sourceRealmURL: string; + publishedRealmURL: string; + ownerUsername?: string; + }) { + let publishedRealmId = uuidv4(); + let { nameExpressions, valueExpressions } = asExpressions({ + id: publishedRealmId, + owner_username: ownerUsername, + source_realm_url: sourceRealmURL, + published_realm_url: publishedRealmURL, + last_published_at: Date.now().toString(), + }); + await query(dbAdapter, insert('published_realms', nameExpressions, valueExpressions)); + } + it('can fetch only own realms, filtering out public and published realms', async function () { + const ownerUserId = '@owner:localhost'; + const sourceRealmURL = 'http://example.com/source/'; + const publishedRealmURL = 'http://example.com/published/'; + const publicRealmURL = 'http://example.com/public/'; + await insertPermissions(dbAdapter, new URL(sourceRealmURL), { + [ownerUserId]: ['read', 'write', 'realm-owner'], + }); + await insertPermissions(dbAdapter, new URL(publicRealmURL), { + '*': ['read'], + }); + await insertPublishedRealm({ sourceRealmURL, publishedRealmURL }); + await insertPermissions(dbAdapter, new URL(publishedRealmURL), { + [ownerUserId]: ['read', 'realm-owner'], + '*': ['read'], + }); + let permissions = await fetchUserPermissions(dbAdapter, { + userId: ownerUserId, + onlyOwnRealms: true, + }); + expect(permissions[sourceRealmURL]).toEqual(['read', 'write', 'realm-owner']); + expect(publicRealmURL in permissions).toBe(false); + expect(publishedRealmURL in permissions).toBe(false); + }); + it('can fetch own and public realms together while filtering published realms', async function () { + const ownerUserId = '@owner:localhost'; + const sourceRealmURL = 'http://example.com/source/'; + const publicRealmURL = 'http://example.com/public/'; + const publishedRealmURL = 'http://example.com/published/'; + const sourceRealmPublicURL = 'http://example.com/source-public/'; + await insertPermissions(dbAdapter, new URL(sourceRealmURL), { + [ownerUserId]: ['read', 'write', 'realm-owner'], + }); + await insertPermissions(dbAdapter, new URL(sourceRealmPublicURL), { + [ownerUserId]: ['read'], + }); + await insertPermissions(dbAdapter, new URL(publicRealmURL), { + '*': ['read'], + }); + await insertPublishedRealm({ + sourceRealmURL, + publishedRealmURL, + }); + await insertPermissions(dbAdapter, new URL(publishedRealmURL), { + [ownerUserId]: ['read', 'realm-owner'], + '*': ['read'], + }); + let permissions = await fetchUserPermissions(dbAdapter, { + userId: ownerUserId, + }); + expect(permissions[sourceRealmURL]).toEqual(['read', 'write', 'realm-owner']); + expect(permissions[publicRealmURL]).toEqual(['read']); + expect(permissions[sourceRealmPublicURL]).toEqual(['read']); + expect(publishedRealmURL in permissions).toBe(false); + }); + }); + describe('fetchAllRealmsWithOwners', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let dbAdapter: PgAdapter; + setupDB(hooks, { + beforeEach: async (_dbAdapter: PgAdapter, _publisher, _runner): Promise => { + dbAdapter = _dbAdapter; + }, + }); + async function insertPublishedRealm({ sourceRealmURL, publishedRealmURL, ownerUsername = '@realm/published-owner', }: { + sourceRealmURL: string; + publishedRealmURL: string; + ownerUsername?: string; + }) { + let publishedRealmId = uuidv4(); + let { nameExpressions, valueExpressions } = asExpressions({ + id: publishedRealmId, + owner_username: ownerUsername, + source_realm_url: sourceRealmURL, + published_realm_url: publishedRealmURL, + last_published_at: Date.now().toString(), + }); + await query(dbAdapter, insert('published_realms', nameExpressions, valueExpressions)); + } + it('uses source realm owner when published realm permissions are missing', async function () { + const ownerUserId = '@owner:localhost'; + const sourceRealmURL = 'http://example.com/source/'; + const publishedRealmURL = 'http://example.com/published/'; + await insertPermissions(dbAdapter, new URL(sourceRealmURL), { + [ownerUserId]: ['read', 'realm-owner'], + }); + await insertPublishedRealm({ sourceRealmURL, publishedRealmURL }); + let owners = await fetchAllRealmsWithOwners(dbAdapter); + let ownerByRealm = new Map(owners.map((owner) => [owner.realm_url, owner.owner_username])); + expect(ownerByRealm.get(sourceRealmURL)).toBe('owner'); + expect(ownerByRealm.get(publishedRealmURL)).toBe('owner'); + }); + it('falls back to published realm owner when source owner is missing', async function () { + const sourceRealmURL = 'http://example.com/missing-source/'; + const publishedRealmURL = 'http://example.com/published-only/'; + const publishedOwner = '@realm/published-only'; + await insertPublishedRealm({ + sourceRealmURL, + publishedRealmURL, + ownerUsername: publishedOwner, + }); + let owners = await fetchAllRealmsWithOwners(dbAdapter); + let ownerByRealm = new Map(owners.map((owner) => [owner.realm_url, owner.owner_username])); + expect(ownerByRealm.get(publishedRealmURL)).toBe('realm/published-only'); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/queue.test.ts b/packages/realm-server/tests-vitest/queue.test.ts new file mode 100644 index 00000000000..6899062d9ad --- /dev/null +++ b/packages/realm-server/tests-vitest/queue.test.ts @@ -0,0 +1,659 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import { createTestPgAdapter, prepareTestDB } from './helpers'; +import { PgAdapter, PgQueuePublisher, PgQueueRunner, } from '@cardstack/postgres'; +import type { QueueCoalesceContext, QueuePublisher, QueueRunner, } from '@cardstack/runtime-common'; +import { Deferred, registerQueueJobDefinition, userInitiatedPriority, } from '@cardstack/runtime-common'; +import { runSharedTest } from '@cardstack/runtime-common/helpers'; +import { INCREMENTAL_INDEX_JOB_TIMEOUT_SEC, makeIncrementalArgsWithCallerMetadata, mapIncrementalDoneResult, type IncrementalIndexEnqueueArgs, } from '@cardstack/runtime-common/jobs/indexing'; +import { FROM_SCRATCH_JOB_TIMEOUT_SEC, type FromScratchArgs, type FromScratchResult, type IncrementalDoneResult, } from '@cardstack/runtime-common/tasks/indexer'; +import queueTests from '@cardstack/runtime-common/tests/queue-test'; +describe("queue-test.ts", function () { + describe('queue', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let publisher: QueuePublisher; + let runner: QueueRunner; + let adapter: PgAdapter; + hooks.beforeEach(async function () { + prepareTestDB(); + adapter = await createTestPgAdapter(); + publisher = new PgQueuePublisher(adapter); + runner = new PgQueueRunner({ adapter, workerId: 'q1', maxTimeoutSec: 2 }); + await runner.start(); + }); + hooks.afterEach(async function () { + await runner.destroy(); + await publisher.destroy(); + await adapter.close(); + }); + async function publishFromScratchIndexJob(args: { + args: FromScratchArgs; + priority: number; + }) { + return await publisher.publish({ + jobType: 'from-scratch-index', + concurrencyGroup: `indexing:${args.args.realmURL}`, + timeout: FROM_SCRATCH_JOB_TIMEOUT_SEC, + priority: args.priority, + args: args.args, + }); + } + async function publishIncrementalIndexJob(args: { + args: IncrementalIndexEnqueueArgs; + clientRequestId: string | null; + priority?: number; + }) { + let priority = args.priority ?? userInitiatedPriority; + return await publisher.publish({ + jobType: 'incremental-index', + concurrencyGroup: `indexing:${args.args.realmURL}`, + timeout: INCREMENTAL_INDEX_JOB_TIMEOUT_SEC, + priority, + args: makeIncrementalArgsWithCallerMetadata(args.args, args.clientRequestId), + mapResult: mapIncrementalDoneResult(args.clientRequestId), + }); + } + it('it can run a job', async function () { + await runSharedTest(queueTests, assert, { runner, publisher }); + }); + it(`a job can throw an exception`, async function () { + await runSharedTest(queueTests, assert, { runner, publisher }); + }); + it('jobs are processed serially within a particular queue', async function () { + await runSharedTest(queueTests, assert, { runner, publisher }); + }); + it('coalesce can join pending jobs and map waiter-specific results', async function () { + await runSharedTest(queueTests, assert, { runner, publisher }); + }); + it('coalesce can join pending jobs and map waiter-specific rejected results', async function () { + await runSharedTest(queueTests, assert, { runner, publisher }); + }); + it('coalesce does not join pending jobs that already have an active reservation', async function () { + await runner.destroy(); + let existingJob = await publisher.publish({ + jobType: 'reserved-candidate', + concurrencyGroup: 'reserved-group', + timeout: 30, + args: 1, + }); + await adapter.execute(`INSERT INTO job_reservations (job_id, locked_until, worker_id) + VALUES ($1, NOW() + INTERVAL '30 seconds', 'test-worker')`, { bind: [existingJob.id] }); + let seenCandidateIds: number[] | undefined; + registerQueueJobDefinition({ + jobType: 'reserved-candidate', + coalesce: ({ incoming, candidates }: QueueCoalesceContext) => { + seenCandidateIds = candidates.map((candidate) => candidate.id); + let candidate = candidates[0]; + if (!candidate) { + return { type: 'insert', job: incoming } as const; + } + return { type: 'join', jobId: candidate.id } as const; + }, + }); + let joinedJob = await publisher.publish({ + jobType: 'reserved-candidate', + concurrencyGroup: 'reserved-group', + timeout: 30, + args: 2, + }); + expect(seenCandidateIds).toEqual([]); + expect(joinedJob.id).not.toBe(existingJob.id); + }); + it('coalesce does not join a currently running job in same concurrency group', async function () { + let started = new Deferred(); + let release = new Deferred(); + runner.register('blocking-job', async (arg: number) => { + started.fulfill(); + await release.promise; + return arg; + }); + let runningJob = await publisher.publish({ + jobType: 'blocking-job', + concurrencyGroup: 'running-group', + timeout: 30, + args: 1, + }); + await started.promise; + registerQueueJobDefinition({ + jobType: 'blocking-job', + coalesce: ({ incoming, candidates }: QueueCoalesceContext) => { + let candidate = candidates[0]; + if (!candidate) { + return { type: 'insert', job: incoming } as const; + } + return { type: 'join', jobId: candidate.id } as const; + }, + }); + let coalescedJob = await publisher.publish({ + jobType: 'blocking-job', + concurrencyGroup: 'running-group', + timeout: 30, + args: 2, + }); + expect(coalescedJob.id).not.toBe(runningJob.id); + release.fulfill(); + let [runningResult, coalescedResult] = await Promise.all([ + runningJob.done, + coalescedJob.done, + ]); + expect(runningResult).toBe(1); + expect(coalescedResult).toBe(2); + }); + it('concurrent coalesce attempts converge to one canonical pending job', async function () { + await runner.destroy(); + registerQueueJobDefinition({ + jobType: 'coalesce-target', + coalesce: ({ incoming, candidates }: QueueCoalesceContext) => { + let candidate = candidates[0]; + if (!candidate) { + return { type: 'insert', job: incoming } as const; + } + return { type: 'join', jobId: candidate.id } as const; + }, + }); + let jobs = await Promise.all([...new Array(20)].map((_, index) => publisher.publish({ + jobType: 'coalesce-target', + concurrencyGroup: 'coalesce-convergence-group', + timeout: 30, + args: index, + }))); + let uniqueJobIds = [...new Set(jobs.map((job) => job.id))]; + expect(uniqueJobIds.length).toBe(1); + let rows = (await adapter.execute(`SELECT id + FROM jobs + WHERE concurrency_group='coalesce-convergence-group' + AND status='unfulfilled'`)) as { + id: number; + }[]; + expect(rows.length).toBe(1); + }); + it('incremental coalesce merges mixed operations and persists per-caller metadata', async function () { + await runner.destroy(); + let realmURL = 'http://example.com/coalesced/'; + let first = await publishIncrementalIndexJob({ + clientRequestId: 'request-1', + args: { + realmURL, + realmUsername: 'owner', + ignoreData: {}, + changes: [ + { url: `${realmURL}a`, operation: 'update' }, + { url: `${realmURL}b`, operation: 'update' }, + ], + }, + }); + let second = await publishIncrementalIndexJob({ + clientRequestId: 'request-2', + args: { + realmURL, + realmUsername: 'owner', + ignoreData: {}, + changes: [ + { url: `${realmURL}b`, operation: 'delete' }, + { url: `${realmURL}c`, operation: 'update' }, + ], + }, + }); + expect(first.id).toBe(second.id); + let [row] = (await adapter.execute(`SELECT job_type, args + FROM jobs + WHERE id = $1`, { bind: [first.id] })) as { + job_type: string; + args: { + changes: { + url: string; + operation: 'update' | 'delete'; + }[]; + coalescedCallers: { + waiterId: string; + clientRequestId: string | null; + }[]; + }; + }[]; + expect(row.job_type).toBe('incremental-index'); + let operationByUrl = new Map(row.args.changes.map((change) => [change.url, change.operation])); + expect([...operationByUrl.entries()].sort((a, b) => a[0].localeCompare(b[0]))).toEqual([ + [`${realmURL}a`, 'update'], + [`${realmURL}b`, 'delete'], + [`${realmURL}c`, 'update'], + ]); + expect(row.args.coalescedCallers + .map((caller) => caller.clientRequestId) + .sort()).toEqual(['request-1', 'request-2']); + }); + it('from-scratch does not coalesce onto pending incremental in same group', async function () { + await runner.destroy(); + let realmURL = 'http://example.com/no-mixed-coalesce/'; + let incremental = await publishIncrementalIndexJob({ + clientRequestId: 'request-1', + args: { + realmURL, + realmUsername: 'owner', + ignoreData: {}, + changes: [{ url: `${realmURL}a`, operation: 'update' }], + }, + }); + let fromScratch = await publishFromScratchIndexJob({ + priority: 123, + args: { + realmURL, + realmUsername: 'owner', + }, + }); + expect(fromScratch.id).not.toBe(incremental.id); + let rows = (await adapter.execute(`SELECT job_type + FROM jobs + WHERE concurrency_group = $1 + AND status = 'unfulfilled' + ORDER BY created_at, id`, { bind: [`indexing:${realmURL}`] })) as { + job_type: string; + }[]; + expect(rows.map((row) => row.job_type)).toEqual(['incremental-index', 'from-scratch-index']); + }); + it('coalesced incremental waiters each receive their own clientRequestId in done payload', async function () { + await runner.destroy(); + let realmURL = 'http://example.com/done-shape/'; + let first = await publishIncrementalIndexJob({ + clientRequestId: 'request-1', + args: { + realmURL, + realmUsername: 'owner', + ignoreData: {}, + changes: [{ url: `${realmURL}a`, operation: 'update' }], + }, + }); + let second = await publishIncrementalIndexJob({ + clientRequestId: 'request-2', + args: { + realmURL, + realmUsername: 'owner', + ignoreData: {}, + changes: [{ url: `${realmURL}b`, operation: 'delete' }], + }, + }); + let worker = new PgQueueRunner({ adapter, workerId: 'coalesce-worker' }); + try { + worker.register('incremental-index', async (args: { + changes: { + url: string; + }[]; + }) => ({ + invalidations: args.changes.map((change) => change.url), + ignoreData: {}, + stats: { + instancesIndexed: 0, + filesIndexed: 0, + instanceErrors: 0, + fileErrors: 0, + totalIndexEntries: 0, + }, + })); + await worker.start(); + let [firstResult, secondResult] = await Promise.all([ + first.done, + second.done, + ]); + expect(firstResult.clientRequestId).toBe('request-1'); + expect(secondResult.clientRequestId).toBe('request-2'); + expect(firstResult.invalidations.sort()).toEqual(secondResult.invalidations.sort()); + } + finally { + await worker.destroy(); + } + }); + it('incremental does not coalesce onto pending from-scratch in same group', async function () { + await runner.destroy(); + let realmURL = 'http://example.com/no-mixed-coalesce-reverse/'; + let fromScratch = await publishFromScratchIndexJob({ + priority: 123, + args: { + realmURL, + realmUsername: 'owner', + }, + }); + let incremental = await publishIncrementalIndexJob({ + clientRequestId: 'request-1', + args: { + realmURL, + realmUsername: 'owner', + ignoreData: {}, + changes: [{ url: `${realmURL}a`, operation: 'update' }], + }, + }); + expect(incremental.id).not.toBe(fromScratch.id); + let rows = (await adapter.execute(`SELECT job_type + FROM jobs + WHERE concurrency_group = $1 + AND status = 'unfulfilled' + ORDER BY created_at, id`, { bind: [`indexing:${realmURL}`] })) as { + job_type: string; + }[]; + expect(rows.map((row) => row.job_type)).toEqual(['from-scratch-index', 'incremental-index']); + }); + it('worker stops waiting for job after its been running longer than max time-out', async function () { + let events: string[] = []; + let runs = 0; + let logJob = async () => { + let me = runs; + events.push(`job${me} start`); + if (runs++ === 0) { + await new Promise((r) => setTimeout(r, 3000)); + } + events.push(`job${me} finish`); + return me; + }; + runner.register('logJob', logJob); + let job = await publisher.publish({ + jobType: 'logJob', + concurrencyGroup: 'log-group', + timeout: 1, + args: null, + }); + try { + await job.done; + throw new Error(`expected timeout to be thrown`); + } + catch (error: any) { + expect(error.message).toBe('Timed-out after 2s waiting for job 1 to complete'); + } + }); + // Concurrency control using different queues is only supported in pg-queue, + // so these are not tests that are shared with the browser queue implementation. + describe('multiple queue clients', function () { + const nestedHooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let runner2: QueueRunner; + let adapter2: PgAdapter; + nestedHooks.beforeEach(async function () { + adapter2 = new PgAdapter(); + runner2 = new PgQueueRunner({ adapter: adapter2, workerId: 'q2' }); + await runner2.start(); + // Because we need tight timing control for this test, ensure both + // adapters have active DB connections before measuring behavior. + await adapter.execute('select 1'); + await adapter2.execute('select 1'); + }); + nestedHooks.afterEach(async function () { + await runner2.destroy(); + await adapter2.close(); + }); + it('jobs in different concurrency groups can run in parallel', async function () { + let events: string[] = []; + let logJob = async (jobNum: number) => { + events.push(`job${jobNum} start`); + await new Promise((r) => setTimeout(r, 500)); + events.push(`job${jobNum} finish`); + }; + runner.register('logJob', logJob); + runner2.register('logJob', logJob); + let promiseForJob1 = publisher.publish({ + jobType: 'logJob', + concurrencyGroup: 'log-group', + timeout: 5000, + args: 1, + }); + // start the 2nd job before the first job finishes + await new Promise((r) => setTimeout(r, 100)); + let promiseForJob2 = publisher.publish({ + jobType: 'logJob', + concurrencyGroup: 'other-group', + timeout: 5000, + args: 2, + }); + let [job1, job2] = await Promise.all([promiseForJob1, promiseForJob2]); + await Promise.all([job1.done, job2.done]); + expect(events).toEqual([ + 'job1 start', + 'job2 start', + 'job1 finish', + 'job2 finish', + ]); + }); + it('jobs are processed serially within a particular queue across different queue clients', async function () { + let events: string[] = []; + let logJob = async (jobNum: number) => { + events.push(`job${jobNum} start`); + await new Promise((r) => setTimeout(r, 500)); + events.push(`job${jobNum} finish`); + }; + runner.register('logJob', logJob); + runner2.register('logJob', logJob); + let promiseForJob1 = publisher.publish({ + jobType: 'logJob', + concurrencyGroup: 'log-group', + timeout: 5000, + args: 1, + }); + // start the 2nd job before the first job finishes + await new Promise((r) => setTimeout(r, 100)); + let promiseForJob2 = publisher.publish({ + jobType: 'logJob', + concurrencyGroup: 'log-group', + timeout: 5000, + args: 2, + }); + let [job1, job2] = await Promise.all([promiseForJob1, promiseForJob2]); + await Promise.all([job1.done, job2.done]); + expect(events).toEqual([ + 'job1 start', + 'job1 finish', + 'job2 start', + 'job2 finish', + ]); + }); + it('job can timeout; timed out job is picked up by another worker', async function () { + let events: string[] = []; + let runs = 0; + let logJob = async () => { + let me = runs; + events.push(`job${me} start`); + if (runs++ === 0) { + await new Promise((r) => setTimeout(r, 2000)); + } + events.push(`job${me} finish`); + return me; + }; + runner.register('logJob', logJob); + runner2.register('logJob', logJob); + let job = await publisher.publish({ + jobType: 'logJob', + concurrencyGroup: 'log-group', + timeout: 1, + args: null, + }); + // just after our job has timed out, kick the queue so that another worker + // will notice it. Otherwise we'd be stuck until the polling comes around. + await new Promise((r) => setTimeout(r, 1100)); + await adapter.execute('NOTIFY jobs'); + let result = await job.done; + expect(result).toBe(1); + // at this point the long-running first job is still stuck. it will + // eventually also log "job0 finish", but that is absorbed by our test + // afterEach + expect(events).toEqual(['job0 start', 'job1 start', 'job1 finish']); + }); + }); + }); + describe('queue - high priority worker', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let publisher: QueuePublisher; + let runner: QueueRunner; + let adapter: PgAdapter; + hooks.beforeEach(async function () { + prepareTestDB(); + adapter = await createTestPgAdapter(); + publisher = new PgQueuePublisher(adapter); + runner = new PgQueueRunner({ + adapter, + workerId: 'q1', + maxTimeoutSec: 1, + priority: 10, + }); + await runner.start(); + }); + hooks.afterEach(async function () { + await runner.destroy(); + await publisher.destroy(); + await adapter.close(); + }); + it('worker can be set to only process jobs greater or equal to a particular priority', async function () { + let events: string[] = []; + let logJob = async ({ name }: { + name: string; + }) => { + events.push(name); + }; + runner.register('logJob', logJob); + let lowPriorityJob = await publisher.publish({ + jobType: 'logJob', + concurrencyGroup: null, + timeout: 1, + args: { name: 'low priority' }, + priority: 0, + }); + let highPriorityJob1 = await publisher.publish({ + jobType: 'logJob', + concurrencyGroup: 'logGroup', + timeout: 1, + args: { name: 'high priority 1' }, + priority: 10, + }); + let highPriorityJob2 = await publisher.publish({ + jobType: 'logJob', + concurrencyGroup: 'logGroup', + timeout: 1, + args: { name: 'high priority 2' }, + priority: 11, + }); + await highPriorityJob1.done; + await highPriorityJob2.done; + await Promise.race([ + lowPriorityJob.done, + // the low priority job will never get picked up, so we race it against a timeout + new Promise((r) => setTimeout(r, 2)), + ]); + expect(events.sort()).toEqual(['high priority 1', 'high priority 2']); + }); + }); + describe('queue - high priority worker and all priority worker', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let publisher: QueuePublisher; + let allPriorityRunner: QueueRunner; + let highPriorityRunner: QueueRunner; + let adapter: PgAdapter; + let adapter2: PgAdapter; + hooks.beforeEach(async function () { + prepareTestDB(); + adapter = await createTestPgAdapter(); + adapter2 = new PgAdapter(); + publisher = new PgQueuePublisher(adapter); + allPriorityRunner = new PgQueueRunner({ + adapter, + workerId: 'q1', + maxTimeoutSec: 1, + priority: 0, + }); + highPriorityRunner = new PgQueueRunner({ + adapter: adapter2, + workerId: 'hp1', + priority: 100, + }); + await allPriorityRunner.start(); + await highPriorityRunner.start(); + // Because we need tight timing control for this test, ensure both + // adapters have active DB connections before measuring behavior. + await adapter.execute('select 1'); + await adapter2.execute('select 1'); + }); + hooks.afterEach(async function () { + await allPriorityRunner.destroy(); + await highPriorityRunner.destroy(); + await publisher.destroy(); + await adapter.close(); + await adapter2.close(); + }); + it('concurrency group enforces concurrency against running jobs', async function () { + // Emulate realm server start up with full indexing competing with + // incremental indexing. make slow jobs with low priority so the + // concurrency group we are testing is waiting on a low priority worker. + // make another job with high priority worker that runs while the low + // priority job with same concurrency group is still waiting. + let events: string[] = []; + let slowLogJob = async (jobNum: number) => { + events.push(`job${jobNum} start`); + await new Promise((r) => setTimeout(r, 500)); + events.push(`job${jobNum} finish`); + }; + let fastLogJob = async (jobNum: number) => { + events.push(`job${jobNum} start`); + await new Promise((r) => setTimeout(r, 10)); + events.push(`job${jobNum} finish`); + }; + allPriorityRunner.register('slowLogJob', slowLogJob); + allPriorityRunner.register('fastLogJob', fastLogJob); + highPriorityRunner.register('fastLogJob', fastLogJob); + let promiseForSlowJob1 = publisher.publish({ + jobType: 'slowLogJob', + concurrencyGroup: 'other-group', + timeout: 5000, + priority: 0, + args: 1, + }); + // start the 2nd slow job before the first job finishes + await new Promise((r) => setTimeout(r, 100)); + let promiseForSlowJob2 = publisher.publish({ + jobType: 'slowLogJob', + concurrencyGroup: 'log-group', // same concurrency group as the fast job + timeout: 5000, + priority: 0, + args: 2, + }); + // start the fast job after the 2nd slow job is published but before the + // first job finishes + await new Promise((r) => setTimeout(r, 100)); + let promiseForFastJob3 = publisher.publish({ + jobType: 'fastLogJob', + concurrencyGroup: 'log-group', // same concurrency group as the waiting slow job + timeout: 5000, + priority: 100, // this is a high priority job so it should be picked up by the idle high priority runner immediately + args: 3, + }); + let [slowJob1, slowJob2, fastJob3] = await Promise.all([ + promiseForSlowJob1, + promiseForSlowJob2, + promiseForFastJob3, + ]); + await Promise.all([slowJob1.done, slowJob2.done, fastJob3.done]); + expect(events).toEqual([ + 'job1 start', + // job 3 is a high priority job and it should not be blocked because + // job 2 is waiting--concurrency group is based on running jobs not + // waiting jobs + 'job3 start', + 'job3 finish', + 'job1 finish', + 'job2 start', + 'job2 finish', + ]); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/realm-auth.test.ts b/packages/realm-server/tests-vitest/realm-auth.test.ts new file mode 100644 index 00000000000..44649b995ea --- /dev/null +++ b/packages/realm-server/tests-vitest/realm-auth.test.ts @@ -0,0 +1,61 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import type { SuperTest, Test as SupertestTest } from 'supertest'; +import sinon from 'sinon'; +import type { PgAdapter } from '@cardstack/postgres'; +import { MatrixClient } from '@cardstack/runtime-common/matrix-client'; +import { fetchSessionRoom } from '@cardstack/runtime-common/db-queries/session-room-queries'; +import { setupPermissionedRealmCached, realmSecretSeed, testRealmHref, } from './helpers'; +import { createJWT as createRealmServerJWT } from '../utils/jwt'; +describe("realm-auth-test.ts", function () { + describe('realm auth handler', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let dbAdapter: PgAdapter; + let request: SuperTest; + const matrixUserId = '@firsttimer:localhost'; + setupPermissionedRealmCached(hooks, { + permissions: { + '*': ['read'], + [matrixUserId]: ['read', 'write'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + onRealmSetup: ({ dbAdapter: adapter, request: req }) => { + dbAdapter = adapter; + request = req; + }, + }); + hooks.afterEach(function () { + sinon.restore(); + }); + it('POST /_realm-auth creates session rooms when missing', async function () { + let expectedRoomId = '!new-session-room:localhost'; + let createDMStub = sinon + .stub(MatrixClient.prototype, 'createDM') + .resolves(expectedRoomId); + sinon.stub(MatrixClient.prototype, 'sendEvent').resolves(); + sinon.stub(MatrixClient.prototype, 'getJoinedRooms').resolves({ + joined_rooms: [], + }); + sinon.stub(MatrixClient.prototype, 'joinRoom').resolves(); + let existingRoom = await fetchSessionRoom(dbAdapter, matrixUserId); + expect(existingRoom).toBe(null); + let response = await request + .post('/_realm-auth') + .set('Accept', 'application/json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'server-session-room' }, realmSecretSeed)}`) + .send('{}'); + expect(response.status).toBe(200); + expect(response.body[testRealmHref]).toBeTruthy(); + expect(createDMStub.called).toBe(true); + expect(createDMStub.calledWith(matrixUserId)).toBe(true); + let sessionRoom = await fetchSessionRoom(dbAdapter, matrixUserId); + expect(sessionRoom).toBe(expectedRoomId); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/realm-endpoints.test.ts b/packages/realm-server/tests-vitest/realm-endpoints.test.ts new file mode 100644 index 00000000000..422e78eb331 --- /dev/null +++ b/packages/realm-server/tests-vitest/realm-endpoints.test.ts @@ -0,0 +1,1297 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { fileURLToPath } from "url"; +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import type { Test, SuperTest } from 'supertest'; +import supertest from 'supertest'; +import { join, resolve, dirname } from 'path'; +import type { Server } from 'http'; +import { dirSync, type DirResult } from 'tmp'; +import { copySync, ensureDirSync, existsSync, readFileSync, readJSONSync, removeSync, writeFileSync, } from 'fs-extra'; +import type { Realm } from '@cardstack/runtime-common'; +import { baseRealm, CachingDefinitionLookup, SupportedMimeType, type LooseSingleCardDocument, type QueuePublisher, type QueueRunner, } from '@cardstack/runtime-common'; +import { setupPermissionedRealmCached, runTestRealmServer, setupDB, setupMatrixRoom, createRealm, realmServerTestMatrix, realmServerSecretSeed, realmSecretSeed, grafanaSecret, createVirtualNetwork, matrixURL, closeServer, getIndexHTML, matrixRegistrationSecret, testRealmInfo, waitUntil, testRealmHref, testRealmURL, createJWT, cardInfo, getTestPrerenderer, testCreatePrerenderAuth, type RealmRequest, withRealmPath, } from './helpers'; +import { expectIncrementalIndexEvent } from './helpers/indexing'; +import '@cardstack/runtime-common/helpers/code-equality-assertion'; +import { RealmServer } from '../server'; +import { MatrixClient } from '@cardstack/runtime-common/matrix-client'; +import type { PgAdapter } from '@cardstack/postgres'; +import { APP_BOXEL_REALM_EVENT_TYPE } from '@cardstack/runtime-common/matrix-constants'; +import type { IncrementalIndexEventContent, MatrixEvent, RealmEvent, RealmEventContent, } from 'https://cardstack.com/base/matrix-event'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const testRealm2URL = new URL('http://127.0.0.1:4445/test/'); +describe("realm-endpoints-test.ts", function () { + describe('Realm-specific Endpoints', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let realmURL = new URL('http://127.0.0.1:4444/test/'); + let testRealmHref = realmURL.href; + let testRealmURL = realmURL; + let testRealm: Realm; + let testRealmHttpServer: Server; + let request: RealmRequest; + let serverRequest: SuperTest; + let dir: DirResult; + let dbAdapter: PgAdapter; + let testRealmHttpServer2: Server; + let testRealm2: Realm; + let dbAdapter2: PgAdapter; + let publisher: QueuePublisher; + let runner: QueueRunner; + let testRealmDir: string; + function onRealmSetup(args: { + testRealm: Realm; + testRealmHttpServer: Server; + request: SuperTest; + dir: DirResult; + dbAdapter: PgAdapter; + }) { + testRealm = args.testRealm; + testRealmHttpServer = args.testRealmHttpServer; + serverRequest = args.request; + request = withRealmPath(args.request, realmURL); + dir = args.dir; + dbAdapter = args.dbAdapter; + } + function getRealmSetup() { + return { + testRealm, + testRealmHttpServer, + request, + serverRequest, + dir, + dbAdapter, + }; + } + setupPermissionedRealmCached(hooks, { + permissions: { + '*': ['read', 'write'], + user: ['read', 'write', 'realm-owner'], + carol: ['read', 'write'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + realmURL, + onRealmSetup, + }); + let { getMessagesSince } = setupMatrixRoom(hooks, getRealmSetup); + let virtualNetwork = createVirtualNetwork(); + async function startRealmServer(dbAdapter: PgAdapter, publisher: QueuePublisher, runner: QueueRunner) { + if (testRealm2) { + virtualNetwork.unmount(testRealm2.handle); + } + ({ testRealm: testRealm2, testRealmHttpServer: testRealmHttpServer2 } = + await runTestRealmServer({ + virtualNetwork, + testRealmDir, + realmsRootPath: join(dir.name, 'realm_server_2'), + realmURL: testRealm2URL, + dbAdapter, + publisher, + runner, + matrixURL, + })); + await testRealm.logInToMatrix(); + } + setupDB(hooks, { + beforeEach: async (_dbAdapter, _publisher, _runner) => { + dbAdapter2 = _dbAdapter; + publisher = _publisher; + runner = _runner; + testRealmDir = join(dir.name, 'realm_server_2', 'test'); + ensureDirSync(testRealmDir); + copySync(join(__dirname, 'cards'), testRealmDir); + await startRealmServer(dbAdapter2, publisher, runner); + }, + afterEach: async () => { + await closeServer(testRealmHttpServer2); + }, + }); + it('can set response ETag and Cache-Control headers for module request', async function () { + let response = await request + .get(`/person`) + .set('Accept', SupportedMimeType.All) + .set('Authorization', `Bearer ${createJWT(testRealm, 'user', ['read', 'write'])}`); + expect(response.headers['etag']).toBeTruthy(); + expect(response.headers['cache-control']).toBe('public, max-age=0'); + }); + it('can set response Cache-Control header for card source request', async function () { + let response = await request + .get(`/person.gts`) + .set('Accept', SupportedMimeType.CardSource) + .set('Authorization', `Bearer ${createJWT(testRealm, 'user', ['read', 'write'])}`); + expect(response.headers['etag']).toBeTruthy(); + expect(response.headers['cache-control']).toBe('public, max-age=0'); + }); + it('can set response Cache-Control header for card json request', async function () { + let response = await request + .get(`/hassan`) + .set('Accept', SupportedMimeType.CardJson) + .set('Authorization', `Bearer ${createJWT(testRealm, 'user', ['read', 'write'])}`); + expect(response.headers['cache-control']).toBe('no-store, no-cache, must-revalidate'); + }); + it('serves file meta with dedicated accept header', async function () { + let response = await request + .get(`/person.gts`) + .set('Accept', SupportedMimeType.FileMeta) + .set('Authorization', `Bearer ${createJWT(testRealm, 'user', ['read', 'write'])}`); + expect(response.status).toBe(200); + expect(response.headers['content-type']?.startsWith(SupportedMimeType.FileMeta)).toBeTruthy(); + let json = response.body as LooseSingleCardDocument; + expect(json.data.type).toBe('file-meta'); + expect(json.data.attributes?.name).toBe('person.gts'); + expect(json.data.meta?.adoptsFrom).toEqual({ + module: `${baseRealm.url}gts-file-def`, + name: 'GtsFileDef', + }); + }); + it('serves markdown file meta subclass for noCache requests', async function () { + await testRealm.write('guide.md', '# Guide\n\nThis markdown file should resolve to MarkdownDef.'); + let response = await request + .get(`/guide.md?noCache=true`) + .set('Accept', SupportedMimeType.FileMeta) + .set('Authorization', `Bearer ${createJWT(testRealm, 'user', ['read', 'write'])}`); + expect(response.status).toBe(200); + expect(response.headers['content-type']?.startsWith(SupportedMimeType.FileMeta)).toBeTruthy(); + let json = response.body as LooseSingleCardDocument; + expect(json.data.type).toBe('file-meta'); + expect(json.data.attributes?.name).toBe('guide.md'); + expect(json.data.meta?.adoptsFrom).toEqual({ + module: `${baseRealm.url}markdown-file-def`, + name: 'MarkdownDef', + }); + }); + it('sets canonical path header for nested module requests', async function () { + let response = await request + .get(`/nested/example`) + .set('Accept', SupportedMimeType.All) + .set('Authorization', `Bearer ${createJWT(testRealm, 'user', ['read', 'write'])}`); + expect(response.status).toBe(200); + expect(response.headers['x-boxel-canonical-path']).toBe(`${testRealmURL}nested/example.js`); + }); + it('can set response Cache-Control header for json api request', async function () { + let response = await request + .post(`/_info`) + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', SupportedMimeType.JSONAPI) + .set('Authorization', `Bearer ${createJWT(testRealm, 'user', ['read', 'write'])}`); + expect(response.headers['cache-control']).toBe('no-store, no-cache, must-revalidate'); + }); + describe('realm config patch', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let realmConfigPath: string; + let initialConfig: any; + hooks.beforeEach(function () { + realmConfigPath = join(dir.name, 'realm_server_1', 'test', '.realm.json'); + initialConfig = existsSync(realmConfigPath) + ? readJSONSync(realmConfigPath) + : undefined; + }); + it('non-owner cannot patch realm config', async function () { + let response = await request + .patch('/_config') + .set('Accept', SupportedMimeType.JSON) + .set('Authorization', `Bearer ${createJWT(testRealm, 'carol', ['read', 'write'])}`) + .send({ + data: { + type: 'realm-config', + attributes: { backgroundURL: 'new-bg' }, + }, + }); + expect(response.status).toBe(403); + if (initialConfig) { + expect(readJSONSync(realmConfigPath)).toEqual(initialConfig); + } + }); + it('realm-owner can patch allowed realm config property', async function () { + let response = await request + .patch('/_config') + .set('Accept', SupportedMimeType.JSON) + .set('Authorization', `Bearer ${createJWT(testRealm, 'user', [ + 'read', + 'write', + 'realm-owner', + ])}`) + .send({ + data: { + type: 'realm-config', + attributes: { backgroundURL: 'new-bg' }, + }, + }); + expect(response.status).toBe(200); + expect(response.body).toEqual({ + data: { + id: testRealmHref, + type: 'realm-config', + attributes: { + ...testRealmInfo, + backgroundURL: 'new-bg', + }, + }, + }); + expect(readJSONSync(realmConfigPath)).toEqual({ ...(initialConfig ?? {}), backgroundURL: 'new-bg' }); + }); + it('allows any property except showAsCatalog', async function () { + let response = await request + .patch('/_config') + .set('Accept', SupportedMimeType.JSON) + .set('Authorization', `Bearer ${createJWT(testRealm, 'user', [ + 'read', + 'write', + 'realm-owner', + ])}`) + .send({ + data: { + type: 'realm-config', + attributes: { publishable: true }, + }, + }); + expect(response.status).toBe(200); + expect(readJSONSync(realmConfigPath)).toEqual({ ...(initialConfig ?? {}), publishable: true }); + let showAsCatalogResponse = await request + .patch('/_config') + .set('Accept', SupportedMimeType.JSON) + .set('Authorization', `Bearer ${createJWT(testRealm, 'user', [ + 'read', + 'write', + 'realm-owner', + ])}`) + .send({ + data: { + type: 'realm-config', + attributes: { showAsCatalog: true }, + }, + }); + expect(showAsCatalogResponse.status).toBe(400); + expect(readJSONSync(realmConfigPath)).toEqual({ ...(initialConfig ?? {}), publishable: true }); + }); + it('realm-owner can patch multiple properties including interactHome and hostHome', async function () { + let interactHome = 'card://realm-url.com/space'; + let hostHome = 'https://hosted.space/'; + let response = await request + .patch('/_config') + .set('Accept', SupportedMimeType.JSON) + .set('Authorization', `Bearer ${createJWT(testRealm, 'user', [ + 'read', + 'write', + 'realm-owner', + ])}`) + .send({ + data: { + type: 'realm-config', + attributes: { interactHome, hostHome }, + }, + }); + expect(response.status).toBe(200); + expect(response.body.data.type).toBe('realm-config'); + expect(response.body.data.attributes.interactHome).toBe(interactHome); + expect(response.body.data.attributes.hostHome).toBe(hostHome); + expect(readJSONSync(realmConfigPath)).toEqual({ ...(initialConfig ?? {}), interactHome, hostHome }); + }); + it('returns bad request for invalid json body', async function () { + let response = await request + .patch('/_config') + .set('Accept', SupportedMimeType.JSON) + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'user', [ + 'read', + 'write', + 'realm-owner', + ])}`) + .send('{"data":'); + expect(response.status).toBe(400); + expect(response.body.errors?.[0]?.message?.startsWith('The request body was not json')).toBeTruthy(); + if (initialConfig) { + expect(readJSONSync(realmConfigPath)).toEqual(initialConfig); + } + else { + expect(existsSync(realmConfigPath)).toBe(false); + } + }); + it('returns bad request when request structure is missing data or attributes', async function () { + let missingDataResponse = await request + .patch('/_config') + .set('Accept', SupportedMimeType.JSON) + .set('Authorization', `Bearer ${createJWT(testRealm, 'user', [ + 'read', + 'write', + 'realm-owner', + ])}`) + .send({}); + expect(missingDataResponse.status).toBe(400); + let missingAttributesResponse = await request + .patch('/_config') + .set('Accept', SupportedMimeType.JSON) + .set('Authorization', `Bearer ${createJWT(testRealm, 'user', [ + 'read', + 'write', + 'realm-owner', + ])}`) + .send({ data: { type: 'realm-config' } }); + expect(missingAttributesResponse.status).toBe(400); + if (initialConfig) { + expect(readJSONSync(realmConfigPath)).toEqual(initialConfig); + } + else { + expect(existsSync(realmConfigPath)).toBe(false); + } + }); + it('returns bad request when property name is empty', async function () { + let response = await request + .patch('/_config') + .set('Accept', SupportedMimeType.JSON) + .set('Authorization', `Bearer ${createJWT(testRealm, 'user', [ + 'read', + 'write', + 'realm-owner', + ])}`) + .send({ + data: { + type: 'realm-config', + attributes: { '': 'value' }, + }, + }); + expect(response.status).toBe(400); + expect(response.body.errors?.[0]?.message).toBe('Property names cannot be empty'); + if (initialConfig) { + expect(readJSONSync(realmConfigPath)).toEqual(initialConfig); + } + else { + expect(existsSync(realmConfigPath)).toBe(false); + } + }); + it('returns error when existing .realm.json cannot be parsed', async function () { + let invalidContent = '{ "name": "realm" '; + writeFileSync(realmConfigPath, invalidContent); + try { + let response = await request + .patch('/_config') + .set('Accept', SupportedMimeType.JSON) + .set('Authorization', `Bearer ${createJWT(testRealm, 'user', [ + 'read', + 'write', + 'realm-owner', + ])}`) + .send({ + data: { + type: 'realm-config', + attributes: { backgroundURL: 'updated-bg' }, + }, + }); + expect(response.status).toBe(500); + expect(response.body.errors?.[0]?.message?.startsWith('Unable to parse existing realm config:')).toBeTruthy(); + expect(readFileSync(realmConfigPath, 'utf8')).toBe(invalidContent); + } + finally { + if (initialConfig) { + writeFileSync(realmConfigPath, JSON.stringify(initialConfig, null, 2) + '\\n'); + } + else { + removeSync(realmConfigPath); + } + } + }); + }); + it('serves module requests through read-through cache', async function () { + let modulePath = 'module-cache-test.js'; + let authHeader = `Bearer ${createJWT(testRealm, 'user', ['read', 'write'])}`; + await testRealm.write(modulePath, `export const value = 1;`); + let firstResponse = await request + .get(`/${modulePath}`) + .set('Accept', SupportedMimeType.All) + .set('Authorization', authHeader); + expect(firstResponse.status).toBe(200); + expect(/value\s*=\s*1/.test(firstResponse.text)).toBe(true); + expect(['hit', 'miss'].includes(firstResponse.headers['x-boxel-cache'])).toBeTruthy(); + let cachedResponse = await request + .get(`/${modulePath}`) + .set('Accept', SupportedMimeType.All) + .set('Authorization', authHeader); + expect(cachedResponse.headers['x-boxel-cache']).toBe('hit'); + await testRealm.write(modulePath, `export const value = 2;`); + let afterWriteResponse = await request + .get(`/${modulePath}`) + .set('Accept', SupportedMimeType.All) + .set('Authorization', authHeader); + expect(afterWriteResponse.headers['x-boxel-cache']).toBe('miss'); + expect(/value\s*=\s*2/.test(afterWriteResponse.text)).toBe(true); + let repopulatedResponse = await request + .get(`/${modulePath}`) + .set('Accept', SupportedMimeType.All) + .set('Authorization', authHeader); + expect(repopulatedResponse.headers['x-boxel-cache']).toBe('hit'); + expect(/value\s*=\s*2/.test(repopulatedResponse.text)).toBe(true); + }); + const transpileTestCardSource = ` + import { + linksToMany, + field, + Component, + FieldDef, + } from 'https://cardstack.com/base/card-api'; + import { Country } from './country'; + + export class TranspileTestField extends FieldDef { + static displayName = 'Trips'; + @field countriesVisited = linksToMany(Country); + + static embedded = class Embedded extends Component { + + }; + } + `; + it('serves transpiled .gts modules when Accept is */*', async function () { + let modulePath = 'transpile-test.gts'; + let authHeader = `Bearer ${createJWT(testRealm, 'user', ['read', 'write'])}`; + await testRealm.write(modulePath, transpileTestCardSource); + let response = await request + .get(`/${modulePath}`) + .set('Accept', SupportedMimeType.All) + .set('Authorization', authHeader); + expect(response.status).toBe(200); + expect(response.headers['content-type']).toBe('text/javascript'); + expect(response.text.includes('setComponentTemplate')).toBeTruthy(); + expect(response.text.includes('; + let dir: DirResult; + hooks.beforeEach(async function () { + dir = dirSync(); + }); + setupDB(hooks, { + beforeEach: async (dbAdapter, publisher, runner) => { + let testRealmDir = join(dir.name, 'realm_server_3', 'test'); + ensureDirSync(testRealmDir); + copySync(join(__dirname, 'cards'), testRealmDir); + testRealmServer = (await runTestRealmServer({ + virtualNetwork: createVirtualNetwork(), + testRealmDir, + realmsRootPath: join(dir.name, 'realm_server_3'), + realmURL: testRealmURL, + dbAdapter, + publisher, + runner, + matrixURL, + })).testRealmHttpServer; + request = supertest(testRealmServer); + }, + afterEach: async () => { + await closeServer(testRealmServer); + }, + }); + it('serves an origin realm directory GET request', async function () { + let response = await request + .get('/') + .set('Accept', 'application/vnd.api+json'); + expect(response.status).toBe(200); + let json = response.body; + for (let relationship of Object.values(json.data.relationships)) { + delete (relationship as any).meta.lastModified; + delete (relationship as any).meta.resourceCreatedAt; + } + expect(json).toEqual({ + data: { + id: testRealmHref, + type: 'directory', + relationships: { + '%F0%9F%98%80.gts': { + links: { + related: 'http://127.0.0.1:4444/%F0%9F%98%80.gts', + }, + meta: { + kind: 'file', + }, + }, + 'a.js': { + links: { + related: `${testRealmHref}a.js`, + }, + meta: { + kind: 'file', + }, + }, + 'b.js': { + links: { + related: `${testRealmHref}b.js`, + }, + meta: { + kind: 'file', + }, + }, + 'c.js': { + links: { + related: `${testRealmHref}c.js`, + }, + meta: { + kind: 'file', + }, + }, + 'chess-gallery.gts': { + links: { + related: `${testRealmHref}chess-gallery.gts`, + }, + meta: { + kind: 'file', + }, + }, + 'ChessGallery/': { + links: { + related: `${testRealmHref}ChessGallery/`, + }, + meta: { + kind: 'directory', + }, + }, + 'code-ref-test.gts': { + links: { + related: `${testRealmHref}code-ref-test.gts`, + }, + meta: { + kind: 'file', + }, + }, + 'cycle-one.js': { + links: { + related: `${testRealmHref}cycle-one.js`, + }, + meta: { + kind: 'file', + }, + }, + 'cycle-two.js': { + links: { + related: `${testRealmHref}cycle-two.js`, + }, + meta: { + kind: 'file', + }, + }, + 'd.js': { + links: { + related: `${testRealmHref}d.js`, + }, + meta: { + kind: 'file', + }, + }, + 'deadlock/': { + links: { + related: `${testRealmHref}deadlock/`, + }, + meta: { + kind: 'directory', + }, + }, + 'dir/': { + links: { + related: `${testRealmHref}dir/`, + }, + meta: { + kind: 'directory', + }, + }, + 'e.js': { + links: { + related: `${testRealmHref}e.js`, + }, + meta: { + kind: 'file', + }, + }, + 'f.js': { + links: { + related: `${testRealmHref}f.js`, + }, + meta: { + kind: 'file', + }, + }, + 'family_photo_card.gts': { + links: { + related: `${testRealmHref}family_photo_card.gts`, + }, + meta: { + kind: 'file', + }, + }, + 'FamilyPhotoCard/': { + links: { + related: `${testRealmHref}FamilyPhotoCard/`, + }, + meta: { + kind: 'directory', + }, + }, + 'friend-with-used-link.gts': { + links: { + related: `${testRealmHref}friend-with-used-link.gts`, + }, + meta: { + kind: 'file', + }, + }, + 'friend.gts': { + links: { + related: `${testRealmHref}friend.gts`, + }, + meta: { + kind: 'file', + }, + }, + 'g.js': { + links: { + related: `${testRealmHref}g.js`, + }, + meta: { + kind: 'file', + }, + }, + 'hassan-x.json': { + links: { + related: `${testRealmHref}hassan-x.json`, + }, + meta: { + kind: 'file', + }, + }, + 'hassan.json': { + links: { + related: `${testRealmHref}hassan.json`, + }, + meta: { + kind: 'file', + }, + }, + 'home.gts': { + links: { + related: `${testRealmHref}home.gts`, + }, + meta: { + kind: 'file', + }, + }, + 'index.json': { + links: { + related: `${testRealmHref}index.json`, + }, + meta: { + kind: 'file', + }, + }, + 'jade-x.json': { + links: { + related: `${testRealmHref}jade-x.json`, + }, + meta: { + kind: 'file', + }, + }, + 'jade.json': { + links: { + related: `${testRealmHref}jade.json`, + }, + meta: { + kind: 'file', + }, + }, + 'missing-link.json': { + links: { + related: `${testRealmHref}missing-link.json`, + }, + meta: { + kind: 'file', + }, + }, + 'multiple-default-exports-card.gts': { + links: { + related: `${testRealmHref}multiple-default-exports-card.gts`, + }, + meta: { + kind: 'file', + }, + }, + 'multiple-default-exports-card.json': { + links: { + related: `${testRealmHref}multiple-default-exports-card.json`, + }, + meta: { + kind: 'file', + }, + }, + 'multiple-default-exports.gts': { + links: { + related: `${testRealmHref}multiple-default-exports.gts`, + }, + meta: { + kind: 'file', + }, + }, + 'nested/': { + links: { + related: `${testRealmHref}nested/`, + }, + meta: { + kind: 'directory', + }, + }, + 'person-1.json': { + links: { + related: `${testRealmHref}person-1.json`, + }, + meta: { + kind: 'file', + }, + }, + 'person-2.json': { + links: { + related: `${testRealmHref}person-2.json`, + }, + meta: { + kind: 'file', + }, + }, + 'person-with-error.gts': { + links: { + related: `${testRealmHref}person-with-error.gts`, + }, + meta: { + kind: 'file', + }, + }, + 'person.gts': { + links: { + related: `${testRealmHref}person.gts`, + }, + meta: { + kind: 'file', + }, + }, + 'person.json': { + links: { + related: `${testRealmHref}person.json`, + }, + meta: { + kind: 'file', + }, + }, + 'PersonCard/': { + links: { + related: `${testRealmHref}PersonCard/`, + }, + meta: { + kind: 'directory', + }, + }, + 'query-test-cards.gts': { + links: { + related: `${testRealmHref}query-test-cards.gts`, + }, + meta: { + kind: 'file', + }, + }, + 'sample.md': { + links: { + related: `${testRealmHref}sample.md`, + }, + meta: { + kind: 'file', + }, + }, + 'timers-card.gts': { + links: { + related: `${testRealmHref}timers-card.gts`, + }, + meta: { + kind: 'file', + }, + }, + 'timers-card.json': { + links: { + related: `${testRealmHref}timers-card.json`, + }, + meta: { + kind: 'file', + }, + }, + 'unused-card.gts': { + links: { + related: `${testRealmHref}unused-card.gts`, + }, + meta: { + kind: 'file', + }, + }, + }, + }, + }); + }); + }); + describe('Realm server serving multiple realms', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let testRealmServer: Server; + let request: SuperTest; + let dir: DirResult; + let base: Realm; + let testRealm: Realm; + let virtualNetwork = createVirtualNetwork(); + const basePath = resolve(join(__dirname, '..', '..', 'base')); + hooks.beforeEach(async function () { + dir = dirSync(); + ensureDirSync(join(dir.name, 'demo')); + copySync(join(__dirname, 'cards'), join(dir.name, 'demo')); + }); + setupDB(hooks, { + beforeEach: async (dbAdapter, publisher, runner) => { + let localBaseRealmURL = new URL('http://127.0.0.1:4446/base/'); + let prerenderer = await getTestPrerenderer(); + let definitionLookup = new CachingDefinitionLookup(dbAdapter, prerenderer, virtualNetwork, testCreatePrerenderAuth); + virtualNetwork.addURLMapping(new URL(baseRealm.url), localBaseRealmURL); + ({ realm: base } = await createRealm({ + definitionLookup, + withWorker: true, + prerenderer, + dir: basePath, + realmURL: baseRealm.url, + virtualNetwork, + publisher, + runner, + dbAdapter, + deferStartUp: true, + })); + virtualNetwork.mount(base.handle); + ({ realm: testRealm } = await createRealm({ + definitionLookup, + withWorker: true, + prerenderer, + dir: join(dir.name, 'demo'), + virtualNetwork, + realmURL: 'http://127.0.0.1:4446/demo/', + publisher, + runner, + dbAdapter, + deferStartUp: true, + })); + virtualNetwork.mount(testRealm.handle); + let matrixClient = new MatrixClient({ + matrixURL: realmServerTestMatrix.url, + username: realmServerTestMatrix.username, + seed: realmSecretSeed, + }); + testRealmServer = new RealmServer({ + realms: [base, testRealm], + virtualNetwork, + matrixClient, + realmServerSecretSeed, + realmSecretSeed, + grafanaSecret, + matrixRegistrationSecret, + realmsRootPath: dir.name, + dbAdapter, + queue: publisher, + getIndexHTML, + serverURL: new URL('http://127.0.0.1:4446'), + assetsURL: new URL(`http://example.com/notional-assets-host/`), + definitionLookup, + prerenderer, + }).listen(parseInt(localBaseRealmURL.port)); + await base.start(); + await testRealm.start(); + request = supertest(testRealmServer); + }, + afterEach: async () => { + await closeServer(testRealmServer); + }, + }); + it(`Can perform full indexing multiple times on a server that runs multiple realms`, async function () { + { + let response = await request + .get('/demo/person-1') + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(200); + } + await base.reindex(); + await testRealm.reindex(); + { + let response = await request + .get('/demo/person-1') + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(200); + } + await base.reindex(); + await testRealm.reindex(); + { + let response = await request + .get('/demo/person-1') + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(200); + } + }); + }); + describe('Realm Server serving from a subdirectory', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let testRealmServer: Server; + let request: SuperTest; + let dir: DirResult; + hooks.beforeEach(async function () { + dir = dirSync(); + }); + setupDB(hooks, { + beforeEach: async (dbAdapter, publisher, runner) => { + dir = dirSync(); + let testRealmDir = join(dir.name, 'realm_server_4', 'test'); + ensureDirSync(testRealmDir); + copySync(join(__dirname, 'cards'), testRealmDir); + testRealmServer = (await runTestRealmServer({ + virtualNetwork: createVirtualNetwork(), + testRealmDir, + realmsRootPath: join(dir.name, 'realm_server_4'), + realmURL: new URL('http://127.0.0.1:4446/demo/'), + dbAdapter, + publisher, + runner, + matrixURL, + })).testRealmHttpServer; + request = supertest(testRealmServer); + }, + afterEach: async () => { + await closeServer(testRealmServer); + }, + }); + it('serves a subdirectory GET request that results in redirect', async function () { + let response = await request.get('/demo'); + expect(response.status).toBe(302); + expect(response.headers['location']).toBe('http://127.0.0.1:4446/demo/'); + }); + it('redirection keeps query params intact', async function () { + let response = await request.get('/demo?operatorModeEnabled=true&operatorModeState=%7B%22stacks%22%3A%5B%7B%22items%22%3A%5B%7B%22card%22%3A%7B%22id%22%3A%22http%3A%2F%2Flocalhost%3A4204%2Findex%22%7D%2C%22format%22%3A%22isolated%22%7D%5D%7D%5D%7D'); + expect(response.status).toBe(302); + expect(response.headers['location']).toBe('http://127.0.0.1:4446/demo/?operatorModeEnabled=true&operatorModeState=%7B%22stacks%22%3A%5B%7B%22items%22%3A%5B%7B%22card%22%3A%7B%22id%22%3A%22http%3A%2F%2Flocalhost%3A4204%2Findex%22%7D%2C%22format%22%3A%22isolated%22%7D%5D%7D%5D%7D'); + }); + }); +}); +async function waitForIncrementalIndexEvent(getMessagesSince: (since: number) => Promise, since: number) { + try { + await waitUntil(async () => { + let matrixMessages = await getMessagesSince(since); + return matrixMessages.some((m) => m.type === APP_BOXEL_REALM_EVENT_TYPE && + m.content.eventName === 'index' && + m.content.indexType === 'incremental'); + }); + } + catch (e) { + let matrixMessages = await getMessagesSince(since); + console.log('waitForIncrementalIndexEvent failed, no event found. Events:'); + console.log(JSON.stringify(matrixMessages, null, 2)); + throw e; + } +} +function findRealmEvent(events: MatrixEvent[], eventName: string, indexType: string): RealmEvent | undefined { + return events.find((m) => m.type === APP_BOXEL_REALM_EVENT_TYPE && + m.content.eventName === eventName && + (realmEventIsIndex(m.content) ? m.content.indexType === indexType : true)) as RealmEvent | undefined; +} +function realmEventIsIndex(event: RealmEventContent): event is IncrementalIndexEventContent { + return event.eventName === 'index'; +} diff --git a/packages/realm-server/tests-vitest/realm-endpoints/cancel-indexing-job.test.ts b/packages/realm-server/tests-vitest/realm-endpoints/cancel-indexing-job.test.ts new file mode 100644 index 00000000000..d898dd966d2 --- /dev/null +++ b/packages/realm-server/tests-vitest/realm-endpoints/cancel-indexing-job.test.ts @@ -0,0 +1,237 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import type { SuperTest, Test } from 'supertest'; +import type { QueuePublisher, QueueRunner, Realm, } from '@cardstack/runtime-common'; +import { Deferred } from '@cardstack/runtime-common'; +import { PgAdapter, PgQueueRunner } from '@cardstack/postgres'; +import { createJWT, setupPermissionedRealmCached, waitUntil } from '../helpers'; +import type { PgAdapter as TestPgAdapter } from '@cardstack/postgres'; +describe("realm-endpoints/cancel-indexing-job-test.ts", function () { + describe('Realm-specific Endpoints | POST _cancel-indexing-job', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let testRealm: Realm; + let request: SuperTest; + let dbAdapter: TestPgAdapter; + let publisher: QueuePublisher; + let runner: QueueRunner; + function onRealmSetup(args: { + testRealm: Realm; + request: SuperTest; + dbAdapter: TestPgAdapter; + publisher: QueuePublisher; + runner: QueueRunner; + }) { + testRealm = args.testRealm; + request = args.request; + dbAdapter = args.dbAdapter; + publisher = args.publisher; + runner = args.runner; + } + setupPermissionedRealmCached(hooks, { + permissions: { + writer: ['read', 'write'], + reader: ['read'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + onRealmSetup, + }); + it('returns 401 without JWT for private realm', async function () { + let response = await request + .post('/_cancel-indexing-job') + .set('Accept', 'application/json'); + expect(response.status).toBe(401); + }); + it('returns 403 for user without write access', async function () { + let response = await request + .post('/_cancel-indexing-job') + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'reader', ['read'])}`); + expect(response.status).toBe(403); + }); + it('cancels running indexing jobs but does not cancel pending indexing jobs', async function () { + let concurrencyGroup = `indexing:${testRealm.url}`; + let [{ id: runningJobId }] = (await dbAdapter.execute(`INSERT INTO jobs + (args, job_type, concurrency_group, timeout, priority) + VALUES + ( + '{"realmURL": "${testRealm.url}", "realmUsername":"node-test_realm"}', + 'from-scratch-index', + '${concurrencyGroup}', + 180, + 0 + ) RETURNING id`)) as { + id: string; + }[]; + await dbAdapter.execute(`INSERT INTO job_reservations + (job_id, locked_until ) VALUES (${runningJobId}, NOW() + INTERVAL '3 minutes')`); + let [{ id: pendingJobId }] = (await dbAdapter.execute(`INSERT INTO jobs + (args, job_type, concurrency_group, timeout, priority) + VALUES + ( + '{"realmURL": "${testRealm.url}", "realmUsername":"node-test_realm"}', + 'incremental-index', + '${concurrencyGroup}', + 180, + 0 + ) RETURNING id`)) as { + id: string; + }[]; + let response = await request + .post('/_cancel-indexing-job') + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'writer', ['read', 'write'])}`); + expect(response.status).toBe(204); + let [runningJob] = await dbAdapter.execute(`SELECT status, result, finished_at FROM jobs WHERE id = ${runningJobId}`); + expect(runningJob.status).toBe('rejected'); + expect(runningJob.result).toEqual({ + status: 418, + message: 'User initiated job cancellation', + }); + expect(runningJob.finished_at).toBeTruthy(); + let runningReservations = await dbAdapter.execute(`SELECT id FROM job_reservations WHERE job_id = ${runningJobId} AND completed_at IS NULL`); + expect(runningReservations.length).toBe(0); + let [pendingJob] = await dbAdapter.execute(`SELECT status, result, finished_at FROM jobs WHERE id = ${pendingJobId}`); + expect(pendingJob.status).toBe('unfulfilled'); + expect(pendingJob.result).toBe(null); + expect(pendingJob.finished_at).toBe(null); + }); + it('returns 204 and does nothing when there is no running indexing job', async function () { + let concurrencyGroup = `indexing:${testRealm.url}`; + let [{ id: pendingJobId }] = (await dbAdapter.execute(`INSERT INTO jobs + (args, job_type, concurrency_group, timeout, priority) + VALUES + ( + '{"realmURL": "${testRealm.url}", "realmUsername":"node-test_realm"}', + 'from-scratch-index', + '${concurrencyGroup}', + 180, + 0 + ) RETURNING id`)) as { + id: string; + }[]; + let response = await request + .post('/_cancel-indexing-job') + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'writer', ['read', 'write'])}`); + expect(response.status).toBe(204); + let [pendingJob] = await dbAdapter.execute(`SELECT status, result, finished_at FROM jobs WHERE id = ${pendingJobId}`); + expect(pendingJob.status).toBe('unfulfilled'); + expect(pendingJob.result).toBe(null); + expect(pendingJob.finished_at).toBe(null); + }); + it('does not treat expired reservations as running jobs', async function () { + let concurrencyGroup = `indexing:${testRealm.url}`; + let [{ id: jobId }] = (await dbAdapter.execute(`INSERT INTO jobs + (args, job_type, concurrency_group, timeout, priority) + VALUES + ( + '{"realmURL": "${testRealm.url}", "realmUsername":"node-test_realm"}', + 'from-scratch-index', + '${concurrencyGroup}', + 180, + 0 + ) RETURNING id`)) as { + id: string; + }[]; + await dbAdapter.execute(`INSERT INTO job_reservations + (job_id, locked_until ) VALUES (${jobId}, NOW() - INTERVAL '1 minutes')`); + let response = await request + .post('/_cancel-indexing-job') + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'writer', ['read', 'write'])}`); + expect(response.status).toBe(204); + let [job] = await dbAdapter.execute(`SELECT status, result, finished_at FROM jobs WHERE id = ${jobId}`); + expect(job.status).toBe('unfulfilled'); + expect(job.result).toBe(null); + expect(job.finished_at).toBe(null); + }); + it('worker can continue to process new jobs after canceling a running indexing job', async function () { + let jobStarted = new Deferred(); + let releaseJob = new Deferred(); + let jobFinished = new Deferred(); + let events: string[] = []; + runner.register('blocking-job', async ({ jobNum }: { + jobNum: number; + }) => { + events.push(`job${jobNum} start`); + if (jobNum === 1) { + jobStarted.fulfill(); + await releaseJob.promise; + } + events.push(`job${jobNum} finish`); + if (jobNum === 1) { + jobFinished.fulfill(); + } + return jobNum; + }); + let concurrencyGroup = `indexing:${testRealm.url}`; + let job1 = await publisher.publish({ + jobType: 'blocking-job', + concurrencyGroup, + timeout: 30, + args: { jobNum: 1 }, + }); + let job1Outcome = job1.done.then((result) => ({ outcome: 'resolved' as const, result }), (error) => ({ outcome: 'rejected' as const, error })); + await jobStarted.promise; + await waitUntil(async () => { + let rows = await dbAdapter.execute(`SELECT id FROM job_reservations WHERE job_id = ${job1.id} AND completed_at IS NULL`); + return rows.length > 0; + }); + let response = await request + .post('/_cancel-indexing-job') + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'writer', ['read', 'write'])}`); + expect(response.status).toBe(204); + let [job1Record] = await dbAdapter.execute(`SELECT status FROM jobs WHERE id = ${job1.id}`); + expect(job1Record.status).toBe('rejected'); + let adapter2 = new PgAdapter(); + let runner2 = new PgQueueRunner({ + adapter: adapter2, + workerId: 'cancel-indexing-job-test-worker-2', + }); + runner2.register('blocking-job', async ({ jobNum }: { + jobNum: number; + }) => { + events.push(`job${jobNum} start`); + events.push(`job${jobNum} finish`); + return jobNum; + }); + await runner2.start(); + try { + let job2 = await publisher.publish({ + jobType: 'blocking-job', + concurrencyGroup, + timeout: 30, + args: { jobNum: 2 }, + }); + let job2Result = await job2.done; + expect(job2Result).toBe(2); + } + finally { + releaseJob.fulfill(); + await jobFinished.promise; + await runner2.destroy(); + await adapter2.close(); + } + let outcome = await job1Outcome; + expect(outcome.outcome).toBe('rejected'); + if (outcome.outcome === 'rejected') { + expect(outcome.error).toEqual({ + status: 418, + message: 'User initiated job cancellation', + }); + } + expect(events).toEqual([ + 'job1 start', + 'job2 start', + 'job2 finish', + 'job1 finish', + ]); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/realm-endpoints/dependencies.test.ts b/packages/realm-server/tests-vitest/realm-endpoints/dependencies.test.ts new file mode 100644 index 00000000000..8836cca87f1 --- /dev/null +++ b/packages/realm-server/tests-vitest/realm-endpoints/dependencies.test.ts @@ -0,0 +1,75 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import type { SuperTest, Test } from 'supertest'; +import type { Realm } from '@cardstack/runtime-common'; +import { SupportedMimeType } from '@cardstack/runtime-common'; +import type { Server } from 'http'; +import { closeServer, setupPermissionedRealmCached } from '../helpers'; +describe("realm-endpoints/dependencies-test.ts", function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let testRealm: Realm; + let testRealmHttpServer: Server; + let request: SuperTest; + function onRealmSetup({ testRealm: realm, testRealmHttpServer: server, request: req, }: { + testRealm: Realm; + testRealmHttpServer: Server; + request: SuperTest; + }) { + testRealm = realm; + testRealmHttpServer = server; + request = req; + } + hooks.afterEach(async function () { + await closeServer(testRealmHttpServer); + }); + setupPermissionedRealmCached(hooks, { + permissions: { + '*': ['read'], + }, + onRealmSetup, + }); + it('returns resource index entries for an existing file', async function () { + await testRealm.write('dependencies-card.gts', ` + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + export class ResourceIndexCard extends CardDef { + @field title = contains(StringField); + } + `); + await testRealm.realmIndexUpdater.fullIndex(); + let targetUrl = `${testRealm.url}dependencies-card.gts`; + let response = await request + .get(`/_dependencies?url=${encodeURIComponent(targetUrl)}&type=file`) + .set('Accept', SupportedMimeType.JSONAPI); + expect(response.status).toBe(200); + expect(response.body.data.length > 0).toBe(true); + let entry = response.body.data.find((candidate: any) => candidate.attributes?.entryType === 'file'); + expect(entry).toBeTruthy(); + expect(entry.id).toBe(targetUrl); + expect(entry.attributes.canonicalUrl).toBe(targetUrl); + expect(entry.attributes.realmUrl).toBe(testRealm.url); + expect(entry.attributes.entryType).toBe('file'); + expect(entry.attributes.hasError).toBe(false); + expect(entry.attributes.dependencies.includes('https://cardstack.com/base/card-api')).toBe(true); + expect(entry.attributes.dependencies.includes(targetUrl)).toBe(false); + }); + it('returns empty array for unknown resources', async function () { + let response = await request + .get(`/_dependencies?url=${encodeURIComponent(`${testRealm.url}missing-resource.gts`)}`) + .set('Accept', SupportedMimeType.JSONAPI); + expect(response.status).toBe(200); + expect(response.body.data).toEqual([]); + }); + it('returns bad request when url parameter is missing', async function () { + let response = await request + .get('/_dependencies') + .set('Accept', SupportedMimeType.JSONAPI); + expect(response.status).toBe(400); + expect(response.body.errors?.[0]?.message).toBe('The request is missing the url query parameter'); + }); +}); diff --git a/packages/realm-server/tests-vitest/realm-endpoints/directory.test.ts b/packages/realm-server/tests-vitest/realm-endpoints/directory.test.ts new file mode 100644 index 00000000000..1dcc8112f5d --- /dev/null +++ b/packages/realm-server/tests-vitest/realm-endpoints/directory.test.ts @@ -0,0 +1,139 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { fileURLToPath } from "url"; +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import type { Test, SuperTest } from 'supertest'; +import { join, dirname } from 'path'; +import { dirSync, type DirResult } from 'tmp'; +import { copySync } from 'fs-extra'; +import type { Realm } from '@cardstack/runtime-common'; +import { setupPermissionedRealmCached, testRealmHref, createJWT, } from '../helpers'; +import '@cardstack/runtime-common/helpers/code-equality-assertion'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +describe("realm-endpoints/directory-test.ts", function () { + describe('Realm-specific Endpoints | GET directory path', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let testRealm: Realm; + let request: SuperTest; + let dir: DirResult; + hooks.beforeEach(async function () { + dir = dirSync(); + copySync(join(__dirname, '..', 'cards'), dir.name); + }); + function onRealmSetup(args: { + testRealm: Realm; + request: SuperTest; + dir: DirResult; + }) { + testRealm = args.testRealm; + request = args.request; + dir = args.dir; + } + describe('public readable realm', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + permissions: { + '*': ['read'], + }, + onRealmSetup, + }); + it('serves the request', async function () { + let response = await request + .get('/dir/') + .set('Accept', 'application/vnd.api+json'); + expect(response.status).toBe(200); + expect(response.get('X-boxel-realm-url')).toBe(testRealmHref); + expect(response.get('X-boxel-realm-public-readable')).toBe('true'); + let json = response.body; + for (let relationship of Object.values(json.data.relationships)) { + delete (relationship as any).meta.lastModified; + delete (relationship as any).meta.resourceCreatedAt; + } + expect(json).toEqual({ + data: { + id: `${testRealmHref}dir/`, + type: 'directory', + relationships: { + 'bar.txt': { + links: { + related: `${testRealmHref}dir/bar.txt`, + }, + meta: { + kind: 'file', + }, + }, + 'foo.txt': { + links: { + related: `${testRealmHref}dir/foo.txt`, + }, + meta: { + kind: 'file', + }, + }, + 'subdir/': { + links: { + related: `${testRealmHref}dir/subdir/`, + }, + meta: { + kind: 'directory', + }, + }, + }, + }, + }); + }); + }); + describe('permissioned realm', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + permissions: { + john: ['read'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + onRealmSetup, + }); + it('401 with invalid JWT', async function () { + let response = await request + .get('/dir/') + .set('Accept', 'application/vnd.api+json') + .set('Authorization', `Bearer invalid-token`); + expect(response.status).toBe(401); + }); + it('401 without a JWT', async function () { + let response = await request + .get('/dir/') + .set('Accept', 'application/vnd.api+json'); // no Authorization header + expect(response.status).toBe(401); + }); + it('403 without permission', async function () { + let response = await request + .get('/dir/') + .set('Accept', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'not-john')}`); + expect(response.status).toBe(403); + }); + it('200 with permission', async function () { + let response = await request + .get('/dir/') + .set('Accept', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'john', ['read'])}`); + expect(response.status).toBe(200); + }); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/realm-endpoints/info.test.ts b/packages/realm-server/tests-vitest/realm-endpoints/info.test.ts new file mode 100644 index 00000000000..bf76ebe7fad --- /dev/null +++ b/packages/realm-server/tests-vitest/realm-endpoints/info.test.ts @@ -0,0 +1,217 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { fileURLToPath } from "url"; +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import type { Test, SuperTest } from 'supertest'; +import { join, dirname } from 'path'; +import type { Server } from 'http'; +import { dirSync, type DirResult } from 'tmp'; +import { copySync } from 'fs-extra'; +import type { Realm } from '@cardstack/runtime-common'; +import { setupPermissionedRealmCached, closeServer, testRealmInfo, createJWT, testRealmURLFor, } from '../helpers'; +import '@cardstack/runtime-common/helpers/code-equality-assertion'; +import { resetCatalogRealms } from '../../handlers/handle-fetch-catalog-realms'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +describe("realm-endpoints/info-test.ts", function () { + describe('Realm-specific Endpoints | QUERY _info', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let realmURL = testRealmURLFor('test/'); + let testRealm: Realm; + let testRealmHttpServer: Server; + let request: SuperTest; + let dir: DirResult; + function onRealmSetup(args: { + testRealm: Realm; + testRealmHttpServer: Server; + request: SuperTest; + dir: DirResult; + }) { + testRealm = args.testRealm; + testRealmHttpServer = args.testRealmHttpServer; + request = args.request; + dir = args.dir; + } + hooks.beforeEach(async function () { + dir = dirSync(); + copySync(join(__dirname, '..', 'cards'), dir.name); + }); + hooks.afterEach(async function () { + await closeServer(testRealmHttpServer); + resetCatalogRealms(); + }); + describe('public readable realm', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + permissions: { + '*': ['read'], + }, + realmURL, + onRealmSetup, + }); + it('serves the request', async function () { + let infoPath = new URL('_info', realmURL).pathname; + let response = await request + .post(infoPath) + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', 'application/vnd.api+json'); + expect(response.status).toBe(200); + expect(response.get('X-boxel-realm-url')).toBe(realmURL.href); + expect(response.get('X-boxel-realm-public-readable')).toBe('true'); + let json = response.body; + expect(json).toEqual({ + data: { + id: realmURL.href, + type: 'realm-info', + attributes: { + ...testRealmInfo, + }, + }, + }); + }); + }); + describe('permissioned realm', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + permissions: { + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + realmURL, + onRealmSetup, + }); + it('401 with invalid JWT', async function () { + let infoPath = new URL('_info', realmURL).pathname; + let response = await request + .post(infoPath) + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', 'application/vnd.api+json'); + expect(response.status).toBe(401); + }); + it('401 without a JWT', async function () { + let infoPath = new URL('_info', realmURL).pathname; + let response = await request + .post(infoPath) + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', 'application/vnd.api+json'); // no Authorization header + expect(response.status).toBe(401); + }); + it('403 without permission', async function () { + let infoPath = new URL('_info', realmURL).pathname; + let response = await request + .post(infoPath) + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'not-a-user')}`); + expect(response.status).toBe(403); + }); + it('200 with permission', async function () { + let infoPath = new URL('_info', realmURL).pathname; + let response = await request + .post(infoPath) + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createJWT(testRealm, '@node-test_realm:localhost', ['read', 'realm-owner'])}`); + expect(response.status).toBe(200); + let json = response.body; + expect(json).toEqual({ + data: { + id: realmURL.href, + type: 'realm-info', + attributes: { + ...testRealmInfo, + visibility: 'private', + }, + }, + }); + }); + }); + describe('shared realm because there is `users` permission', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + permissions: { + users: ['read'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + realmURL, + onRealmSetup, + }); + it('200 with permission', async function () { + let infoPath = new URL('_info', realmURL).pathname; + let response = await request + .post(infoPath) + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'users', ['read'])}`); + expect(response.status).toBe(200); + let json = response.body; + expect(json).toEqual({ + data: { + id: realmURL.href, + type: 'realm-info', + attributes: { + ...testRealmInfo, + visibility: 'shared', + }, + }, + }); + }); + }); + describe('shared realm because there are multiple users', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + permissions: { + bob: ['read'], + jane: ['read'], + john: ['read', 'write'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + realmURL, + onRealmSetup, + }); + it('200 with permission', async function () { + let infoPath = new URL('_info', realmURL).pathname; + let response = await request + .post(infoPath) + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'john', ['read', 'write'])}`); + expect(response.status).toBe(200); + let json = response.body; + expect(json).toEqual({ + data: { + id: realmURL.href, + type: 'realm-info', + attributes: { + ...testRealmInfo, + visibility: 'shared', + }, + }, + }); + }); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/realm-endpoints/invalidate-urls.test.ts b/packages/realm-server/tests-vitest/realm-endpoints/invalidate-urls.test.ts new file mode 100644 index 00000000000..bb7c9aa5c1e --- /dev/null +++ b/packages/realm-server/tests-vitest/realm-endpoints/invalidate-urls.test.ts @@ -0,0 +1,217 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import type { SuperTest, Test } from 'supertest'; +import type { Realm } from '@cardstack/runtime-common'; +import { SupportedMimeType } from '@cardstack/runtime-common'; +import { createJWT, setupPermissionedRealmCached } from '../helpers'; +import type { PgAdapter as TestPgAdapter } from '@cardstack/postgres'; +describe("realm-endpoints/invalidate-urls-test.ts", function () { + describe('Realm-specific Endpoints | POST _invalidate', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let testRealm: Realm; + let request: SuperTest; + let dbAdapter: TestPgAdapter; + function onRealmSetup(args: { + testRealm: Realm; + request: SuperTest; + dbAdapter: TestPgAdapter; + }) { + testRealm = args.testRealm; + request = args.request; + dbAdapter = args.dbAdapter; + } + setupPermissionedRealmCached(hooks, { + permissions: { + writer: ['read', 'write'], + reader: ['read'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + onRealmSetup, + }); + async function aKnownIndexedURL(): Promise { + let rows = (await dbAdapter.execute(`SELECT url + FROM boxel_index + WHERE realm_url = $1 + ORDER BY url + LIMIT 1`, { bind: [testRealm.url] })) as { + url: string; + }[]; + if (!rows[0]?.url) { + throw new Error('expected at least one indexed row in test realm'); + } + return rows[0].url; + } + it('returns 401 without JWT for private realm', async function () { + let response = await request + .post('/_invalidate') + .set('Accept', SupportedMimeType.JSONAPI) + .set('Content-Type', SupportedMimeType.JSONAPI) + .send({ + data: { + type: 'invalidation-request', + attributes: { + urls: [`${testRealm.url}mango`], + }, + }, + }); + expect(response.status).toBe(401); + }); + it('returns 403 for user without write access', async function () { + let response = await request + .post('/_invalidate') + .set('Accept', SupportedMimeType.JSONAPI) + .set('Content-Type', SupportedMimeType.JSONAPI) + .set('Authorization', `Bearer ${createJWT(testRealm, 'reader', ['read'])}`) + .send({ + data: { + type: 'invalidation-request', + attributes: { + urls: [`${testRealm.url}mango`], + }, + }, + }); + expect(response.status).toBe(403); + }); + it('returns 400 for malformed JSON body', async function () { + let response = await request + .post('/_invalidate') + .set('Accept', SupportedMimeType.JSONAPI) + .set('Content-Type', SupportedMimeType.JSONAPI) + .set('Authorization', `Bearer ${createJWT(testRealm, 'writer', ['read', 'write'])}`) + .send('{ nope'); + expect(response.status).toBe(400); + }); + it('returns 400 when urls attribute is missing', async function () { + let response = await request + .post('/_invalidate') + .set('Accept', SupportedMimeType.JSONAPI) + .set('Content-Type', SupportedMimeType.JSONAPI) + .set('Authorization', `Bearer ${createJWT(testRealm, 'writer', ['read', 'write'])}`) + .send({ + data: { + type: 'invalidation-request', + attributes: {}, + }, + }); + expect(response.status).toBe(400); + }); + it('returns 400 when urls attribute is not an array', async function () { + let response = await request + .post('/_invalidate') + .set('Accept', SupportedMimeType.JSONAPI) + .set('Content-Type', SupportedMimeType.JSONAPI) + .set('Authorization', `Bearer ${createJWT(testRealm, 'writer', ['read', 'write'])}`) + .send({ + data: { + type: 'invalidation-request', + attributes: { + urls: `${testRealm.url}mango`, + }, + }, + }); + expect(response.status).toBe(400); + }); + it('returns 400 when urls contains an invalid URL string', async function () { + let response = await request + .post('/_invalidate') + .set('Accept', SupportedMimeType.JSONAPI) + .set('Content-Type', SupportedMimeType.JSONAPI) + .set('Authorization', `Bearer ${createJWT(testRealm, 'writer', ['read', 'write'])}`) + .send({ + data: { + type: 'invalidation-request', + attributes: { + urls: ['not-a-valid-url'], + }, + }, + }); + expect(response.status).toBe(400); + }); + it('returns 400 and does not process when any url is out of realm', async function () { + let initialVersionRows = (await dbAdapter.execute(`SELECT current_version FROM realm_versions WHERE realm_url = $1`, { bind: [testRealm.url] })) as { + current_version: number; + }[]; + let response = await request + .post('/_invalidate') + .set('Accept', SupportedMimeType.JSONAPI) + .set('Content-Type', SupportedMimeType.JSONAPI) + .set('Authorization', `Bearer ${createJWT(testRealm, 'writer', ['read', 'write'])}`) + .send({ + data: { + type: 'invalidation-request', + attributes: { + urls: [ + `${testRealm.url}mango`, + 'https://example.com/not-this-realm/person.gts', + ], + }, + }, + }); + expect(response.status).toBe(400); + let currentVersionRows = (await dbAdapter.execute(`SELECT current_version FROM realm_versions WHERE realm_url = $1`, { bind: [testRealm.url] })) as { + current_version: number; + }[]; + expect(currentVersionRows[0]?.current_version).toBe(initialVersionRows[0]?.current_version); + }); + it('returns 204 and accepts missing urls as pass-through invalidation seeds', async function () { + let indexedURL = await aKnownIndexedURL(); + let response = await request + .post('/_invalidate') + .set('Accept', SupportedMimeType.JSONAPI) + .set('Content-Type', SupportedMimeType.JSONAPI) + .set('Authorization', `Bearer ${createJWT(testRealm, 'writer', ['read', 'write'])}`) + .send({ + data: { + type: 'invalidation-request', + attributes: { + urls: [indexedURL, `${testRealm.url}does-not-exist`], + }, + }, + }); + expect(response.status).toBe(204); + let rows = (await dbAdapter.execute(`SELECT type, realm_version + FROM boxel_index + WHERE realm_url = $1 + AND url = $2`, { bind: [testRealm.url, indexedURL] })) as { + type: 'instance' | 'file'; + realm_version: number; + }[]; + expect(rows.length > 0).toBe(true); + expect(rows.every((row) => row.realm_version === 2)).toBe(true); + }); + it('returns 204 and silently deduplicates urls', async function () { + let indexedURL = await aKnownIndexedURL(); + let response = await request + .post('/_invalidate') + .set('Accept', SupportedMimeType.JSONAPI) + .set('Content-Type', SupportedMimeType.JSONAPI) + .set('Authorization', `Bearer ${createJWT(testRealm, 'writer', ['read', 'write'])}`) + .send({ + data: { + type: 'invalidation-request', + attributes: { + urls: [indexedURL, indexedURL], + }, + }, + }); + expect(response.status).toBe(204); + let targetRows = (await dbAdapter.execute(`SELECT realm_version + FROM boxel_index + WHERE realm_url = $1 + AND url = $2`, { bind: [testRealm.url, indexedURL] })) as { + realm_version: number; + }[]; + expect(targetRows.length > 0).toBe(true); + expect(targetRows.every((row) => row.realm_version === 2)).toBe(true); + let versionRows = (await dbAdapter.execute(`SELECT current_version FROM realm_versions WHERE realm_url = $1`, { bind: [testRealm.url] })) as { + current_version: number; + }[]; + expect(versionRows[0]?.current_version).toBe(2); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/realm-endpoints/lint.test.ts b/packages/realm-server/tests-vitest/realm-endpoints/lint.test.ts new file mode 100644 index 00000000000..35e4a0cf414 --- /dev/null +++ b/packages/realm-server/tests-vitest/realm-endpoints/lint.test.ts @@ -0,0 +1,497 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import type { Test, SuperTest } from 'supertest'; +import type { Realm } from '@cardstack/runtime-common'; +import { setupPermissionedRealmCached, createJWT } from '../helpers'; +import { benchmarkOperation, createConcurrentTestData, createErrorTestCases, createPerformanceAssertion, } from '../helpers/prettier-test-utils'; +import '@cardstack/runtime-common/helpers/code-equality-assertion'; +describe("realm-endpoints/lint-test.ts", function () { + describe('Realm-specific Endpoints | POST _lint', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let testRealm: Realm; + let request: SuperTest; + function onRealmSetup(args: { + testRealm: Realm; + testRealmPath: string; + request: SuperTest; + }) { + testRealm = args.testRealm; + request = args.request; + } + setupPermissionedRealmCached(hooks, { + permissions: { + john: ['read', 'write'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + onRealmSetup, + }); + it('parser inference logic is correct', function () { + function inferPrettierParser(filename: string): string { + const parsers = { + '.gts': 'glimmer', + '.ts': 'typescript', + '.js': 'babel', + }; + const extension = filename.substring(filename.lastIndexOf('.')); + return parsers[extension as keyof typeof parsers] || 'glimmer'; + } + expect(inferPrettierParser('test.gts')).toBe('glimmer'); + expect(inferPrettierParser('test.ts')).toBe('typescript'); + expect(inferPrettierParser('test.js')).toBe('babel'); + expect(inferPrettierParser('unknown.ext')).toBe('glimmer'); + }); + it('existing lint consumers continue to work', async function () { + const errors: string[] = []; + const results: any[] = []; + const testCases = [ + { + name: 'Plain text request', + source: `import { CardDef } from 'https://cardstack.com/base/card-api'; +export class MyCard extends CardDef { + @field name = contains(StringField); +}`, + expectedIncludes: ['import StringField from'], + }, + { + name: 'Template invokable fix', + source: `import MyComponent from 'somewhere'; +`, + expectedIncludes: ['import { eq } from'], + }, + ]; + for (const testCase of testCases) { + try { + const response = await request + .post('/_lint') + .set('Authorization', `Bearer ${createJWT(testRealm, 'john', ['read', 'write'])}`) + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', 'application/json') + .send(testCase.source); + if (response.status !== 200) { + errors.push(`${testCase.name} failed with status ${response.status}`); + continue; + } + const result = JSON.parse(response.text); + if (!result.output || typeof result.output !== 'string') { + errors.push(`${testCase.name} did not return valid output`); + continue; + } + // Check for expected fixes + for (const expectedInclude of testCase.expectedIncludes) { + if (!result.output.includes(expectedInclude)) { + errors.push(`${testCase.name} missing expected content: ${expectedInclude}`); + } + } + results.push({ + name: testCase.name, + input: testCase.source, + output: result.output, + success: true, + }); + } + catch (error) { + errors.push(`${testCase.name} error: ${(error as Error).message}`); + results.push({ + name: testCase.name, + input: testCase.source, + output: null, + success: false, + error: (error as Error).message, + }); + } + } + const compatibility = { + isCompatible: errors.length === 0, + errors, + results, + }; + expect(compatibility.isCompatible).toBeTruthy(); + expect(compatibility.results.length > 0).toBeTruthy(); + for (const result of compatibility.results) { + expect(result.success).toBeTruthy(); + } + }); + it('does not flag explicit this parameters', async function () { + const source = `const computeVia = function (this: { title: string }) { + return this.title; +}; + +computeVia.call({ title: 'Tic Tac Toe' }); +`; + const response = await request + .post('/_lint') + .set('Authorization', `Bearer ${createJWT(testRealm, 'john', ['read', 'write'])}`) + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', 'application/json') + .set('X-Filename', 'example.ts') + .send(source); + expect(response.status).toBe(200); + const result = JSON.parse(response.text); + const messages = Array.isArray(result.messages) ? result.messages : []; + expect(messages).toEqual([]); + }); + it('handles various error scenarios gracefully', async function () { + const errorCases = createErrorTestCases(); + for (const [_caseKey, errorCase] of Object.entries(errorCases)) { + try { + const response = await request + .post('/_lint') + .set('Authorization', `Bearer ${createJWT(testRealm, 'john', ['read', 'write'])}`) + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', 'application/json') + .send(errorCase.source); + // Should not fail completely, should return 200 with fallback behavior + expect(response.status).toBe(200); + const result = JSON.parse(response.text); + expect(result.output).toBeTruthy(); + } + catch (error) { + expect(false).toBeTruthy(); + } + } + }); + it('fallback to eslint-only when prettier fails', async function () { + const malformedSource = `import { CardDef } from 'https://cardstack.com/base/card-api'; +export class MyCard extends CardDef { + @field name = contains(StringField); + // Malformed syntax that prettier cannot parse + @field }{ invalid +}`; + const response = await request + .post('/_lint') + .set('Authorization', `Bearer ${createJWT(testRealm, 'john', ['read', 'write'])}`) + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', 'application/json') + .send(malformedSource); + expect(response.status).toBe(200); + const result = JSON.parse(response.text); + expect(result.output).toBeTruthy(); + // Should still apply ESLint fixes even if prettier fails + // NOTE: This test will fail until implementation is complete + // For now, we're testing the infrastructure expectation + if (result.output.includes('import StringField from')) { + expect(result.output.includes('import StringField from')).toBeTruthy(); + expect(result.output.includes('import { CardDef, field, contains }')).toBeTruthy(); + } + else { + // Current behavior - just verify we get some output + expect(typeof result.output).toBe('string'); + // Skip the ESLint fix checks for now - they will be implemented later + } + }); + it('lint operations complete within performance threshold', async function () { + const testSource = `import { CardDef } from 'https://cardstack.com/base/card-api'; +export class MyCard extends CardDef { + @field name = contains(StringField); +}`; + const benchmark = await benchmarkOperation('lint-operation', async () => { + const response = await request + .post('/_lint') + .set('Authorization', `Bearer ${createJWT(testRealm, 'john', ['read', 'write'])}`) + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', 'application/json') + .send(testSource); + return response; + }, 20); + const performance = { + threshold: 200, // 200ms for CI environment, + iterations: 50, + assertion: createPerformanceAssertion(200), + }; + performance.assertion(benchmark, assert); + }); + it('concurrent requests are handled correctly', async function () { + const testData = createConcurrentTestData(3); + const promises = testData.map(async (data) => { + const response = await request + .post('/_lint') + .set('Authorization', `Bearer ${createJWT(testRealm, 'john', ['read', 'write'])}`) + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', 'application/json') + .send(data.source); + return { + data, + response, + }; + }); + const results = await Promise.all(promises); + for (const result of results) { + expect(result.response.status).toBe(200); + const responseData = JSON.parse(result.response.text); + expect(responseData.output).toBeTruthy(); + } + }); + it('memory usage during lint operations', async function () { + const initialMemory = process.memoryUsage().heapUsed; + const testSource = `import { CardDef } from 'https://cardstack.com/base/card-api'; +export class MyCard extends CardDef { + @field name = contains(StringField); +}`; + // Run multiple lint operations to test memory usage + const operations = []; + for (let i = 0; i < 10; i++) { + operations.push(request + .post('/_lint') + .set('Authorization', `Bearer ${createJWT(testRealm, 'john', ['read', 'write'])}`) + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', 'application/json') + .send(testSource)); + } + const results = await Promise.all(operations); + // Verify all operations succeeded + results.forEach((result, index) => { + expect(result.status).toBe(200); + }); + const finalMemory = process.memoryUsage().heapUsed; + const memoryIncrease = finalMemory - initialMemory; + // Memory increase should be reasonable (less than 45MB for lint operations) + expect(memoryIncrease < 45 * 1024 * 1024).toBeTruthy(); + }); + it('applies prettier formatting after eslint fixes', async function () { + let response = await request + .post('/_lint') + .set('Authorization', `Bearer ${createJWT(testRealm, 'john', ['read', 'write'])}`) + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', 'application/json') + .send(`import { CardDef } from 'https://cardstack.com/base/card-api'; +import { CardDef } from 'https://cardstack.com/base/card-api'; +export class MyCard extends CardDef { +@field name = contains(StringField) +} +`); + expect(response.status).toBe(200); + let responseJson = JSON.parse(response.text); + expect(responseJson.output).toBe(`import StringField from 'https://cardstack.com/base/string'; +import { CardDef, field, contains } from 'https://cardstack.com/base/card-api'; + +export class MyCard extends CardDef { + @field name = contains(StringField); +} +`); + }); + it('formats GTS template content properly', async function () { + let response = await request + .post('/_lint') + .set('Authorization', `Bearer ${createJWT(testRealm, 'john', ['read', 'write'])}`) + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', 'application/json') + .send(`import MyComponent from 'somewhere'; + +`); + expect(response.status).toBe(200); + let responseJson = JSON.parse(response.text); + expect(responseJson.output).toBe(`import { eq } from '@cardstack/boxel-ui/helpers'; +import MyComponent from 'somewhere'; + +`); + }); + it('handles mixed JavaScript and template content', async function () { + let response = await request + .post('/_lint') + .set('Authorization', `Bearer ${createJWT(testRealm, 'john', ['read', 'write'])}`) + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', 'application/json') + .send(`import { CardDef } from 'https://cardstack.com/base/card-api'; +import MyComponent from 'somewhere'; + +export class MyCard extends CardDef { +@field name = contains(StringField); +} + + +`); + expect(response.status).toBe(200); + let responseJson = JSON.parse(response.text); + expect(responseJson.output).toBe(`import { eq } from '@cardstack/boxel-ui/helpers'; +import StringField from 'https://cardstack.com/base/string'; +import { CardDef, field, contains } from 'https://cardstack.com/base/card-api'; +import MyComponent from 'somewhere'; + +export class MyCard extends CardDef { + @field name = contains(StringField); +} + + +`); + }); + it('formats import statements properly', async function () { + let response = await request + .post('/_lint') + .set('Authorization', `Bearer ${createJWT(testRealm, 'john', ['read', 'write'])}`) + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', 'application/json') + .send(`import{CardDef}from 'https://cardstack.com/base/card-api'; +import{StringField}from 'https://cardstack.com/base/string'; +export class MyCard extends CardDef{ +@field name=contains(StringField); +}`); + expect(response.status).toBe(200); + let responseJson = JSON.parse(response.text); + expect(responseJson.output).toBe(`import { CardDef, field, contains } from 'https://cardstack.com/base/card-api'; +import { StringField } from 'https://cardstack.com/base/string'; +export class MyCard extends CardDef { + @field name = contains(StringField); +} +`); + }); + it('warns about position: fixed in card CSS', async function () { + let response = await request + .post('/_lint') + .set('Authorization', `Bearer ${createJWT(testRealm, 'john', ['read', 'write'])}`) + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', 'application/json') + .send(`import { CardDef } from 'https://cardstack.com/base/card-api'; +export class MyCard extends CardDef { +} + +`); + expect(response.status).toBe(200); + let responseJson = JSON.parse(response.text); + let messages = responseJson.messages; + let positionFixedWarning = messages.find((m: any) => m.ruleId === '@cardstack/boxel/no-css-position-fixed'); + expect(positionFixedWarning).toBeTruthy(); + expect(positionFixedWarning.severity).toBe(1); + }); + it('does not warn about position: fixed when not present', async function () { + let response = await request + .post('/_lint') + .set('Authorization', `Bearer ${createJWT(testRealm, 'john', ['read', 'write'])}`) + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', 'application/json') + .send(`import { CardDef } from 'https://cardstack.com/base/card-api'; +export class MyCard extends CardDef { +} + +`); + expect(response.status).toBe(200); + let responseJson = JSON.parse(response.text); + let messages = responseJson.messages; + let positionFixedWarning = messages.find((m: any) => m.ruleId === '@cardstack/boxel/no-css-position-fixed'); + expect(positionFixedWarning).toBeFalsy(); + }); + it('handles nested template structures correctly', async function () { + let response = await request + .post('/_lint') + .set('Authorization', `Bearer ${createJWT(testRealm, 'john', ['read', 'write'])}`) + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', 'application/json') + .send(`import MyComponent from 'somewhere'; +`); + expect(response.status).toBe(200); + let responseJson = JSON.parse(response.text); + expect(responseJson.output).toBe(`import { eq } from '@cardstack/boxel-ui/helpers'; +import MyComponent from 'somewhere'; + +`); + }); + it('respects prettier configuration settings', async function () { + let response = await request + .post('/_lint') + .set('Authorization', `Bearer ${createJWT(testRealm, 'john', ['read', 'write'])}`) + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', 'application/json') + .send(`import { CardDef } from "https://cardstack.com/base/card-api"; +export class MyCard extends CardDef { +@field name = contains(StringField, { cardDescription: "test description" }); +}`); + expect(response.status).toBe(200); + let responseJson = JSON.parse(response.text); + // Should use single quotes based on prettier configuration + expect(responseJson.output.includes("'https://cardstack.com/base/string'")).toBeTruthy(); + expect(responseJson.output.includes("'https://cardstack.com/base/card-api'")).toBeTruthy(); + expect(responseJson.output.includes("'test description'")).toBeTruthy(); + }); + it('supports X-Filename header for parser detection', async function () { + let response = await request + .post('/_lint') + .set('Authorization', `Bearer ${createJWT(testRealm, 'john', ['read', 'write'])}`) + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', 'application/json') + .set('X-Filename', 'my-card.gts') + .send(`import { CardDef } from 'https://cardstack.com/base/card-api'; +export class MyCard extends CardDef { +@field name = contains(StringField); +} +`); + expect(response.status).toBe(200); + let responseJson = JSON.parse(response.text); + expect(responseJson.output).toBe(`import StringField from 'https://cardstack.com/base/string'; +import { CardDef, field, contains } from 'https://cardstack.com/base/card-api'; +export class MyCard extends CardDef { + @field name = contains(StringField); +} +`); + }); + it('handles large files within reasonable time', async function () { + // Create a large file with many imports and classes + let largeContent = `import { CardDef } from 'https://cardstack.com/base/card-api';\n`; + for (let i = 0; i < 100; i++) { + largeContent += ` +export class MyCard${i} extends CardDef { +@field name${i} = contains(StringField); +@field email${i} = contains(EmailField); +} +`; + } + let startTime = Date.now(); + let response = await request + .post('/_lint') + .set('Authorization', `Bearer ${createJWT(testRealm, 'john', ['read', 'write'])}`) + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', 'application/json') + .send(largeContent); + let endTime = Date.now(); + let duration = endTime - startTime; + expect(response.status).toBe(200); + expect(duration < 5000).toBeTruthy(); + let responseJson = JSON.parse(response.text); + expect(responseJson.output.includes('import StringField from')).toBeTruthy(); + expect(responseJson.output.includes(' @field name0 = contains(StringField);')).toBeTruthy(); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/realm-endpoints/mtimes.test.ts b/packages/realm-server/tests-vitest/realm-endpoints/mtimes.test.ts new file mode 100644 index 00000000000..d0a4b030057 --- /dev/null +++ b/packages/realm-server/tests-vitest/realm-endpoints/mtimes.test.ts @@ -0,0 +1,61 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import type { Test, SuperTest } from 'supertest'; +import type { Realm } from '@cardstack/runtime-common'; +import { setupPermissionedRealmCached, mtimes, testRealmHref, testRealmURL, createJWT, } from '../helpers'; +import '@cardstack/runtime-common/helpers/code-equality-assertion'; +describe("realm-endpoints/mtimes-test.ts", function () { + describe('Realm-specific Endpoints | GET _mtimes', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let testRealm: Realm; + let testRealmPath: string; + let request: SuperTest; + function onRealmSetup(args: { + testRealm: Realm; + testRealmPath: string; + request: SuperTest; + }) { + testRealm = args.testRealm; + testRealmPath = args.testRealmPath; + request = args.request; + } + setupPermissionedRealmCached(hooks, { + permissions: { + mary: ['read'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + onRealmSetup, + }); + it('non read permission GET /_mtimes', async function () { + let response = await request + .get('/_mtimes') + .set('Accept', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'not-mary')}`); + expect(response.status).toBe(403); + }); + it('read permission GET /_mtimes', async function () { + let expectedMtimes = mtimes(testRealmPath, testRealmURL); + delete expectedMtimes[`${testRealmURL}.realm.json`]; + let response = await request + .get('/_mtimes') + .set('Accept', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'mary', ['read'])}`); + expect(response.status).toBe(200); + let json = response.body; + expect(json).toEqual({ + data: { + type: 'mtimes', + id: testRealmHref, + attributes: { + mtimes: expectedMtimes, + }, + }, + }); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/realm-endpoints/permissions.test.ts b/packages/realm-server/tests-vitest/realm-endpoints/permissions.test.ts new file mode 100644 index 00000000000..4f88882d5bd --- /dev/null +++ b/packages/realm-server/tests-vitest/realm-endpoints/permissions.test.ts @@ -0,0 +1,331 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { fileURLToPath } from "url"; +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import type { Test, SuperTest } from 'supertest'; +import { join, dirname } from 'path'; +import { dirSync, type DirResult } from 'tmp'; +import { copySync } from 'fs-extra'; +import type { Realm } from '@cardstack/runtime-common'; +import { fetchRealmPermissions } from '@cardstack/runtime-common'; +import { setupPermissionedRealmCached, testRealmHref, testRealmURL, createJWT, } from '../helpers'; +import '@cardstack/runtime-common/helpers/code-equality-assertion'; +import type { PgAdapter } from '@cardstack/postgres'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +describe("realm-endpoints/permissions-test.ts", function () { + describe('Realm-specific Endpoints | _permissions', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let testRealm: Realm; + let request: SuperTest; + let dir: DirResult; + let dbAdapter: PgAdapter; + function onRealmSetup(args: { + testRealm: Realm; + request: SuperTest; + dbAdapter: PgAdapter; + dir: DirResult; + }) { + testRealm = args.testRealm; + request = args.request; + dbAdapter = args.dbAdapter; + dir = args.dir; + } + hooks.beforeEach(async function () { + dir = dirSync(); + copySync(join(__dirname, '..', 'cards'), dir.name); + }); + describe('permissions requests', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + fileSystem: {}, + permissions: { + mary: ['read', 'write', 'realm-owner'], + bob: ['read', 'write'], + }, + onRealmSetup, + }); + it('non-owner GET /_permissions', async function () { + let response = await request + .get('/_permissions') + .set('Accept', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'bob', ['read', 'write'])}`); + expect(response.status).toBe(403); + }); + it('realm-owner GET /_permissions', async function () { + let response = await request + .get('/_permissions') + .set('Accept', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'mary', [ + 'read', + 'write', + 'realm-owner', + ])}`); + expect(response.status).toBe(200); + let json = response.body; + expect(json).toEqual({ + data: { + type: 'permissions', + id: testRealmHref, + attributes: { + permissions: { + mary: ['read', 'write', 'realm-owner'], + bob: ['read', 'write'], + }, + }, + }, + }); + }); + it('non-owner PATCH /_permissions', async function () { + let response = await request + .patch('/_permissions') + .set('Accept', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'bob', ['read', 'write'])}`) + .send({ + data: { + id: testRealmHref, + type: 'permissions', + attributes: { + permissions: { + mango: ['read'], + }, + }, + }, + }); + expect(response.status).toBe(403); + let permissions = await fetchRealmPermissions(dbAdapter, testRealmURL); + expect(permissions).toEqual({ + mary: ['read', 'write', 'realm-owner'], + bob: ['read', 'write'], + }); + }); + it('realm-owner PATCH /_permissions', async function () { + let response = await request + .patch('/_permissions') + .set('Accept', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'mary', [ + 'read', + 'write', + 'realm-owner', + ])}`) + .send({ + data: { + id: testRealmHref, + type: 'permissions', + attributes: { + permissions: { + mango: ['read'], + }, + }, + }, + }); + expect(response.status).toBe(200); + let json = response.body; + expect(json).toEqual({ + data: { + type: 'permissions', + id: testRealmHref, + attributes: { + permissions: { + mary: ['read', 'write', 'realm-owner'], + bob: ['read', 'write'], + mango: ['read'], + }, + }, + }, + }); + let permissions = await fetchRealmPermissions(dbAdapter, testRealmURL); + expect(permissions).toEqual({ + mary: ['read', 'write', 'realm-owner'], + bob: ['read', 'write'], + mango: ['read'], + }); + }); + it('remove permissions from PATCH /_permissions using empty array', async function () { + let response = await request + .patch('/_permissions') + .set('Accept', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'mary', [ + 'read', + 'write', + 'realm-owner', + ])}`) + .send({ + data: { + id: testRealmHref, + type: 'permissions', + attributes: { + permissions: { + bob: [], + }, + }, + }, + }); + expect(response.status).toBe(200); + let json = response.body; + expect(json).toEqual({ + data: { + type: 'permissions', + id: testRealmHref, + attributes: { + permissions: { + mary: ['read', 'write', 'realm-owner'], + }, + }, + }, + }); + let permissions = await fetchRealmPermissions(dbAdapter, testRealmURL); + expect(permissions).toEqual({ + mary: ['read', 'write', 'realm-owner'], + }); + }); + it('remove permissions from PATCH /_permissions using null', async function () { + let response = await request + .patch('/_permissions') + .set('Accept', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'mary', [ + 'read', + 'write', + 'realm-owner', + ])}`) + .send({ + data: { + id: testRealmHref, + type: 'permissions', + attributes: { + permissions: { + bob: null, + }, + }, + }, + }); + expect(response.status).toBe(200); + let json = response.body; + expect(json).toEqual({ + data: { + type: 'permissions', + id: testRealmHref, + attributes: { + permissions: { + mary: ['read', 'write', 'realm-owner'], + }, + }, + }, + }); + let permissions = await fetchRealmPermissions(dbAdapter, testRealmURL); + expect(permissions).toEqual({ + mary: ['read', 'write', 'realm-owner'], + }); + }); + it('cannot remove realm-owner permissions from PATCH /_permissions', async function () { + let response = await request + .patch('/_permissions') + .set('Accept', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'mary', [ + 'read', + 'write', + 'realm-owner', + ])}`) + .send({ + data: { + id: testRealmHref, + type: 'permissions', + attributes: { + permissions: { + mary: [], + }, + }, + }, + }); + expect(response.status).toBe(400); + let permissions = await fetchRealmPermissions(dbAdapter, testRealmURL); + expect(permissions).toEqual({ + mary: ['read', 'write', 'realm-owner'], + bob: ['read', 'write'], + }); + }); + it('cannot add realm-owner permissions from PATCH /_permissions', async function () { + let response = await request + .patch('/_permissions') + .set('Accept', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'mary', [ + 'read', + 'write', + 'realm-owner', + ])}`) + .send({ + data: { + id: testRealmHref, + type: 'permissions', + attributes: { + permissions: { + mango: ['realm-owner', 'write', 'read'], + }, + }, + }, + }); + expect(response.status).toBe(400); + let permissions = await fetchRealmPermissions(dbAdapter, testRealmURL); + expect(permissions).toEqual({ + mary: ['read', 'write', 'realm-owner'], + bob: ['read', 'write'], + }); + }); + it('receive 400 error on invalid JSON API', async function () { + let response = await request + .patch('/_permissions') + .set('Accept', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'mary', [ + 'read', + 'write', + 'realm-owner', + ])}`) + .send({ + data: { nothing: null }, + }); + expect(response.status).toBe(400); + let permissions = await fetchRealmPermissions(dbAdapter, testRealmURL); + expect(permissions).toEqual({ + mary: ['read', 'write', 'realm-owner'], + bob: ['read', 'write'], + }); + }); + it('receive 400 error on invalid permissions shape', async function () { + let response = await request + .patch('/_permissions') + .set('Accept', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'mary', [ + 'read', + 'write', + 'realm-owner', + ])}`) + .send({ + data: { + id: testRealmHref, + type: 'permissions', + attributes: { + permissions: { + larry: { read: true }, + }, + }, + }, + }); + expect(response.status).toBe(400); + let permissions = await fetchRealmPermissions(dbAdapter, testRealmURL); + expect(permissions).toEqual({ + mary: ['read', 'write', 'realm-owner'], + bob: ['read', 'write'], + }); + }); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/realm-endpoints/publishability.test.ts b/packages/realm-server/tests-vitest/realm-endpoints/publishability.test.ts new file mode 100644 index 00000000000..8aa2d5facc4 --- /dev/null +++ b/packages/realm-server/tests-vitest/realm-endpoints/publishability.test.ts @@ -0,0 +1,628 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import supertest from 'supertest'; +import type { SuperTest, Test } from 'supertest'; +import type { Realm } from '@cardstack/runtime-common'; +import { DEFAULT_PERMISSIONS, SupportedMimeType, } from '@cardstack/runtime-common'; +import { createJWT, setupPermissionedRealmCached, setupPermissionedRealmsCached, testRealmURLFor, type RealmRequest, withRealmPath, } from '../helpers'; +const ownerUserId = '@mango:localhost'; +describe("realm-endpoints/publishability-test.ts", function () { + describe('with a publishable realm', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let realmURL = testRealmURLFor('test/'); + let request: RealmRequest; + let testRealm: Realm; + setupPermissionedRealmCached(hooks, { + permissions: { + [ownerUserId]: ['read', 'write', 'realm-owner'], + }, + realmURL, + fileSystem: { + 'source-card.gts': ` + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + import CreateAiAssistantRoomCommand from "@cardstack/boxel-host/commands/create-ai-assistant-room"; + + // Ensure data: dependencies are ignored + import "data:text/javascript,export%20default%200;"; + + export class SourceCard extends CardDef { + @field label = contains(StringField); + + command = CreateAiAssistantRoomCommand; + + + } + `, + 'source-instance.json': { + data: { + type: 'card', + attributes: { + label: 'Public Label', + }, + meta: { + adoptsFrom: { + module: './source-card', + name: 'SourceCard', + }, + }, + }, + }, + }, + onRealmSetup({ testRealm: realm, request: req }) { + testRealm = realm; + request = withRealmPath(req, realmURL); + }, + }); + it('reports publishable realm when there are no private dependencies', async function () { + let response = await request + .get('/_publishability') + .set('Accept', SupportedMimeType.JSONAPI) + .set('Authorization', `Bearer ${createJWT(testRealm, ownerUserId, DEFAULT_PERMISSIONS)}`); + expect(response.status).toBe(200); + expect(response.body.data.attributes.publishable).toBe(true); + expect(response.body.data.type).toBe('realm-publishability'); + expect(response.body.data.attributes.violations).toEqual([]); + let warningTypes = response.body.data.attributes.warningTypes ?? []; + expect(warningTypes).toEqual([]); + }); + }); + describe('with error documents', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let sourceRealm: Realm; + let request: SuperTest; + let sourceRealmURL = new URL('http://127.0.0.1:4800/source/'); + let dbAdapter: import('@cardstack/postgres').PgAdapter; + setupPermissionedRealmsCached(hooks, { + realms: [ + { + realmURL: sourceRealmURL.href, + permissions: { + [ownerUserId]: DEFAULT_PERMISSIONS, + }, + fileSystem: { + 'broken-card.gts': ` + import { CardDef, field, contains } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + // Intentionally broken: references an undefined symbol + export class BrokenCard extends CardDef { + @field title = contains(StringField); + } + `, + 'broken-instance.json': { + data: { + type: 'card', + attributes: { + cardTitle: 'Broken', + }, + meta: { + adoptsFrom: { + module: './broken-card.gts', + name: 'BrokenCard', + }, + }, + }, + }, + }, + }, + ], + onRealmSetup({ realms, dbAdapter: adapter }) { + dbAdapter = adapter; + sourceRealm = realms.find(({ realm }) => realm.url === sourceRealmURL.href)!.realm; + request = supertest(realms.find(({ realm }) => realm.url === sourceRealmURL.href)! + .realmHttpServer); + }, + }); + it('marks realm as not publishable when error documents exist', async function () { + // Ensure realm is indexed so that any broken cards are reflected in boxel_index + await sourceRealm.realmIndexUpdater.fullIndex(); + // Force an error entry into the index to simulate a failed card + let errorDoc = { + message: 'render failed', + status: 500, + additionalErrors: null, + }; + let cardURL = `${sourceRealm.url}broken-instance.json`; + for (let table of ['boxel_index', 'boxel_index_working']) { + await dbAdapter.execute(`UPDATE ${table} + SET has_error = TRUE, error_doc = $1::jsonb + WHERE url = $2 AND type = 'instance'`, { + bind: [JSON.stringify(errorDoc), cardURL], + }); + } + let response = await request + .get(`${new URL(sourceRealm.url).pathname}_publishability`) + .set('Accept', SupportedMimeType.JSONAPI) + .set('Authorization', `Bearer ${createJWT(sourceRealm, ownerUserId, DEFAULT_PERMISSIONS)}`); + expect(response.status).toBe(200); + expect(response.body.data.attributes.publishable).toBe(false); + expect(Array.isArray(response.body.data.attributes.violations)).toBeTruthy(); + expect(response.body.data.attributes.violations.some((violation: any) => violation.kind === 'error-document' && + violation.resource === `${sourceRealm.url}broken-instance.json`)).toBeTruthy(); + expect((response.body.data.attributes.warningTypes ?? []).sort()).toEqual(['has-error-card-documents']); + }); + }); + describe('with additional realms', function () { + describe('lists direct dependencies on private realms', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let sourceRealm: Realm; + let privateRealm: Realm; + let request: SuperTest; + let sourceRealmURL = new URL('http://127.0.0.1:4700/source/'); + let privateRealmURL = new URL('http://127.0.0.1:4701/private/'); + setupPermissionedRealmsCached(hooks, { + realms: [ + { + realmURL: privateRealmURL.href, + permissions: { + [ownerUserId]: DEFAULT_PERMISSIONS, + }, + fileSystem: { + 'secret-card.gts': ` + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class SecretCard extends CardDef { + @field name = contains(StringField); + } + `, + }, + }, + { + realmURL: sourceRealmURL.href, + permissions: { + [ownerUserId]: DEFAULT_PERMISSIONS, + }, + fileSystem: { + 'source-card.gts': ` + import { + contains, + field, + linksTo, + CardDef, + } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + import { SecretCard } from "${privateRealmURL}secret-card"; + + export class SourceCard extends CardDef { + @field label = contains(StringField); + @field secret = linksTo(() => SecretCard); + } + `, + 'source-instance.json': { + data: { + type: 'card', + attributes: { + label: 'Secret label', + }, + meta: { + adoptsFrom: { + module: './source-card', + name: 'SourceCard', + }, + }, + }, + }, + }, + }, + ], + onRealmSetup({ realms }) { + sourceRealm = realms.find(({ realm }) => realm.url === sourceRealmURL.href)!.realm; + privateRealm = realms.find(({ realm }) => realm.url === privateRealmURL.href)!.realm; + request = supertest(realms.find(({ realm }) => realm.url === sourceRealmURL.href)! + .realmHttpServer); + }, + }); + it('lists direct dependencies on private realms', async function () { + let response = await request + .get(`${new URL(sourceRealm.url).pathname}_publishability`) + .set('Accept', SupportedMimeType.JSONAPI) + .set('Authorization', `Bearer ${createJWT(sourceRealm, ownerUserId, DEFAULT_PERMISSIONS)}`); + expect(response.status).toBe(200); + expect(response.body.data.attributes.publishable).toBe(false); + expect(response.body.data.attributes.violations.length).toBe(1); + let warningTypes = response.body.data.attributes.warningTypes ?? []; + expect(warningTypes).toEqual(['has-private-dependencies']); + let violation = response.body.data.attributes.violations[0]; + expect(violation.resource).toBe(`${sourceRealm.url}source-instance.json`); + expect(violation.externalDependencies.some((dep: any) => String(dep.dependency).startsWith(`${privateRealm.url}secret-card`))).toBe(true); + expect(violation.externalDependencies.every((dep: any) => dep.realmURL === privateRealm.url)).toBe(true); + }); + }); + describe('for a realm with private dependencies referenced through local modules', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let sourceRealm: Realm; + let request: SuperTest; + let sourceRealmURL = new URL('http://127.0.0.1:4462/source/'); + let privateRealmURL = new URL('http://127.0.0.1:4463/private/'); + setupPermissionedRealmsCached(hooks, { + realms: [ + { + realmURL: privateRealmURL.href, + permissions: { + [ownerUserId]: DEFAULT_PERMISSIONS, + }, + fileSystem: { + 'secret-card.gts': ` + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class SecretCard extends CardDef { + @field name = contains(StringField); + } + `, + }, + }, + { + realmURL: sourceRealmURL.href, + permissions: { + [ownerUserId]: DEFAULT_PERMISSIONS, + }, + fileSystem: { + 'helper-card.gts': ` + import { + contains, + field, + linksTo, + CardDef, + } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + import { SecretCard } from "${privateRealmURL}secret-card"; + + export class HelperCard extends CardDef { + @field label = contains(StringField); + @field secret = linksTo(() => SecretCard); + } + `, + 'source-card.gts': ` + import { + contains, + field, + linksTo, + CardDef, + } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + import { HelperCard } from "./helper-card"; + + export class SourceCard extends CardDef { + @field label = contains(StringField); + @field helper = linksTo(() => HelperCard); + } + `, + 'source-instance.json': { + data: { + type: 'card', + attributes: { + label: 'Local helper label', + }, + meta: { + adoptsFrom: { + module: './source-card', + name: 'SourceCard', + }, + }, + }, + }, + }, + }, + ], + onRealmSetup({ realms }) { + sourceRealm = realms.find(({ realm }) => realm.url === sourceRealmURL.href)!.realm; + request = supertest(realms.find(({ realm }) => realm.url === sourceRealmURL.href)! + .realmHttpServer); + }, + }); + it('reports them', async function () { + let response = await request + .get(`${new URL(sourceRealm.url).pathname}_publishability`) + .set('Accept', SupportedMimeType.JSONAPI) + .set('Authorization', `Bearer ${createJWT(sourceRealm, ownerUserId, DEFAULT_PERMISSIONS)}`); + expect(response.status).toBe(200); + expect(response.body.data.attributes.publishable).toBe(false); + expect(response.body.data.attributes.violations.length).toBe(1); + let violation = response.body.data.attributes.violations[0]; + expect(violation.resource).toBe(`${sourceRealm.url}source-instance.json`); + expect(violation.externalDependencies.some((dep: any) => String(dep.dependency).startsWith(`${privateRealmURL}secret-card`))).toBe(true); + }); + }); + describe('for a realm with private dependencies served by another realm server', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let sourceRealm: Realm; + let request: SuperTest; + let sourceRealmURL = new URL('http://127.0.0.1:4464/source/'); + let remoteRealmURL = new URL('http://127.0.0.1:4465/remote/'); + setupPermissionedRealmsCached(hooks, { + realms: [ + { + realmURL: remoteRealmURL.href, + permissions: { + [ownerUserId]: DEFAULT_PERMISSIONS, + }, + fileSystem: { + 'secret-card.gts': ` + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class SecretCard extends CardDef { + @field name = contains(StringField); + } + `, + }, + }, + { + realmURL: sourceRealmURL.href, + permissions: { + [ownerUserId]: DEFAULT_PERMISSIONS, + }, + fileSystem: { + 'source-card.gts': ` + import { + contains, + field, + linksTo, + CardDef, + } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + import { SecretCard } from "${remoteRealmURL}secret-card"; + + export class SourceCard extends CardDef { + @field label = contains(StringField); + @field secret = linksTo(() => SecretCard); + } + `, + 'source-instance.json': { + data: { + type: 'card', + attributes: { + label: 'Remote secret label', + }, + meta: { + adoptsFrom: { + module: './source-card', + name: 'SourceCard', + }, + }, + }, + }, + }, + }, + ], + onRealmSetup({ realms }) { + sourceRealm = realms.find(({ realm }) => realm.url === sourceRealmURL.href)!.realm; + request = supertest(realms.find(({ realm }) => realm.url === sourceRealmURL.href)! + .realmHttpServer); + }, + }); + it('reports them', async function () { + let response = await request + .get(`${new URL(sourceRealm.url).pathname}_publishability`) + .set('Accept', SupportedMimeType.JSONAPI) + .set('Authorization', `Bearer ${createJWT(sourceRealm, ownerUserId, DEFAULT_PERMISSIONS)}`); + expect(response.status).toBe(200); + expect(response.body.data.attributes.publishable).toBe(false); + expect(response.body.data.attributes.violations.length).toBe(1); + let remoteViolation = response.body.data.attributes.violations[0]; + expect(remoteViolation.resource).toBe(`${sourceRealm.url}source-instance.json`); + expect(remoteViolation.externalDependencies.some((dep: any) => String(dep.dependency).startsWith(`${remoteRealmURL}secret-card`))).toBe(true); + }); + }); + describe('for a realm with circular dependencies', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let sourceRealm: Realm; + let privateRealm: Realm; + let request: SuperTest; + let sourceRealmURL = new URL('http://127.0.0.1:4466/source/'); + let privateRealmURL = new URL('http://127.0.0.1:4467/private/'); + setupPermissionedRealmsCached(hooks, { + realms: [ + { + realmURL: privateRealmURL.href, + permissions: { + [ownerUserId]: DEFAULT_PERMISSIONS, + }, + fileSystem: { + 'secret-card.gts': ` + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class SecretCard extends CardDef { + @field name = contains(StringField); + } + `, + }, + }, + { + realmURL: sourceRealmURL.href, + permissions: { + [ownerUserId]: DEFAULT_PERMISSIONS, + }, + fileSystem: { + 'source-card.gts': ` + import { + contains, + field, + linksTo, + CardDef, + } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + import { SecretCard } from "${privateRealmURL}secret-card"; + + export class SourceCard extends CardDef { + @field label = contains(StringField); + @field secret = linksTo(() => SecretCard); + } + `, + 'source-instance.json': { + data: { + type: 'card', + attributes: { + label: 'Circular secret label', + }, + meta: { + adoptsFrom: { + module: './source-card', + name: 'SourceCard', + }, + }, + }, + }, + }, + }, + ], + onRealmSetup({ realms }) { + sourceRealm = realms.find(({ realm }) => realm.url === sourceRealmURL.href)!.realm; + privateRealm = realms.find(({ realm }) => realm.url === privateRealmURL.href)!.realm; + request = supertest(realms.find(({ realm }) => realm.url === sourceRealmURL.href)! + .realmHttpServer); + }, + }); + it('publishability can be determined', async function () { + await privateRealm.write('secret-card.gts', ` + import { + contains, + field, + linksTo, + CardDef, + } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + import { SourceCard } from "${sourceRealmURL}source-card"; + + export class SecretCard extends CardDef { + @field name = contains(StringField); + @field source = linksTo(() => SourceCard); + } + `); + await privateRealm.realmIndexUpdater.fullIndex(); + let response = await request + .get(`${new URL(sourceRealm.url).pathname}_publishability`) + .set('Accept', SupportedMimeType.JSONAPI) + .set('Authorization', `Bearer ${createJWT(sourceRealm, ownerUserId, DEFAULT_PERMISSIONS)}`); + expect(response.status).toBe(200); + expect(response.body.data.attributes.publishable).toBe(false); + expect(response.body.data.attributes.violations.length).toBe(1); + }); + }); + describe('ignores deleted instances', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let sourceRealm: Realm; + let request: SuperTest; + let sourceRealmURL = new URL('http://127.0.0.1:4468/source/'); + let privateRealmURL = new URL('http://127.0.0.1:4469/private/'); + setupPermissionedRealmsCached(hooks, { + realms: [ + { + realmURL: privateRealmURL.href, + permissions: { + [ownerUserId]: DEFAULT_PERMISSIONS, + }, + fileSystem: { + 'secret-card.gts': ` + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class SecretCard extends CardDef { + @field name = contains(StringField); + } + `, + }, + }, + { + realmURL: sourceRealmURL.href, + permissions: { + [ownerUserId]: DEFAULT_PERMISSIONS, + }, + fileSystem: { + 'source-card.gts': ` + import { + contains, + field, + linksTo, + CardDef, + } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + import { SecretCard } from "${privateRealmURL}secret-card"; + + export class SourceCard extends CardDef { + @field label = contains(StringField); + @field secret = linksTo(() => SecretCard); + } + `, + 'source-instance.json': { + data: { + type: 'card', + attributes: { + label: 'Temporary', + }, + meta: { + adoptsFrom: { + module: './source-card', + name: 'SourceCard', + }, + }, + }, + }, + }, + }, + ], + onRealmSetup({ realms }) { + sourceRealm = realms.find(({ realm }) => realm.url === sourceRealmURL.href)!.realm; + request = supertest(realms.find(({ realm }) => realm.url === sourceRealmURL.href)! + .realmHttpServer); + }, + }); + it('ignores deleted instances', async function () { + await sourceRealm.delete('source-instance.json'); + await sourceRealm.realmIndexUpdater.fullIndex(); + let response = await request + .get(`${new URL(sourceRealm.url).pathname}_publishability`) + .set('Accept', SupportedMimeType.JSONAPI) + .set('Authorization', `Bearer ${createJWT(sourceRealm, ownerUserId, DEFAULT_PERMISSIONS)}`); + expect(response.status).toBe(200); + expect(response.body.data.attributes.publishable).toBe(true); + expect(response.body.data.attributes.violations).toEqual([]); + }); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/realm-endpoints/reindex.test.ts b/packages/realm-server/tests-vitest/realm-endpoints/reindex.test.ts new file mode 100644 index 00000000000..e1bcb390eae --- /dev/null +++ b/packages/realm-server/tests-vitest/realm-endpoints/reindex.test.ts @@ -0,0 +1,317 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import { join } from 'path'; +import { readFileSync, utimesSync, writeFileSync } from 'fs'; +import type { SuperTest, Test } from 'supertest'; +import type { Realm } from '@cardstack/runtime-common'; +import type { MatrixEvent } from 'https://cardstack.com/base/matrix-event'; +import type { Server } from 'http'; +import type { DirResult } from 'tmp'; +import { createJWT, setupMatrixRoom, setupPermissionedRealmCached, testRealmHref, waitUntil, } from '../helpers'; +import type { PgAdapter as TestPgAdapter } from '@cardstack/postgres'; +const PERSON_CARD_SOURCE = ` +import { + contains, + field, + Component, + CardDef, +} from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; + +export class Person extends CardDef { + static displayName = 'Person'; + @field firstName = contains(StringField); + @field cardTitle = contains(StringField, { + computeVia: function (this: Person) { + return this.firstName; + }, + }); + static isolated = class Isolated extends Component { + + }; +} +`; +const ARTICLE_CARD_SOURCE = ` +import { + contains, + field, + Component, + CardDef, +} from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; + +export class Article extends CardDef { + static displayName = 'Article'; + @field title = contains(StringField); + @field cardTitle = contains(StringField, { + computeVia: function (this: Article) { + return this.title; + }, + }); + static isolated = class Isolated extends Component { + + }; +} +`; +const PERSON_INSTANCE = JSON.stringify({ + data: { + type: 'card', + attributes: { + firstName: 'Mango', + }, + meta: { + adoptsFrom: { + module: './person.gts', + name: 'Person', + }, + }, + }, +}); +const ARTICLE_INSTANCE = JSON.stringify({ + data: { + type: 'card', + attributes: { + title: 'Unchanged Article', + }, + meta: { + adoptsFrom: { + module: './article.gts', + name: 'Article', + }, + }, + }, +}); +describe("realm-endpoints/reindex-test.ts", function () { + describe('Realm-specific Endpoints | POST _reindex and _full-reindex', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let testRealm: Realm; + let request: SuperTest; + let dbAdapter: TestPgAdapter; + let testRealmPath: string; + let testRealmHttpServer: Server; + let dir: DirResult; + function onRealmSetup(args: { + testRealm: Realm; + request: SuperTest; + dbAdapter: TestPgAdapter; + testRealmPath: string; + testRealmHttpServer: Server; + dir: DirResult; + }) { + testRealm = args.testRealm; + request = args.request; + dbAdapter = args.dbAdapter; + testRealmPath = args.testRealmPath; + testRealmHttpServer = args.testRealmHttpServer; + dir = args.dir; + } + setupPermissionedRealmCached(hooks, { + subscribeToRealmEvents: true, + fileSystem: { + 'person.gts': PERSON_CARD_SOURCE, + 'person-1.json': PERSON_INSTANCE, + 'article.gts': ARTICLE_CARD_SOURCE, + 'article-1.json': ARTICLE_INSTANCE, + }, + permissions: { + writer: ['read', 'write'], + reader: ['read'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + onRealmSetup, + }); + let { getMessagesSince } = setupMatrixRoom(hooks, () => ({ + testRealm, + testRealmHttpServer, + request, + dir, + dbAdapter, + })); + async function latestFromScratchJobCount() { + let rows = (await dbAdapter.execute(`SELECT id FROM jobs WHERE job_type = 'from-scratch-index'`)) as { + id: number; + }[]; + return rows.length; + } + async function latestFromScratchJob() { + let [row] = (await dbAdapter.execute(`SELECT id, status, result + FROM jobs + WHERE job_type = 'from-scratch-index' + ORDER BY id DESC + LIMIT 1`)) as { + id: number; + status: string; + result: { + invalidations: string[]; + }; + }[]; + return row; + } + function bumpFileMtime(path: string) { + let contents = readFileSync(path, 'utf8'); + writeFileSync(path, `${contents}\n// reindex test`); + let now = Date.now() / 1000; + utimesSync(path, now + 5, now + 5); + } + function hasMatchingInvalidations(actual: string[], expected: string[]): boolean { + return (JSON.stringify([...actual].sort()) === + JSON.stringify([...expected].sort())); + } + async function waitForIncrementalRealmEvent(since: number, expectedInvalidations: string[]): Promise { + return (await waitUntil(async () => { + let messages = await getMessagesSince(since); + return messages.find((event): event is MatrixEvent & { + content: { + invalidations: string[]; + }; + } => event.type === 'app.boxel.realm-event' && + event.content.eventName === 'index' && + event.content.indexType === 'incremental' && + hasMatchingInvalidations(event.content.invalidations, expectedInvalidations)); + }, { timeout: 20000 })) as MatrixEvent & { + content: { + invalidations: string[]; + }; + }; + } + async function waitForFullRealmEvent(since: number): Promise { + return (await waitUntil(async () => { + let messages = await getMessagesSince(since); + return messages.find((event): event is MatrixEvent & { + content: { + indexType: string; + }; + } => event.type === 'app.boxel.realm-event' && + event.content.eventName === 'index' && + event.content.indexType === 'full'); + }, { timeout: 20000 })) as MatrixEvent & { + content: { + indexType: string; + }; + }; + } + async function establishBaselineIndex() { + await testRealm.reindex(); + } + it('returns 401 without JWT for private realm', async function () { + let response = await request + .post('/_reindex') + .set('Accept', 'application/json'); + expect(response.status).toBe(401); + }); + it('returns 403 for user without write access', async function () { + let response = await request + .post('/_reindex') + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'reader', ['read'])}`); + expect(response.status).toBe(403); + }); + it('returns 401 without JWT for private realm on full reindex', async function () { + let response = await request + .post('/_full-reindex') + .set('Accept', 'application/json'); + expect(response.status).toBe(401); + }); + it('returns 403 for user without write access on full reindex', async function () { + let response = await request + .post('/_full-reindex') + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'reader', ['read'])}`); + expect(response.status).toBe(403); + }); + it('reindex publishes a normal from-scratch job and broadcasts changed invalidations', async function () { + await establishBaselineIndex(); + bumpFileMtime(join(testRealmPath, 'person.gts')); + let initialJobCount = await latestFromScratchJobCount(); + let realmEventTimestampStart = Date.now(); + let response = await request + .post('/_reindex') + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'writer', ['read', 'write'])}`); + expect(response.status).toBe(204); + await waitUntil(async () => { + let currentCount = await latestFromScratchJobCount(); + return currentCount === initialJobCount + 1 ? true : undefined; + }); + let job = await waitUntil(async () => { + let row = await latestFromScratchJob(); + return row?.status === 'resolved' ? row : undefined; + }, { timeout: 20000 }); + expect(job).toBeTruthy(); + if (!job) { + throw new Error('expected latest from-scratch job to resolve'); + } + let event = await waitForIncrementalRealmEvent(realmEventTimestampStart, job.result.invalidations); + let fullEvent = await waitForFullRealmEvent(realmEventTimestampStart); + expect(event.content.invalidations).toEqual(job.result.invalidations); + expect([...event.content.invalidations].sort()).toEqual([ + `${testRealmHref}person-1.json`, + `${testRealmHref}person.gts`, + ].sort()); + expect(fullEvent.content.indexType).toBe('full'); + }); + it('Realm.reindex broadcasts incremental invalidations and full index events', async function () { + await establishBaselineIndex(); + bumpFileMtime(join(testRealmPath, 'person.gts')); + let realmEventTimestampStart = Date.now(); + await testRealm.reindex(); + let expectedInvalidations = [ + `${testRealmHref}person-1.json`, + `${testRealmHref}person.gts`, + ]; + let incrementalEvent = await waitForIncrementalRealmEvent(realmEventTimestampStart, expectedInvalidations); + let fullEvent = await waitForFullRealmEvent(realmEventTimestampStart); + expect([...incrementalEvent.content.invalidations].sort()).toEqual(expectedInvalidations.sort()); + expect(fullEvent.content.indexType).toBe('full'); + }); + it('full reindex forces all files to invalidate and broadcasts the full invalidation set', async function () { + await establishBaselineIndex(); + let initialJobCount = await latestFromScratchJobCount(); + let realmEventTimestampStart = Date.now(); + let response = await request + .post('/_full-reindex') + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'writer', ['read', 'write'])}`); + expect(response.status).toBe(204); + await waitUntil(async () => { + let currentCount = await latestFromScratchJobCount(); + return currentCount === initialJobCount + 1 ? true : undefined; + }); + let job = await waitUntil(async () => { + let row = await latestFromScratchJob(); + return row?.status === 'resolved' ? row : undefined; + }, { timeout: 20000 }); + expect(job).toBeTruthy(); + if (!job) { + throw new Error('expected latest full from-scratch job to resolve'); + } + let event = await waitForIncrementalRealmEvent(realmEventTimestampStart, job.result.invalidations); + let fullEvent = await waitForFullRealmEvent(realmEventTimestampStart); + expect(event.content.invalidations).toEqual(job.result.invalidations); + expect([...event.content.invalidations].sort()).toEqual([ + `${testRealmHref}article-1.json`, + `${testRealmHref}article.gts`, + `${testRealmHref}person-1.json`, + `${testRealmHref}person.gts`, + ].sort()); + expect(fullEvent.content.indexType).toBe('full'); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/realm-endpoints/search.test.ts b/packages/realm-server/tests-vitest/realm-endpoints/search.test.ts new file mode 100644 index 00000000000..fd20cbb5341 --- /dev/null +++ b/packages/realm-server/tests-vitest/realm-endpoints/search.test.ts @@ -0,0 +1,746 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import type { Test, SuperTest } from 'supertest'; +import { baseRealm, type Realm } from '@cardstack/runtime-common'; +import type { Query } from '@cardstack/runtime-common/query'; +import { setupPermissionedRealmCached, createJWT, testRealmURLFor, } from '../helpers'; +import '@cardstack/runtime-common/helpers/code-equality-assertion'; +describe("realm-endpoints/search-test.ts", function () { + describe('Realm-specific Endpoints | _search', function () { + let testRealm: Realm; + let request: SuperTest; + let realmURL: URL; + let realmHref: string; + let searchPath: string; + function onRealmSetup(args: { + testRealm: Realm; + request: SuperTest; + }) { + testRealm = args.testRealm; + request = args.request; + realmURL = new URL(testRealm.url); + realmHref = realmURL.href; + searchPath = `${realmURL.pathname.replace(/\/$/, '')}/_search`; + } + function buildPersonQuery(firstName = 'Mango'): Query { + return { + filter: { + on: { + module: `${realmHref}person`, + name: 'Person', + }, + eq: { + firstName, + }, + }, + }; + } + function buildFileDefQuery(): Query { + return { + filter: { + type: { + module: `${baseRealm.url}file-api`, + name: 'FileDef', + }, + }, + }; + } + describe('QUERY request (public realm)', function () { + const _hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let query = () => buildPersonQuery('Mango'); + describe('public readable realm', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + permissions: { + '*': ['read'], + }, + realmURL: testRealmURLFor('test/'), + onRealmSetup, + }); + it('serves a /_search QUERY request', async function () { + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send(query()); + expect(response.status).toBe(200); + expect(response.get('X-boxel-realm-url')).toBe(realmHref); + expect(response.get('X-boxel-realm-public-readable')).toBe('true'); + let json = response.body; + expect(json.data.length).toBe(1); + expect(json.data[0].id).toBe(`${realmHref}person-1`); + expect(json.meta.page.total).toBe(1); + }); + it('gets no results when asking for a type that the realm does not have knowledge of', async function () { + let unknownTypeQuery: Query = { + filter: { + on: { + module: 'http://some-realm-server/some-realm/some-card', + name: 'SomeCard', + }, + eq: { + firstName: 'Mango', + }, + }, + }; + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send(unknownTypeQuery); + expect(response.status).toBe(200); + let json = response.body; + expect(json.data.length).toBe(0); + expect(json.meta.page.total).toBe(0); + }); + it('can paginate search results', async function () { + // Query for all persons to get multiple results + let paginationQuery: Query = { + filter: { + type: { + module: `${realmHref}person`, + name: 'Person', + }, + }, + page: { + number: 0, + size: 1, + }, + sort: [ + { + by: 'firstName', + on: { module: `${realmHref}person`, name: 'Person' }, + direction: 'asc', + }, + ], + }; + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send(paginationQuery); + expect(response.status).toBe(200); + let json = response.body; + expect(json.data.length).toBe(1); + expect(json.meta).toBeTruthy(); + expect(json.meta.page).toBeTruthy(); + expect(json.meta.page.total).toBe(3); + // Get the second page + paginationQuery.page = { number: 1, size: 1 }; + response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send(paginationQuery); + expect(response.status).toBe(200); + let json2 = response.body; + expect(json2.data.length).toBe(1); + expect(json2.meta.page.total).toBe(3); + // Ensure different results on different pages + expect(json.data[0].id).not.toBe(json2.data[0].id); + }); + it('serves file-meta results when querying for FileDef', async function () { + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send(buildFileDefQuery()); + expect(response.status).toBe(200); + let json = response.body as { + data: { + id?: string; + type: string; + }[]; + }; + expect(json.data.length > 0).toBeTruthy(); + expect(json.data.every((entry) => entry.type === 'file-meta')).toBeTruthy(); + expect(json.data.some((entry) => entry.id === `${realmHref}dir/foo.txt`)).toBeTruthy(); + }); + it('filters file-meta results by url', async function () { + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send({ + filter: { + on: { + module: `${baseRealm.url}file-api`, + name: 'FileDef', + }, + eq: { + url: `${realmHref}dir/foo.txt`, + }, + }, + }); + expect(response.status).toBe(200); + let json = response.body as { + data: { + id?: string; + type: string; + }[]; + }; + expect(json.data.map((entry) => entry.id)).toEqual([`${realmHref}dir/foo.txt`]); + expect(json.data[0]?.type).toBe('file-meta'); + }); + it('filters file-meta results by FileDef subclass type', async function () { + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send({ + filter: { + type: { + module: `${baseRealm.url}markdown-file-def`, + name: 'MarkdownDef', + }, + }, + }); + expect(response.status).toBe(200); + let json = response.body as { + data: { + id?: string; + type: string; + }[]; + }; + expect(json.data.some((entry) => entry.id === `${realmHref}sample.md`)).toBeTruthy(); + }); + it('sparse fieldsets: empty fields returns resources with no attributes', async function () { + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send({ + ...buildFileDefQuery(), + fields: { 'file-meta': [] }, + asData: true, + }); + expect(response.status).toBe(200); + let json = response.body as { + data: { + id?: string; + type: string; + attributes?: Record; + meta?: Record; + links?: Record; + }[]; + }; + expect(json.data.length > 0).toBeTruthy(); + for (let entry of json.data) { + expect(entry.type).toBe('file-meta'); + expect(entry.id).toBeTruthy(); + expect(entry.meta).toBeTruthy(); + expect(entry.attributes).toEqual({}); + } + }); + it('sparse fieldsets: specific fields returns only those attributes', async function () { + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send({ + ...buildFileDefQuery(), + fields: { 'file-meta': ['name', 'url'] }, + asData: true, + }); + expect(response.status).toBe(200); + let json = response.body as { + data: { + id?: string; + type: string; + attributes?: Record; + }[]; + }; + expect(json.data.length > 0).toBeTruthy(); + for (let entry of json.data) { + let attrKeys = Object.keys(entry.attributes ?? {}); + expect(attrKeys.every((k) => ['name', 'url'].includes(k))).toBeTruthy(); + expect(entry.attributes?.name).not.toBe(undefined); + expect(entry.attributes?.url).not.toBe(undefined); + } + }); + it('sparse fieldsets: invalid fields value returns 400', async function () { + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send({ + ...buildFileDefQuery(), + fields: { 'file-meta': 'not-an-array' }, + asData: true, + }); + expect(response.status).toBe(400); + }); + it('sparse fieldsets: fields without asData returns 400', async function () { + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send({ + ...buildFileDefQuery(), + fields: { 'file-meta': ['name'] }, + }); + expect(response.status).toBe(400); + }); + it('query without fields returns all attributes (backward compat)', async function () { + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send(buildFileDefQuery()); + expect(response.status).toBe(200); + let json = response.body as { + data: { + id?: string; + type: string; + attributes?: Record; + }[]; + }; + expect(json.data.length > 0).toBeTruthy(); + let entry = json.data.find((e) => e.id === `${realmHref}dir/foo.txt`); + expect(entry).toBeTruthy(); + expect(entry!.attributes).toBeTruthy(); + expect(Object.keys(entry!.attributes!).length > 0).toBeTruthy(); + expect(entry!.attributes?.name).not.toBe(undefined); + expect(entry!.attributes?.url).not.toBe(undefined); + }); + it('sparse fieldsets for cards: empty fields returns card resources with no attributes', async function () { + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send({ + ...buildPersonQuery('Mango'), + fields: { card: [] }, + asData: true, + }); + expect(response.status).toBe(200); + let json = response.body as { + data: { + id?: string; + type: string; + attributes?: Record; + meta?: Record; + links?: Record; + }[]; + }; + expect(json.data.length).toBe(1); + let entry = json.data[0]; + expect(entry.type).toBe('card'); + expect(entry.id).toBeTruthy(); + expect(entry.meta).toBeTruthy(); + expect(entry.attributes).toEqual({}); + }); + it('sparse fieldsets for cards: specific fields returns only those attributes', async function () { + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send({ + ...buildPersonQuery('Mango'), + fields: { card: ['firstName'] }, + asData: true, + }); + expect(response.status).toBe(200); + let json = response.body as { + data: { + id?: string; + type: string; + attributes?: Record; + }[]; + }; + expect(json.data.length).toBe(1); + let entry = json.data[0]; + let attrKeys = Object.keys(entry.attributes ?? {}); + expect(attrKeys).toEqual(['firstName']); + expect(entry.attributes?.firstName).toBe('Mango'); + }); + it('sparse fieldsets for cards: query without fields returns all attributes', async function () { + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send(buildPersonQuery('Mango')); + expect(response.status).toBe(200); + let json = response.body as { + data: { + id?: string; + type: string; + attributes?: Record; + }[]; + }; + expect(json.data.length).toBe(1); + let entry = json.data[0]; + expect(entry!.attributes).toBeTruthy(); + expect(Object.keys(entry!.attributes!).length > 0).toBeTruthy(); + expect(entry.attributes?.firstName).toBe('Mango'); + }); + }); + describe('fields-based link loading', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + permissions: { + '*': ['read'], + }, + realmURL: testRealmURLFor('test/'), + fileSystem: { + 'friend.gts': ` + import { + contains, + linksTo, + linksToMany, + field, + CardDef, + Component, + } from 'https://cardstack.com/base/card-api'; + import StringField from 'https://cardstack.com/base/string'; + + export class Friend extends CardDef { + @field firstName = contains(StringField); + @field friend = linksTo(() => Friend); + @field friends = linksToMany(() => Friend); + static isolated = class Isolated extends Component { + + }; + } + `, + 'friend-1.json': { + data: { + type: 'card', + attributes: { + firstName: 'Alice', + }, + relationships: { + friend: { + links: { + self: './friend-2', + }, + }, + 'friends.0': { + links: { + self: './friend-2', + }, + }, + 'friends.1': { + links: { + self: './friend-3', + }, + }, + }, + meta: { + adoptsFrom: { + module: './friend', + name: 'Friend', + }, + }, + }, + }, + 'friend-2.json': { + data: { + type: 'card', + attributes: { + firstName: 'Bob', + }, + relationships: { + friend: { + links: { + self: './friend-3', + }, + }, + }, + meta: { + adoptsFrom: { + module: './friend', + name: 'Friend', + }, + }, + }, + }, + 'friend-3.json': { + data: { + type: 'card', + attributes: { + firstName: 'Charlie', + }, + meta: { + adoptsFrom: { + module: './friend', + name: 'Friend', + }, + }, + }, + }, + }, + onRealmSetup, + }); + function buildFriendQuery(firstName: string): Query { + return { + filter: { + on: { + module: `${realmHref}friend`, + name: 'Friend', + }, + eq: { + firstName, + }, + }, + }; + } + it('fields with relationship field name loads that relationship into included', async function () { + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send({ + ...buildFriendQuery('Alice'), + fields: { card: ['firstName', 'friend'] }, + asData: true, + }); + expect(response.status).toBe(200); + let json = response.body; + expect(json.data.length).toBe(1); + expect(json.data[0].attributes.firstName).toBe('Alice'); + expect(json.included).toBeTruthy(); + expect(json.included.length > 0).toBeTruthy(); + let includedIds = json.included.map((r: { + id: string; + }) => r.id); + expect(includedIds.some((id: string) => id.includes('friend-2'))).toBeTruthy(); + }); + it('fields with only attribute field names does not load links', async function () { + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send({ + ...buildFriendQuery('Alice'), + fields: { card: ['firstName'] }, + asData: true, + }); + expect(response.status).toBe(200); + let json = response.body; + expect(json.data.length).toBe(1); + expect(json.data[0].attributes.firstName).toBe('Alice'); + expect(json.included).toBe(undefined); + }); + it('fields filter applies only at root level - nested relationships are fully loaded', async function () { + // Alice links to Bob via `friend`, and Bob links to Charlie via + // `friend`. When we request only `friend` in fields, Bob's nested + // link to Charlie should also be included because linkFields is + // cleared for recursive link loading. + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send({ + ...buildFriendQuery('Alice'), + fields: { card: ['friend'] }, + asData: true, + }); + expect(response.status).toBe(200); + let json = response.body; + expect(json.data.length).toBe(1); + expect(json.included).toBeTruthy(); + let includedIds = json.included.map((r: { + id: string; + }) => r.id); + expect(includedIds.some((id: string) => id.includes('friend-2'))).toBeTruthy(); + expect(includedIds.some((id: string) => id.includes('friend-3'))).toBeTruthy(); + }); + it('fields with linksToMany relationship loads those links', async function () { + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send({ + ...buildFriendQuery('Alice'), + fields: { card: ['friends'] }, + asData: true, + }); + expect(response.status).toBe(200); + let json = response.body; + expect(json.data.length).toBe(1); + expect(json.included).toBeTruthy(); + let includedIds = json.included.map((r: { + id: string; + }) => r.id); + expect(includedIds.some((id: string) => id.includes('friend-2'))).toBeTruthy(); + expect(includedIds.some((id: string) => id.includes('friend-3'))).toBeTruthy(); + }); + }); + }); + describe('QUERY request (permissioned realm)', function () { + const _hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let query = () => buildPersonQuery('Mango'); + describe('public readable realm', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + permissions: { + '*': ['read'], + }, + realmURL: testRealmURLFor('test/'), + onRealmSetup, + }); + it('serves a /_search QUERY request', async function () { + let response = await request + .post(searchPath) + .send(query()) + .set('Accept', 'application/vnd.card+json') + .set('Content-Type', 'application/json') + .set('X-HTTP-Method-Override', 'QUERY'); // Use method override since supertest doesn't support QUERY directly + expect(response.status).toBe(200); + expect(response.get('X-boxel-realm-url')).toBe(realmHref); + expect(response.get('X-boxel-realm-public-readable')).toBe('true'); + let json = response.body; + expect(json.data.length).toBe(1); + expect(json.data[0].id).toBe(`${realmHref}person-1`); + expect(json.meta.page.total).toBe(1); + }); + it('handles complex queries in request body', async function () { + let complexQuery = { + filter: { + on: { + module: `${realmHref}person`, + name: 'Person', + }, + any: [ + { eq: { firstName: 'Mango' } }, + { eq: { firstName: 'Tango' } }, + ], + }, + sort: [ + { + by: 'firstName', + on: { module: `${realmHref}person`, name: 'Person' }, + direction: 'asc', + }, + ], + }; + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send(complexQuery); + expect(response.status).toBe(200); + let json = response.body; + expect(json.data).toBeTruthy(); + expect(json.meta.page.total).toBe(1); + }); + }); + describe('permissioned realm', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + permissions: { + john: ['read'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + realmURL: testRealmURLFor('test/'), + onRealmSetup, + }); + it('401 with invalid JWT', async function () { + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .set('Authorization', `Bearer invalid-token`) + .send(query()); + expect(response.status).toBe(401); + }); + it('401 without a JWT', async function () { + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send(query()); // no Authorization header + expect(response.status).toBe(401); + }); + it('403 without permission', async function () { + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .set('Authorization', `Bearer ${createJWT(testRealm, 'not-john')}`) + .send(query()); + expect(response.status).toBe(403); + }); + it('200 with permission', async function () { + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .set('Authorization', `Bearer ${createJWT(testRealm, 'john', ['read'])}`) + .send(query()); + expect(response.status).toBe(200); + }); + }); + describe('search query validation', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + permissions: { + '*': ['read'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + realmURL: testRealmURLFor('test/'), + onRealmSetup, + }); + it('400 with invalid query schema', async function () { + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send({ invalid: 'query structure' }); + expect(response.status).toBe(400); + expect(response.body.errors[0].message.includes('Invalid query')).toBeTruthy(); + }); + it('400 with invalid filter logic', async function () { + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send({ + filter: { + badOperator: { firstName: 'Mango' }, + }, + }); + expect(response.status).toBe(400); + }); + }); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/realm-endpoints/user.test.ts b/packages/realm-server/tests-vitest/realm-endpoints/user.test.ts new file mode 100644 index 00000000000..05ec3222c2c --- /dev/null +++ b/packages/realm-server/tests-vitest/realm-endpoints/user.test.ts @@ -0,0 +1,432 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { fileURLToPath } from "url"; +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import type { Test, SuperTest } from 'supertest'; +import { join, dirname } from 'path'; +import type { Server } from 'http'; +import { dirSync, type DirResult } from 'tmp'; +import { copySync } from 'fs-extra'; +import type { Realm } from '@cardstack/runtime-common'; +import { setupPermissionedRealmCached, closeServer, insertUser, insertPlan, createJWT, } from '../helpers'; +import '@cardstack/runtime-common/helpers/code-equality-assertion'; +import type { PgAdapter } from '@cardstack/postgres'; +import { addToCreditsLedger, insertSubscriptionCycle, insertSubscription, getUserByMatrixUserId, sumUpCreditsLedger, } from '@cardstack/billing/billing-queries'; +import { resetCatalogRealms } from '../../handlers/handle-fetch-catalog-realms'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +describe("realm-endpoints/user-test.ts", function () { + describe('Realm-specific Endpoints | GET _user', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let testRealm: Realm; + let testRealmHttpServer: Server; + let request: SuperTest; + let dir: DirResult; + let dbAdapter: PgAdapter; + let originalLowCreditThreshold: string | undefined; + function onRealmSetup(args: { + testRealm: Realm; + testRealmHttpServer: Server; + request: SuperTest; + dbAdapter: PgAdapter; + dir: DirResult; + }) { + testRealm = args.testRealm; + testRealmHttpServer = args.testRealmHttpServer; + request = args.request; + dbAdapter = args.dbAdapter; + dir = args.dir; + } + hooks.beforeEach(async function () { + originalLowCreditThreshold = process.env.LOW_CREDIT_THRESHOLD; + process.env.LOW_CREDIT_THRESHOLD = '2000'; + dir = dirSync(); + copySync(join(__dirname, '..', 'cards'), dir.name); + }); + hooks.afterEach(async function () { + await closeServer(testRealmHttpServer); + resetCatalogRealms(); + if (originalLowCreditThreshold == null) { + delete process.env.LOW_CREDIT_THRESHOLD; + } + else { + process.env.LOW_CREDIT_THRESHOLD = originalLowCreditThreshold; + } + }); + setupPermissionedRealmCached(hooks, { + permissions: { + john: ['read', 'write'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + onRealmSetup, + }); + it('responds with 404 if user is not found', async function () { + let response = await request + .get(`/_user`) + .set('Accept', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'user', ['read', 'write'])}`); + expect(response.status).toBe(404); + }); + it('responds with 200 and free plan if user is not subscribed via stripe', async function () { + let user = await insertUser(dbAdapter, 'user@test', 'cus_123', 'user@test.com'); + let response = await request + .get(`/_user`) + .set('Accept', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'user@test', ['read', 'write'])}`); + expect(response.status).toBe(200); + let json = response.body; + expect(json.data.attributes.nextDailyCreditGrantAt).toBeTruthy(); + expect(json).toEqual({ + data: { + type: 'user', + id: user.id, + attributes: { + matrixUserId: user.matrixUserId, + stripeCustomerId: user.stripeCustomerId, + stripeCustomerEmail: user.stripeCustomerEmail, + creditsAvailableInPlanAllowance: null, + creditsIncludedInPlanAllowance: null, + extraCreditsAvailableInBalance: 0, + lowCreditThreshold: 2000, + lastDailyCreditGrantAt: null, + nextDailyCreditGrantAt: json.data.attributes.nextDailyCreditGrantAt, + dailyCreditGrantCount: 0, + }, + relationships: { + subscription: null, + }, + }, + included: [ + { + type: 'plan', + id: 'free', + attributes: { + name: 'Free', + monthlyPrice: 0, + creditsIncluded: 0, + }, + }, + ], + }); + }); + it('response has correct values for subscribed user who has some extra credits', async function () { + let user = await insertUser(dbAdapter, 'user@test', 'cus_123', 'user@test.com'); + let someOtherUser = await insertUser(dbAdapter, 'some-other-user@test', 'cus_1234', 'other@test.com'); // For the purposes of testing that we don't return the wrong user's subscription's data + let plan = await insertPlan(dbAdapter, 'Creator', 12, 2500, 'prod_creator'); + let subscription = await insertSubscription(dbAdapter, { + user_id: user.id, + plan_id: plan.id, + started_at: 1, + status: 'active', + stripe_subscription_id: 'sub_1234567890', + }); + let subscriptionCycle = await insertSubscriptionCycle(dbAdapter, { + subscriptionId: subscription.id, + periodStart: 1, + periodEnd: 2, + }); + await addToCreditsLedger(dbAdapter, { + userId: user.id, + creditAmount: 100, + creditType: 'extra_credit', + subscriptionCycleId: subscriptionCycle.id, + }); + await addToCreditsLedger(dbAdapter, { + userId: user.id, + creditAmount: 2500, + creditType: 'plan_allowance', + subscriptionCycleId: subscriptionCycle.id, + }); + // Set up other user's subscription + let otherUserSubscription = await insertSubscription(dbAdapter, { + user_id: someOtherUser.id, + plan_id: plan.id, + started_at: 1, + status: 'active', + stripe_subscription_id: 'sub_1234567891', + }); + let otherUserSubscriptionCycle = await insertSubscriptionCycle(dbAdapter, { + subscriptionId: otherUserSubscription.id, + periodStart: 1, + periodEnd: 2, + }); + await addToCreditsLedger(dbAdapter, { + userId: someOtherUser.id, + creditAmount: 100, + creditType: 'extra_credit', + subscriptionCycleId: otherUserSubscriptionCycle.id, + }); // this is to test that this extra credit amount does not influence the original user's credit calculation + let response = await request + .get(`/_user`) + .set('Accept', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'user@test', ['read', 'write'])}`); + expect(response.status).toBe(200); + let json = response.body; + expect(json.data.attributes.nextDailyCreditGrantAt).toBeTruthy(); + expect(json).toEqual({ + data: { + type: 'user', + id: user.id, + attributes: { + matrixUserId: user.matrixUserId, + stripeCustomerId: user.stripeCustomerId, + stripeCustomerEmail: user.stripeCustomerEmail, + creditsAvailableInPlanAllowance: 2500, + creditsIncludedInPlanAllowance: 2500, + extraCreditsAvailableInBalance: 100, + lowCreditThreshold: 2000, + lastDailyCreditGrantAt: null, + nextDailyCreditGrantAt: json.data.attributes.nextDailyCreditGrantAt, + dailyCreditGrantCount: 0, + }, + relationships: { + subscription: { + data: { + type: 'subscription', + id: subscription.id, + }, + }, + }, + }, + included: [ + { + type: 'subscription', + id: subscription.id, + attributes: { + startedAt: 1, + endedAt: null, + status: 'active', + }, + relationships: { + plan: { + data: { + type: 'plan', + id: plan.id, + }, + }, + }, + }, + { + type: 'plan', + id: plan.id, + attributes: { + name: plan.name, + monthlyPrice: plan.monthlyPrice, + creditsIncluded: plan.creditsIncluded, + }, + }, + ], + }); + }); + it('responds with nextDailyCreditGrantAt when user is below low credit threshold', async function () { + await insertUser(dbAdapter, 'user@test', 'cus_123', 'user@test.com'); + let response = await request + .get(`/_user`) + .set('Accept', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'user@test', ['read', 'write'])}`); + expect(response.status).toBe(200); + let json = response.body; + expect(json.data.attributes.lowCreditThreshold).toBe(2000); + expect(json.data.attributes.nextDailyCreditGrantAt).toBeTruthy(); + expect(json.data.attributes.lastDailyCreditGrantAt).toBe(null); + }); + it('responds with lastDailyCreditGrantAt when user is above low credit threshold', async function () { + let user = await insertUser(dbAdapter, 'user@test', 'cus_123', 'user@test.com'); + await addToCreditsLedger(dbAdapter, { + userId: user.id, + creditAmount: 3000, + creditType: 'extra_credit', + subscriptionCycleId: null, + }); + await addToCreditsLedger(dbAdapter, { + userId: user.id, + creditAmount: 100, + creditType: 'daily_credit', + subscriptionCycleId: null, + }); + let response = await request + .get(`/_user`) + .set('Accept', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'user@test', ['read', 'write'])}`); + expect(response.status).toBe(200); + let json = response.body; + expect(json.data.attributes.lowCreditThreshold).toBe(2000); + expect(json.data.attributes.lastDailyCreditGrantAt).toBeTruthy(); + expect(json.data.attributes.dailyCreditGrantCount).toBe(1); + }); + it('responds without daily grant timestamps when low credit threshold is unset', async function () { + delete process.env.LOW_CREDIT_THRESHOLD; + await insertUser(dbAdapter, 'user-threshold-unset@test', 'cus_999', 'user-threshold-unset@test.com'); + let response = await request + .get(`/_user`) + .set('Accept', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'user-threshold-unset@test', [ + 'read', + 'write', + ])}`); + expect(response.status).toBe(200); + let json = response.body; + expect(json.data.attributes.lowCreditThreshold).toBe(null); + expect(json.data.attributes.nextDailyCreditGrantAt).toBe(null); + expect(json.data.attributes.lastDailyCreditGrantAt).toBe(null); + }); + it('responds with the most recent daily grant timestamp', async function () { + let user = await insertUser(dbAdapter, 'user-multi-daily@test', 'cus_456', 'user-multi-daily@test.com'); + await dbAdapter.execute(`INSERT INTO credits_ledger (user_id, credit_amount, credit_type, subscription_cycle_id, created_at) + VALUES ($1, $2, $3, $4, $5)`, { + bind: [user.id, 50, 'daily_credit', null, 1000], + }); + await dbAdapter.execute(`INSERT INTO credits_ledger (user_id, credit_amount, credit_type, subscription_cycle_id, created_at) + VALUES ($1, $2, $3, $4, $5)`, { + bind: [user.id, 75, 'daily_credit', null, 2000], + }); + let response = await request + .get(`/_user`) + .set('Accept', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'user-multi-daily@test', [ + 'read', + 'write', + ])}`); + expect(response.status).toBe(200); + let json = response.body; + expect(json.data.attributes.lastDailyCreditGrantAt).toBe(2000); + expect(json.data.attributes.dailyCreditGrantCount).toBe(2); + }); + }); + describe('Realm-specific Endpoints | POST _user', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let testRealm: Realm; + let testRealmHttpServer: Server; + let request: SuperTest; + let dir: DirResult; + let dbAdapter: PgAdapter; + let originalLowCreditThreshold: string | undefined; + function onRealmSetup(args: { + testRealm: Realm; + testRealmHttpServer: Server; + request: SuperTest; + dbAdapter: PgAdapter; + dir: DirResult; + }) { + testRealm = args.testRealm; + testRealmHttpServer = args.testRealmHttpServer; + request = args.request; + dbAdapter = args.dbAdapter; + dir = args.dir; + } + hooks.beforeEach(async function () { + originalLowCreditThreshold = process.env.LOW_CREDIT_THRESHOLD; + process.env.LOW_CREDIT_THRESHOLD = '2000'; + dir = dirSync(); + copySync(join(__dirname, '..', 'cards'), dir.name); + }); + hooks.afterEach(async function () { + await closeServer(testRealmHttpServer); + resetCatalogRealms(); + if (originalLowCreditThreshold == null) { + delete process.env.LOW_CREDIT_THRESHOLD; + } + else { + process.env.LOW_CREDIT_THRESHOLD = originalLowCreditThreshold; + } + }); + setupPermissionedRealmCached(hooks, { + permissions: { + john: ['read', 'write'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + onRealmSetup, + }); + it('creates a new user with initial credits', async function () { + let response = await request + .post(`/_user`) + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'newuser@test', ['read', 'write'])}`) + .send({ + data: { + type: 'user', + attributes: { + registrationToken: 'reg_token_123', + }, + }, + }); + expect(response.status).toBe(200); + expect(response.text).toBe('ok'); + // Verify user was created + let user = await getUserByMatrixUserId(dbAdapter, 'newuser@test'); + expect(user).toBeTruthy(); + expect(user!.matrixRegistrationToken).toBe('reg_token_123'); + // Verify credits were added + let dailyCredits = await sumUpCreditsLedger(dbAdapter, { + userId: user!.id, + creditType: 'daily_credit', + }); + expect(dailyCredits).toBe(2000); + let extraCredits = await sumUpCreditsLedger(dbAdapter, { + userId: user!.id, + creditType: 'extra_credit', + }); + expect(extraCredits).toBe(0); + let planAllowance = await sumUpCreditsLedger(dbAdapter, { + userId: user!.id, + creditType: 'plan_allowance', + }); + expect(planAllowance).toBe(0); + // Try running the endpoint again - should be idempotent + response = await request + .post(`/_user`) + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'newuser@test', ['read', 'write'])}`) + .send({ + data: { + type: 'user', + attributes: { + registrationToken: 'reg_token_456', + }, + }, + }); + expect(response.status).toBe(200); + expect(response.text).toBe('ok'); + // Verify credits were NOT doubled + dailyCredits = await sumUpCreditsLedger(dbAdapter, { + userId: user!.id, + creditType: 'daily_credit', + }); + expect(dailyCredits).toBe(2000); + // Verify registration token was updated + user = await getUserByMatrixUserId(dbAdapter, 'newuser@test'); + expect(user!.matrixRegistrationToken).toBe('reg_token_456'); + }); + it('responds with 500 when low credit threshold is missing', async function () { + delete process.env.LOW_CREDIT_THRESHOLD; + let response = await request + .post(`/_user`) + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'missing-credits@test', ['read', 'write'])}`) + .send({ + data: { + type: 'user', + attributes: { + registrationToken: 'reg_token_123', + }, + }, + }); + expect(response.status).toBe(500); + expect(response.body.errors).toEqual(['LOW_CREDIT_THRESHOLD must be set to run daily-credit-grant']); + let user = await getUserByMatrixUserId(dbAdapter, 'missing-credits@test'); + expect(user).toBeFalsy(); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/remote-prerenderer.test.ts b/packages/realm-server/tests-vitest/remote-prerenderer.test.ts new file mode 100644 index 00000000000..06d747fd3b4 --- /dev/null +++ b/packages/realm-server/tests-vitest/remote-prerenderer.test.ts @@ -0,0 +1,280 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import { createServer } from 'http'; +import { createRemotePrerenderer } from '../prerender/remote-prerenderer'; +import { PRERENDER_SERVER_DRAINING_STATUS_CODE, PRERENDER_SERVER_STATUS_DRAINING, PRERENDER_SERVER_STATUS_HEADER, } from '../prerender/prerender-constants'; +describe("remote-prerenderer-test.ts", function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + hooks.afterEach(function () { + delete process.env.PRERENDER_MANAGER_RETRY_ATTEMPTS; + delete process.env.PRERENDER_MANAGER_RETRY_DELAY_MS; + delete process.env.PRERENDER_MANAGER_REQUEST_TIMEOUT_MS; + delete process.env.PRERENDER_MANAGER_MAX_DELAY_MS; + }); + describe('remote prerenderer payload', function () { + async function expectValidationFailure(assert: Assert, attrs: any, message: RegExp) { + let originalFetch = globalThis.fetch; + let fetchCalled = false; + (globalThis as any).fetch = () => { + fetchCalled = true; + throw new Error('fetch should not be called when validation fails'); + }; + try { + let prerenderer = createRemotePrerenderer('http://127.0.0.1:0'); + await expect(prerenderer.prerenderModule(attrs as any)).rejects.toThrow(message); + expect(fetchCalled).toBe(false); + } + finally { + (globalThis as any).fetch = originalFetch; + } + } + async function expectRunCommandValidationFailure(assert: Assert, attrs: any, message: RegExp) { + let originalFetch = globalThis.fetch; + let fetchCalled = false; + (globalThis as any).fetch = () => { + fetchCalled = true; + throw new Error('fetch should not be called when validation fails'); + }; + try { + let prerenderer = createRemotePrerenderer('http://127.0.0.1:0'); + await expect(prerenderer.runCommand(attrs as any)).rejects.toThrow(message); + expect(fetchCalled).toBe(false); + } + finally { + (globalThis as any).fetch = originalFetch; + } + } + it('sends JSON:API headers and attributes', async function () { + let receivedHeaders: any; + let receivedBody: any; + let server = createServer((req, res) => { + receivedHeaders = req.headers; + let body: Buffer[] = []; + req.on('data', (chunk) => body.push(chunk)); + req.on('end', () => { + receivedBody = JSON.parse(Buffer.concat(body).toString('utf-8')); + res.statusCode = 201; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + data: { attributes: { ok: true } }, + })); + }); + }).listen(0); + try { + let url = `http://127.0.0.1:${(server.address() as any).port}`; + let prerenderer = createRemotePrerenderer(url); + await prerenderer.prerenderModule({ + affinityType: 'realm', + affinityValue: 'realm-1', + realm: 'realm-1', + url: 'https://example.com/module', + auth: '{"token":"x"}', + }); + expect(receivedHeaders?.['content-type']).toBe('application/vnd.api+json'); + expect(receivedHeaders?.accept).toBe('application/vnd.api+json'); + expect(receivedBody?.data?.attributes).toEqual({ + affinityType: 'realm', + affinityValue: 'realm-1', + realm: 'realm-1', + url: 'https://example.com/module', + auth: '{"token":"x"}', + renderOptions: {}, + }); + } + finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + it('rejects empty realm before sending', async function () { + await expectValidationFailure(assert, { realm: '', url: 'https://example.com/module', auth: '{}' }, /Missing prerender prerender-module-request attributes: affinityValue, realm/); + }); + it('rejects empty url before sending', async function () { + await expectValidationFailure(assert, { realm: 'realm', url: '', auth: '{}' }, /Missing prerender prerender-module-request attributes: url/); + }); + it('rejects empty auth before sending', async function () { + await expectValidationFailure(assert, { realm: 'realm', url: 'https://example.com/module', auth: '' }, /Missing prerender prerender-module-request attributes: auth/); + }); + it('rejects missing auth before sending', async function () { + await expectValidationFailure(assert, { realm: 'realm', url: 'https://example.com/module' }, /Missing prerender prerender-module-request attributes: auth/); + }); + it('sends run-command payload with user affinity derived from userId', async function () { + let receivedBody: any; + let server = createServer((req, res) => { + let body: Buffer[] = []; + req.on('data', (chunk) => body.push(chunk)); + req.on('end', () => { + receivedBody = JSON.parse(Buffer.concat(body).toString('utf-8')); + res.statusCode = 201; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + data: { + attributes: { + status: 'ready', + cardResultString: '{"ok":true}', + }, + }, + })); + }); + }).listen(0); + try { + let url = `http://127.0.0.1:${(server.address() as any).port}`; + let prerenderer = createRemotePrerenderer(url); + await prerenderer.runCommand({ + userId: '@alice:localhost', + auth: '{}', + command: 'https://example.com/commands/test/default', + commandInput: { value: 1 }, + }); + expect(receivedBody?.data?.attributes).toEqual({ + affinityType: 'user', + affinityValue: '@alice:localhost', + auth: '{}', + command: 'https://example.com/commands/test/default', + commandInput: { value: 1 }, + }); + } + finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + it('rejects empty userId before sending run-command', async function () { + await expectRunCommandValidationFailure(assert, { + userId: '', + auth: '{}', + command: 'https://example.com/commands/test/default', + }, /Missing prerender run-command-request attributes: affinityValue/); + }); + }); + describe('remote prerenderer retries', function () { + it('retries draining responses and succeeds', async function () { + let attempts = 0; + let server = createServer((req, res) => { + attempts++; + if (req.url?.endsWith('/prerender-card') && attempts < 3) { + res.statusCode = PRERENDER_SERVER_DRAINING_STATUS_CODE; + res.setHeader(PRERENDER_SERVER_STATUS_HEADER, PRERENDER_SERVER_STATUS_DRAINING); + res.end('draining'); + return; + } + res.statusCode = 201; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + data: { attributes: { ok: true } }, + })); + }).listen(0); + let url = `http://127.0.0.1:${(server.address() as any).port}`; + let prerenderer = createRemotePrerenderer(url); + let result = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: 'realm', + realm: 'realm', + url: 'https://example.com/card', + auth: '{}', + }); + expect((result as any).ok).toBe(true); + await new Promise((resolve) => server.close(() => resolve())); + }); + it('fails after exhausting retries on 503', async function () { + process.env.PRERENDER_MANAGER_RETRY_ATTEMPTS = '2'; + let attempts = 0; + let server = createServer((_req, res) => { + attempts++; + res.statusCode = 503; + res.end('unavailable'); + }).listen(0); + let url = `http://127.0.0.1:${(server.address() as any).port}`; + let prerenderer = createRemotePrerenderer(url); + try { + await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: 'realm', + realm: 'realm', + url: 'https://example.com/card', + auth: '{}', + }); + expect(false).toBeTruthy(); + } + catch (e: any) { + expect(/status 503/.test(e.message)).toBeTruthy(); + expect(attempts >= 2).toBeTruthy(); + } + finally { + await new Promise((resolve) => server.close(() => resolve())); + delete process.env.PRERENDER_MANAGER_RETRY_ATTEMPTS; + delete process.env.PRERENDER_MANAGER_RETRY_MAX_ELAPSED_MS; + } + }); + it('retries on manager 500 and succeeds', async function () { + process.env.PRERENDER_MANAGER_RETRY_ATTEMPTS = '3'; + process.env.PRERENDER_MANAGER_RETRY_DELAY_MS = '1'; + let attempts = 0; + let server = createServer((_req, res) => { + attempts++; + if (attempts === 1) { + res.statusCode = 500; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + errors: [{ status: 500, message: 'Protocol error' }], + })); + return; + } + res.statusCode = 201; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ + data: { attributes: { ok: true } }, + })); + }).listen(0); + try { + let url = `http://127.0.0.1:${(server.address() as any).port}`; + let prerenderer = createRemotePrerenderer(url); + let result = await prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: 'realm', + realm: 'realm', + url: 'https://example.com/card', + auth: '{}', + }); + expect((result as any).ok).toBe(true); + expect(attempts >= 2).toBeTruthy(); + } + finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + }); +}); +describe("remote-prerenderer-test.ts", function () { + describe('remote prerenderer timeouts', function () { + it('does not retry when the client aborts from request timeout', async function () { + process.env.PRERENDER_MANAGER_RETRY_ATTEMPTS = '3'; + process.env.PRERENDER_MANAGER_RETRY_DELAY_MS = '1'; + process.env.PRERENDER_MANAGER_REQUEST_TIMEOUT_MS = '20'; + let attempts = 0; + let server = createServer((_req, res) => { + attempts++; + // Never respond; let client-side timeout abort the request. + res.on('error', () => { }); + }).listen(0); + try { + let url = `http://127.0.0.1:${(server.address() as any).port}`; + let prerenderer = createRemotePrerenderer(url); + await expect(prerenderer.prerenderCard({ + affinityType: 'realm', + affinityValue: 'realm', + realm: 'realm', + url: 'https://example.com/card', + auth: '{}', + })).rejects.toThrow(/aborted/); + expect(attempts).toBe(1); + } + finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/request-forward.test.ts b/packages/realm-server/tests-vitest/request-forward.test.ts new file mode 100644 index 00000000000..012bb9e9e39 --- /dev/null +++ b/packages/realm-server/tests-vitest/request-forward.test.ts @@ -0,0 +1,581 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { fileURLToPath } from "url"; +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import sinon from 'sinon'; +import type { Test, SuperTest } from 'supertest'; +import supertest from 'supertest'; +import { join, dirname } from 'path'; +import type { Server } from 'http'; +import { dirSync, type DirResult } from 'tmp'; +import { copySync, ensureDirSync } from 'fs-extra'; +import { setupDB, runTestRealmServer, closeServer, createJWT, insertUser, insertPlan, realmSecretSeed, createVirtualNetwork, } from './helpers'; +import { createJWT as createRealmServerJWT } from '../utils/jwt'; +import { addToCreditsLedger, getUserByMatrixUserId, sumUpCreditsLedger, } from '@cardstack/billing/billing-queries'; +import { AllowedProxyDestinations } from '../lib/allowed-proxy-destinations'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +describe("request-forward-test.ts", function () { + describe('Realm-specific Endpoints | _request-forward', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let testRealmHttpServer: Server; + let testRealm: any; + let dbAdapter: any; + let publisher: any; + let runner: any; + let request: SuperTest; + let testRealmDir: string; + let dir: DirResult; + let virtualNetwork = createVirtualNetwork(); + hooks.beforeEach(async function () { + dir = dirSync(); + copySync(join(__dirname, 'cards'), dir.name); + }); + async function startRealmServer(dbAdapter: any, publisher: any, runner: any) { + if (testRealm) { + virtualNetwork.unmount(testRealm.handle); + } + ({ testRealm: testRealm, testRealmHttpServer: testRealmHttpServer } = + await runTestRealmServer({ + virtualNetwork, + testRealmDir, + realmsRootPath: join(dir.name, 'realm_server_2'), + realmURL: new URL('http://127.0.0.1:4445/test/'), + dbAdapter, + publisher, + runner, + matrixURL: new URL('http://localhost:8008'), + })); + request = supertest(testRealmHttpServer); + } + setupDB(hooks, { + beforeEach: async (_dbAdapter, _publisher, _runner) => { + dbAdapter = _dbAdapter; + publisher = _publisher; + runner = _runner; + testRealmDir = join(dir.name, 'realm_server_2', 'test'); + ensureDirSync(testRealmDir); + copySync(join(__dirname, 'cards'), testRealmDir); + // Set up allowed proxy destinations in database BEFORE starting server + await dbAdapter.execute(`INSERT INTO proxy_endpoints (id, url, api_key, credit_strategy, supports_streaming, auth_method, auth_parameter_name, created_at, updated_at) + VALUES + (gen_random_uuid(), 'https://openrouter.ai/api/v1/chat/completions', 'openrouter-api-key', 'openrouter', true, NULL, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (gen_random_uuid(), 'https://api.example.com', 'example-api-key', 'no-credit', false, NULL, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (gen_random_uuid(), 'https://www.googleapis.com/customsearch/v1', 'google-api-key', 'no-credit', false, 'url-parameter', 'key', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (gen_random_uuid(), 'https://gateway.ai.cloudflare.com/v1/4a94a1eb2d21bbbe160234438a49f687/boxel/', 'cloudflare-api-key', 'no-credit', true, 'header', 'cf-aig-authorization', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ON CONFLICT (url) + DO UPDATE SET + api_key = EXCLUDED.api_key, + credit_strategy = EXCLUDED.credit_strategy, + supports_streaming = EXCLUDED.supports_streaming, + updated_at = CURRENT_TIMESTAMP`); + await startRealmServer(dbAdapter, publisher, runner); + // Set up test user + await insertUser(dbAdapter, '@testuser:localhost', 'cus_test123', 'test@example.com'); + // Set up test plan + await insertPlan(dbAdapter, 'Test Plan', 1000, 100, // 100 credits included + 'price_test123'); + // Add extra credits to the user for testing + const user = await getUserByMatrixUserId(dbAdapter, '@testuser:localhost'); + if (user) { + await addToCreditsLedger(dbAdapter, { + userId: user.id, + creditAmount: 50, // Add 50 extra credits + creditType: 'extra_credit', + subscriptionCycleId: null, + }); + } + }, + afterEach: async () => { + AllowedProxyDestinations.reset(); + await closeServer(testRealmHttpServer); + }, + }); + it('should forward request to OpenRouter and deduct credits', async function () { + // Mock external fetch calls + const originalFetch = global.fetch; + const mockFetch = sinon.stub(global, 'fetch'); + // Mock OpenRouter response + const mockOpenRouterResponse = { + id: 'gen-test-123', + choices: [{ text: 'Test response from OpenRouter' }], + usage: { total_tokens: 150 }, + }; + // Mock generation cost API response + const mockCostResponse = { + data: { + id: 'gen-test-123', + total_cost: 0.003, + total_tokens: 150, + model: 'openai/gpt-3.5-turbo', + }, + }; + // Set up fetch to return different responses based on URL + mockFetch.callsFake(async (input: string | URL | Request, _init?: RequestInit) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/generation?id=')) { + return new Response(JSON.stringify(mockCostResponse), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + else if (url.includes('/chat/completions')) { + return new Response(JSON.stringify(mockOpenRouterResponse), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + else { + return new Response(JSON.stringify({ error: 'Not found' }), { + status: 404, + headers: { 'content-type': 'application/json' }, + }); + } + }); + try { + // Create JWT token for authentication + const jwt = createRealmServerJWT({ user: '@testuser:localhost', sessionRoom: 'test-session-room' }, realmSecretSeed); + // Make request to _request-forward endpoint + const response = await request + .post('/_request-forward') + .set('Accept', 'application/json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${jwt}`) + .send({ + url: 'https://openrouter.ai/api/v1/chat/completions', + method: 'POST', + requestBody: JSON.stringify({ + model: 'openai/gpt-3.5-turbo', + messages: [{ role: 'user', content: 'Hello' }], + }), + }); + // Verify response + expect(response.status).toBe(200); + expect(response.body).toEqual(mockOpenRouterResponse); + // Verify fetch was called correctly (allowing unrelated fetches) + const calls = mockFetch.getCalls(); + const chatCallIndex = calls.findIndex((call) => { + const url = call.args[0]; + const href = typeof url === 'string' ? url : url?.toString(); + return Boolean(href && href.includes('/chat/completions')); + }); + const generationCallIndex = calls.findIndex((call) => { + const url = call.args[0]; + const href = typeof url === 'string' ? url : url?.toString(); + return Boolean(href && href.includes('/generation?id=')); + }); + expect(chatCallIndex >= 0).toBe(true); + expect(generationCallIndex >= 0).toBe(true); + expect(chatCallIndex < generationCallIndex).toBe(true); + // Verify authorization header was set correctly + const firstCallHeaders = calls[chatCallIndex].args[1] + ?.headers as Record; + // Note: The actual authorization header will include the JWT token, not the API key + // The API key is added by the proxy handler, not the test + expect(firstCallHeaders?.Authorization?.startsWith('Bearer ')).toBe(true); + } + finally { + mockFetch.restore(); + global.fetch = originalFetch; + } + }); + it('should reject non-whitelisted endpoints', async function () { + const jwt = createJWT(testRealm, '@testuser:localhost'); + const response = await request + .post('/_request-forward') + .set('Accept', 'application/json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${jwt}`) + .send({ + url: 'https://malicious-api.com/v1/chat/completions', + method: 'POST', + requestBody: JSON.stringify({ + model: 'gpt-3.5-turbo', + messages: [{ role: 'user', content: 'Hello' }], + }), + }); + expect(response.status).toBe(400); + expect(response.body.errors?.[0]?.includes('not whitelisted')).toBe(true); + }); + it('should handle streaming requests', async function () { + // Mock external fetch calls + const originalFetch = global.fetch; + const mockFetch = sinon.stub(global, 'fetch'); + // Mock streaming response + const mockStreamResponse = new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('data: {"id":"gen-stream-123","choices":[{"text":"Hello"}]}\n\n')); + controller.enqueue(new TextEncoder().encode('data: {"choices":[{"text":" world"}]}\n\n')); + controller.enqueue(new TextEncoder().encode('data: [DONE]\n\n')); + controller.close(); + }, + }), { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + }); + // Mock generation cost API response + const mockCostResponse = { + data: { + id: 'gen-stream-123', + total_cost: 0.002, + total_tokens: 100, + model: 'openai/gpt-3.5-turbo', + }, + }; + // Set up fetch to return different responses based on URL + mockFetch.callsFake(async (input: string | URL | Request, _init?: RequestInit) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/generation?id=')) { + return new Response(JSON.stringify(mockCostResponse), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + else if (url.includes('/chat/completions')) { + return mockStreamResponse; + } + else { + return new Response(JSON.stringify({ error: 'Not found' }), { + status: 404, + headers: { 'content-type': 'application/json' }, + }); + } + }); + try { + const jwt = createJWT(testRealm, '@testuser:localhost'); + const response = await request + .post('/_request-forward') + .set('Accept', 'text/event-stream') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${jwt}`) + .send({ + url: 'https://openrouter.ai/api/v1/chat/completions', + method: 'POST', + requestBody: JSON.stringify({ + model: 'openai/gpt-3.5-turbo', + messages: [{ role: 'user', content: 'Hello' }], + stream: true, + }), + stream: true, + }); + // Verify streaming response headers + expect(response.status).toBe(200); + // Note: content-type header is not captured by supertest for streaming responses + // because it's sent immediately with flushHeaders(), but we can verify other SSE headers + expect(response.headers['cache-control']).toBe('no-cache, no-store, must-revalidate'); + expect(response.headers['connection']).toBe('keep-alive'); + expect(response.headers['x-accel-buffering']).toBe('no'); + // Verify streaming response body + const responseText = response.text; + expect(responseText.includes('data: {"id":"gen-stream-123"')).toBe(true); + expect(responseText.includes('data: {"choices":[{"text":" world"}]}')).toBe(true); + expect(responseText.includes('data: [DONE]')).toBe(true); + } + finally { + mockFetch.restore(); + global.fetch = originalFetch; + } + }); + it('should reject streaming for non-streaming endpoints', async function () { + const jwt = createRealmServerJWT({ user: '@testuser:localhost', sessionRoom: 'test-session-room' }, realmSecretSeed); + const response = await request + .post('/_request-forward') + .set('Accept', 'application/json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${jwt}`) + .send({ + url: 'https://api.example.com/v1/chat/completions', + method: 'POST', + requestBody: JSON.stringify({ + model: 'gpt-3.5-turbo', + messages: [{ role: 'user', content: 'Hello' }], + stream: true, + }), + stream: true, + }); + expect(response.status).toBe(400); + expect(response.body.errors?.[0]?.includes('Streaming is not supported')).toBe(true); + }); + it('should handle insufficient credits', async function () { + // First, reduce the user's credits below the minimum + const user = await getUserByMatrixUserId(dbAdapter, '@testuser:localhost'); + if (user) { + // Calculate current credits and deduct to get below minimum + const currentCredits = await sumUpCreditsLedger(dbAdapter, { + creditType: ['extra_credit', 'extra_credit_used'], + userId: user.id, + }); + // Deduct enough to get below the minimum (10 credits) + const creditsToDeduct = currentCredits + 1; // This ensures we go below 10 + await addToCreditsLedger(dbAdapter, { + userId: user.id, + creditAmount: -creditsToDeduct, + creditType: 'extra_credit_used', + subscriptionCycleId: null, + }); + } + const jwt = createRealmServerJWT({ user: '@testuser:localhost', sessionRoom: 'test-session-room' }, realmSecretSeed); + const response = await request + .post('/_request-forward') + .set('Accept', 'application/json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${jwt}`) + .send({ + url: 'https://openrouter.ai/api/v1/chat/completions', + method: 'POST', + requestBody: JSON.stringify({ + model: 'openai/gpt-3.5-turbo', + messages: [{ role: 'user', content: 'Hello' }], + }), + }); + // Should return 403 Forbidden due to insufficient credits + expect(response.status).toBe(403); + expect(response.body.errors?.[0]?.includes('minimum of 10 credits')).toBe(true); + }); + it('should handle missing authentication token', async function () { + const response = await request + .post('/_request-forward') + .set('Accept', 'application/json') + .set('Content-Type', 'application/json') + .send({ + url: 'https://openrouter.ai/api/v1/chat/completions', + method: 'POST', + requestBody: JSON.stringify({ + model: 'openai/gpt-3.5-turbo', + messages: [{ role: 'user', content: 'Hello' }], + }), + }); + expect(response.status).toBe(401); + expect(response.body.errors?.[0]?.includes('Missing Authorization header')).toBe(true); + }); + it('should handle invalid request body', async function () { + const jwt = createRealmServerJWT({ user: '@testuser:localhost', sessionRoom: 'test-session-room' }, realmSecretSeed); + const response = await request + .post('/_request-forward') + .set('Accept', 'application/json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${jwt}`) + .send({ + // Missing required fields + url: 'https://openrouter.ai/api/v1/chat/completions', + }); + expect(response.status).toBe(400); + expect(response.body.errors?.[0]?.includes('must include url and method fields')).toBe(true); + }); + it('should forward request to Google Custom Search API with URL parameter authentication', async function () { + // Mock external fetch calls + const originalFetch = global.fetch; + const mockFetch = sinon.stub(global, 'fetch'); + // Mock Google Custom Search API response + const mockGoogleResponse = { + items: [ + { + cardTitle: 'Test Image 1', + link: 'https://example.com/image1.jpg', + image: { + thumbnailLink: 'https://example.com/thumb1.jpg', + contextLink: 'https://example.com/page1', + width: 800, + height: 600, + }, + }, + ], + searchInformation: { + totalResults: '1', + searchTime: 0.5, + }, + }; + // Set up fetch to return Google response + mockFetch.callsFake(async (input: string | URL | Request, _init?: RequestInit) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('googleapis.com/customsearch/v1')) { + return new Response(JSON.stringify(mockGoogleResponse), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + else { + return new Response(JSON.stringify({ error: 'Not found' }), { + status: 404, + headers: { 'content-type': 'application/json' }, + }); + } + }); + try { + // Create JWT token for authentication + const jwt = createRealmServerJWT({ user: '@testuser:localhost', sessionRoom: 'test-session-room' }, realmSecretSeed); + // Make request to _request-forward endpoint + const response = await request + .post('/_request-forward') + .set('Accept', 'application/json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${jwt}`) + .send({ + url: 'https://www.googleapis.com/customsearch/v1?q=test&searchType=image&num=10', + method: 'GET', + }); + // Verify response + expect(response.status).toBe(200); + expect(response.body).toEqual(mockGoogleResponse); + // Verify fetch was called correctly + expect(mockFetch.calledOnce).toBe(true); + const calls = mockFetch.getCalls(); + // Check that the URL includes the API key as a parameter + const callUrl = calls[0].args[0]; + const url = typeof callUrl === 'string' ? callUrl : callUrl.toString(); + expect(url.includes('key=google-api-key')).toBe(true); + expect(url.includes('q=test')).toBe(true); + expect(url.includes('searchType=image')).toBe(true); + expect(url.includes('num=10')).toBe(true); + // Verify no authorization header was set (since we're using URL parameters) + const callHeaders = calls[0].args[1]?.headers as Record; + expect(callHeaders?.Authorization).toBeFalsy(); + } + finally { + mockFetch.restore(); + global.fetch = originalFetch; + } + }); + it('should forward request to Cloudflare AI Gateway with custom header token authentication', async function () { + // Mock external fetch calls + const originalFetch = global.fetch; + const mockFetch = sinon.stub(global, 'fetch'); + // Mock Cloudflare AI Gateway response + const mockResponse = { + example: 'ok', + }; + // Set up fetch to return Cloudflare response + mockFetch.callsFake(async (input: string | URL | Request, _init?: RequestInit) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('gateway.ai.cloudflare.com/v1/4a94a1eb2d21bbbe160234438a49f687/boxel/')) { + return new Response(JSON.stringify(mockResponse), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + else { + return new Response(JSON.stringify({ error: 'Not found' }), { + status: 404, + headers: { 'content-type': 'application/json' }, + }); + } + }); + try { + // Create JWT token for authentication + const jwt = createRealmServerJWT({ user: '@testuser:localhost', sessionRoom: 'test-session-room' }, realmSecretSeed); + // Make request to _request-forward endpoint + const response = await request + .post('/_request-forward') + .set('Accept', 'application/json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${jwt}`) + .send({ + url: 'https://gateway.ai.cloudflare.com/v1/4a94a1eb2d21bbbe160234438a49f687/boxel/replicate/predictions', + method: 'POST', + requestBody: JSON.stringify({ + input: { prompt: 'What is Cloudflare?' }, + }), + }); + // Verify response + expect(response.status).toBe(200); + expect(response.body).toEqual(mockResponse); + // Verify fetch was called correctly (at least once—there can be unrelated background fetches) + expect(mockFetch.callCount >= 1).toBe(true); + const calls = mockFetch.getCalls(); + // Find the Cloudflare call we care about + const cloudflareCall = calls.find((call) => { + const urlArg = call.args[0]; + const url = typeof urlArg === 'string' ? urlArg : urlArg.toString(); + return url.includes('gateway.ai.cloudflare.com/v1/4a94a1eb2d21bbbe160234438a49f687/boxel/'); + }); + expect(cloudflareCall).toBeTruthy(); + const callHeaders = cloudflareCall!.args[1]?.headers as Record; + expect(callHeaders['cf-aig-authorization']).toBe('Bearer cloudflare-api-key'); + // Verify no authorization header was set (since we're storing the replicate token at cloudflare) + expect(callHeaders.Authorization).toBeFalsy(); + } + finally { + mockFetch.restore(); + global.fetch = originalFetch; + } + }); + it('should forward multipart form data when multipart flag is set', async function () { + const originalFetch = global.fetch; + const mockFetch = sinon.stub(global, 'fetch'); + let capturedInit: RequestInit | undefined; + mockFetch.callsFake(async (_input: string | URL | Request, init?: RequestInit) => { + capturedInit = init; + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }); + try { + const jwt = createRealmServerJWT({ user: '@testuser:localhost', sessionRoom: 'test-session-room' }, realmSecretSeed); + const response = await request + .post('/_request-forward') + .set('Accept', 'application/json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${jwt}`) + .send({ + url: 'https://api.example.com/upload', + method: 'POST', + multipart: true, + requestBody: JSON.stringify({ + name: 'Test', + requireSigned: false, + file: { + filename: 'hello.txt', + content: Buffer.from('hello world', 'utf-8').toString('base64'), + contentType: 'text/plain', + }, + }), + }); + expect(response.status).toBe(200); + expect(response.body).toEqual({ ok: true }); + expect(capturedInit).toBeTruthy(); + const headersRecord = capturedInit?.headers instanceof Headers + ? Object.fromEntries(capturedInit.headers.entries()) + : ((capturedInit?.headers as Record | undefined) ?? + {}); + const contentTypeHeader = headersRecord['Content-Type']; + expect(contentTypeHeader).toBeTruthy(); + const boundaryMatch = /multipart\/form-data; boundary=(.*)$/.exec(contentTypeHeader as string); + expect(boundaryMatch).toBeTruthy(); + const boundary = boundaryMatch?.[1]; + expect(boundary).toBeTruthy(); + const bodyText = Buffer.from(capturedInit?.body as Uint8Array).toString('utf-8'); + expect(bodyText.includes(`--${boundary}`)).toBe(true); + expect(bodyText.includes(`Content-Disposition: form-data; name="name"`)).toBe(true); + expect(bodyText.includes('Test')).toBe(true); + expect(bodyText.includes(`name="file"; filename="hello.txt"`)).toBe(true); + expect(bodyText.includes('Content-Type: text/plain')).toBe(true); + expect(bodyText.includes('hello world')).toBe(true); + } + finally { + mockFetch.restore(); + global.fetch = originalFetch; + } + }); + it('should return a 400 when multipart payload is not an object', async function () { + const jwt = createRealmServerJWT({ user: '@testuser:localhost', sessionRoom: 'test-session-room' }, realmSecretSeed); + const response = await request + .post('/_request-forward') + .set('Accept', 'application/json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${jwt}`) + .send({ + url: 'https://api.example.com/upload', + method: 'POST', + multipart: true, + requestBody: JSON.stringify(['not-an-object']), + }); + expect(response.status).toBe(400); + expect(response.body.errors?.[0]?.includes('requestBody must be a JSON object when multipart is true')).toBe(true); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/run-command-task.test.ts b/packages/realm-server/tests-vitest/run-command-task.test.ts new file mode 100644 index 00000000000..ea3a053be9d --- /dev/null +++ b/packages/realm-server/tests-vitest/run-command-task.test.ts @@ -0,0 +1,20 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect } from "vitest"; +import { runSharedTest } from '@cardstack/runtime-common/helpers'; +import runCommandTaskTests from '@cardstack/runtime-common/tests/run-command-task-shared-tests'; +describe("run-command-task-test.ts", function () { + describe('run-command task', function () { + it('returns error when runAs has no realm permissions', async function () { + await runSharedTest(runCommandTaskTests, assert, {}); + }); + it('returns error when command specifier is invalid', async function () { + await runSharedTest(runCommandTaskTests, assert, {}); + }); + it('normalizes legacy /commands URL and defaults export name', async function () { + await runSharedTest(runCommandTaskTests, assert, {}); + }); + it('passes scoped command through unchanged', async function () { + await runSharedTest(runCommandTaskTests, assert, {}); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/runtime-dependency-tracker.test.ts b/packages/realm-server/tests-vitest/runtime-dependency-tracker.test.ts new file mode 100644 index 00000000000..d9e506862f7 --- /dev/null +++ b/packages/realm-server/tests-vitest/runtime-dependency-tracker.test.ts @@ -0,0 +1,250 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import { Loader, beginRuntimeDependencyTrackingSession, endRuntimeDependencyTrackingSession, resetRuntimeDependencyTracker, snapshotRuntimeDependencies, trackRuntimeFileDependency, trackRuntimeInstanceDependency, trackRuntimeModuleDependency, withRuntimeDependencyTrackingContext, } from '@cardstack/runtime-common'; +describe("runtime-dependency-tracker-test.ts", function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + hooks.afterEach(() => { + endRuntimeDependencyTrackingSession(); + resetRuntimeDependencyTracker(); + }); + it('resets tracked deps between sessions', async function () { + beginRuntimeDependencyTrackingSession({ + sessionKey: 'session-a', + rootURL: 'https://example.com/root-a.json', + rootKind: 'instance', + }); + await withRuntimeDependencyTrackingContext({ + mode: 'non-query', + source: 'test:session-a', + consumer: 'https://example.com/root-a.json', + consumerKind: 'instance', + }, async () => { + trackRuntimeInstanceDependency('https://example.com/first-dep'); + }); + let firstSnapshot = snapshotRuntimeDependencies({ excludeQueryOnly: true }); + expect(firstSnapshot.deps.includes('https://example.com/first-dep.json')).toBe(true); + beginRuntimeDependencyTrackingSession({ + sessionKey: 'session-b', + rootURL: 'https://example.com/root-b.json', + rootKind: 'instance', + }); + let secondSnapshot = snapshotRuntimeDependencies({ + excludeQueryOnly: true, + }); + expect(secondSnapshot.deps).toEqual([]); + }); + it('classifies async query-context deps as query-only', async function () { + beginRuntimeDependencyTrackingSession({ + sessionKey: 'async-query', + rootURL: 'https://example.com/root.json', + rootKind: 'instance', + }); + await withRuntimeDependencyTrackingContext({ + mode: 'query', + queryField: 'matches', + source: 'test:async-query', + consumer: 'https://example.com/root.json', + consumerKind: 'instance', + }, async () => { + await Promise.resolve(); + trackRuntimeInstanceDependency('https://example.com/query-target'); + }); + let snapshot = snapshotRuntimeDependencies({ excludeQueryOnly: true }); + expect(snapshot.deps.includes('https://example.com/query-target.json')).toBeFalsy(); + expect(snapshot.excludedQueryOnlyDeps.includes('https://example.com/query-target.json')).toBe(true); + }); + it('keeps unscoped module accesses and reports them as unscoped', async function () { + beginRuntimeDependencyTrackingSession({ + sessionKey: 'unscoped-query', + rootURL: 'https://example.com/root.json', + rootKind: 'instance', + }); + await withRuntimeDependencyTrackingContext({ + mode: 'query', + queryField: 'matches', + source: 'test:unscoped-query', + }, async () => { + await Promise.resolve(); + trackRuntimeModuleDependency('https://example.com/query-module.gts'); + }); + let snapshot = snapshotRuntimeDependencies({ excludeQueryOnly: true }); + expect(snapshot.deps.includes('https://example.com/query-module')).toBe(true); + expect(snapshot.unscopedDeps.includes('https://example.com/query-module')).toBe(true); + expect(snapshot.excludedQueryOnlyDeps.includes('https://example.com/query-module')).toBeFalsy(); + }); + it('retains dep seen in both query and non-query contexts', async function () { + beginRuntimeDependencyTrackingSession({ + sessionKey: 'query-non-query-overlap', + rootURL: 'https://example.com/root.json', + rootKind: 'instance', + }); + await withRuntimeDependencyTrackingContext({ + mode: 'query', + queryField: 'matches', + source: 'test:query-overlap', + consumer: 'https://example.com/root.json', + consumerKind: 'instance', + }, async () => { + trackRuntimeInstanceDependency('https://example.com/shared-target'); + }); + await withRuntimeDependencyTrackingContext({ + mode: 'non-query', + source: 'test:non-query-overlap', + consumer: 'https://example.com/root.json', + consumerKind: 'instance', + }, async () => { + trackRuntimeInstanceDependency('https://example.com/shared-target'); + }); + let snapshot = snapshotRuntimeDependencies({ excludeQueryOnly: true }); + expect(snapshot.deps.includes('https://example.com/shared-target.json')).toBe(true); + expect(snapshot.excludedQueryOnlyDeps.includes('https://example.com/shared-target.json')).toBeFalsy(); + }); + it('excludes root from deps even when tracked directly', async function () { + beginRuntimeDependencyTrackingSession({ + sessionKey: 'root-exclusion', + rootURL: 'https://example.com/root.json', + rootKind: 'instance', + }); + await withRuntimeDependencyTrackingContext({ + mode: 'non-query', + source: 'test:root-exclusion', + consumer: 'https://example.com/root.json', + consumerKind: 'instance', + }, async () => { + trackRuntimeInstanceDependency('https://example.com/root.json'); + trackRuntimeInstanceDependency('https://example.com/other-dep'); + }); + let snapshot = snapshotRuntimeDependencies({ excludeQueryOnly: true }); + expect(snapshot.deps.includes('https://example.com/root.json')).toBeFalsy(); + expect(snapshot.deps.includes('https://example.com/other-dep.json')).toBe(true); + }); + it('excludes file root across extensionless and .json aliases', async function () { + beginRuntimeDependencyTrackingSession({ + sessionKey: 'file-root-alias-exclusion', + rootURL: 'https://example.com/file-root', + rootKind: 'file', + }); + await withRuntimeDependencyTrackingContext({ + mode: 'non-query', + source: 'test:file-root-alias-exclusion', + consumer: 'https://example.com/file-root', + consumerKind: 'file', + }, async () => { + trackRuntimeFileDependency('https://example.com/file-root'); + trackRuntimeInstanceDependency('https://example.com/file-root'); + trackRuntimeInstanceDependency('https://example.com/other-instance'); + }); + let snapshot = snapshotRuntimeDependencies({ excludeQueryOnly: true }); + expect(snapshot.deps.includes('https://example.com/file-root')).toBeFalsy(); + expect(snapshot.deps.includes('https://example.com/file-root.json')).toBeFalsy(); + expect(snapshot.deps.includes('https://example.com/other-instance.json')).toBe(true); + }); + it('explicit dependency contexts remain isolated across overlapping async work', async function () { + beginRuntimeDependencyTrackingSession({ + sessionKey: 'explicit-overlap-contexts', + rootURL: 'https://example.com/root.json', + rootKind: 'instance', + }); + let releaseQuery: (() => void) | undefined; + let releaseNonQuery: (() => void) | undefined; + let queryGate = new Promise((resolve) => (releaseQuery = resolve)); + let nonQueryGate = new Promise((resolve) => (releaseNonQuery = resolve)); + let queryContext = { + mode: 'query' as const, + queryField: 'matches', + source: 'test:explicit-query-overlap', + consumer: 'https://example.com/query-consumer.json', + consumerKind: 'instance' as const, + }; + let nonQueryContext = { + mode: 'non-query' as const, + source: 'test:explicit-non-query-overlap', + consumer: 'https://example.com/non-query-consumer.json', + consumerKind: 'instance' as const, + }; + let queryPromise = (async () => { + await queryGate; + trackRuntimeInstanceDependency('https://example.com/query-target', queryContext); + })(); + let nonQueryPromise = (async () => { + await nonQueryGate; + trackRuntimeInstanceDependency('https://example.com/non-query-target', nonQueryContext); + })(); + releaseQuery!(); + await Promise.resolve(); + releaseNonQuery!(); + await Promise.all([queryPromise, nonQueryPromise]); + let snapshot = snapshotRuntimeDependencies({ excludeQueryOnly: true }); + expect(snapshot.deps.includes('https://example.com/query-target.json')).toBeFalsy(); + expect(snapshot.excludedQueryOnlyDeps.includes('https://example.com/query-target.json')).toBe(true); + expect(snapshot.deps.includes('https://example.com/non-query-target.json')).toBe(true); + }); + it('tracks module deps on loader cache hits without refetch', async function () { + let fetchCount = 0; + let loader = new Loader(async () => { + fetchCount++; + return new Response('export const value = 1;', { status: 200 }); + }); + let moduleURL = 'https://example.com/cards/cached-module.gts'; + beginRuntimeDependencyTrackingSession({ + sessionKey: 'loader-cache-first', + rootURL: 'https://example.com/root-a.json', + rootKind: 'instance', + }); + await withRuntimeDependencyTrackingContext({ + mode: 'non-query', + source: 'test:loader-cache-first', + consumer: 'https://example.com/root-a.json', + consumerKind: 'instance', + }, async () => { + await loader.import(moduleURL); + }); + let firstSnapshot = snapshotRuntimeDependencies({ excludeQueryOnly: true }); + expect(firstSnapshot.deps.includes('https://example.com/cards/cached-module')).toBe(true); + expect(fetchCount).toBe(1); + beginRuntimeDependencyTrackingSession({ + sessionKey: 'loader-cache-second', + rootURL: 'https://example.com/root-b.json', + rootKind: 'instance', + }); + await withRuntimeDependencyTrackingContext({ + mode: 'non-query', + source: 'test:loader-cache-second', + consumer: 'https://example.com/root-b.json', + consumerKind: 'instance', + }, async () => { + await loader.import(moduleURL); + }); + let secondSnapshot = snapshotRuntimeDependencies({ + excludeQueryOnly: true, + }); + expect(secondSnapshot.deps.includes('https://example.com/cards/cached-module')).toBe(true); + expect(fetchCount).toBe(1); + }); + it('getter-level attribution tracks only accessed relationship targets', async function () { + beginRuntimeDependencyTrackingSession({ + sessionKey: 'getter-level-attribution', + rootURL: 'https://example.com/root.json', + rootKind: 'instance', + }); + // This models relationship getter-level attribution: we only track the link + // whose getter was actually consumed during render. + await withRuntimeDependencyTrackingContext({ + mode: 'non-query', + source: 'test:getter-level-attribution', + consumer: 'https://example.com/root.json', + consumerKind: 'instance', + }, async () => { + trackRuntimeInstanceDependency('https://example.com/rendered-link'); + // intentionally do not track hidden-link because its getter was not read + }); + let snapshot = snapshotRuntimeDependencies({ excludeQueryOnly: true }); + expect(snapshot.deps.includes('https://example.com/rendered-link.json')).toBe(true); + expect(snapshot.deps.includes('https://example.com/hidden-link.json')).toBeFalsy(); + }); +}); diff --git a/packages/realm-server/tests-vitest/sanitize-head-html.test.ts b/packages/realm-server/tests-vitest/sanitize-head-html.test.ts new file mode 100644 index 00000000000..d89bce46aab --- /dev/null +++ b/packages/realm-server/tests-vitest/sanitize-head-html.test.ts @@ -0,0 +1,175 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect } from "vitest"; +import { JSDOM } from 'jsdom'; +import { sanitizeHeadHTML, sanitizeHeadHTMLToString, findDisallowedHeadTags, } from '@cardstack/runtime-common'; +function makeDoc() { + return new JSDOM().window.document; +} +describe("sanitize-head-html-test.ts", function () { + describe('sanitizeHeadHTML', function () { + it('allows title, meta, and link tags', function () { + let doc = makeDoc(); + let html = 'Test'; + let fragment = sanitizeHeadHTML(html, doc); + expect(fragment).toBeTruthy(); + let container = doc.createElement('div'); + container.appendChild(fragment!); + expect(container.querySelector('title')).toBeTruthy(); + expect(container.querySelector('meta[name="description"]')).toBeTruthy(); + expect(container.querySelector('link[rel="canonical"]')).toBeTruthy(); + }); + const strippedTags: { + tag: string; + html: string; + }[] = [ + { tag: 'script', html: '' }, + { tag: 'style', html: '' }, + { tag: 'noscript', html: '' }, + { tag: 'base', html: '' }, + { tag: 'div', html: '
bad
' }, + { tag: 'h1', html: '

heading

' }, + { tag: 'p', html: '

paragraph

' }, + ]; + for (let { tag, html: disallowedHtml } of strippedTags) { + it(`strips ${tag} tags`, function () { + let doc = makeDoc(); + let html = `Test${disallowedHtml}`; + let fragment = sanitizeHeadHTML(html, doc); + expect(fragment).toBeTruthy(); + let container = doc.createElement('div'); + container.appendChild(fragment!); + expect(container.querySelector('title')).toBeTruthy(); + expect(container.querySelector(tag)).toBeFalsy(); + }); + } + it('returns null when all content is disallowed', function () { + let doc = makeDoc(); + let html = ''; + let fragment = sanitizeHeadHTML(html, doc); + expect(fragment).toBe(null); + }); + it('returns null for empty string', function () { + let doc = makeDoc(); + let fragment = sanitizeHeadHTML('', doc); + expect(fragment).toBe(null); + }); + it('strips disallowed attributes from meta tags', function () { + let doc = makeDoc(); + let html = ''; + let fragment = sanitizeHeadHTML(html, doc); + expect(fragment).toBeTruthy(); + let container = doc.createElement('div'); + container.appendChild(fragment!); + let meta = container.querySelector('meta'); + expect(meta).toBeTruthy(); + expect(meta!.getAttribute('name')).toBe('description'); + expect(meta!.getAttribute('content')).toBe('test'); + expect(meta!.hasAttribute('onclick')).toBeFalsy(); + expect(meta!.hasAttribute('data-custom')).toBeFalsy(); + }); + it('strips link tags with unsafe rel values', function () { + let doc = makeDoc(); + let html = ''; + let fragment = sanitizeHeadHTML(html, doc); + expect(fragment).toBeTruthy(); + let container = doc.createElement('div'); + container.appendChild(fragment!); + let links = container.querySelectorAll('link'); + expect(links.length).toBe(1); + expect(links[0].getAttribute('rel')).toBe('canonical'); + }); + it('strips link tags with javascript: href', function () { + let doc = makeDoc(); + let html = ''; + let fragment = sanitizeHeadHTML(html, doc); + expect(fragment).toBe(null); + }); + }); + describe('sanitizeHeadHTMLToString', function () { + it('returns sanitized HTML as a string', function () { + let doc = makeDoc(); + let html = 'Test'; + let result = sanitizeHeadHTMLToString(html, doc); + expect(result).toBeTruthy(); + expect(result!.includes('')).toBeTruthy(); + expect(result!.includes('<meta')).toBeTruthy(); + expect(result!.includes('<script')).toBeFalsy(); + expect(result!.includes('alert')).toBeFalsy(); + }); + it('returns null when all content is disallowed', function () { + let doc = makeDoc(); + let result = sanitizeHeadHTMLToString('<script>alert(1)</script>', doc); + expect(result).toBe(null); + }); + it('returns null for empty string', function () { + let doc = makeDoc(); + let result = sanitizeHeadHTMLToString('', doc); + expect(result).toBe(null); + }); + }); + describe('findDisallowedHeadTags', function () { + it('returns empty array for valid content', function () { + let doc = makeDoc(); + let html = '<title>Test'; + let result = findDisallowedHeadTags(html, doc); + expect(result).toEqual([]); + }); + const detectedTags: { + tag: string; + html: string; + }[] = [ + { tag: 'script', html: '' }, + { tag: 'style', html: '' }, + { tag: 'noscript', html: '' }, + { tag: 'base', html: '' }, + ]; + for (let { tag, html: disallowedHtml } of detectedTags) { + it(`detects ${tag} tags`, function () { + let doc = makeDoc(); + let html = `Test${disallowedHtml}`; + let result = findDisallowedHeadTags(html, doc); + expect(result).toEqual([tag]); + }); + } + it('detects multiple disallowed tag types', function () { + let doc = makeDoc(); + let html = ''; + let result = findDisallowedHeadTags(html, doc); + expect(result.includes('script')).toBeTruthy(); + expect(result.includes('style')).toBeTruthy(); + expect(result.includes('noscript')).toBeTruthy(); + expect(result.includes('base')).toBeTruthy(); + }); + it('deduplicates repeated disallowed tags', function () { + let doc = makeDoc(); + let html = ''; + let result = findDisallowedHeadTags(html, doc); + expect(result).toEqual(['script']); + }); + it('returns empty array for empty string', function () { + let doc = makeDoc(); + let result = findDisallowedHeadTags('', doc); + expect(result).toEqual([]); + }); + it('detects arbitrary HTML elements', function () { + let doc = makeDoc(); + let html = '
bad

heading

'; + let result = findDisallowedHeadTags(html, doc); + expect(result.includes('div')).toBeTruthy(); + expect(result.includes('h1')).toBeTruthy(); + }); + it('ignores wrapper elements when they only contain allowlisted descendants', function () { + let doc = makeDoc(); + let html = '
Test
'; + let result = findDisallowedHeadTags(html, doc); + expect(result).toEqual([]); + }); + it('detects disallowed nested tags even when wrapped', function () { + let doc = makeDoc(); + let html = '
Test
'; + let result = findDisallowedHeadTags(html, doc); + expect(result.includes('script')).toBeTruthy(); + expect(result.includes('div')).toBeFalsy(); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/scripts/boot_preseeded.sh b/packages/realm-server/tests-vitest/scripts/boot_preseeded.sh new file mode 100755 index 00000000000..10debaed497 --- /dev/null +++ b/packages/realm-server/tests-vitest/scripts/boot_preseeded.sh @@ -0,0 +1,23 @@ +#!/bin/sh +set -eu + +mkdir -p /var/lib/postgresql +tar -xf /seed/pgdata.tar -C /var/lib/postgresql +chown -R postgres:postgres /var/lib/postgresql/data + +exec docker-entrypoint.sh postgres \ + -c fsync=off \ + -c full_page_writes=off \ + -c synchronous_commit=off \ + -c shared_buffers=16MB \ + -c max_connections=20 \ + -c wal_level=minimal \ + -c max_wal_senders=0 \ + -c max_replication_slots=0 \ + -c autovacuum=off \ + -c track_counts=off \ + -c track_activities=off \ + -c jit=off \ + -c huge_pages=off \ + -c unix_socket_directories='' \ + -c listen_addresses='0.0.0.0' diff --git a/packages/realm-server/tests-vitest/scripts/create_seeded_db.sh b/packages/realm-server/tests-vitest/scripts/create_seeded_db.sh new file mode 100755 index 00000000000..fa72e80580c --- /dev/null +++ b/packages/realm-server/tests-vitest/scripts/create_seeded_db.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/../../../.." && pwd)" +POSTGRES_PKG_DIR="${ROOT_DIR}/packages/postgres" +source "${SCRIPT_DIR}/test-pg-config.sh" + +cleanup() { + docker rm -f "$TEST_PG_SEED_CONTAINER" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +mkdir -p "$TEST_PG_CACHE_DIR" + +docker rm -f "$TEST_PG_SEED_CONTAINER" >/dev/null 2>&1 || true + +cid=$(docker run -d \ + --name "$TEST_PG_SEED_CONTAINER" \ + -p "127.0.0.1:${TEST_PG_SEED_PORT}:5432" \ + -e POSTGRES_HOST_AUTH_METHOD=trust \ + postgres:16.3-alpine \ + -c fsync=off \ + -c full_page_writes=off \ + -c synchronous_commit=off) +"${SCRIPT_DIR}/wait-for-container-pg.sh" "$TEST_PG_SEED_CONTAINER" "$cid" + +docker exec "$TEST_PG_SEED_CONTAINER" psql -U postgres -d postgres -v ON_ERROR_STOP=1 -c \ + "CREATE DATABASE ${TEST_PG_SEED_DB};" + +( + cd "$POSTGRES_PKG_DIR" + + PGHOST=127.0.0.1 \ + PGPORT="${TEST_PG_SEED_PORT}" \ + PGUSER=postgres \ + PGDATABASE="${TEST_PG_SEED_DB}" \ + pnpm exec node-pg-migrate \ + --migrations-table migrations \ + --check-order false \ + --no-verbose \ + up +) + +# Pre-create a template DB in the seed for future test-db cloning paths. +docker exec "$TEST_PG_SEED_CONTAINER" psql -U postgres -d postgres -v ON_ERROR_STOP=1 -c \ + "CREATE DATABASE ${TEST_PG_SEED_DB}_template TEMPLATE ${TEST_PG_SEED_DB};" +docker exec "$TEST_PG_SEED_CONTAINER" psql -U postgres -d postgres -v ON_ERROR_STOP=1 -c \ + "ALTER DATABASE ${TEST_PG_SEED_DB}_template WITH IS_TEMPLATE true;" + +# Clean shutdown so PGDATA is consistent before snapshotting. +docker stop "$TEST_PG_SEED_CONTAINER" >/dev/null + +# Snapshot PGDATA. +docker cp "$TEST_PG_SEED_CONTAINER":/var/lib/postgresql/data - > "$TEST_PG_SEED_TAR" + +docker rm "$TEST_PG_SEED_CONTAINER" >/dev/null +trap - EXIT + +echo "Seed snapshot written to $TEST_PG_SEED_TAR" diff --git a/packages/realm-server/tests-vitest/scripts/prepare-test-pg.sh b/packages/realm-server/tests-vitest/scripts/prepare-test-pg.sh new file mode 100755 index 00000000000..0b5dcd9ef56 --- /dev/null +++ b/packages/realm-server/tests-vitest/scripts/prepare-test-pg.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/../../../.." && pwd)" +source "${SCRIPT_DIR}/test-pg-config.sh" + +compute_seed_fingerprint() { + ( + cd "$ROOT_DIR" + # Use POSIX cksum + line-based sort for portability across GNU/BSD userlands. + find packages/postgres/migrations -type f -exec cksum {} + \ + | LC_ALL=C sort + cksum \ + packages/realm-server/tests/scripts/create_seeded_db.sh \ + packages/realm-server/tests/scripts/test-pg-config.sh + ) | cksum | awk '{ print $1 "-" $2 }' +} + +mkdir -p "$TEST_PG_CACHE_DIR" + +"${SCRIPT_DIR}/stop-test-pg.sh" + +current_fingerprint="$(compute_seed_fingerprint)" +previous_fingerprint="" +if [ -f "$TEST_PG_SEED_FINGERPRINT" ]; then + previous_fingerprint="$(cat "$TEST_PG_SEED_FINGERPRINT")" +fi + +if [ ! -f "$TEST_PG_SEED_TAR" ] || [ "$current_fingerprint" != "$previous_fingerprint" ]; then + if [ ! -f "$TEST_PG_SEED_TAR" ]; then + echo "Building seeded test postgres tar (missing seed tar)" + else + echo "Rebuilding seeded test postgres tar (migration fingerprint changed)" + fi + "${SCRIPT_DIR}/create_seeded_db.sh" + printf '%s\n' "$current_fingerprint" > "$TEST_PG_SEED_FINGERPRINT" +else + echo "Seeded test postgres tar is up to date" +fi + +"${SCRIPT_DIR}/start-test-pg.sh" diff --git a/packages/realm-server/tests-vitest/scripts/run-vitest-with-test-pg.sh b/packages/realm-server/tests-vitest/scripts/run-vitest-with-test-pg.sh new file mode 100755 index 00000000000..94affc2bdaf --- /dev/null +++ b/packages/realm-server/tests-vitest/scripts/run-vitest-with-test-pg.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +"${SCRIPT_DIR}/prepare-test-pg.sh" +trap '"${SCRIPT_DIR}/stop-test-pg.sh" >/dev/null 2>&1 || true' EXIT INT TERM + +BASE_LOG_LEVELS="*=error,prerenderer-chrome=silent,pg-adapter=warn,realm:requests=warn" +EXTRA_LOG_LEVELS="${LOG_LEVELS-}" +if [ -n "$EXTRA_LOG_LEVELS" ]; then + EFFECTIVE_LOG_LEVELS="${BASE_LOG_LEVELS},${EXTRA_LOG_LEVELS}" +else + EFFECTIVE_LOG_LEVELS="$BASE_LOG_LEVELS" +fi + +LOG_LEVELS="$EFFECTIVE_LOG_LEVELS" \ +NODE_NO_WARNINGS=1 \ +PGPORT=55436 \ +STRIPE_WEBHOOK_SECRET=stripe-webhook-secret \ +STRIPE_API_KEY=stripe-api-key \ +vitest run $@ diff --git a/packages/realm-server/tests-vitest/scripts/start-test-pg.sh b/packages/realm-server/tests-vitest/scripts/start-test-pg.sh new file mode 100755 index 00000000000..b0eedf7e46d --- /dev/null +++ b/packages/realm-server/tests-vitest/scripts/start-test-pg.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "${SCRIPT_DIR}/test-pg-config.sh" + +if [ ! -f "$TEST_PG_SEED_TAR" ]; then + echo "Seed tar not found at $TEST_PG_SEED_TAR. Run ./tests/scripts/create_seeded_db.sh first." >&2 + exit 1 +fi + +docker rm -f "$TEST_PG_CONTAINER" >/dev/null 2>&1 || true + +start_container() { + docker run -d \ + --name "$TEST_PG_CONTAINER" \ + -p "127.0.0.1:${TEST_PG_PORT}:5432" \ + --tmpfs /var/lib/postgresql/data:rw \ + -e POSTGRES_HOST_AUTH_METHOD=trust \ + -v "${TEST_PG_SEED_TAR}:/seed/pgdata.tar:ro" \ + -v "${SCRIPT_DIR}/boot_preseeded.sh:/usr/local/bin/pg-seeded-tmpfs-entrypoint.sh:ro" \ + --entrypoint /bin/sh \ + postgres:16.3-alpine \ + -c /usr/local/bin/pg-seeded-tmpfs-entrypoint.sh +} + +print_start_diagnostics() { + echo "=== Docker containers ===" >&2 + docker ps -a >&2 || true + + echo "=== Matching test containers ===" >&2 + docker ps -a \ + --filter "name=${TEST_PG_CONTAINER}" \ + --filter "name=${TEST_PG_SEED_CONTAINER}" >&2 || true + + echo "=== Port ${TEST_PG_PORT} listeners ===" >&2 + if command -v ss >/dev/null 2>&1; then + ss -ltnp "( sport = :${TEST_PG_PORT} )" >&2 || true + elif command -v lsof >/dev/null 2>&1; then + lsof -nP -iTCP:"${TEST_PG_PORT}" -sTCP:LISTEN >&2 || true + else + echo "Neither ss nor lsof is available for port diagnostics" >&2 + fi + + echo "=== ${TEST_PG_CONTAINER} logs (if present) ===" >&2 + docker logs "$TEST_PG_CONTAINER" >&2 || true +} + +cid="" +start_err="" +container_ref="$TEST_PG_CONTAINER" +max_attempts=5 +attempt=1 +while [ "$attempt" -le "$max_attempts" ]; do + start_err_file="$(mktemp)" + if cid="$(start_container 2>"$start_err_file")"; then + start_err="$(cat "$start_err_file")" + rm -f "$start_err_file" + if printf '%s' "$cid" | grep -Eq '^[0-9a-f]{12,64}$'; then + container_ref="$cid" + fi + break + fi + start_err="$(cat "$start_err_file")" + rm -f "$start_err_file" + + if printf '%s' "$start_err" | grep -qi 'address already in use'; then + if [ "$attempt" -lt "$max_attempts" ]; then + echo "Port ${TEST_PG_PORT} still in use, retrying container start (${attempt}/${max_attempts})..." >&2 + docker rm -f "$TEST_PG_CONTAINER" >/dev/null 2>&1 || true + sleep 1 + attempt=$((attempt + 1)) + continue + fi + fi + + print_start_diagnostics + echo "$start_err" >&2 + exit 1 +done + +if [ -z "$cid" ]; then + echo "Failed to start $TEST_PG_CONTAINER after ${max_attempts} attempts" >&2 + print_start_diagnostics + exit 1 +fi + +"${SCRIPT_DIR}/wait-for-container-pg.sh" "$TEST_PG_CONTAINER" "$container_ref" + +# Sanity check the migrated DB exists in the seeded cluster. +seed_db_present="$(docker exec "$TEST_PG_CONTAINER" psql -h 127.0.0.1 -U postgres -d postgres -Atqc \ + "select datname from pg_database where datname = '${TEST_PG_SEED_DB}'")" +if [ "$seed_db_present" != "$TEST_PG_SEED_DB" ]; then + echo "Expected seeded DB '${TEST_PG_SEED_DB}' to exist in $TEST_PG_CONTAINER" >&2 + docker logs "$container_ref" >&2 || true + exit 1 +fi + +echo "Started $TEST_PG_CONTAINER on 127.0.0.1:${TEST_PG_PORT}" diff --git a/packages/realm-server/tests-vitest/scripts/stop-test-pg.sh b/packages/realm-server/tests-vitest/scripts/stop-test-pg.sh new file mode 100755 index 00000000000..c4f2e47e3df --- /dev/null +++ b/packages/realm-server/tests-vitest/scripts/stop-test-pg.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "${SCRIPT_DIR}/test-pg-config.sh" + +docker rm -f "$TEST_PG_CONTAINER" >/dev/null 2>&1 || true diff --git a/packages/realm-server/tests-vitest/scripts/test-pg-config.sh b/packages/realm-server/tests-vitest/scripts/test-pg-config.sh new file mode 100644 index 00000000000..795f5858ceb --- /dev/null +++ b/packages/realm-server/tests-vitest/scripts/test-pg-config.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TESTS_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +TEST_PG_CONTAINER="boxel-realm-test-pg" +TEST_PG_PORT="55436" + +TEST_PG_SEED_CONTAINER="boxel-realm-test-pg-seed-build" +TEST_PG_SEED_PORT="55435" +TEST_PG_SEED_DB="boxel_migrated" + +TEST_PG_CACHE_DIR="${TESTS_DIR}/.test-pg-cache" +TEST_PG_SEED_TAR="${TEST_PG_CACHE_DIR}/boxel-realm-test-pgdata-seeded.tar" +TEST_PG_SEED_FINGERPRINT="${TEST_PG_CACHE_DIR}/boxel-realm-test-pgdata-seeded.fingerprint" diff --git a/packages/realm-server/tests-vitest/scripts/wait-for-container-pg.sh b/packages/realm-server/tests-vitest/scripts/wait-for-container-pg.sh new file mode 100755 index 00000000000..50247c79a86 --- /dev/null +++ b/packages/realm-server/tests-vitest/scripts/wait-for-container-pg.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -lt 2 ] || [ "$#" -gt 3 ]; then + echo "Usage: $0 [database]" >&2 + exit 1 +fi + +CONTAINER_NAME="$1" +CONTAINER_REF="$2" +DATABASE_NAME="${3:-postgres}" +MAX_ATTEMPTS=1200 # ~60s at 50ms + +attempts=0 +until docker exec "$CONTAINER_NAME" pg_isready -h 127.0.0.1 -U postgres >/dev/null 2>&1; do + attempts=$((attempts + 1)) + if [ "$attempts" -ge "$MAX_ATTEMPTS" ]; then + echo "Timed out waiting for postgres in container $CONTAINER_NAME" >&2 + docker logs "$CONTAINER_REF" >&2 || true + exit 1 + fi + if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER_REF" 2>/dev/null || echo false)" != "true" ]; then + echo "Postgres container exited before becoming ready: $CONTAINER_NAME" >&2 + docker logs "$CONTAINER_REF" >&2 || true + exit 1 + fi + sleep 0.05 +done + +# Official postgres image can briefly report ready during init before final restart. +attempts=0 +until docker exec "$CONTAINER_NAME" psql -U postgres -h 127.0.0.1 -d "$DATABASE_NAME" -Atqc "select 1" >/dev/null 2>&1; do + attempts=$((attempts + 1)) + if [ "$attempts" -ge "$MAX_ATTEMPTS" ]; then + echo "Timed out waiting for SQL round trip in $CONTAINER_NAME (db=$DATABASE_NAME)" >&2 + docker logs "$CONTAINER_REF" >&2 || true + exit 1 + fi + if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER_REF" 2>/dev/null || echo false)" != "true" ]; then + echo "Postgres container exited before SQL round trip succeeded: $CONTAINER_NAME" >&2 + docker logs "$CONTAINER_REF" >&2 || true + exit 1 + fi + sleep 0.05 +done diff --git a/packages/realm-server/tests-vitest/search-prerendered.test.ts b/packages/realm-server/tests-vitest/search-prerendered.test.ts new file mode 100644 index 00000000000..8d5510cf4b5 --- /dev/null +++ b/packages/realm-server/tests-vitest/search-prerendered.test.ts @@ -0,0 +1,980 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import type { Test, SuperTest } from 'supertest'; +import type { Realm } from '@cardstack/runtime-common'; +import { setupPermissionedRealmCached, createJWT } from './helpers'; +import { PRERENDERED_HTML_FORMATS, baseRealm } from '@cardstack/runtime-common'; +import type { Query } from '@cardstack/runtime-common/query'; +import '@cardstack/runtime-common/helpers/code-equality-assertion'; +const missingPrerenderedHtmlFormatMessage = `Must include a 'prerenderedHtmlFormat' parameter with a value of ${PRERENDERED_HTML_FORMATS.join()} to use this endpoint`; +describe("search-prerendered-test.ts", function () { + describe('Realm-specific Endpoints | _search-prerendered', function () { + let testRealm: Realm; + let request: SuperTest; + let searchPath: string; + let realmHref: string; + let realmURL = new URL('http://127.0.0.1:4444/test/'); + function onRealmSetup(args: { + testRealm: Realm; + request: SuperTest; + }) { + testRealm = args.testRealm; + request = args.request; + let realmURLFromTest = new URL(testRealm.url); + realmHref = realmURLFromTest.href; + searchPath = `${realmURLFromTest.pathname.replace(/\/$/, '')}/_search-prerendered`; + } + describe('QUERY request (formerly GET)', function () { + const _hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + describe('instances with no embedded template css of its own', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + realmURL, + permissions: { + '*': ['read'], + }, + fileSystem: { + 'person.gts': ` + import { contains, field, CardDef, Component } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Person extends CardDef { + @field firstName = contains(StringField); + static isolated = class Isolated extends Component { + + } + static embedded = class Embedded extends Component { + + } + static fitted = class Fitted extends Component { + + } + } + `, + 'john.json': { + data: { + attributes: { + firstName: 'John', + }, + meta: { + adoptsFrom: { + module: './person', + name: 'Person', + }, + }, + }, + }, + 'hello.md': '# Hello from FileDef content', + }, + onRealmSetup, + }); + it('endpoint will respond with a bad request if html format is not provided', async function () { + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send({}); + expect(response.status).toBe(400); + expect(response.body.errors[0].message.includes(missingPrerenderedHtmlFormatMessage)).toBeTruthy(); + }); + it('returns prerendered instances', async function () { + let query: Query & { + prerenderedHtmlFormat: string; + } = { + filter: { + on: { + module: `${realmHref}person`, + name: 'Person', + }, + eq: { + firstName: 'John', + }, + }, + prerenderedHtmlFormat: 'embedded', + }; + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send(query); + expect(response.status).toBe(200); + expect(response.get('X-boxel-realm-url')).toBe(realmHref); + expect(response.get('X-boxel-realm-public-readable')).toBe('true'); + let json = response.body; + expect(json.data.length).toBe(1); + expect(json.data[0].type).toBe('prerendered-card'); + expect(json.data[0].attributes.html + .replace(/\s+/g, ' ') + .includes('Embedded Card Person: John')).toBe(true); + assertScopedCssUrlsContain(assert, json.meta.scopedCssUrls, cardDefModuleDependencies); + expect(json.meta.page.total).toBe(1); + }); + it('returns prerendered file-meta results for FileDef queries', async function () { + let queryBase: Query = { + filter: { + on: { + module: `${baseRealm.url}file-api`, + name: 'FileDef', + }, + eq: { + url: `${realmHref}hello.md`, + }, + }, + }; + let formatsAndExpected: Array<{ + format: 'embedded' | 'fitted' | 'atom' | 'head'; + expectedSnippet: string; + }> = [ + { + format: 'embedded', + expectedSnippet: 'data-test-markdown-embedded', + }, + { + format: 'fitted', + expectedSnippet: 'data-test-markdown-fitted', + }, + { + format: 'atom', + expectedSnippet: 'data-test-markdown-atom', + }, + { + format: 'head', + expectedSnippet: 'data-test-card-head-title', + }, + ]; + for (let { format, expectedSnippet } of formatsAndExpected) { + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send({ + ...queryBase, + prerenderedHtmlFormat: format, + } as Query & { + prerenderedHtmlFormat: string; + }); + expect(response.status).toBe(200); + let json = response.body; + expect(json.data.length).toBe(1); + expect(json.data[0].type).toBe('prerendered-card'); + expect(json.data[0].id).toBe(`${realmHref}hello.md`); + expect(json.data[0].attributes.html.includes(expectedSnippet)).toBe(true); + expect(json.data[0].attributes.html.includes('Hello from FileDef content')).toBe(true); + expect(json.meta.page.total).toBe(1); + expect(json.meta.isFileMeta).toBe(true); + } + }); + }); + describe('instances whose embedded template has css', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + realmURL, + permissions: { + '*': ['read'], + }, + fileSystem: { + 'person.gts': ` + import { contains, field, CardDef, Component } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Person extends CardDef { + @field firstName = contains(StringField); + static isolated = class Isolated extends Component { + + } + static embedded = class Embedded extends Component { + + } + } + `, + 'fancy-person.gts': ` + import { Person } from './person'; + import { contains, field, CardDef, Component } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class FancyPerson extends Person { + @field favoriteColor = contains(StringField); + + static embedded = class Embedded extends Component { + + } + } + `, + 'aaron.json': { + data: { + attributes: { + firstName: 'Aaron', + cardTitle: 'Person Aaron', + }, + meta: { + adoptsFrom: { + module: './person', + name: 'Person', + }, + }, + }, + }, + 'craig.json': { + data: { + attributes: { + firstName: 'Craig', + cardTitle: 'Person Craig', + }, + meta: { + adoptsFrom: { + module: './person', + name: 'Person', + }, + }, + }, + }, + 'jane.json': { + data: { + attributes: { + firstName: 'Jane', + favoriteColor: 'blue', + cardTitle: 'FancyPerson Jane', + }, + meta: { + adoptsFrom: { + module: './fancy-person', + name: 'FancyPerson', + }, + }, + }, + }, + 'jimmy.json': { + data: { + attributes: { + firstName: 'Jimmy', + favoriteColor: 'black', + cardTitle: 'FancyPerson Jimmy', + }, + meta: { + adoptsFrom: { + module: './fancy-person', + name: 'FancyPerson', + }, + }, + }, + }, + }, + onRealmSetup, + }); + it('returns instances with CardDef prerendered embedded html + css when there is no "on" filter', async function () { + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send({ + prerenderedHtmlFormat: 'embedded', + }); + expect(response.status).toBe(200); + expect(response.get('X-boxel-realm-url')).toBe(realmHref); + expect(response.get('X-boxel-realm-public-readable')).toBe('true'); + let json = response.body; + expect(json.data.length).toBe(4); + // 1st card: Person Aaron + expect(json.data[0].type).toBe('prerendered-card'); + expect(json.data[0].attributes.html + .replace(/\s+/g, ' ') + .includes('Embedded Card Person: Aaron')).toBe(true); + // 2nd card: Person Craig + expect(json.data[1].type).toBe('prerendered-card'); + expect(json.data[1].attributes.html + .replace(/\s+/g, ' ') + .includes('Embedded Card Person: Craig')).toBe(true); + // 3rd card: FancyPerson Jane + expect(json.data[2].type).toBe('prerendered-card'); + expect(json.data[2].attributes.html + .replace(/\s+/g, ' ') + .includes('Embedded Card FancyPerson: Jane')).toBe(true); + // 4th card: FancyPerson Jimmy + expect(json.data[3].type).toBe('prerendered-card'); + expect(json.data[3].attributes.html + .replace(/\s+/g, ' ') + .includes('Embedded Card FancyPerson: Jimmy')).toBe(true); + assertScopedCssUrlsContain(assert, json.meta.scopedCssUrls, cardDefModuleDependencies); + expect(json.meta.page.total).toBe(4); + }); + it('returns correct css in relationships, even the one indexed in another realm (CardDef)', async function () { + let query: Query & { + prerenderedHtmlFormat: string; + } = { + filter: { + on: { + module: `${realmHref}fancy-person`, + name: 'FancyPerson', + }, + not: { + eq: { + firstName: 'Peter', + }, + }, + }, + prerenderedHtmlFormat: 'embedded', + }; + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send(query); + let json = response.body; + expect(json.data.length).toBe(2); + // 1st card: FancyPerson Jane + expect(json.data[0].attributes.html + .replace(/\s+/g, ' ') + .includes('Embedded Card FancyPerson: Jane')).toBe(true); + // 2nd card: FancyPerson Jimmy + expect(json.data[1].attributes.html + .replace(/\s+/g, ' ') + .includes('Embedded Card FancyPerson: Jimmy')).toBe(true); + assertScopedCssUrlsContain(assert, json.meta.scopedCssUrls, [ + ...cardDefModuleDependencies, + ...[`${realmHref}fancy-person.gts`, `${realmHref}person.gts`], + ]); + }); + it('can filter prerendered instances', async function () { + let query: Query & { + prerenderedHtmlFormat: string; + } = { + filter: { + on: { + module: `${realmHref}person`, + name: 'Person', + }, + eq: { + firstName: 'Jimmy', + }, + }, + prerenderedHtmlFormat: 'embedded', + }; + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send(query); + let json = response.body; + expect(json.data.length).toBe(1); + expect(json.data[0].id).toBe(`${realmHref}jimmy.json`); + }); + it('can use cardUrls to filter prerendered instances', async function () { + let query: Query & { + prerenderedHtmlFormat: string; + cardUrls: string[]; + } = { + prerenderedHtmlFormat: 'embedded', + cardUrls: [`${realmHref}jimmy.json`], + }; + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send(query); + let json = response.body; + expect(json.data.length).toBe(1); + expect(json.data[0].id).toBe(`${realmHref}jimmy.json`); + query = { + prerenderedHtmlFormat: 'embedded', + cardUrls: [`${realmHref}jimmy.json`, `${realmHref}jane.json`], + }; + response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send(query); + json = response.body; + expect(json.data.length).toBe(2); + expect(json.data[0].id).toBe(`${realmHref}jane.json`); + expect(json.data[1].id).toBe(`${realmHref}jimmy.json`); + }); + it('can sort prerendered instances', async function () { + let query: Query & { + prerenderedHtmlFormat: string; + } = { + sort: [ + { + by: 'firstName', + on: { module: `${realmHref}person`, name: 'Person' }, + direction: 'desc', + }, + ], + prerenderedHtmlFormat: 'embedded', + }; + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send(query); + let json = response.body; + expect(json.data.length).toBe(4); + // firstName descending + expect(json.data[0].id).toBe(`${realmHref}jimmy.json`); + expect(json.data[1].id).toBe(`${realmHref}jane.json`); + expect(json.data[2].id).toBe(`${realmHref}craig.json`); + expect(json.data[3].id).toBe(`${realmHref}aaron.json`); + }); + it('can paginate prerendered instances', async function () { + // First page with size 2 + let query: Query & { + prerenderedHtmlFormat: string; + } = { + page: { + number: 0, + size: 2, + }, + sort: [ + { + by: 'firstName', + on: { module: `${realmHref}person`, name: 'Person' }, + direction: 'asc', + }, + ], + prerenderedHtmlFormat: 'embedded', + }; + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send(query); + let json = response.body; + expect(json.data.length).toBe(2); + expect(json.meta.page.total).toBe(4); + expect(json.data[0].id).toBe(`${realmHref}aaron.json`); + expect(json.data[1].id).toBe(`${realmHref}craig.json`); + // Second page + query.page = { number: 1, size: 2 }; + response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send(query); + json = response.body; + expect(json.data.length).toBe(2); + expect(json.meta.page.total).toBe(4); + expect(json.data[0].id).toBe(`${realmHref}jane.json`); + expect(json.data[1].id).toBe(`${realmHref}jimmy.json`); + }); + }); + }); + describe('QUERY request', function () { + const _hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + describe('instances with no embedded template css of its own', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + realmURL, + permissions: { + '*': ['read'], + }, + fileSystem: { + 'person.gts': ` + import { contains, field, CardDef, Component } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Person extends CardDef { + @field firstName = contains(StringField); + static isolated = class Isolated extends Component { + + } + static embedded = class Embedded extends Component { + + } + static fitted = class Fitted extends Component { + + } + } + `, + 'john.json': { + data: { + attributes: { + firstName: 'John', + }, + meta: { + adoptsFrom: { + module: './person', + name: 'Person', + }, + }, + }, + }, + }, + onRealmSetup, + }); + it('endpoint will respond with a bad request if html format is not provided', async function () { + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send({}); + expect(response.status).toBe(400); + expect(response.body.errors[0].message.includes(missingPrerenderedHtmlFormatMessage)).toBeTruthy(); + }); + it('returns prerendered instances', async function () { + let query: Query & { + prerenderedHtmlFormat: string; + } = { + filter: { + on: { + module: `${realmHref}person`, + name: 'Person', + }, + eq: { + firstName: 'John', + }, + }, + prerenderedHtmlFormat: 'embedded', + }; + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send(query); + expect(response.status).toBe(200); + expect(response.get('X-boxel-realm-url')).toBe(realmHref); + expect(response.get('X-boxel-realm-public-readable')).toBe('true'); + let json = response.body; + expect(json.data.length).toBe(1); + expect(json.data[0].type).toBe('prerendered-card'); + expect(json.data[0].attributes.html + .replace(/\s+/g, ' ') + .includes('Embedded Card Person: John')).toBe(true); + assertScopedCssUrlsContain(assert, json.meta.scopedCssUrls, cardDefModuleDependencies); + expect(json.meta.page.total).toBe(1); + }); + }); + describe('instances whose embedded template has css', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + realmURL, + permissions: { + '*': ['read'], + }, + fileSystem: { + 'person.gts': ` + import { contains, field, CardDef, Component } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Person extends CardDef { + @field firstName = contains(StringField); + static isolated = class Isolated extends Component { + + } + static embedded = class Embedded extends Component { + + } + } + `, + 'fancy-person.gts': ` + import { Person } from './person'; + import { contains, field, CardDef, Component } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class FancyPerson extends Person { + @field favoriteColor = contains(StringField); + + static embedded = class Embedded extends Component { + + } + } + `, + 'aaron.json': { + data: { + attributes: { + firstName: 'Aaron', + cardTitle: 'Person Aaron', + }, + meta: { + adoptsFrom: { + module: './person', + name: 'Person', + }, + }, + }, + }, + 'craig.json': { + data: { + attributes: { + firstName: 'Craig', + cardTitle: 'Person Craig', + }, + meta: { + adoptsFrom: { + module: './person', + name: 'Person', + }, + }, + }, + }, + 'jane.json': { + data: { + attributes: { + firstName: 'Jane', + favoriteColor: 'blue', + cardTitle: 'FancyPerson Jane', + }, + meta: { + adoptsFrom: { + module: './fancy-person', + name: 'FancyPerson', + }, + }, + }, + }, + 'jimmy.json': { + data: { + attributes: { + firstName: 'Jimmy', + favoriteColor: 'black', + cardTitle: 'FancyPerson Jimmy', + }, + meta: { + adoptsFrom: { + module: './fancy-person', + name: 'FancyPerson', + }, + }, + }, + }, + }, + onRealmSetup, + }); + it('returns instances with CardDef prerendered embedded html + css using QUERY method', async function () { + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send({ + prerenderedHtmlFormat: 'embedded', + }); + expect(response.status).toBe(200); + expect(response.get('X-boxel-realm-url')).toBe(realmHref); + expect(response.get('X-boxel-realm-public-readable')).toBe('true'); + let json = response.body; + expect(json.data.length).toBe(4); + // 1st card: Person Aaron + expect(json.data[0].type).toBe('prerendered-card'); + expect(json.data[0].attributes.html + .replace(/\s+/g, ' ') + .includes('Embedded Card Person: Aaron')).toBe(true); + // 4th card: FancyPerson Jimmy + expect(json.data[3].type).toBe('prerendered-card'); + expect(json.data[3].attributes.html + .replace(/\s+/g, ' ') + .includes('Embedded Card FancyPerson: Jimmy')).toBe(true); + assertScopedCssUrlsContain(assert, json.meta.scopedCssUrls, cardDefModuleDependencies); + expect(json.meta.page.total).toBe(4); + }); + it('can use cardUrls to filter prerendered instances using QUERY method', async function () { + let query: Query & { + prerenderedHtmlFormat: string; + cardUrls: string[]; + } = { + prerenderedHtmlFormat: 'embedded', + cardUrls: [`${realmHref}jimmy.json`, `${realmHref}jane.json`], + }; + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send(query); + let json = response.body; + expect(json.data.length).toBe(2); + expect(json.data[0].id).toBe(`${realmHref}jane.json`); + expect(json.data[1].id).toBe(`${realmHref}jimmy.json`); + }); + it('can filter prerendered instances with complex query in request body', async function () { + let complexQuery = { + filter: { + on: { + module: `${realmHref}fancy-person`, + name: 'FancyPerson', + }, + not: { + eq: { + firstName: 'Peter', + }, + }, + }, + prerenderedHtmlFormat: 'embedded', + }; + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send(complexQuery); + let json = response.body; + expect(json.data.length).toBe(2); + // 1st card: FancyPerson Jane + expect(json.data[0].attributes.html + .replace(/\s+/g, ' ') + .includes('Embedded Card FancyPerson: Jane')).toBe(true); + assertScopedCssUrlsContain(assert, json.meta.scopedCssUrls, [ + ...cardDefModuleDependencies, + ...[`${realmHref}fancy-person.gts`, `${realmHref}person.gts`], + ]); + }); + it('gets no results when asking for a type that the realm does not have knowledge of', async function () { + let complexQuery = { + filter: { + on: { + module: `http://some-realm-server/some-realm/some-card`, + name: 'SomeCard', + }, + not: { + eq: { + firstName: 'Peter', + }, + }, + }, + prerenderedHtmlFormat: 'embedded', + }; + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send(complexQuery); + expect(response.status).toBe(200); + let json = response.body; + expect(json.data.length).toBe(0); + }); + it('can sort prerendered instances using QUERY method', async function () { + let query = { + sort: [ + { + by: 'firstName', + on: { module: `${realmHref}person`, name: 'Person' }, + direction: 'desc', + }, + ], + prerenderedHtmlFormat: 'embedded', + }; + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send(query); + let json = response.body; + expect(json.data.length).toBe(4); + // firstName descending + expect(json.data[0].id).toBe(`${realmHref}jimmy.json`); + expect(json.data[1].id).toBe(`${realmHref}jane.json`); + expect(json.data[2].id).toBe(`${realmHref}craig.json`); + expect(json.data[3].id).toBe(`${realmHref}aaron.json`); + }); + }); + describe('permissioned realm', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + realmURL, + permissions: { + john: ['read'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + fileSystem: { + 'person.gts': ` + import { contains, field, CardDef, Component } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Person extends CardDef { + @field firstName = contains(StringField); + static embedded = class Embedded extends Component { + + } + } + `, + 'john.json': { + data: { + attributes: { + firstName: 'John', + }, + meta: { + adoptsFrom: { + module: './person', + name: 'Person', + }, + }, + }, + }, + }, + onRealmSetup, + }); + it('401 with invalid JWT', async function () { + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .set('Authorization', `Bearer invalid-token`) + .send({ prerenderedHtmlFormat: 'embedded' }); + expect(response.status).toBe(401); + }); + it('401 without a JWT', async function () { + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send({ prerenderedHtmlFormat: 'embedded' }); // no Authorization header + expect(response.status).toBe(401); + }); + it('403 without permission', async function () { + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .set('Authorization', `Bearer ${createJWT(testRealm, 'not-john')}`) + .send({ prerenderedHtmlFormat: 'embedded' }); + expect(response.status).toBe(403); + }); + it('200 with permission', async function () { + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .set('Authorization', `Bearer ${createJWT(testRealm, 'john', ['read'])}`) + .send({ prerenderedHtmlFormat: 'embedded' }); + expect(response.status).toBe(200); + }); + }); + describe('search query validation', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + setupPermissionedRealmCached(hooks, { + realmURL, + permissions: { + '*': ['read'], + }, + onRealmSetup, + }); + it('400 with invalid query schema', async function () { + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send({ + invalid: 'query structure', + prerenderedHtmlFormat: 'embedded', + }); + expect(response.status).toBe(400); + expect(response.body.errors[0].message.includes('Invalid query')).toBeTruthy(); + }); + it('400 with invalid filter logic', async function () { + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send({ + filter: { + badOperator: { firstName: 'Mango' }, + }, + prerenderedHtmlFormat: 'embedded', + }); + expect(response.status).toBe(400); + }); + }); + }); + }); +}); +function assertScopedCssUrlsContain(assert: Assert, scopedCssUrls: string[], moduleUrls: string[]) { + moduleUrls.forEach((url) => { + let pattern = new RegExp(`^${url}\\.[^.]+\\.glimmer-scoped\\.css$`); + expect(scopedCssUrls.some((scopedCssUrl) => pattern.test(scopedCssUrl))).toBe(true); + }); +} +// These modules have CSS that CardDef consumes, so we expect to see them in all relationships of a prerendered card +let cardDefModuleDependencies = [ + 'https://cardstack.com/base/default-templates/embedded.gts', + 'https://cardstack.com/base/default-templates/isolated-and-edit.gts', + 'https://cardstack.com/base/default-templates/field-edit.gts', + 'https://cardstack.com/base/field-component.gts', + 'https://cardstack.com/base/contains-many-component.gts', + 'https://cardstack.com/base/links-to-editor.gts', + 'https://cardstack.com/base/links-to-many-component.gts', +]; diff --git a/packages/realm-server/tests-vitest/server-endpoints/authentication.test.ts b/packages/realm-server/tests-vitest/server-endpoints/authentication.test.ts new file mode 100644 index 00000000000..2f08b39cb4b --- /dev/null +++ b/packages/realm-server/tests-vitest/server-endpoints/authentication.test.ts @@ -0,0 +1,86 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import type { Test, SuperTest } from 'supertest'; +import type { Server } from 'http'; +import jwt from 'jsonwebtoken'; +import { MatrixClient } from '@cardstack/runtime-common/matrix-client'; +import type { RealmServerTokenClaim } from '../../utils/jwt'; +import { realmSecretSeed, realmServerTestMatrix, setupPermissionedRealmCached, testRealmURL, } from '../helpers'; +import { createRealmServerSession } from './helpers'; +import { getUserByMatrixUserId } from '@cardstack/billing/billing-queries'; +import type { PgAdapter } from '@cardstack/postgres'; +import '@cardstack/runtime-common/helpers/code-equality-assertion'; +describe("server-endpoints/authentication-test.ts", function () { + describe('Realm server authentication', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let request: SuperTest; + let dbAdapter: PgAdapter; + function onRealmSetup(args: { + testRealmHttpServer: Server; + request: SuperTest; + dbAdapter: PgAdapter; + }) { + dbAdapter = args.dbAdapter; + request = args.request; + } + setupPermissionedRealmCached(hooks, { + fileSystem: {}, + permissions: { + '@test_realm:localhost': ['read', 'realm-owner'], + }, + realmURL: testRealmURL, + onRealmSetup: onRealmSetup, + }); + it('authenticates user and creates session room', async function () { + let matrixClient = new MatrixClient({ + matrixURL: realmServerTestMatrix.url, + // it's a little awkward that we are hijacking a realm user to pretend to + // act like a normal user, but that's what's happening here + username: 'test_realm', + seed: realmSecretSeed, + }); + await matrixClient.login(); + let userId = matrixClient.getUserId()!; + // User exists (created by ensureTestUser in test setup) but has no session room + let userBefore = await getUserByMatrixUserId(dbAdapter, userId); + expect(userBefore).toBeTruthy(); + expect(userBefore!.sessionRoomId).toBe(null); + let { jwt: token, status } = await createRealmServerSession(matrixClient, request); + expect(status).toBe(201); + let decoded = jwt.verify(token, realmSecretSeed) as RealmServerTokenClaim; + expect(decoded.user).toBe(userId); + expect(decoded.sessionRoom).not.toBe(undefined); + // Session room should now be stored + let userAfter = await getUserByMatrixUserId(dbAdapter, userId); + expect(userAfter!.sessionRoomId).toBeTruthy(); + // Creating another session should reuse the session room + let { status: status2, sessionRoom: sessionRoom2 } = await createRealmServerSession(matrixClient, request); + expect(status2).toBe(201); + expect(sessionRoom2).toBe(decoded.sessionRoom); + }); + it('saves registration token passed during session creation', async function () { + let matrixClient = new MatrixClient({ + matrixURL: realmServerTestMatrix.url, + username: 'test_realm', + seed: realmSecretSeed, + }); + await matrixClient.login(); + let userId = matrixClient.getUserId()!; + // User exists from test setup but has no registration token + let userBefore = await getUserByMatrixUserId(dbAdapter, userId); + expect(userBefore!.matrixRegistrationToken).toBe(null); + // Create session with a registration token (simulates initial signup) + let { status } = await createRealmServerSession(matrixClient, request, { + registrationToken: 'my-invite-code', + }); + expect(status).toBe(201); + let user = await getUserByMatrixUserId(dbAdapter, userId); + expect(user!.matrixRegistrationToken).toBe('my-invite-code'); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/server-endpoints/bot-commands.test.ts b/packages/realm-server/tests-vitest/server-endpoints/bot-commands.test.ts new file mode 100644 index 00000000000..9d2a066162c --- /dev/null +++ b/packages/realm-server/tests-vitest/server-endpoints/bot-commands.test.ts @@ -0,0 +1,462 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import { createJWT as createRealmServerJWT } from '../../utils/jwt'; +import { realmSecretSeed, insertUser } from '../helpers'; +import { param, query, uuidv4 } from '@cardstack/runtime-common'; +import { setupServerEndpointsTest } from './helpers'; +describe("server-endpoints/bot-commands-test.ts", function () { + describe('Realm Server Endpoints', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let context = setupServerEndpointsTest(hooks); + it('requires auth to add bot command', async function () { + let response = await context.request.post('/_bot-commands').send({}); + expect(response.status).toBe(401); + }); + it('requires auth to list bot commands', async function () { + let response = await context.request.get('/_bot-commands'); + expect(response.status).toBe(401); + }); + it('can add bot command for registered bot', async function () { + let matrixUserId = '@user:localhost'; + await insertUser(context.dbAdapter, matrixUserId, 'cus_123', 'user@example.com'); + let botRegistrationId = uuidv4(); + await query(context.dbAdapter, [ + `INSERT INTO bot_registrations (id, username, created_at) VALUES (`, + param(botRegistrationId), + `,`, + param(matrixUserId), + `,`, + `CURRENT_TIMESTAMP`, + `)`, + ]); + let response = await context.request + .post('/_bot-commands') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'bot-command', + attributes: { + botId: botRegistrationId, + command: 'https://example.com/bot/command/default', + filter: { + type: 'matrix-event', + event_type: 'app.boxel.bot-trigger', + content_type: 'create-listing-pr', + }, + }, + }, + }); + expect(response.status).toBe(201); + expect(response.body.data.attributes.botId).toBe(botRegistrationId); + expect(response.body.data.attributes.command).toBe('https://example.com/bot/command/default'); + expect(response.body.data.attributes.filter).toEqual({ + type: 'matrix-event', + event_type: 'app.boxel.bot-trigger', + content_type: 'create-listing-pr', + }); + expect(response.body.data.id).toBeTruthy(); + expect(response.body.data.attributes.createdAt).toBeTruthy(); + let rows = await context.dbAdapter.execute(`SELECT id, bot_id, command, command_filter, created_at FROM bot_commands`); + expect(rows.length).toBe(1); + expect(rows[0].id).toBeTruthy(); + expect(rows[0].bot_id).toBe(botRegistrationId); + expect(rows[0].command).toBe('https://example.com/bot/command/default'); + expect(rows[0].command_filter).toEqual({ + type: 'matrix-event', + event_type: 'app.boxel.bot-trigger', + content_type: 'create-listing-pr', + }); + expect(rows[0].created_at).toBeTruthy(); + }); + it('accepts @cardstack/boxel-host command specifier', async function () { + let matrixUserId = '@user:localhost'; + await insertUser(context.dbAdapter, matrixUserId, 'cus_123', 'user@example.com'); + let botRegistrationId = uuidv4(); + await query(context.dbAdapter, [ + `INSERT INTO bot_registrations (id, username, created_at) VALUES (`, + param(botRegistrationId), + `,`, + param(matrixUserId), + `,`, + `CURRENT_TIMESTAMP`, + `)`, + ]); + let commandSpecifier = '@cardstack/boxel-host/commands/show-card/default'; + let response = await context.request + .post('/_bot-commands') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'bot-command', + attributes: { + botId: botRegistrationId, + command: commandSpecifier, + filter: { + type: 'matrix-event', + event_type: 'app.boxel.bot-trigger', + content_type: 'show-card', + }, + }, + }, + }); + expect(response.status).toBe(201); + expect(response.body.data.attributes.command).toBe(commandSpecifier); + }); + it('lists bot commands for authenticated user', async function () { + let matrixUserId = '@user:localhost'; + let otherMatrixUserId = '@other-user:localhost'; + await insertUser(context.dbAdapter, matrixUserId, 'cus_123', 'user@example.com'); + await insertUser(context.dbAdapter, otherMatrixUserId, 'cus_124', 'other@example.com'); + let botRegistrationId = uuidv4(); + let otherBotRegistrationId = uuidv4(); + await query(context.dbAdapter, [ + `INSERT INTO bot_registrations (id, username, created_at) VALUES (`, + param(botRegistrationId), + `,`, + param(matrixUserId), + `,`, + `CURRENT_TIMESTAMP`, + `)`, + ]); + await query(context.dbAdapter, [ + `INSERT INTO bot_registrations (id, username, created_at) VALUES (`, + param(otherBotRegistrationId), + `,`, + param(otherMatrixUserId), + `,`, + `CURRENT_TIMESTAMP`, + `)`, + ]); + await query(context.dbAdapter, [ + `INSERT INTO bot_commands (id, bot_id, command, command_filter, created_at) VALUES (`, + param(uuidv4()), + `,`, + param(botRegistrationId), + `,`, + param('https://example.com/bot/command/default'), + `,`, + param({ + type: 'matrix-event', + event_type: 'app.boxel.bot-trigger', + content_type: 'create-listing-pr', + }), + `,`, + `CURRENT_TIMESTAMP`, + `)`, + ]); + await query(context.dbAdapter, [ + `INSERT INTO bot_commands (id, bot_id, command, command_filter, created_at) VALUES (`, + param(uuidv4()), + `,`, + param(otherBotRegistrationId), + `,`, + param('https://example.com/bot/command/default'), + `,`, + param({ + type: 'matrix-event', + event_type: 'app.boxel.bot-trigger', + content_type: 'create-listing-pr', + }), + `,`, + `CURRENT_TIMESTAMP`, + `)`, + ]); + let response = await context.request + .get('/_bot-commands') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`); + expect(response.status).toBe(200); + expect(response.body.data.length).toBe(1); + expect(response.body.data[0].attributes.botId).toBe(botRegistrationId); + }); + it('rejects bot command for a different user', async function () { + let matrixUserId = '@user:localhost'; + let otherMatrixUserId = '@other-user:localhost'; + await insertUser(context.dbAdapter, matrixUserId, 'cus_123', 'user@example.com'); + await insertUser(context.dbAdapter, otherMatrixUserId, 'cus_124', 'other@example.com'); + let botRegistrationId = uuidv4(); + await query(context.dbAdapter, [ + `INSERT INTO bot_registrations (id, username, created_at) VALUES (`, + param(botRegistrationId), + `,`, + param(otherMatrixUserId), + `,`, + `CURRENT_TIMESTAMP`, + `)`, + ]); + let response = await context.request + .post('/_bot-commands') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'bot-command', + attributes: { + botId: botRegistrationId, + command: 'https://example.com/bot/command/default', + filter: { + type: 'matrix-event', + event_type: 'app.boxel.bot-trigger', + content_type: 'create-listing-pr', + }, + }, + }, + }); + expect(response.status).toBe(403); + }); + it('rejects bot command when bot registration is missing', async function () { + let matrixUserId = '@user:localhost'; + await insertUser(context.dbAdapter, matrixUserId, 'cus_123', 'user@example.com'); + let response = await context.request + .post('/_bot-commands') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'bot-command', + attributes: { + botId: uuidv4(), + command: 'https://example.com/bot/command/default', + filter: { + type: 'matrix-event', + event_type: 'app.boxel.bot-trigger', + content_type: 'create-listing-pr', + }, + }, + }, + }); + expect(response.status).toBe(404); + }); + it('rejects bot command with invalid botId', async function () { + let matrixUserId = '@user:localhost'; + await insertUser(context.dbAdapter, matrixUserId, 'cus_123', 'user@example.com'); + let response = await context.request + .post('/_bot-commands') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'bot-command', + attributes: { + botId: 'not-a-uuid', + command: 'https://example.com/bot/command/default', + filter: { + type: 'matrix-event', + event_type: 'app.boxel.bot-trigger', + content_type: 'create-listing-pr', + }, + }, + }, + }); + expect(response.status).toBe(400); + }); + it('rejects invalid command', async function () { + let matrixUserId = '@user:localhost'; + await insertUser(context.dbAdapter, matrixUserId, 'cus_123', 'user@example.com'); + let botRegistrationId = uuidv4(); + await query(context.dbAdapter, [ + `INSERT INTO bot_registrations (id, username, created_at) VALUES (`, + param(botRegistrationId), + `,`, + param(matrixUserId), + `,`, + `CURRENT_TIMESTAMP`, + `)`, + ]); + let response = await context.request + .post('/_bot-commands') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'bot-command', + attributes: { + botId: botRegistrationId, + command: ' ', + }, + }, + }); + expect(response.status).toBe(400); + }); + it('rejects missing command or filter', async function () { + let matrixUserId = '@user:localhost'; + await insertUser(context.dbAdapter, matrixUserId, 'cus_123', 'user@example.com'); + let botRegistrationId = uuidv4(); + await query(context.dbAdapter, [ + `INSERT INTO bot_registrations (id, username, created_at) VALUES (`, + param(botRegistrationId), + `,`, + param(matrixUserId), + `,`, + `CURRENT_TIMESTAMP`, + `)`, + ]); + let baseRequest = context.request + .post('/_bot-commands') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`); + let missingCommandResponse = await baseRequest.send({ + data: { + type: 'bot-command', + attributes: { + botId: botRegistrationId, + filter: { + type: 'matrix-event', + event_type: 'app.boxel.bot-trigger', + content_type: 'create-listing-pr', + }, + }, + }, + }); + expect(missingCommandResponse.status).toBe(400); + let missingFilterResponse = await baseRequest.send({ + data: { + type: 'bot-command', + attributes: { + botId: botRegistrationId, + command: 'https://example.com/bot/command/default', + }, + }, + }); + expect(missingFilterResponse.status).toBe(400); + }); + it('rejects non-matrix filter type', async function () { + let matrixUserId = '@user:localhost'; + await insertUser(context.dbAdapter, matrixUserId, 'cus_123', 'user@example.com'); + let botRegistrationId = uuidv4(); + await query(context.dbAdapter, [ + `INSERT INTO bot_registrations (id, username, created_at) VALUES (`, + param(botRegistrationId), + `,`, + param(matrixUserId), + `,`, + `CURRENT_TIMESTAMP`, + `)`, + ]); + let response = await context.request + .post('/_bot-commands') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'bot-command', + attributes: { + botId: botRegistrationId, + command: 'https://example.com/bot/command/default', + filter: { + type: 'http-event', + event_type: 'app.boxel.bot-trigger', + content_type: 'create-listing-pr', + }, + }, + }, + }); + expect(response.status).toBe(400); + }); + it('deletes bot commands when bot registration is removed', async function () { + let matrixUserId = '@user:localhost'; + await insertUser(context.dbAdapter, matrixUserId, 'cus_123', 'user@example.com'); + let botRegistrationId = uuidv4(); + await query(context.dbAdapter, [ + `INSERT INTO bot_registrations (id, username, created_at) VALUES (`, + param(botRegistrationId), + `,`, + param(matrixUserId), + `,`, + `CURRENT_TIMESTAMP`, + `)`, + ]); + await query(context.dbAdapter, [ + `INSERT INTO bot_commands (id, bot_id, command, command_filter, created_at) VALUES (`, + param(uuidv4()), + `,`, + param(botRegistrationId), + `,`, + param('https://example.com/bot/command/default'), + `,`, + param({ + type: 'matrix-event', + event_type: 'app.boxel.bot-trigger', + content_type: 'create-listing-pr', + }), + `,`, + `CURRENT_TIMESTAMP`, + `)`, + ]); + let deleteResponse = await context.request + .delete('/_bot-registration') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'bot-registration', + id: botRegistrationId, + }, + }); + expect(deleteResponse.status).toBe(204); + let rows = await context.dbAdapter.execute(`SELECT id FROM bot_commands WHERE bot_id = '${botRegistrationId}'`); + expect(rows.length).toBe(0); + }); + it('can delete bot command', async function () { + let matrixUserId = '@user:localhost'; + await insertUser(context.dbAdapter, matrixUserId, 'cus_123', 'user@example.com'); + let botRegistrationId = uuidv4(); + await query(context.dbAdapter, [ + `INSERT INTO bot_registrations (id, username, created_at) VALUES (`, + param(botRegistrationId), + `,`, + param(matrixUserId), + `,`, + `CURRENT_TIMESTAMP`, + `)`, + ]); + let botCommandId = uuidv4(); + await query(context.dbAdapter, [ + `INSERT INTO bot_commands (id, bot_id, command, command_filter, created_at) VALUES (`, + param(botCommandId), + `,`, + param(botRegistrationId), + `,`, + param('https://example.com/bot/command/default'), + `,`, + param({ + type: 'matrix-event', + event_type: 'app.boxel.bot-trigger', + content_type: 'create-listing-pr', + }), + `,`, + `CURRENT_TIMESTAMP`, + `)`, + ]); + let response = await context.request + .delete('/_bot-commands') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'bot-command', + id: botCommandId, + }, + }); + expect(response.status).toBe(204); + let rows = await context.dbAdapter.execute(`SELECT id FROM bot_commands WHERE id = '${botCommandId}'`); + expect(rows.length).toBe(0); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/server-endpoints/bot-registration.test.ts b/packages/realm-server/tests-vitest/server-endpoints/bot-registration.test.ts new file mode 100644 index 00000000000..306ed1e4d5d --- /dev/null +++ b/packages/realm-server/tests-vitest/server-endpoints/bot-registration.test.ts @@ -0,0 +1,270 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import { createJWT as createRealmServerJWT } from '../../utils/jwt'; +import { realmSecretSeed, insertUser } from '../helpers'; +import { param, query, uuidv4 } from '@cardstack/runtime-common'; +import { setupServerEndpointsTest } from './helpers'; +describe("server-endpoints/bot-registration-test.ts", function () { + describe('Realm Server Endpoints (not specific to one realm)', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let context = setupServerEndpointsTest(hooks); + it('requires auth to register bot', async function () { + let response = await context.request + .post('/_bot-registration') + .send({}); + expect(response.status).toBe(401); + }); + it('requires auth to list bot registrations', async function () { + let response = await context.request.get('/_bot-registrations'); + expect(response.status).toBe(401); + }); + it('requires auth to unregister bot', async function () { + let response = await context.request.delete('/_bot-registration').send({ + data: { + type: 'bot-registration', + id: 'bot-reg-1', + }, + }); + expect(response.status).toBe(401); + }); + it('can register bot for user', async function () { + let matrixUserId = '@user:localhost'; + await insertUser(context.dbAdapter, matrixUserId, 'cus_123', 'user@example.com'); + let response = await context.request + .post('/_bot-registration') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'bot-registration', + attributes: { + username: matrixUserId, + }, + }, + }); + expect(response.status).toBe(201); + expect(response.body.data.attributes.username).toBe(matrixUserId); + expect(response.body.data.id).toBeTruthy(); + expect(response.body.data.attributes.username).toBeTruthy(); + expect(response.body.data.attributes.createdAt).toBeTruthy(); + let rows = await context.dbAdapter.execute(`SELECT id, username, created_at FROM bot_registrations`); + expect(rows.length).toBe(1); + expect(rows[0].id).toBeTruthy(); + expect(rows[0].username).toBeTruthy(); + expect(rows[0].created_at).toBeTruthy(); + }); + it('can register more than one bot for a single user', async function () { + let matrixUserId = '@user:localhost'; + await insertUser(context.dbAdapter, matrixUserId, 'cus_123', 'user@example.com'); + let firstResponse = await context.request + .post('/_bot-registration') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'bot-registration', + attributes: { + username: matrixUserId, + }, + }, + }); + let secondResponse = await context.request + .post('/_bot-registration') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'bot-registration', + attributes: { + username: matrixUserId, + }, + }, + }); + expect(firstResponse.status).toBe(201); + expect(secondResponse.status).toBe(201); + let rows = await query(context.dbAdapter, [ + `SELECT id FROM bot_registrations WHERE username = `, + param(matrixUserId), + ]); + expect(rows.length).toBe(2); + }); + it('rejects registration for a different matrix user', async function () { + let matrixUserId = '@user:localhost'; + await insertUser(context.dbAdapter, matrixUserId, 'cus_123', 'user@example.com'); + let response = await context.request + .post('/_bot-registration') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'bot-registration', + attributes: { + username: '@other-user:localhost', + }, + }, + }); + expect(response.status).toBe(403); + }); + it('can unregister bot registration', async function () { + let matrixUserId = '@user:localhost'; + await insertUser(context.dbAdapter, matrixUserId, 'cus_123', 'user@example.com'); + let registerResponse = await context.request + .post('/_bot-registration') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'bot-registration', + attributes: { + username: matrixUserId, + }, + }, + }); + let botRegistrationId = registerResponse.body.data.id; + let deleteResponse = await context.request + .delete('/_bot-registration') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'bot-registration', + id: botRegistrationId, + }, + }); + expect(deleteResponse.status).toBe(204); + let rows = await context.dbAdapter.execute(`SELECT id FROM bot_registrations`); + expect(rows.length).toBe(0); + }); + it('unregistering a non-existent bot returns 204', async function () { + let matrixUserId = '@user:localhost'; + await insertUser(context.dbAdapter, matrixUserId, 'cus_123', 'user@example.com'); + let response = await context.request + .delete('/_bot-registration') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'bot-registration', + id: uuidv4(), + }, + }); + expect(response.status).toBe(204); + }); + it('rejects unregistration for a different user', async function () { + let ownerUserId = '@user:localhost'; + let otherUserId = '@other-user:localhost'; + await insertUser(context.dbAdapter, ownerUserId, 'cus_123', 'user@example.com'); + await insertUser(context.dbAdapter, otherUserId, 'cus_124', 'other@example.com'); + let registerResponse = await context.request + .post('/_bot-registration') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'bot-registration', + attributes: { + username: ownerUserId, + }, + }, + }); + let botRegistrationId = registerResponse.body.data.id; + let response = await context.request + .delete('/_bot-registration') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: otherUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'bot-registration', + id: botRegistrationId, + }, + }); + expect(response.status).toBe(403); + let rows = await context.dbAdapter.execute(`SELECT username FROM bot_registrations`); + expect(rows.length).toBe(1); + expect(rows[0].username).toBeTruthy(); + }); + it('lists bot registrations for the authenticated user only', async function () { + let matrixUserId = '@user:localhost'; + let otherMatrixUserId = '@other-user:localhost'; + await insertUser(context.dbAdapter, matrixUserId, 'cus_123', 'user@example.com'); + await insertUser(context.dbAdapter, otherMatrixUserId, 'cus_124', 'other@example.com'); + await query(context.dbAdapter, [ + `INSERT INTO bot_registrations (id, username, created_at) VALUES (`, + param(uuidv4()), + `,`, + param(matrixUserId), + `,`, + `CURRENT_TIMESTAMP`, + `)`, + ]); + await query(context.dbAdapter, [ + `INSERT INTO bot_registrations (id, username, created_at) VALUES (`, + param(uuidv4()), + `,`, + param(otherMatrixUserId), + `,`, + `CURRENT_TIMESTAMP`, + `)`, + ]); + let response = await context.request + .get('/_bot-registrations') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`); + expect(response.status).toBe(200); + expect(response.body.data.length).toBe(1); + let usernames = response.body.data.map((entry: any) => entry.attributes.username); + expect(usernames.includes(matrixUserId)).toBeTruthy(); + expect(usernames.includes(otherMatrixUserId)).toBeFalsy(); + }); + it('lists bot registrations created via endpoint for the authenticated user', async function () { + let matrixUserId = '@user:localhost'; + let otherMatrixUserId = '@other-user:localhost'; + await insertUser(context.dbAdapter, matrixUserId, 'cus_123', 'user@example.com'); + await insertUser(context.dbAdapter, otherMatrixUserId, 'cus_124', 'other@example.com'); + await context.request + .post('/_bot-registration') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'bot-registration', + attributes: { + username: matrixUserId, + }, + }, + }); + await context.request + .post('/_bot-registration') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: otherMatrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'bot-registration', + attributes: { + username: otherMatrixUserId, + }, + }, + }); + let response = await context.request + .get('/_bot-registrations') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`); + expect(response.status).toBe(200); + expect(response.body.data.length).toBe(1); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/server-endpoints/download-realm.test.ts b/packages/realm-server/tests-vitest/server-endpoints/download-realm.test.ts new file mode 100644 index 00000000000..341bfad7c04 --- /dev/null +++ b/packages/realm-server/tests-vitest/server-endpoints/download-realm.test.ts @@ -0,0 +1,110 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import { setupServerEndpointsTest, testRealmURL } from './helpers'; +import { realmSecretSeed } from '../helpers'; +import { createJWT } from '../../utils/jwt'; +import { createURLSignatureSync } from '@cardstack/runtime-common/url-signature'; +import type { Response } from 'superagent'; +function binaryParser(res: Response, callback: (err: Error | null, body: Buffer) => void) { + let data = ''; + res.setEncoding('binary'); + res.on('data', (chunk: string) => { + data += chunk; + }); + res.on('end', () => { + callback(null, Buffer.from(data, 'binary')); + }); +} +describe("server-endpoints/download-realm-test.ts", function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let context = setupServerEndpointsTest(hooks); + it('downloads realm as a zip archive', async function () { + let response = await context.request + .get('/_download-realm') + .query({ realm: testRealmURL.href }) + .buffer(true) + .parse(binaryParser); + let bodyPreview = response.body?.toString?.('utf8') ?? response.text ?? ''; + expect(response.status).toBe(200); + expect(response.headers['content-type']).toBe('application/zip'); + expect(response.headers['content-disposition']?.includes('.zip')).toBeTruthy(); + expect(response.body instanceof Buffer).toBeTruthy(); + expect(response.body.subarray(0, 2).toString('utf8')).toBe('PK'); + expect(response.body.includes(Buffer.from('.realm.json'))).toBeTruthy(); + }); + it('requires auth when realm is not public', async function () { + await context.dbAdapter.execute(`DELETE FROM realm_user_permissions WHERE realm_url = '${testRealmURL.href}' AND username = '*'`); + let response = await context.request + .get('/_download-realm') + .query({ realm: testRealmURL.href }); + expect(response.status).toBe(401); + }); + it('returns 400 when realm is missing from query params', async function () { + let response = await context.request.get('/_download-realm'); + expect(response.status).toBe(400); + expect(response.body.errors?.[0]?.includes('single realm must be specified')).toBeTruthy(); + }); + it('returns 404 when realm is not registered on the server', async function () { + let response = await context.request + .get('/_download-realm') + .query({ realm: 'http://127.0.0.1:4445/missing/' }); + expect(response.status).toBe(404); + expect(response.body.errors?.[0]?.includes('Realm not found')).toBeTruthy(); + }); + it('accepts auth token via query param with valid signature', async function () { + // Remove public permissions to require authentication + await context.dbAdapter.execute(`DELETE FROM realm_user_permissions WHERE realm_url = '${testRealmURL.href}' AND username = '*'`); + // Add read permission for the test user + let testUser = '@test:localhost'; + await context.dbAdapter.execute(`INSERT INTO realm_user_permissions (realm_url, username, read, write, realm_owner) + VALUES ('${testRealmURL.href}', '${testUser}', true, false, false)`); + // Create a valid JWT token + let token = createJWT({ user: testUser, sessionRoom: '!test:localhost' }, realmSecretSeed); + // Build the URL and compute signature + let downloadURL = new URL('/_download-realm', testRealmURL.origin); + downloadURL.searchParams.set('realm', testRealmURL.href); + downloadURL.searchParams.set('token', token); + let sig = createURLSignatureSync(token, downloadURL); + // Request with token and signature in query params + let response = await context.request + .get('/_download-realm') + .query({ realm: testRealmURL.href, token, sig }) + .buffer(true) + .parse(binaryParser); + let bodyPreview = response.body?.toString?.('utf8') ?? response.text ?? ''; + expect(response.status).toBe(200); + expect(response.headers['content-type']).toBe('application/zip'); + }); + it('rejects token via query param without signature', async function () { + // Remove public permissions to require authentication + await context.dbAdapter.execute(`DELETE FROM realm_user_permissions WHERE realm_url = '${testRealmURL.href}' AND username = '*'`); + let token = createJWT({ user: '@test:localhost', sessionRoom: '!test:localhost' }, realmSecretSeed); + let response = await context.request + .get('/_download-realm') + .query({ realm: testRealmURL.href, token }); + expect(response.status).toBe(400); + }); + it('rejects token via query param with invalid signature', async function () { + // Remove public permissions to require authentication + await context.dbAdapter.execute(`DELETE FROM realm_user_permissions WHERE realm_url = '${testRealmURL.href}' AND username = '*'`); + let token = createJWT({ user: '@test:localhost', sessionRoom: '!test:localhost' }, realmSecretSeed); + let response = await context.request + .get('/_download-realm') + .query({ realm: testRealmURL.href, token, sig: 'invalid-signature' }); + expect(response.status).toBe(401); + }); + it('rejects invalid token via query param', async function () { + // Remove public permissions to require authentication + await context.dbAdapter.execute(`DELETE FROM realm_user_permissions WHERE realm_url = '${testRealmURL.href}' AND username = '*'`); + // Invalid token with a signature (signature doesn't matter since token is invalid) + let response = await context.request + .get('/_download-realm') + .query({ realm: testRealmURL.href, token: 'invalid-token', sig: 'any' }); + expect(response.status).toBe(401); + }); +}); diff --git a/packages/realm-server/tests-vitest/server-endpoints/federated-types.test.ts b/packages/realm-server/tests-vitest/server-endpoints/federated-types.test.ts new file mode 100644 index 00000000000..a8f42e29fa8 --- /dev/null +++ b/packages/realm-server/tests-vitest/server-endpoints/federated-types.test.ts @@ -0,0 +1,308 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import supertest from 'supertest'; +import type { Test, SuperTest } from 'supertest'; +import { join } from 'path'; +import { dirSync } from 'tmp'; +import type { LooseSingleCardDocument, QueuePublisher, QueueRunner, Realm, } from '@cardstack/runtime-common'; +import type { FederatedCardTypeSummaryEntry } from '@cardstack/runtime-common/document-types'; +import type { PgAdapter } from '@cardstack/postgres'; +import { resetCatalogRealms } from '../../handlers/handle-fetch-catalog-realms'; +import { closeServer, createVirtualNetwork, setupDB, matrixURL, realmSecretSeed, runTestRealmServerWithRealms, } from '../helpers'; +import { createJWT as createRealmServerJWT } from '../../utils/jwt'; +import type { Server } from 'http'; +interface FederatedTypesResponse { + data: FederatedCardTypeSummaryEntry[]; + meta: { + page: { + total: number; + }; + }; +} +describe("server-endpoints/federated-types-test.ts", function () { + const _hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + describe('Realm Server Endpoints | /_federated-types', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let testRealm: Realm; + let secondaryRealm: Realm; + let request: SuperTest; + let dbAdapter: PgAdapter; + let testRealmHttpServer: Server; + let ownerUserId = '@mango:localhost'; + let realmFileSystem: Record = { + 'test-card.json': { + data: { + type: 'card', + attributes: { + cardInfo: { + name: 'Test Card', + }, + }, + meta: { + adoptsFrom: { + module: 'https://cardstack.com/base/card-api', + name: 'CardDef', + }, + }, + }, + }, + }; + async function startTypesRealmServer({ dbAdapter, publisher, runner, }: { + dbAdapter: PgAdapter; + publisher: QueuePublisher; + runner: QueueRunner; + }) { + let virtualNetwork = createVirtualNetwork(); + let dir = dirSync(); + let testRealmURL = new URL('http://127.0.0.1:4444/test/'); + let secondaryRealmURL = new URL('http://127.0.0.1:4444/secondary/'); + let result = await runTestRealmServerWithRealms({ + virtualNetwork, + realmsRootPath: join(dir.name, 'realm_server_1'), + realms: [ + { + realmURL: testRealmURL, + fileSystem: { + '.realm.json': JSON.stringify({ name: 'Primary Realm' }), + ...realmFileSystem, + }, + permissions: { + '*': ['read'], + [ownerUserId]: ['read', 'write', 'realm-owner'], + }, + }, + { + realmURL: secondaryRealmURL, + fileSystem: { + '.realm.json': JSON.stringify({ name: 'Secondary Realm' }), + ...realmFileSystem, + }, + permissions: { + [ownerUserId]: ['read', 'write', 'realm-owner'], + }, + }, + ], + dbAdapter, + publisher, + runner, + matrixURL, + }); + testRealmHttpServer = result.testRealmHttpServer; + request = supertest(result.testRealmHttpServer); + testRealm = result.realms.find((realm) => realm.url === testRealmURL.href)!; + secondaryRealm = result.realms.find((realm) => realm.url === secondaryRealmURL.href)!; + } + async function stopTypesRealmServer() { + testRealm.unsubscribe(); + secondaryRealm.unsubscribe(); + await closeServer(testRealmHttpServer); + resetCatalogRealms(); + } + setupDB(hooks, { + beforeEach: async (_dbAdapter, publisher, runner) => { + dbAdapter = _dbAdapter; + await startTypesRealmServer({ dbAdapter, publisher, runner }); + }, + afterEach: async () => { + await stopTypesRealmServer(); + }, + }); + function makeAuthenticatedRequest(realms: string[], extra?: Record) { + let realmServerToken = createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed); + return request + .post('/_federated-types') + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${realmServerToken}`) + .send({ realms, ...extra }); + } + it('QUERY /_federated-types returns flat type summaries from multiple realms', async function () { + let response = await makeAuthenticatedRequest([ + testRealm.url, + secondaryRealm.url, + ]); + expect(response.status).toBe(200); + let body = response.body as FederatedTypesResponse; + expect(Array.isArray(body.data)).toBeTruthy(); + expect(body.data.length > 0).toBeTruthy(); + let primaryEntries = body.data.filter((entry) => entry.meta.realmURL === testRealm.url); + let secondaryEntries = body.data.filter((entry) => entry.meta.realmURL === secondaryRealm.url); + expect(primaryEntries.length > 0).toBeTruthy(); + expect(secondaryEntries.length > 0).toBeTruthy(); + expect(body.data[0].type).toBe('card-type-summary'); + expect(body.data[0].meta.realmURL).toBeTruthy(); + expect(body.data[0].attributes.displayName).toBeTruthy(); + expect(typeof body.data[0].attributes.total).toBe('number'); + expect(body.meta.page.total).toBe(body.data.length); + let publicHeader = response.headers['x-boxel-realms-public-readable'] ?? ''; + expect(publicHeader).toBeTruthy(); + let publicRealms = publicHeader + .split(',') + .map((value: string) => value.trim()); + expect(publicRealms.includes(testRealm.url)).toBeTruthy(); + expect(publicRealms.includes(secondaryRealm.url)).toBeFalsy(); + }); + it('QUERY /_federated-types returns 401 for unauthenticated request to non-public realm', async function () { + let response = await request + .post('/_federated-types') + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', 'application/json') + .send({ realms: [secondaryRealm.url] }); + expect(response.status).toBe(401); + expect(response.body.errors?.[0]?.includes(secondaryRealm.url)).toBeTruthy(); + }); + it('QUERY /_federated-types returns 403 for authenticated request to non-public realm without read permission', async function () { + let unauthorizedToken = createRealmServerJWT({ user: 'unauthorized-user', sessionRoom: 'session-room-test' }, realmSecretSeed); + let response = await request + .post('/_federated-types') + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', 'application/json') + .set('Authorization', `Bearer ${unauthorizedToken}`) + .send({ realms: [secondaryRealm.url] }); + expect(response.status).toBe(403); + expect(response.body.errors?.[0]?.includes(secondaryRealm.url)).toBeTruthy(); + }); + it('QUERY /_federated-types returns 400 when realms are missing', async function () { + let response = await request + .post('/_federated-types') + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', 'application/json') + .send({}); + expect(response.status).toBe(400); + expect(response.body.errors?.[0]?.includes('realms must be supplied in request body')).toBeTruthy(); + }); + it('QUERY /_federated-types returns type summaries for public realm without auth', async function () { + let response = await request + .post('/_federated-types') + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', 'application/json') + .send({ realms: [testRealm.url] }); + expect(response.status).toBe(200); + let body = response.body as FederatedTypesResponse; + expect(Array.isArray(body.data)).toBeTruthy(); + expect(body.data.length > 0).toBeTruthy(); + expect(body.data[0].meta.realmURL).toBe(testRealm.url); + }); + it('QUERY /_federated-types with pagination returns limited results', async function () { + let response = await makeAuthenticatedRequest([testRealm.url, secondaryRealm.url], { page: { number: 0, size: 1 } }); + expect(response.status).toBe(200); + let body = response.body as FederatedTypesResponse; + expect(body.data.length).toBe(1); + expect(body.meta.page.total > 1).toBeTruthy(); + }); + it('QUERY /_federated-types pagination page 2 returns different items', async function () { + let page0Response = await makeAuthenticatedRequest([testRealm.url, secondaryRealm.url], { page: { number: 0, size: 1 } }); + let page1Response = await makeAuthenticatedRequest([testRealm.url, secondaryRealm.url], { page: { number: 1, size: 1 } }); + let page0Body = page0Response.body as FederatedTypesResponse; + let page1Body = page1Response.body as FederatedTypesResponse; + expect(page0Body.data.length).toBe(1); + expect(page1Body.data.length).toBe(1); + let page0Id = `${page0Body.data[0].id}-${page0Body.data[0].meta.realmURL}`; + let page1Id = `${page1Body.data[0].id}-${page1Body.data[0].meta.realmURL}`; + expect(page0Id).not.toBe(page1Id); + expect(page0Body.meta.page.total).toBe(page1Body.meta.page.total); + }); + it('QUERY /_federated-types without pagination returns all items', async function () { + let allResponse = await makeAuthenticatedRequest([ + testRealm.url, + secondaryRealm.url, + ]); + let paginatedResponse = await makeAuthenticatedRequest([testRealm.url, secondaryRealm.url], { page: { number: 0, size: 1 } }); + let allBody = allResponse.body as FederatedTypesResponse; + let paginatedBody = paginatedResponse.body as FederatedTypesResponse; + expect(allBody.data.length).toBe(allBody.meta.page.total); + expect(allBody.meta.page.total).toBe(paginatedBody.meta.page.total); + }); + it('QUERY /_federated-types with searchKey filters results', async function () { + let allResponse = await makeAuthenticatedRequest([testRealm.url]); + let allBody = allResponse.body as FederatedTypesResponse; + // Use a displayName or code_ref substring from the actual results + let firstEntry = allBody.data[0]; + let searchTerm = firstEntry.attributes.displayName.substring(0, 4); + let searchResponse = await makeAuthenticatedRequest([testRealm.url], { + searchKey: searchTerm, + }); + let searchBody = searchResponse.body as FederatedTypesResponse; + expect(searchBody.data.length > 0).toBeTruthy(); + expect(searchBody.data.every((entry) => entry.attributes.displayName + .toLowerCase() + .includes(searchTerm.toLowerCase()) || + entry.id.toLowerCase().includes(searchTerm.toLowerCase()))).toBeTruthy(); + expect(searchBody.meta.page.total).toBe(searchBody.data.length); + }); + it('QUERY /_federated-types with searchKey and pagination combined', async function () { + let allResponse = await makeAuthenticatedRequest([testRealm.url]); + let allBody = allResponse.body as FederatedTypesResponse; + let firstEntry = allBody.data[0]; + let searchTerm = firstEntry.attributes.displayName.substring(0, 3); + let response = await makeAuthenticatedRequest([testRealm.url], { + searchKey: searchTerm, + page: { number: 0, size: 1 }, + }); + let body = response.body as FederatedTypesResponse; + expect(body.data.length).toBe(1); + let matchesDisplayName = body.data[0].attributes.displayName + .toLowerCase() + .includes(searchTerm.toLowerCase()); + let matchesCodeRef = body.data[0].id + .toLowerCase() + .includes(searchTerm.toLowerCase()); + let matchesSearch = matchesDisplayName || matchesCodeRef; + expect(matchesSearch).toBeTruthy(); + expect(body.meta.page.total >= 1).toBeTruthy(); + }); + it('QUERY /_federated-types with searchKey that matches nothing returns empty data', async function () { + let response = await makeAuthenticatedRequest([testRealm.url], { + searchKey: 'zzzzNonExistentTypezzzzz', + }); + expect(response.status).toBe(200); + let body = response.body as FederatedTypesResponse; + expect(body.data.length).toBe(0); + expect(body.meta.page.total).toBe(0); + }); + it('QUERY /_federated-types searchKey is case-insensitive', async function () { + let allResponse = await makeAuthenticatedRequest([testRealm.url]); + let allBody = allResponse.body as FederatedTypesResponse; + let firstEntry = allBody.data[0]; + let searchTerm = firstEntry.attributes.displayName + .toUpperCase() + .substring(0, 4); + let response = await makeAuthenticatedRequest([testRealm.url], { + searchKey: searchTerm, + }); + let body = response.body as FederatedTypesResponse; + expect(body.data.length > 0).toBeTruthy(); + }); + it('QUERY /_federated-types each item has meta.realmURL', async function () { + let response = await makeAuthenticatedRequest([ + testRealm.url, + secondaryRealm.url, + ]); + let body = response.body as FederatedTypesResponse; + expect(body.data.every((entry) => typeof entry.meta.realmURL === 'string' && + entry.meta.realmURL.length > 0)).toBeTruthy(); + let realmURLs = new Set(body.data.map((entry) => entry.meta.realmURL)); + expect(realmURLs.has(testRealm.url)).toBeTruthy(); + expect(realmURLs.has(secondaryRealm.url)).toBeTruthy(); + }); + it('QUERY /_federated-types pagination beyond range returns empty data', async function () { + let response = await makeAuthenticatedRequest([testRealm.url], { + page: { number: 9999, size: 50 }, + }); + expect(response.status).toBe(200); + let body = response.body as FederatedTypesResponse; + expect(body.data.length).toBe(0); + expect(body.meta.page.total > 0).toBeTruthy(); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/server-endpoints/helpers.ts b/packages/realm-server/tests-vitest/server-endpoints/helpers.ts new file mode 100644 index 00000000000..05141dee30c --- /dev/null +++ b/packages/realm-server/tests-vitest/server-endpoints/helpers.ts @@ -0,0 +1,103 @@ +import type { Test, SuperTest } from 'supertest'; +import type { DirResult } from 'tmp'; +import type { + QueuePublisher, + QueueRunner, + Realm, + VirtualNetwork, +} from '@cardstack/runtime-common'; +import type { Server } from 'http'; +import type { PgAdapter } from '@cardstack/postgres'; +import type { RealmServer } from '../../server'; +import { setupPermissionedRealmCached, testPort } from '../helpers'; +import type { MatrixClient } from '@cardstack/runtime-common/matrix-client'; + +export const testRealmURL = new URL(`http://127.0.0.1:${testPort(4445)}/test/`); + +export type ServerEndpointsTestContext = { + testRealm: Realm; + request: SuperTest; + dir: DirResult; + dbAdapter: PgAdapter; + testRealmServer: RealmServer; + testRealmHttpServer: Server; + publisher: QueuePublisher; + runner: QueueRunner; + testRealmDir: string; + virtualNetwork: VirtualNetwork; +}; + +export function setupServerEndpointsTest(hooks: NestedHooks) { + let context = {} as ServerEndpointsTestContext; + + function onRealmSetup(args: { + testRealmServer: { + testRealmServer: RealmServer; + testRealmHttpServer: Server; + }; + testRealm: Realm; + testRealmPath: string; + request: SuperTest; + dir: DirResult; + dbAdapter: PgAdapter; + runner: QueueRunner; + publisher: QueuePublisher; + virtualNetwork: VirtualNetwork; + }) { + context.testRealmServer = args.testRealmServer.testRealmServer; + context.testRealmHttpServer = args.testRealmServer.testRealmHttpServer; + context.testRealm = args.testRealm; + context.request = args.request; + context.dir = args.dir; + context.dbAdapter = args.dbAdapter; + context.runner = args.runner; + context.publisher = args.publisher; + context.testRealmDir = args.testRealmPath; + context.virtualNetwork = args.virtualNetwork; + } + + setupPermissionedRealmCached(hooks, { + realmURL: testRealmURL, + permissions: { + '*': ['read', 'write'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + onRealmSetup, + }); + + return context; +} + +export async function createRealmServerSession( + matrixClient: MatrixClient, + request: SuperTest, + options?: { registrationToken?: string }, +) { + let openIdToken = await matrixClient.getOpenIdToken(); + if (!openIdToken) { + throw new Error('matrixClient did not return an OpenID token'); + } + let body: Record = { ...openIdToken }; + if (options?.registrationToken) { + body.registration_token = options.registrationToken; + } + let response = await request + .post('/_server-session') + .send(JSON.stringify(body)) + .set('Accept', 'application/json') + .set('Content-Type', 'application/json'); + + let jwt = response.header['authorization']; + if (!jwt) { + throw new Error('Realm server did not send Authorization header'); + } + let payload = JSON.parse( + Buffer.from(jwt.split('.')[1], 'base64').toString('utf8'), + ) as { sessionRoom: string }; + + return { + sessionRoom: payload.sessionRoom, + jwt, + status: response.status, + }; +} diff --git a/packages/realm-server/tests-vitest/server-endpoints/incoming-webhook.test.ts b/packages/realm-server/tests-vitest/server-endpoints/incoming-webhook.test.ts new file mode 100644 index 00000000000..c501bb3b8f4 --- /dev/null +++ b/packages/realm-server/tests-vitest/server-endpoints/incoming-webhook.test.ts @@ -0,0 +1,314 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import { createJWT as createRealmServerJWT } from '../../utils/jwt'; +import { realmSecretSeed, insertUser } from '../helpers'; +import { param, query, uuidv4 } from '@cardstack/runtime-common'; +import { setupServerEndpointsTest } from './helpers'; +describe("server-endpoints/incoming-webhook-test.ts", function () { + describe('Incoming Webhook Endpoints', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let context = setupServerEndpointsTest(hooks); + it('requires auth to create incoming webhook', async function () { + let response = await context.request.post('/_incoming-webhooks').send({}); + expect(response.status).toBe(401); + }); + it('requires auth to list incoming webhooks', async function () { + let response = await context.request.get('/_incoming-webhooks'); + expect(response.status).toBe(401); + }); + it('requires auth to delete incoming webhook', async function () { + let response = await context.request.delete('/_incoming-webhooks').send({ + data: { + type: 'incoming-webhook', + id: uuidv4(), + }, + }); + expect(response.status).toBe(401); + }); + it('can create incoming webhook', async function () { + let matrixUserId = '@user:localhost'; + await insertUser(context.dbAdapter, matrixUserId, 'cus_123', 'user@example.com'); + let response = await context.request + .post('/_incoming-webhooks') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'incoming-webhook', + attributes: { + verificationType: 'HMAC_SHA256_HEADER', + verificationConfig: { + header: 'X-Hub-Signature-256', + encoding: 'hex', + }, + }, + }, + }); + expect(response.status).toBe(201); + expect(response.body.data.id).toBeTruthy(); + expect(response.body.data.attributes.username).toBe(matrixUserId); + expect(response.body.data.attributes.webhookPath).toBeTruthy(); + expect(response.body.data.attributes.webhookPath.startsWith('whk_')).toBeTruthy(); + expect(response.body.data.attributes.verificationType).toBe('HMAC_SHA256_HEADER'); + expect(response.body.data.attributes.verificationConfig).toEqual({ header: 'X-Hub-Signature-256', encoding: 'hex' }); + expect(response.body.data.attributes.signingSecret).toBeTruthy(); + expect(response.body.data.attributes.signingSecret.length).toBe(64); + expect(response.body.data.attributes.createdAt).toBeTruthy(); + expect(response.body.data.attributes.updatedAt).toBeTruthy(); + let rows = await context.dbAdapter.execute(`SELECT id, username, webhook_path, verification_type, verification_config, signing_secret, created_at, updated_at FROM incoming_webhooks`); + expect(rows.length).toBe(1); + expect(rows[0].id).toBeTruthy(); + expect(rows[0].username).toBe(matrixUserId); + expect(rows[0].webhook_path).toBeTruthy(); + expect(rows[0].signing_secret).toBeTruthy(); + }); + it('rejects unsupported verificationType', async function () { + let matrixUserId = '@user:localhost'; + await insertUser(context.dbAdapter, matrixUserId, 'cus_123', 'user@example.com'); + let response = await context.request + .post('/_incoming-webhooks') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'incoming-webhook', + attributes: { + verificationType: 'UNSUPPORTED_TYPE', + verificationConfig: { + header: 'X-Custom-Header', + encoding: 'hex', + }, + }, + }, + }); + expect(response.status).toBe(400); + }); + it('rejects missing verificationConfig header', async function () { + let matrixUserId = '@user:localhost'; + await insertUser(context.dbAdapter, matrixUserId, 'cus_123', 'user@example.com'); + let response = await context.request + .post('/_incoming-webhooks') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'incoming-webhook', + attributes: { + verificationType: 'HMAC_SHA256_HEADER', + verificationConfig: { + encoding: 'hex', + }, + }, + }, + }); + expect(response.status).toBe(400); + }); + it('rejects invalid verificationConfig encoding', async function () { + let matrixUserId = '@user:localhost'; + await insertUser(context.dbAdapter, matrixUserId, 'cus_123', 'user@example.com'); + let response = await context.request + .post('/_incoming-webhooks') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'incoming-webhook', + attributes: { + verificationType: 'HMAC_SHA256_HEADER', + verificationConfig: { + header: 'X-Hub-Signature-256', + encoding: 'invalid', + }, + }, + }, + }); + expect(response.status).toBe(400); + }); + it('lists incoming webhooks for authenticated user only', async function () { + let matrixUserId = '@user:localhost'; + let otherMatrixUserId = '@other-user:localhost'; + await insertUser(context.dbAdapter, matrixUserId, 'cus_123', 'user@example.com'); + await insertUser(context.dbAdapter, otherMatrixUserId, 'cus_124', 'other@example.com'); + await query(context.dbAdapter, [ + `INSERT INTO incoming_webhooks (id, username, webhook_path, verification_type, verification_config, signing_secret, created_at, updated_at) VALUES (`, + param(uuidv4()), + `,`, + param(matrixUserId), + `,`, + param('whk_user1'), + `,`, + param('HMAC_SHA256_HEADER'), + `,`, + `'{"header": "X-Hub-Signature-256", "encoding": "hex"}'::jsonb`, + `,`, + param('secret1'), + `,`, + `CURRENT_TIMESTAMP`, + `,`, + `CURRENT_TIMESTAMP`, + `)`, + ]); + await query(context.dbAdapter, [ + `INSERT INTO incoming_webhooks (id, username, webhook_path, verification_type, verification_config, signing_secret, created_at, updated_at) VALUES (`, + param(uuidv4()), + `,`, + param(otherMatrixUserId), + `,`, + param('whk_other1'), + `,`, + param('HMAC_SHA256_HEADER'), + `,`, + `'{"header": "X-Hub-Signature-256", "encoding": "hex"}'::jsonb`, + `,`, + param('secret2'), + `,`, + `CURRENT_TIMESTAMP`, + `,`, + `CURRENT_TIMESTAMP`, + `)`, + ]); + let response = await context.request + .get('/_incoming-webhooks') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`); + expect(response.status).toBe(200); + expect(response.body.data.length).toBe(1); + expect(response.body.data[0].attributes.username).toBe(matrixUserId); + }); + it('can delete own incoming webhook', async function () { + let matrixUserId = '@user:localhost'; + await insertUser(context.dbAdapter, matrixUserId, 'cus_123', 'user@example.com'); + let createResponse = await context.request + .post('/_incoming-webhooks') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'incoming-webhook', + attributes: { + verificationType: 'HMAC_SHA256_HEADER', + verificationConfig: { + header: 'X-Hub-Signature-256', + encoding: 'hex', + }, + }, + }, + }); + let webhookId = createResponse.body.data.id; + let deleteResponse = await context.request + .delete('/_incoming-webhooks') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'incoming-webhook', + id: webhookId, + }, + }); + expect(deleteResponse.status).toBe(204); + let rows = await context.dbAdapter.execute(`SELECT id FROM incoming_webhooks`); + expect(rows.length).toBe(0); + }); + it('rejects deletion of another user incoming webhook', async function () { + let ownerUserId = '@user:localhost'; + let otherUserId = '@other-user:localhost'; + await insertUser(context.dbAdapter, ownerUserId, 'cus_123', 'user@example.com'); + await insertUser(context.dbAdapter, otherUserId, 'cus_124', 'other@example.com'); + let createResponse = await context.request + .post('/_incoming-webhooks') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'incoming-webhook', + attributes: { + verificationType: 'HMAC_SHA256_HEADER', + verificationConfig: { + header: 'X-Hub-Signature-256', + encoding: 'hex', + }, + }, + }, + }); + let webhookId = createResponse.body.data.id; + let deleteResponse = await context.request + .delete('/_incoming-webhooks') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: otherUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'incoming-webhook', + id: webhookId, + }, + }); + expect(deleteResponse.status).toBe(403); + let rows = await context.dbAdapter.execute(`SELECT id FROM incoming_webhooks`); + expect(rows.length).toBe(1); + }); + it('deleting incoming webhook cascades to webhook commands', async function () { + let matrixUserId = '@user:localhost'; + await insertUser(context.dbAdapter, matrixUserId, 'cus_123', 'user@example.com'); + let createResponse = await context.request + .post('/_incoming-webhooks') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'incoming-webhook', + attributes: { + verificationType: 'HMAC_SHA256_HEADER', + verificationConfig: { + header: 'X-Hub-Signature-256', + encoding: 'hex', + }, + }, + }, + }); + let webhookId = createResponse.body.data.id; + await query(context.dbAdapter, [ + `INSERT INTO webhook_commands (id, incoming_webhook_id, command, command_filter, created_at, updated_at) VALUES (`, + param(uuidv4()), + `,`, + param(webhookId), + `,`, + param('https://example.com/webhook-handler'), + `,`, + `'{"eventType": "pull_request"}'::jsonb`, + `,`, + `CURRENT_TIMESTAMP`, + `,`, + `CURRENT_TIMESTAMP`, + `)`, + ]); + let deleteResponse = await context.request + .delete('/_incoming-webhooks') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'incoming-webhook', + id: webhookId, + }, + }); + expect(deleteResponse.status).toBe(204); + let webhookRows = await context.dbAdapter.execute(`SELECT id FROM incoming_webhooks`); + expect(webhookRows.length).toBe(0); + let commandRows = await context.dbAdapter.execute(`SELECT id FROM webhook_commands`); + expect(commandRows.length).toBe(0); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/server-endpoints/index-responses.test.ts b/packages/realm-server/tests-vitest/server-endpoints/index-responses.test.ts new file mode 100644 index 00000000000..a59b87be42a --- /dev/null +++ b/packages/realm-server/tests-vitest/server-endpoints/index-responses.test.ts @@ -0,0 +1,978 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import { join } from 'path'; +import supertest from 'supertest'; +import type { Test, SuperTest } from 'supertest'; +import type { Server } from 'http'; +import { dirSync, type DirResult } from 'tmp'; +import { DEFAULT_PERMISSIONS, systemInitiatedPriority, type DBAdapter, type Realm, } from '@cardstack/runtime-common'; +import type { PgAdapter } from '@cardstack/postgres'; +import { testRealmURL } from './helpers'; +import { closeServer, createVirtualNetwork, matrixURL, realmSecretSeed, runTestRealmServer, setupDB, setupPermissionedRealmCached, waitUntil, } from '../helpers'; +import { createJWT as createRealmServerJWT } from '../../utils/jwt'; +import { ensureDirSync } from 'fs-extra'; +import '@cardstack/runtime-common/helpers/code-equality-assertion'; +describe("server-endpoints/index-responses-test.ts", function () { + describe('Realm Server Endpoints (not specific to one realm)', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let request: SuperTest; + let dbAdapter: DBAdapter; + function onRealmSetup(args: { + request: SuperTest; + testRealm: Realm; + dbAdapter: DBAdapter; + }) { + request = args.request; + dbAdapter = args.dbAdapter; + } + setupPermissionedRealmCached(hooks, { + realmURL: testRealmURL, + fileSystem: { + 'index.json': { + data: { + type: 'card', + attributes: {}, + meta: { + adoptsFrom: { + module: './home.gts', + name: 'Home', + }, + }, + }, + }, + 'home.gts': `import { Component, CardDef } from 'https://cardstack.com/base/card-api'; + export class Home extends CardDef { + static isolated = class Isolated extends Component { + + }; + }`, + 'person.gts': `import { + contains, + field, + Component, + CardDef, + } from 'https://cardstack.com/base/card-api'; + import StringField from 'https://cardstack.com/base/string'; + + export class Person extends CardDef { + static displayName = 'Person'; + @field firstName = contains(StringField); + @field cardTitle = contains(StringField, { + computeVia: function (this: Person) { + return this.firstName; + }, + }); + static isolated = class Isolated extends Component { + + }; + }`, + 'subdirectory/index.json': { + data: { + type: 'card', + attributes: { + firstName: 'Subdirectory Index', + }, + meta: { + adoptsFrom: { + module: '../person.gts', + name: 'Person', + }, + }, + }, + }, + 'isolated-card.gts': ` + import { Component, CardDef } from 'https://cardstack.com/base/card-api'; + + export class IsolatedCard extends CardDef { + static isolated = class Isolated extends Component { + + }; + } + `, + 'isolated-test.json': { + data: { + type: 'card', + attributes: {}, + meta: { + adoptsFrom: { + module: './isolated-card.gts', + name: 'IsolatedCard', + }, + }, + }, + }, + 'dollar-sign-card.gts': ` + import { Component, CardDef } from 'https://cardstack.com/base/card-api'; + + export class DollarSignCard extends CardDef { + static isolated = class Isolated extends Component { + + }; + } + `, + 'dollar-sign-test.json': { + data: { + type: 'card', + attributes: {}, + meta: { + adoptsFrom: { + module: './dollar-sign-card.gts', + name: 'DollarSignCard', + }, + }, + }, + }, + 'head-card.gts': ` + import { Component, CardDef } from 'https://cardstack.com/base/card-api'; + + export class HeadCard extends CardDef { + static isolated = class Isolated extends Component { + + }; + + static head = class Head extends Component { + + }; + } + `, + 'private-index-test.json': { + data: { + type: 'card', + attributes: {}, + meta: { + adoptsFrom: { + module: './head-card.gts', + name: 'HeadCard', + }, + }, + }, + }, + 'unsafe-head-card.gts': ` + import { Component, CardDef } from 'https://cardstack.com/base/card-api'; + + export class UnsafeHeadCard extends CardDef { + static isolated = class Isolated extends Component { + + }; + + static head = class Head extends Component { + + }; + } + `, + 'unsafe-head-test.json': { + data: { + type: 'card', + attributes: {}, + meta: { + adoptsFrom: { + module: './unsafe-head-card.gts', + name: 'UnsafeHeadCard', + }, + }, + }, + }, + 'scoped-css-card.gts': ` + import { Component, CardDef } from 'https://cardstack.com/base/card-api'; + + export class ScopedCssCard extends CardDef { + static isolated = class Isolated extends Component { + + }; + } + `, + 'scoped-css-test.json': { + data: { + type: 'card', + attributes: {}, + meta: { + adoptsFrom: { + module: './scoped-css-card.gts', + name: 'ScopedCssCard', + }, + }, + }, + }, + // Cards for testing scoped CSS from linked card instances. + // The parent declares linksTo with a base type, but the actual linked + // instance is a subclass with its own scoped CSS. This means the child's + // CSS is NOT reachable through the parent's static module imports — it + // can only be found by iterating over serialized.included resources. + 'linked-css-base.gts': ` + import { Component, CardDef } from 'https://cardstack.com/base/card-api'; + + export class LinkedCssBase extends CardDef { + static embedded = class Embedded extends Component { + + }; + } + `, + 'linked-css-child.gts': ` + import { Component } from 'https://cardstack.com/base/card-api'; + import { LinkedCssBase } from './linked-css-base.gts'; + + export class LinkedCssChild extends LinkedCssBase { + static isolated = class Isolated extends Component { + + }; + static embedded = class Embedded extends Component { + + }; + } + `, + 'linked-css-parent.gts': ` + import { Component, CardDef, field, linksTo } from 'https://cardstack.com/base/card-api'; + import { LinkedCssBase } from './linked-css-base.gts'; + + export class LinkedCssParent extends CardDef { + @field child = linksTo(() => LinkedCssBase); + static isolated = class Isolated extends Component { + + }; + } + `, + 'linked-css-child-1.json': { + data: { + type: 'card', + attributes: {}, + meta: { + adoptsFrom: { + module: './linked-css-child.gts', + name: 'LinkedCssChild', + }, + }, + }, + }, + 'linked-css-parent-1.json': { + data: { + type: 'card', + attributes: {}, + relationships: { + child: { + links: { + self: './linked-css-child-1', + }, + }, + }, + meta: { + adoptsFrom: { + module: './linked-css-parent.gts', + name: 'LinkedCssParent', + }, + }, + }, + }, + // Cards for testing default head template with cardInfo.theme + 'a-test-theme.json': { + data: { + type: 'card', + attributes: { + cardInfo: { + cardThumbnailURL: 'https://example.com/brand-icon.png', + }, + }, + meta: { + adoptsFrom: { + module: 'https://cardstack.com/base/card-api', + name: 'Theme', + }, + }, + }, + }, + 'a-brand-guide-theme.json': { + data: { + type: 'card', + attributes: { + markUsage: { + socialMediaProfileIcon: 'https://example.com/social-icon.png', + }, + }, + meta: { + adoptsFrom: { + module: 'https://cardstack.com/base/brand-guide', + name: 'default', + }, + }, + }, + }, + }, + permissions: { + '*': ['read', 'write'], + }, + onRealmSetup, + }); + it('startup indexing uses system initiated queue priority', async function () { + let [job] = (await dbAdapter.execute(`SELECT priority FROM jobs WHERE job_type = 'from-scratch-index' AND args->>'realmURL' = '${testRealmURL.href}' ORDER BY created_at DESC LIMIT 1`)) as { + priority: number; + }[]; + expect(job).toBeTruthy(); + expect(job.priority).toBe(systemInitiatedPriority); + }); + it('serves isolated HTML for realm index request', async function () { + let response = await request.get('/test').set('Accept', 'text/html'); + expect(response.status).toBe(200); + expect(response.text.includes('data-test-home-card')).toBeTruthy(); + }); + it('serves isolated HTML in index responses for card URLs', async function () { + let response = await request + .get('/test/isolated-test') + .set('Accept', 'text/html'); + expect(response.status).toBe(200); + expect(response.text.includes('data-test-isolated-html')).toBeTruthy(); + }); + it('HTML response does not include boxel-ready class on body', async function () { + let response = await request + .get('/test/isolated-test') + .set('Accept', 'text/html'); + expect(response.status).toBe(200); + expect(response.text.includes('boxel-ready')).toBeFalsy(); + }); + it('serves isolated HTML for /subdirectory/index.json at /subdirectory/', async function () { + let response = await request + .get('/test/subdirectory/') + .set('Accept', 'text/html'); + expect(response.status).toBe(200); + expect(response.text.includes('Subdirectory Index')).toBeTruthy(); + }); + it('does not inject head or isolated HTML when realm is not public', async function () { + await dbAdapter.execute(`DELETE FROM realm_user_permissions WHERE realm_url = '${testRealmURL.href}' AND username = '*'`); + let response = await request + .get('/test/private-index-test') + .set('Accept', 'text/html'); + expect(response.status).toBe(200); + expect(response.text.includes('data-test-head-html')).toBeFalsy(); + expect(response.text.includes('data-test-isolated-html')).toBeFalsy(); + }); + it('serves scoped CSS in index responses for card URLs', async function () { + let response = await request + .get('/test/scoped-css-test') + .set('Accept', 'text/html'); + expect(response.status).toBe(200); + expect(response.text.includes('data-boxel-scoped-css')).toBeTruthy(); + expect(response.text.includes('--scoped-css-marker: 1')).toBeTruthy(); + }); + it('serves scoped CSS from linked cards in index responses', async function () { + let response = await request + .get('/test/linked-css-parent-1') + .set('Accept', 'text/html'); + expect(response.status).toBe(200); + expect(response.text.includes('data-test-linked-parent')).toBeTruthy(); + expect(response.text.includes('--linked-child-css: 1')).toBeTruthy(); + }); + it('sanitizes disallowed tags from head HTML in index responses', async function () { + let response = await request + .get('/test/unsafe-head-test') + .set('Accept', 'text/html'); + expect(response.status).toBe(200); + // Extract content between head markers + let headMatch = response.text.match(/data-boxel-head-start[^>]*>([\s\S]*?)data-boxel-head-end/); + let headContent = headMatch?.[1] ?? ''; + expect(headContent.includes('')).toBeTruthy(); + expect(headContent.includes('<meta')).toBeTruthy(); + expect(headContent.includes('<script')).toBeFalsy(); + expect(headContent.includes('void 0')).toBeFalsy(); + expect(headContent.includes('.injected-style')).toBeFalsy(); + }); + it('serves isolated HTML containing dollar signs without corruption', async function () { + let response = await request + .get('/test/dollar-sign-test') + .set('Accept', 'text/html'); + expect(response.status).toBe(200); + expect(response.text.includes('data-test-dollar-sign')).toBeTruthy(); + expect(response.text.includes('$0.50')).toBeTruthy(); + expect(response.text.includes('boxel-isolated-end')).toBeTruthy(); + }); + it('ignores deleted index entries for head, isolated, and scoped CSS injection', async function () { + let deleteSlugs = ['private-index-test', 'scoped-css-test']; + for (let slug of deleteSlugs) { + let deleteResponse = await request + .delete(`/test/${slug}`) + .set('Accept', 'application/vnd.card+json'); + expect(deleteResponse.status).toBe(204); + } + await waitUntil(async () => { + let realmURLNoProtocol = testRealmURL.href.replace(/^https?:\/\//, ''); + for (let slug of deleteSlugs) { + for (let table of ['boxel_index', 'boxel_index_working']) { + let rows = (await dbAdapter.execute(`SELECT COUNT(*) AS count + FROM ${table} + WHERE type = 'instance' + AND is_deleted IS NOT TRUE + AND (regexp_replace(url, '^https?://', '') LIKE '${realmURLNoProtocol}%${slug}%' + OR regexp_replace(file_alias, '^https?://', '') LIKE '${realmURLNoProtocol}%${slug}%')`)) as { + count: string | number; + }[]; + if (Number(rows[0]?.count ?? 0) > 0) { + return false; + } + } + } + return true; + }, { + timeout: 5000, + interval: 200, + timeoutMessage: 'Timed out waiting for deleted index entries to be tombstoned', + }); + let headResponse = await request + .get('/test/private-index-test') + .set('Accept', 'text/html'); + expect(headResponse.status).toBe(200); + expect(headResponse.text.includes('data-test-head-html')).toBeFalsy(); + expect(headResponse.text.includes('data-test-isolated-html')).toBeFalsy(); + let scopedCSSResponse = await request + .get('/test/scoped-css-test') + .set('Accept', 'text/html'); + expect(scopedCSSResponse.status).toBe(200); + expect(scopedCSSResponse.text.includes('data-boxel-scoped-css')).toBeFalsy(); + expect(scopedCSSResponse.text.includes('--scoped-css-marker: 1')).toBeFalsy(); + expect(scopedCSSResponse.text.includes('data-test-scoped-css')).toBeFalsy(); + }); + it('HTML response includes exactly one favicon and one apple-touch-icon', async function () { + let response = await request + .get('/test/isolated-test') + .set('Accept', 'text/html'); + expect(response.status).toBe(200); + let faviconCount = (response.text.match(/rel="icon"/g) || []).length; + let appleTouchIconCount = (response.text.match(/rel="apple-touch-icon"/g) || []).length; + expect(faviconCount).toBe(1); + expect(appleTouchIconCount).toBe(1); + expect(/<title[\s>]/.test(response.text)).toBeTruthy(); + }); + it('default icon links are injected when card has no theme', async function () { + let response = await request + .get('/test/isolated-test') + .set('Accept', 'text/html'); + expect(response.status).toBe(200); + let headMatch = response.text.match(/data-boxel-head-start[^>]*>([\s\S]*?)data-boxel-head-end/); + let headContent = headMatch?.[1] ?? ''; + expect(/<title[\s>]/.test(headContent)).toBeTruthy(); + expect(headContent.includes('rel="icon"')).toBeTruthy(); + expect(headContent.includes('rel="apple-touch-icon"')).toBeTruthy(); + expect(headContent.includes('boxel-favicon.png')).toBeTruthy(); + expect(headContent.includes('boxel-webclip.png')).toBeTruthy(); + }); + it('non-public realm includes exactly one favicon and one apple-touch-icon', async function () { + await dbAdapter.execute(`DELETE FROM realm_user_permissions WHERE realm_url = '${testRealmURL.href}' AND username = '*'`); + let response = await request + .get('/test/private-index-test') + .set('Accept', 'text/html'); + expect(response.status).toBe(200); + let faviconCount = (response.text.match(/rel="icon"/g) || []).length; + let appleTouchIconCount = (response.text.match(/rel="apple-touch-icon"/g) || []).length; + expect(faviconCount).toBe(1); + expect(appleTouchIconCount).toBe(1); + expect(response.text.includes('<title>Boxel')).toBeTruthy(); + }); + it('missing apple-touch-icon is filled with default when only favicon is present in head HTML', async function () { + // Directly set head_html to contain only a favicon link (no apple-touch-icon) + let cardURL = `${testRealmURL.href}isolated-test.json`; + await dbAdapter.execute(`UPDATE boxel_index + SET head_html = 'Test' + WHERE url = '${cardURL}' + AND type = 'instance' + AND is_deleted IS NOT TRUE`); + let response = await request + .get('/test/isolated-test') + .set('Accept', 'text/html'); + expect(response.status).toBe(200); + let headMatch = response.text.match(/data-boxel-head-start[^>]*>([\s\S]*?)data-boxel-head-end/); + let headContent = headMatch?.[1] ?? ''; + expect(headContent.includes(']*>([\s\S]*?)data-boxel-head-end/); + let headContent = headMatch?.[1] ?? ''; + expect(headContent.includes(' { + let rows = (await dbAdapter.execute(`SELECT url, head_html FROM boxel_index + WHERE url LIKE '%card-with-theme%' + AND type = 'instance' + AND is_deleted IS NOT TRUE + LIMIT 1`)) as { + url: string; + head_html: string | null; + }[]; + return rows.length > 0 && rows[0].head_html != null; + }, { + timeout: 30000, + interval: 500, + timeoutMessage: 'Timed out waiting for card-with-theme to be indexed', + }); + let response = await request + .get('/test/card-with-theme') + .set('Accept', 'text/html'); + expect(response.status).toBe(200); + let headMatch = response.text.match(/data-boxel-head-start[^>]*>([\s\S]*?)data-boxel-head-end/); + let headContent = headMatch?.[1] ?? ''; + expect(headContent.includes(' { + let rows = (await dbAdapter.execute(`SELECT url, head_html FROM boxel_index + WHERE url LIKE '%card-with-brand-guide-theme%' + AND type = 'instance' + AND is_deleted IS NOT TRUE + LIMIT 1`)) as { + url: string; + head_html: string | null; + }[]; + return rows.length > 0 && rows[0].head_html != null; + }, { + timeout: 30000, + interval: 500, + timeoutMessage: 'Timed out waiting for card-with-brand-guide-theme to be indexed', + }); + let response = await request + .get('/test/card-with-brand-guide-theme') + .set('Accept', 'text/html'); + expect(response.status).toBe(200); + let headMatch = response.text.match(/data-boxel-head-start[^>]*>([\s\S]*?)data-boxel-head-end/); + let headContent = headMatch?.[1] ?? ''; + expect(headContent.includes(' { + let rows = (await dbAdapter.execute(`SELECT has_error FROM boxel_index + WHERE url = '${testRealmURL.href}scoped-css-test.json' + AND type = 'instance'`)) as { + has_error: boolean; + }[]; + return rows.length > 0 && rows[0].has_error === true; + }, { + timeout: 10000, + interval: 200, + timeoutMessage: 'Timed out waiting for instance to enter error state', + }); + // Verify the database row has an error + let errorRows = (await dbAdapter.execute(`SELECT has_error, last_known_good_deps FROM boxel_index + WHERE url = '${testRealmURL.href}scoped-css-test.json' + AND type = 'instance'`)) as { + has_error: boolean; + last_known_good_deps: string[] | null; + }[]; + expect(errorRows.length).toBe(1); + expect(errorRows[0].has_error).toBe(true); + expect(errorRows[0].last_known_good_deps).toBeTruthy(); + expect(errorRows[0].last_known_good_deps!.some((dep: string) => dep.includes('.glimmer-scoped.css'))).toBeTruthy(); + // Now request the HTML again - it should still include scoped CSS from last_known_good_deps + let errorStateResponse = await request + .get('/test/scoped-css-test') + .set('Accept', 'text/html'); + expect(errorStateResponse.status).toBe(200); + expect(errorStateResponse.text.includes('data-boxel-scoped-css')).toBeTruthy(); + expect(errorStateResponse.text.includes('--scoped-css-marker: 1')).toBeTruthy(); + }); + }); + describe('Published realm index responses', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + // Use a URL with a path segment. Server-level routes are now namespaced + // as /_federated-info, /_federated-search, etc., so they no longer collide + // with the realm's own /_info and /_search handlers. + let realmURL = new URL('http://127.0.0.1:4444/published/'); + let request: SuperTest; + let testRealm: Realm; + function onRealmSetup(args: { + request: SuperTest; + testRealm: Realm; + }) { + request = args.request; + testRealm = args.testRealm; + } + setupPermissionedRealmCached(hooks, { + realmURL, + permissions: { + '*': ['read'], + }, + published: true, + onRealmSetup, + }); + hooks.beforeEach(async function () { + // Wait for indexing to complete before running tests + // This ensures isolated_html is available in the database + await testRealm.indexing(); + }); + it('serves index HTML by default for published realm', async function () { + let response = await request + .get('/published/') + .set('Accept', 'application/json'); + expect(response.status).toBe(200); + expect(response.headers['content-type']?.includes('text/html')).toBeTruthy(); + expect(response.text.includes('data-test-home-card')).toBeTruthy(); + }); + it('skips index HTML when vendor mime type is requested', async function () { + let response = await request + .get('/published/person-1') + .set('Accept', 'application/vnd.card+json'); + expect(response.status).toBe(200); + expect(response.headers['content-type']?.includes('application/vnd.card+json')).toBeTruthy(); + }); + }); + // This module exercises publishing a realm where a card's cardInfo.theme + // linksTo a BrandGuide that lives in the same realm. The themed-card's + // attributes must include a cardInfo key (even if empty) so that the + // cardInfo.theme relationship has a container field to attach to; + // without it the theme resolves to null and the head template + // (packages/base/default-templates/head.gts) renders without icon links. + describe('Published realm: theme icon links after _publish-realm', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let testRealmHttpServer: Server; + let request: SuperTest; + let dbAdapter: PgAdapter; + let dir: DirResult; + let sourceRealmUrlString: string; + let publishedRealmURLString: string; + let publishedRealmHost: string; + let publishedRealmPath: string; + let ownerUserId = '@mango:localhost'; + hooks.beforeEach(function () { + dir = dirSync(); + }); + setupDB(hooks, { + beforeEach: async (_dbAdapter, _publisher, _runner) => { + dbAdapter = _dbAdapter; + let virtualNetwork = createVirtualNetwork(); + let testRealmDir = join(dir.name, 'realm_server_theme', 'test'); + ensureDirSync(testRealmDir); + ({ testRealmHttpServer } = await runTestRealmServer({ + virtualNetwork, + testRealmDir, + fileSystem: {}, + realmsRootPath: join(dir.name, 'realm_server_theme'), + realmURL: new URL('http://127.0.0.1:4444/test/'), + dbAdapter: _dbAdapter, + publisher: _publisher, + runner: _runner, + matrixURL, + permissions: { + '*': ['read', 'write'], + [ownerUserId]: DEFAULT_PERMISSIONS, + }, + domainsForPublishedRealms: { + boxelSpace: 'localhost', + boxelSite: 'localhost:4444', + }, + })); + request = supertest(testRealmHttpServer); + // Create a publishable source realm + let endpoint = 'theme-source'; + let createResponse = await request + .post('/_create-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send(JSON.stringify({ + data: { + type: 'realm', + attributes: { name: 'Theme Source Realm', endpoint }, + }, + })); + if (createResponse.status !== 201) { + throw new Error(`/_create-realm failed with status ${createResponse.status}: ` + + (createResponse.text || + (createResponse.body + ? JSON.stringify(createResponse.body) + : ''))); + } + sourceRealmUrlString = createResponse.body.data.id; + let sourceRealmPath = new URL(sourceRealmUrlString).pathname; + // Make the source realm publicly accessible + await _dbAdapter.execute(` + INSERT INTO realm_user_permissions (realm_url, username, read, write, realm_owner) + VALUES ('${sourceRealmUrlString}', '*', true, true, true) + `); + // Write a BrandGuide theme card with a custom icon + let themeResponse = await request + .post(`${sourceRealmPath}brand-guide-theme.json`) + .set('Accept', 'application/vnd.card+source') + .send(JSON.stringify({ + data: { + type: 'card', + id: `${sourceRealmUrlString}brand-guide-theme`, + attributes: { + markUsage: { + socialMediaProfileIcon: 'https://example.com/published-theme-icon.png', + }, + }, + meta: { + adoptsFrom: { + module: 'https://cardstack.com/base/brand-guide', + name: 'default', + }, + }, + }, + })); + if (themeResponse.status !== 204) { + throw new Error(`Failed to write brand-guide-theme: ${themeResponse.status} ${themeResponse.text}`); + } + // Write a card that links to the BrandGuide via cardInfo.theme + let cardResponse = await request + .post(`${sourceRealmPath}themed-card.json`) + .set('Accept', 'application/vnd.card+source') + .send(JSON.stringify({ + data: { + type: 'card', + id: `${sourceRealmUrlString}themed-card`, + attributes: { cardInfo: {} }, + relationships: { + 'cardInfo.theme': { + links: { + self: `${sourceRealmUrlString}brand-guide-theme`, + }, + }, + }, + meta: { + adoptsFrom: { + module: 'https://cardstack.com/base/card-api', + name: 'CardDef', + }, + }, + }, + })); + if (cardResponse.status !== 204) { + throw new Error(`Failed to write themed-card: ${cardResponse.status} ${cardResponse.text}`); + } + // Publish the source realm — this triggers a full from-scratch reindex + publishedRealmURLString = + 'http://themetest.localhost:4444/theme-source/'; + publishedRealmHost = new URL(publishedRealmURLString).host; + publishedRealmPath = new URL(publishedRealmURLString).pathname; + let publishResponse = await request + .post('/_publish-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send(JSON.stringify({ + sourceRealmURL: sourceRealmUrlString, + publishedRealmURL: publishedRealmURLString, + })); + if (publishResponse.status !== 201) { + throw new Error(`Failed to publish realm: ${publishResponse.status} ${publishResponse.text}`); + } + }, + afterEach: async () => { + await closeServer(testRealmHttpServer); + }, + }); + // CS-10228: The themed-card's attributes must include a cardInfo key so + // that the cardInfo.theme linksTo relationship resolves. Without it the + // head template renders without icon links and the server falls back to + // default boxel icons instead of the theme's socialMediaProfileIcon. + it('themed card in published realm includes theme icon links in head HTML', async function () { + let response = await request + .get(`${publishedRealmPath}themed-card`) + .set('Host', publishedRealmHost) + .set('Accept', 'text/html'); + expect(response.status).toBe(200); + let headMatch = response.text.match(/data-boxel-head-start[^>]*>([\s\S]*?)data-boxel-head-end/); + let headContent = headMatch?.[1] ?? ''; + expect(headContent.includes(' 0).toBeTruthy(); + let pristineDoc = JSON.parse(rows[0].pristine_doc); + let themeRel = pristineDoc?.relationships?.['cardInfo.theme']?.links?.self; + expect(themeRel).toBeTruthy(); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/server-endpoints/info.test.ts b/packages/realm-server/tests-vitest/server-endpoints/info.test.ts new file mode 100644 index 00000000000..a995deb4922 --- /dev/null +++ b/packages/realm-server/tests-vitest/server-endpoints/info.test.ts @@ -0,0 +1,175 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import supertest from 'supertest'; +import type { Test, SuperTest } from 'supertest'; +import { join } from 'path'; +import { dirSync } from 'tmp'; +import type { QueuePublisher, QueueRunner, Realm, } from '@cardstack/runtime-common'; +import type { PgAdapter } from '@cardstack/postgres'; +import { resetCatalogRealms } from '../../handlers/handle-fetch-catalog-realms'; +import { closeServer, createVirtualNetwork, setupDB, matrixURL, realmSecretSeed, runTestRealmServerWithRealms, } from '../helpers'; +import { createJWT as createRealmServerJWT } from '../../utils/jwt'; +import type { Server } from 'http'; +describe("server-endpoints/info-test.ts", function () { + const _hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + describe('Realm Server Endpoints | /_federated-info', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let testRealm: Realm; + let secondaryRealm: Realm; + let request: SuperTest; + let dbAdapter: PgAdapter; + let testRealmHttpServer: Server; + let ownerUserId = '@mango:localhost'; + async function startInfoRealmServer({ dbAdapter, publisher, runner, }: { + dbAdapter: PgAdapter; + publisher: QueuePublisher; + runner: QueueRunner; + }) { + let virtualNetwork = createVirtualNetwork(); + let dir = dirSync(); + let testRealmURL = new URL('http://127.0.0.1:4444/test/'); + let secondaryRealmURL = new URL('http://127.0.0.1:4444/secondary/'); + let result = await runTestRealmServerWithRealms({ + virtualNetwork, + realmsRootPath: join(dir.name, 'realm_server_1'), + realms: [ + { + realmURL: testRealmURL, + fileSystem: { + '.realm.json': JSON.stringify({ name: 'Primary Realm' }), + }, + permissions: { + '*': ['read'], + [ownerUserId]: ['read', 'write', 'realm-owner'], + }, + }, + { + realmURL: secondaryRealmURL, + fileSystem: { + '.realm.json': JSON.stringify({ name: 'Secondary Realm' }), + }, + permissions: { + [ownerUserId]: ['read', 'write', 'realm-owner'], + }, + }, + ], + dbAdapter, + publisher, + runner, + matrixURL, + }); + testRealmHttpServer = result.testRealmHttpServer; + request = supertest(result.testRealmHttpServer); + testRealm = result.realms.find((realm) => realm.url === testRealmURL.href)!; + secondaryRealm = result.realms.find((realm) => realm.url === secondaryRealmURL.href)!; + } + async function stopInfoRealmServer() { + testRealm.unsubscribe(); + secondaryRealm.unsubscribe(); + await closeServer(testRealmHttpServer); + resetCatalogRealms(); + } + setupDB(hooks, { + beforeEach: async (_dbAdapter, publisher, runner) => { + dbAdapter = _dbAdapter; + await startInfoRealmServer({ dbAdapter, publisher, runner }); + }, + afterEach: async () => { + await stopInfoRealmServer(); + }, + }); + it('QUERY /_federated-info federates info across realms and includes public list header', async function () { + let realmServerToken = createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed); + let response = await request + .post('/_federated-info') + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', 'application/vnd.api+json') + .set('Authorization', `Bearer ${realmServerToken}`) + .send({ realms: [testRealm.url, secondaryRealm.url] }); + expect(response.status).toBe(200); + let { data } = response.body as { + data: { + id: string; + type: string; + attributes: { + name: string; + }; + }[]; + }; + expect(data.length).toBe(2); + let dataById = new Map(data.map((entry) => [entry.id, entry])); + expect(dataById.get(testRealm.url)?.attributes.name).toBe('Primary Realm'); + expect(dataById.get(secondaryRealm.url)?.attributes.name).toBe('Secondary Realm'); + let publicHeader = response.headers['x-boxel-realms-public-readable'] ?? ''; + expect(publicHeader).toBeTruthy(); + let publicRealms = publicHeader + .split(',') + .map((value: string) => value.trim()); + expect(publicRealms.includes(testRealm.url)).toBeTruthy(); + expect(publicRealms.includes(secondaryRealm.url)).toBeFalsy(); + }); + it('QUERY /_federated-info returns 403 when user lacks read access', async function () { + let realmServerToken = createRealmServerJWT({ user: '@rando:localhost', sessionRoom: 'session-room-test' }, realmSecretSeed); + let response = await request + .post('/_federated-info') + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', 'application/vnd.api+json') + .set('Authorization', `Bearer ${realmServerToken}`) + .send({ realms: [testRealm.url, secondaryRealm.url] }); + expect(response.status).toBe(403); + expect(response.body.errors?.[0]?.includes(secondaryRealm.url)).toBeTruthy(); + }); + it('QUERY /_federated-info returns 401 when unauthenticated user requests non-public realm', async function () { + let response = await request + .post('/_federated-info') + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', 'application/vnd.api+json') + .send({ realms: [secondaryRealm.url] }); + expect(response.status).toBe(401); + expect(response.body.errors?.[0]?.includes(secondaryRealm.url)).toBeTruthy(); + }); + it('QUERY /_federated-info returns 400 when realms are missing', async function () { + let response = await request + .post('/_federated-info') + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', 'application/vnd.api+json') + .send({}); + expect(response.status).toBe(400); + expect(response.body.errors?.[0]?.includes('realms must be supplied in request body')).toBeTruthy(); + }); + it('QUERY /_federated-info returns info for a public realm without auth when realms body is provided', async function () { + // This tests the scenario where a subdomain-based realm at root path + // (e.g. http://hi.localhost:4201/) sends QUERY to /_federated-info. The path /_federated-info + // matches the server-level route (not the realm's own handler), so the + // request body with realms array is required. + let response = await request + .post('/_federated-info') + .set('X-HTTP-Method-Override', 'QUERY') + .set('Accept', 'application/vnd.api+json') + .send({ realms: [testRealm.url] }); + expect(response.status).toBe(200); + let { data } = response.body as { + data: { + id: string; + type: string; + attributes: { + name: string; + }; + }[]; + }; + expect(data.length).toBe(1); + expect(data[0].id).toBe(testRealm.url); + expect(data[0].attributes.name).toBe('Primary Realm'); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/server-endpoints/maintenance-endpoints.test.ts b/packages/realm-server/tests-vitest/server-endpoints/maintenance-endpoints.test.ts new file mode 100644 index 00000000000..1454bb36417 --- /dev/null +++ b/packages/realm-server/tests-vitest/server-endpoints/maintenance-endpoints.test.ts @@ -0,0 +1,616 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import { v4 as uuidv4 } from 'uuid'; +import sinon from 'sinon'; +import { PgAdapter, PgQueueRunner } from '@cardstack/postgres'; +import { sumUpCreditsLedger } from '@cardstack/billing/billing-queries'; +import * as boxelUIChangeChecker from '../../lib/boxel-ui-change-checker'; +import { grafanaSecret, insertUser, realmSecretSeed } from '../helpers'; +import { createJWT as createRealmServerJWT } from '../../utils/jwt'; +import { setupServerEndpointsTest, testRealmURL } from './helpers'; +import '@cardstack/runtime-common/helpers/code-equality-assertion'; +describe("server-endpoints/maintenance-endpoints-test.ts", function () { + describe('Realm Server Endpoints (not specific to one realm)', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let context = setupServerEndpointsTest(hooks); + it('can force job completion by job_id via grafana endpoint', async function () { + let [{ id }] = (await context.dbAdapter.execute(`INSERT INTO jobs + (args, job_type, concurrency_group, timeout, priority) + VALUES + ( + '{"realmURL": "${testRealmURL.href}", "realmUsername":"node-test_realm"}', + 'from-scratch-index', + 'indexing:${testRealmURL.href}', + 180, + 0 + ) RETURNING id`)) as { + id: string; + }[]; + let response = await context.request + .get(`/_grafana-complete-job?authHeader=${grafanaSecret}&job_id=${id}`) + .set('Content-Type', 'application/json'); + expect(response.status).toBe(204); + let [job] = await context.dbAdapter.execute(`SELECT * FROM jobs WHERE id = ${id}`); + expect(job.status).toBe('rejected'); + expect(job.result).toEqual({ + status: 418, + message: 'User initiated job cancellation', + }); + expect(job.finished_at).toBeTruthy(); + }); + it('grafana endpoint can target both pending and running jobs by job_id', async function () { + let [{ id: pendingJobId }] = (await context.dbAdapter + .execute(`INSERT INTO jobs + (args, job_type, concurrency_group, timeout, priority) + VALUES + ( + '{"realmURL": "${testRealmURL.href}", "realmUsername":"node-test_realm"}', + 'from-scratch-index', + 'indexing:${testRealmURL.href}', + 180, + 0 + ) RETURNING id`)) as { + id: string; + }[]; + let [{ id: runningJobId }] = (await context.dbAdapter + .execute(`INSERT INTO jobs + (args, job_type, concurrency_group, timeout, priority) + VALUES + ( + '{"realmURL": "${testRealmURL.href}", "realmUsername":"node-test_realm"}', + 'incremental-index', + 'indexing:${testRealmURL.href}', + 180, + 0 + ) RETURNING id`)) as { + id: string; + }[]; + await context.dbAdapter.execute(`INSERT INTO job_reservations + (job_id, locked_until ) VALUES (${runningJobId}, NOW() + INTERVAL '3 minutes')`); + let pendingResponse = await context.request + .get(`/_grafana-complete-job?authHeader=${grafanaSecret}&job_id=${pendingJobId}`) + .set('Content-Type', 'application/json'); + expect(pendingResponse.status).toBe(204); + let runningResponse = await context.request + .get(`/_grafana-complete-job?authHeader=${grafanaSecret}&job_id=${runningJobId}`) + .set('Content-Type', 'application/json'); + expect(runningResponse.status).toBe(204); + let [pendingJob] = await context.dbAdapter.execute(`SELECT status, result FROM jobs WHERE id = ${pendingJobId}`); + expect(pendingJob.status).toBe('rejected'); + expect(pendingJob.result).toEqual({ + status: 418, + message: 'User initiated job cancellation', + }); + let [runningJob] = await context.dbAdapter.execute(`SELECT status, result FROM jobs WHERE id = ${runningJobId}`); + expect(runningJob.status).toBe('rejected'); + expect(runningJob.result).toEqual({ + status: 418, + message: 'User initiated job cancellation', + }); + }); + it('can force job completion by reservation_id via grafana endpoint', async function () { + let [{ id: jobId }] = (await context.dbAdapter.execute(`INSERT INTO jobs + (args, job_type, concurrency_group, timeout, priority) + VALUES + ( + '{"realmURL": "${testRealmURL.href}", "realmUsername":"node-test_realm"}', + 'from-scratch-index', + 'indexing:${testRealmURL.href}', + 180, + 0 + ) RETURNING id`)) as { + id: string; + }[]; + let [{ id: reservationId }] = (await context.dbAdapter + .execute(`INSERT INTO job_reservations + (job_id, locked_until ) VALUES (${jobId}, NOW() + INTERVAL '3 minutes') RETURNING id`)) as { + id: string; + }[]; + await context.dbAdapter.execute(`INSERT INTO job_reservations + (job_id, locked_until ) VALUES (${jobId}, NOW() + INTERVAL '2 minutes')`); + let response = await context.request + .get(`/_grafana-complete-job?authHeader=${grafanaSecret}&reservation_id=${reservationId}`) + .set('Content-Type', 'application/json'); + expect(response.status).toBe(204); + let reservations = await context.dbAdapter.execute(`SELECT * FROM job_reservations WHERE job_id = ${jobId} AND completed_at IS NULL`); + expect(reservations.length).toBe(0); + let [job] = await context.dbAdapter.execute(`SELECT * FROM jobs WHERE id = ${jobId}`); + expect(job.status).toBe('rejected'); + expect(job.result).toEqual({ + status: 418, + message: 'User initiated job cancellation', + }); + expect(job.finished_at).toBeTruthy(); + }); + it('can force job completion by job_id where reservation id exists via grafana endpoint', async function () { + let [{ id: jobId }] = (await context.dbAdapter.execute(`INSERT INTO jobs + (args, job_type, concurrency_group, timeout, priority) + VALUES + ( + '{"realmURL": "${testRealmURL.href}", "realmUsername":"node-test_realm"}', + 'from-scratch-index', + 'indexing:${testRealmURL.href}', + 180, + 0 + ) RETURNING id`)) as { + id: string; + }[]; + await context.dbAdapter.execute(`INSERT INTO job_reservations + (job_id, locked_until ) VALUES (${jobId}, NOW() + INTERVAL '3 minutes')`); + await context.dbAdapter.execute(`INSERT INTO job_reservations + (job_id, locked_until ) VALUES (${jobId}, NOW() + INTERVAL '2 minutes')`); + let response = await context.request + .get(`/_grafana-complete-job?authHeader=${grafanaSecret}&job_id=${jobId}`) + .set('Content-Type', 'application/json'); + expect(response.status).toBe(204); + let reservations = await context.dbAdapter.execute(`SELECT * FROM job_reservations WHERE job_id = ${jobId} AND completed_at IS NULL`); + expect(reservations.length).toBe(0); + let [job] = await context.dbAdapter.execute(`SELECT * FROM jobs WHERE id = ${jobId}`); + expect(job.status).toBe('rejected'); + expect(job.result).toEqual({ + status: 418, + message: 'User initiated job cancellation', + }); + expect(job.finished_at).toBeTruthy(); + }); + it('can cancel a running job by reservation_id and allow the next job to run', async function () { + let jobStartedResolve: (() => void) | undefined; + let jobFinishedResolve: (() => void) | undefined; + let releaseJobResolve: (() => void) | undefined; + let jobStarted = new Promise((resolve) => { + jobStartedResolve = resolve; + }); + let jobFinished = new Promise((resolve) => { + jobFinishedResolve = resolve; + }); + let releaseJob = new Promise((resolve) => { + releaseJobResolve = resolve; + }); + let events: string[] = []; + context.runner.register('blocking-job', async ({ jobNum }: { + jobNum: number; + }) => { + events.push(`job${jobNum} start`); + if (jobNum === 1) { + jobStartedResolve?.(); + await releaseJob; + } + events.push(`job${jobNum} finish`); + if (jobNum === 1) { + jobFinishedResolve?.(); + } + return jobNum; + }); + let job1 = await context.publisher.publish({ + jobType: 'blocking-job', + concurrencyGroup: 'grafana-cancel-group', + timeout: 30, + args: { jobNum: 1 }, + }); + let job1Outcome = job1.done.then((result) => ({ outcome: 'resolved' as const, result }), (error) => ({ outcome: 'rejected' as const, error })); + await jobStarted; + let [reservation] = await context.dbAdapter.execute(`SELECT id FROM job_reservations WHERE job_id = ${job1.id} AND completed_at IS NULL`); + let reservationId = reservation?.id; + expect(reservationId).toBeTruthy(); + let response = await context.request + .get(`/_grafana-complete-job?authHeader=${grafanaSecret}&reservation_id=${reservationId}`) + .set('Content-Type', 'application/json'); + expect(response.status).toBe(204); + let reservations = await context.dbAdapter.execute(`SELECT id FROM job_reservations WHERE job_id = ${job1.id} AND completed_at IS NULL`); + expect(reservations.length).toBe(0); + let adapter2 = new PgAdapter(); + let runner2 = new PgQueueRunner({ + adapter: adapter2, + workerId: 'test-worker-2', + }); + runner2.register('blocking-job', async ({ jobNum }: { + jobNum: number; + }) => { + events.push(`job${jobNum} start`); + events.push(`job${jobNum} finish`); + return jobNum; + }); + await runner2.start(); + try { + let job2 = await context.publisher.publish({ + jobType: 'blocking-job', + concurrencyGroup: 'grafana-cancel-group', + timeout: 30, + args: { jobNum: 2 }, + }); + let job2Result = await job2.done; + expect(job2Result).toBe(2); + } + finally { + releaseJobResolve?.(); + await jobFinished; + await runner2.destroy(); + await adapter2.close(); + } + let outcome = await job1Outcome; + expect(outcome.outcome).toBe('rejected'); + if (outcome.outcome === 'rejected') { + expect(outcome.error).toEqual({ + status: 418, + message: 'User initiated job cancellation', + }); + } + else { + expect(false).toBeTruthy(); + } + expect(events).toEqual([ + 'job1 start', + 'job2 start', + 'job2 finish', + 'job1 finish', + ]); + }); + it('returns 401 when calling grafana job completion endpoint without a grafana secret', async function () { + let [{ id }] = (await context.dbAdapter.execute(`INSERT INTO jobs + (args, job_type, concurrency_group, timeout, priority) + VALUES + ( + '{"realmURL": "${testRealmURL.href}", "realmUsername":"node-test_realm"}', + 'from-scratch-index', + 'indexing:${testRealmURL.href}', + 180, + 0 + ) RETURNING id`)) as { + id: string; + }[]; + let response = await context.request + .get(`/_grafana-complete-job?job_id=${id}`) + .set('Content-Type', 'application/json'); + expect(response.status).toBe(401); + let [job] = await context.dbAdapter.execute(`SELECT * FROM jobs WHERE id = ${id}`); + expect(job.status).toBe('unfulfilled'); + expect(job.finished_at).toBe(null); + }); + it('can add user credit via grafana endpoint', async function () { + let user = await insertUser(context.dbAdapter, 'user@test', 'cus_123', 'user@test.com'); + let sum = await sumUpCreditsLedger(context.dbAdapter, { + creditType: ['extra_credit', 'extra_credit_used'], + userId: user.id, + }); + expect(sum).toBe(0); + let response = await context.request + .get(`/_grafana-add-credit?authHeader=${grafanaSecret}&user=${user.matrixUserId}&credit=1000`) + .set('Content-Type', 'application/json'); + expect(response.status).toBe(200); + expect(response.body).toEqual({ + message: `Added 1000 credits to user '${user.matrixUserId}'`, + }); + sum = await sumUpCreditsLedger(context.dbAdapter, { + creditType: ['extra_credit', 'extra_credit_used'], + userId: user.id, + }); + expect(sum).toBe(1000); + }); + it('returns 400 when calling grafana add credit endpoint without a user', async function () { + let response = await context.request + .get(`/_grafana-add-credit?authHeader=${grafanaSecret}&credit=1000`) + .set('Content-Type', 'application/json'); + expect(response.status).toBe(400); + }); + it('returns 400 when calling grafana add credit endpoint with credit amount that is not a number', async function () { + let user = await insertUser(context.dbAdapter, 'user@test', 'cus_123', 'user@test.com'); + let response = await context.request + .get(`/_grafana-add-credit?authHeader=${grafanaSecret}&user=${user.matrixUserId}&credit=a+million+dollars`) + .set('Content-Type', 'application/json'); + expect(response.status).toBe(400); + let sum = await sumUpCreditsLedger(context.dbAdapter, { + creditType: ['extra_credit', 'extra_credit_used'], + userId: user.id, + }); + expect(sum).toBe(0); + }); + it("returns 400 when calling grafana add credit endpoint when user doesn't exist", async function () { + let response = await context.request + .get(`/_grafana-add-credit?authHeader=${grafanaSecret}&user=nobody&credit=1000`) + .set('Content-Type', 'application/json'); + expect(response.status).toBe(400); + }); + it('returns 401 when calling grafana add credit endpoint without a grafana secret', async function () { + let user = await insertUser(context.dbAdapter, 'user@test', 'cus_123', 'user@test.com'); + let response = await context.request + .get(`/_grafana-add-credit?user=${user.matrixUserId}&credit=1000`) + .set('Content-Type', 'application/json'); + expect(response.status).toBe(401); + let sum = await sumUpCreditsLedger(context.dbAdapter, { + creditType: ['extra_credit', 'extra_credit_used'], + userId: user.id, + }); + expect(sum).toBe(0); + }); + it('can reindex a realm via grafana endpoint', async function () { + let endpoint = `test-realm-${uuidv4()}`; + let owner = 'mango'; + let ownerUserId = `@${owner}:localhost`; + let realmURL: string; + { + let response = await context.request + .post('/_create-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send(JSON.stringify({ + data: { + type: 'realm', + attributes: { + name: 'Test Realm', + endpoint, + }, + }, + })); + expect(response.status).toBe(201); + realmURL = response.body.data.id; + } + let initialJobs = await context.dbAdapter.execute('select * from jobs'); + expect(initialJobs.length).toBe(2); + let staleModuleForTargetRealmURL = `${realmURL}stale-module-${uuidv4()}.gts`; + let staleModuleForOtherRealmURL = `${testRealmURL.href}stale-module-${uuidv4()}.gts`; + await context.dbAdapter.execute(`INSERT INTO modules (url, file_alias, definitions, deps, created_at, resolved_realm_url, cache_scope, auth_user_id) + VALUES ('${staleModuleForTargetRealmURL}', '${staleModuleForTargetRealmURL}', '{}', '[]', ${Date.now()}, '${realmURL}', 'public', '')`); + await context.dbAdapter.execute(`INSERT INTO modules (url, file_alias, definitions, deps, created_at, resolved_realm_url, cache_scope, auth_user_id) + VALUES ('${staleModuleForOtherRealmURL}', '${staleModuleForOtherRealmURL}', '{}', '[]', ${Date.now()}, '${testRealmURL.href}', 'public', '')`); + let seededTargetRowsBefore = await context.dbAdapter.execute(`SELECT * FROM modules WHERE url = '${staleModuleForTargetRealmURL}'`); + let seededOtherRowsBefore = await context.dbAdapter.execute(`SELECT * FROM modules WHERE url = '${staleModuleForOtherRealmURL}'`); + expect(seededTargetRowsBefore.length).toBe(1); + expect(seededOtherRowsBefore.length).toBe(1); + { + let realmPath = realmURL.substring(new URL(testRealmURL.origin).href.length); + let response = await context.request + .get(`/_grafana-reindex?authHeader=${grafanaSecret}&realm=${realmPath}`) + .set('Content-Type', 'application/json'); + expect(response.body).toEqual({ + fileErrors: 0, + filesIndexed: 1, + instanceErrors: 0, + instancesIndexed: 1, + totalIndexEntries: 2, + }); + } + let seededTargetRowsAfter = await context.dbAdapter.execute(`SELECT * FROM modules WHERE url = '${staleModuleForTargetRealmURL}'`); + let seededOtherRowsAfter = await context.dbAdapter.execute(`SELECT * FROM modules WHERE url = '${staleModuleForOtherRealmURL}'`); + expect(seededTargetRowsAfter.length).toBe(0); + expect(seededOtherRowsAfter.length).toBe(1); + let finalJobs = await context.dbAdapter.execute('select * from jobs'); + expect(finalJobs.length).toBe(3); + let job = finalJobs.pop()!; + expect(job.job_type).toBe('from-scratch-index'); + expect(job.concurrency_group).toBe(`indexing:${realmURL}`); + expect(job.status).toBe('resolved'); + expect(job.finished_at).toBeTruthy(); + expect(job.args).toEqual({ + realmURL, + realmUsername: owner, + }); + }); + it('returns 401 when calling grafana reindex endpoint without a grafana secret', async function () { + let endpoint = `test-realm-${uuidv4()}`; + let owner = 'mango'; + let ownerUserId = `@${owner}:localhost`; + let realmURL: string; + { + let response = await context.request + .post('/_create-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send(JSON.stringify({ + data: { + type: 'realm', + attributes: { + name: 'Test Realm', + endpoint, + }, + }, + })); + expect(response.status).toBe(201); + realmURL = response.body.data.id; + } + let initialJobs = await context.dbAdapter.execute('select * from jobs'); + { + let response = await context.request + .get(`/_grafana-reindex?realm=${encodeURIComponent(realmURL)}`) + .set('Content-Type', 'application/json'); + expect(response.status).toBe(401); + } + let finalJobs = await context.dbAdapter.execute('select * from jobs'); + expect(finalJobs.length).toBe(initialJobs.length); + }); + it('post-deployment endpoint requires authorization header', async function () { + let response = await context.request + .post('/_post-deployment') + .set('Content-Type', 'application/json'); + expect(response.status).toBe(401); + }); + it('post-deployment endpoint rejects incorrect authorization', async function () { + let response = await context.request + .post('/_post-deployment') + .set('Content-Type', 'application/json') + .set('Authorization', 'wrong-secret'); + expect(response.status).toBe(401); + }); + it('post-deployment endpoint triggers full reindex when checksums differ', async function () { + let compareCurrentBoxelUIChecksumStub = sinon + .stub(boxelUIChangeChecker, 'compareCurrentBoxelUIChecksum') + .resolves({ + previousChecksum: 'old-checksum-123', + currentChecksum: 'new-checksum-456', + }); + let writeCurrentBoxelUIChecksumStub = sinon.stub(boxelUIChangeChecker, 'writeCurrentBoxelUIChecksum'); + try { + // Seed a modules row to verify it gets cleared + await context.dbAdapter.execute(`INSERT INTO modules (url, file_alias, definitions, deps, created_at, resolved_realm_url, cache_scope, auth_user_id) + VALUES ('http://example.com/test-module', 'http://example.com/test-module', '{}', '[]', ${Date.now()}, 'http://example.com/', 'public', '')`); + let modulesBefore = await context.dbAdapter.execute('SELECT * FROM modules'); + expect(modulesBefore.length > 0).toBeTruthy(); + let initialJobs = await context.dbAdapter.execute('select * from jobs'); + let initialJobCount = initialJobs.length; + let response = await context.request + .post('/_post-deployment') + .set('Content-Type', 'application/json') + .set('Authorization', "mum's the word"); + expect(response.status).toBe(200); + expect(response.body).toEqual({ + previousChecksum: 'old-checksum-123', + currentChecksum: 'new-checksum-456', + }); + let modulesAfter = await context.dbAdapter.execute('SELECT * FROM modules'); + expect(modulesAfter.length).toBe(0); + let finalJobs = await context.dbAdapter.execute('select * from jobs'); + expect(finalJobs.length).toBe(initialJobCount + 1); + let reindexJob = finalJobs.find((job) => job.job_type === 'full-reindex'); + expect(reindexJob).toBeTruthy(); + if (reindexJob) { + expect(reindexJob.concurrency_group).toBe('full-reindex-group'); + expect(reindexJob.timeout).toBe(360); + } + expect(writeCurrentBoxelUIChecksumStub.calledOnce).toBeTruthy(); + expect(writeCurrentBoxelUIChecksumStub.calledWith('new-checksum-456')).toBeTruthy(); + } + finally { + compareCurrentBoxelUIChecksumStub.restore(); + writeCurrentBoxelUIChecksumStub.restore(); + } + }); + it('post-deployment endpoint clears modules cache even when checksums match', async function () { + let compareCurrentBoxelUIChecksumStub = sinon + .stub(boxelUIChangeChecker, 'compareCurrentBoxelUIChecksum') + .resolves({ + previousChecksum: 'same-checksum-789', + currentChecksum: 'same-checksum-789', + }); + let writeCurrentBoxelUIChecksumStub = sinon.stub(boxelUIChangeChecker, 'writeCurrentBoxelUIChecksum'); + try { + // Seed a modules row to verify it gets cleared even without reindex + await context.dbAdapter.execute(`INSERT INTO modules (url, file_alias, definitions, deps, created_at, resolved_realm_url, cache_scope, auth_user_id) + VALUES ('http://example.com/test-module', 'http://example.com/test-module', '{}', '[]', ${Date.now()}, 'http://example.com/', 'public', '')`); + let modulesBefore = await context.dbAdapter.execute('SELECT * FROM modules'); + expect(modulesBefore.length > 0).toBeTruthy(); + let initialJobs = await context.dbAdapter.execute('select * from jobs'); + let initialJobCount = initialJobs.length; + let response = await context.request + .post('/_post-deployment') + .set('Content-Type', 'application/json') + .set('Authorization', "mum's the word"); + expect(response.status).toBe(200); + expect(response.body).toEqual({ + previousChecksum: 'same-checksum-789', + currentChecksum: 'same-checksum-789', + }); + let modulesAfter = await context.dbAdapter.execute('SELECT * FROM modules'); + expect(modulesAfter.length).toBe(0); + let finalJobs = await context.dbAdapter.execute('select * from jobs'); + expect(finalJobs.length).toBe(initialJobCount); + expect(writeCurrentBoxelUIChecksumStub.notCalled).toBeTruthy(); + } + finally { + compareCurrentBoxelUIChecksumStub.restore(); + writeCurrentBoxelUIChecksumStub.restore(); + } + }); + it('can reindex all realms via grafana endpoint', async function () { + let endpoint = `test-realm-${uuidv4()}`; + let owner = 'mango'; + let ownerUserId = `@${owner}:localhost`; + let realmURL: string; + { + let response = await context.request + .post('/_create-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send(JSON.stringify({ + data: { + type: 'realm', + attributes: { + name: 'Test Realm', + endpoint, + }, + }, + })); + expect(response.status).toBe(201); + realmURL = response.body.data.id; + } + let initialJobs = await context.dbAdapter.execute('select * from jobs'); + expect(initialJobs.length).toBe(2); + let staleModuleForRealmOneURL = `${testRealmURL.href}stale-module-${uuidv4()}.gts`; + let staleModuleForRealmTwoURL = `${realmURL}stale-module-${uuidv4()}.gts`; + await context.dbAdapter.execute(`INSERT INTO modules (url, file_alias, definitions, deps, created_at, resolved_realm_url, cache_scope, auth_user_id) + VALUES ('${staleModuleForRealmOneURL}', '${staleModuleForRealmOneURL}', '{}', '[]', ${Date.now()}, '${testRealmURL.href}', 'public', '')`); + await context.dbAdapter.execute(`INSERT INTO modules (url, file_alias, definitions, deps, created_at, resolved_realm_url, cache_scope, auth_user_id) + VALUES ('${staleModuleForRealmTwoURL}', '${staleModuleForRealmTwoURL}', '{}', '[]', ${Date.now()}, '${realmURL}', 'public', '')`); + let seededRowsBefore = await context.dbAdapter.execute(`SELECT * FROM modules WHERE url IN ('${staleModuleForRealmOneURL}', '${staleModuleForRealmTwoURL}')`); + expect(seededRowsBefore.length).toBe(2); + { + let response = await context.request + .get(`/_grafana-full-reindex?authHeader=${grafanaSecret}`) + .set('Content-Type', 'application/json'); + expect(response.body.realms).toEqual([testRealmURL.href, realmURL]); + } + let seededRowsAfter = await context.dbAdapter.execute(`SELECT * FROM modules WHERE url IN ('${staleModuleForRealmOneURL}', '${staleModuleForRealmTwoURL}')`); + expect(seededRowsAfter.length).toBe(0); + let finalJobs = await context.dbAdapter.execute('select * from jobs'); + expect(finalJobs.length).toBe(3); + let jobs = finalJobs.slice(2); + expect(jobs[0].job_type).toBe('full-reindex'); + expect(jobs[0].concurrency_group).toBe(`full-reindex-group`); + }); + it('full reindex does not clear modules cache for bot-owned realms', async function () { + let endpoint = `test-realm-${uuidv4()}`; + let owner = 'realm/bot'; + let ownerUserId = `@${owner}:localhost`; + let botRealmURL: string; + { + let response = await context.request + .post('/_create-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send(JSON.stringify({ + data: { + type: 'realm', + attributes: { + name: 'Bot Realm', + endpoint, + }, + }, + })); + expect(response.status).toBe(201); + botRealmURL = response.body.data.id; + } + let staleModuleForNonBotRealmURL = `${testRealmURL.href}stale-module-${uuidv4()}.gts`; + let staleModuleForBotRealmURL = `${botRealmURL}stale-module-${uuidv4()}.gts`; + await context.dbAdapter.execute(`INSERT INTO modules (url, file_alias, definitions, deps, created_at, resolved_realm_url, cache_scope, auth_user_id) + VALUES ('${staleModuleForNonBotRealmURL}', '${staleModuleForNonBotRealmURL}', '{}', '[]', ${Date.now()}, '${testRealmURL.href}', 'public', '')`); + await context.dbAdapter.execute(`INSERT INTO modules (url, file_alias, definitions, deps, created_at, resolved_realm_url, cache_scope, auth_user_id) + VALUES ('${staleModuleForBotRealmURL}', '${staleModuleForBotRealmURL}', '{}', '[]', ${Date.now()}, '${botRealmURL}', 'public', '')`); + let response = await context.request + .get(`/_grafana-full-reindex?authHeader=${grafanaSecret}`) + .set('Content-Type', 'application/json'); + expect(response.status).toBe(200); + let staleRowsForNonBotRealm = await context.dbAdapter.execute(`SELECT * FROM modules WHERE url = '${staleModuleForNonBotRealmURL}'`); + let staleRowsForBotRealm = await context.dbAdapter.execute(`SELECT * FROM modules WHERE url = '${staleModuleForBotRealmURL}'`); + expect(staleRowsForNonBotRealm.length).toBe(0); + expect(staleRowsForBotRealm.length).toBe(1); + }); + it('returns 401 when calling grafana full reindex endpoint without a grafana secret', async function () { + let initialJobs = await context.dbAdapter.execute('select * from jobs'); + { + let response = await context.request + .get(`/_grafana-full-reindex`) + .set('Content-Type', 'application/json'); + expect(response.status).toBe(401); + } + let finalJobs = await context.dbAdapter.execute('select * from jobs'); + expect(finalJobs.length).toBe(initialJobs.length); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/server-endpoints/queue-status.test.ts b/packages/realm-server/tests-vitest/server-endpoints/queue-status.test.ts new file mode 100644 index 00000000000..58e0a900054 --- /dev/null +++ b/packages/realm-server/tests-vitest/server-endpoints/queue-status.test.ts @@ -0,0 +1,66 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import type { Test, SuperTest } from 'supertest'; +import type { PgAdapter } from '@cardstack/postgres'; +import { insertJob, setupPermissionedRealmCached } from '../helpers'; +import { monitoringAuthToken } from '../../utils/monitoring'; +import '@cardstack/runtime-common/helpers/code-equality-assertion'; +describe("server-endpoints/queue-status-test.ts", function () { + describe('Realm Server Endpoints (not specific to one realm)', function () { + describe('_queue-status', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let request: SuperTest; + let dbAdapter: PgAdapter; + function onRealmSetup(args: { + request: SuperTest; + dbAdapter: PgAdapter; + }) { + request = args.request; + dbAdapter = args.dbAdapter; + } + setupPermissionedRealmCached(hooks, { + fileSystem: {}, + permissions: { + '*': ['read', 'write'], + }, + onRealmSetup, + }); + it('returns 200 with JSON-API doc', async function () { + await insertJob(dbAdapter, { + job_type: 'test-job', + }); + await insertJob(dbAdapter, { + job_type: 'test-job', + status: 'resolved', + finished_at: new Date().toISOString(), + }); + let response = await request.get('/_queue-status'); + expect(response.status).toBe(401); + response = await request + .get('/_queue-status') + .set('Authorization', `Bearer no-good`); + expect(response.status).toBe(401); + const REALM_SERVER_SECRET_SEED = "mum's the word"; + response = await request + .get('/_queue-status') + .set('Authorization', `Bearer ${monitoringAuthToken(REALM_SERVER_SECRET_SEED)}`); + expect(response.status).toBe(200); + let json = response.body; + expect(json).toEqual({ + data: { + type: 'queue-status', + id: 'queue-status', + attributes: { + pending: 1, + }, + }, + }); + }); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/server-endpoints/realm-lifecycle.test.ts b/packages/realm-server/tests-vitest/server-endpoints/realm-lifecycle.test.ts new file mode 100644 index 00000000000..25bc0190029 --- /dev/null +++ b/packages/realm-server/tests-vitest/server-endpoints/realm-lifecycle.test.ts @@ -0,0 +1,623 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import { join } from 'path'; +import { existsSync, readJSONSync } from 'fs-extra'; +import supertest from 'supertest'; +import type { Test, SuperTest } from 'supertest'; +import { v4 as uuidv4 } from 'uuid'; +import type { Query } from '@cardstack/runtime-common/query'; +import { baseCardRef, fetchRealmPermissions, userInitiatedPriority, } from '@cardstack/runtime-common'; +import type { SingleCardDocument } from '@cardstack/runtime-common'; +import type { CardCollectionDocument } from '@cardstack/runtime-common/document-types'; +import { cardSrc } from '@cardstack/runtime-common/etc/test-fixtures'; +import { closeServer, createJWT, matrixURL, realmSecretSeed, runTestRealmServer, setupPermissionedRealmCached, testRealmInfo, testRealmURL as rootTestRealmURL, } from '../helpers'; +import { createJWT as createRealmServerJWT } from '../../utils/jwt'; +import { setupServerEndpointsTest, testRealmURL } from './helpers'; +import '@cardstack/runtime-common/helpers/code-equality-assertion'; +describe("server-endpoints/realm-lifecycle-test.ts", function () { + describe('Realm Server Endpoints (not specific to one realm)', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let context = setupServerEndpointsTest(hooks); + it('POST /_create-realm', async function () { + // we randomize the realm and owner names so that we can isolate matrix + // test state--there is no "delete user" matrix API + let endpoint = `test-realm-${uuidv4()}`; + let owner = 'mango'; + let ownerUserId = '@mango:localhost'; + let response = await context.request + .post('/_create-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send(JSON.stringify({ + data: { + type: 'realm', + attributes: { + ...testRealmInfo, + endpoint, + backgroundURL: 'http://example.com/background.jpg', + iconURL: 'http://example.com/icon.jpg', + }, + }, + })); + expect(response.status).toBe(201); + let json = response.body; + expect(json).toEqual({ + data: { + type: 'realm', + id: `${testRealmURL.origin}/${owner}/${endpoint}/`, + attributes: { + ...testRealmInfo, + endpoint, + backgroundURL: 'http://example.com/background.jpg', + iconURL: 'http://example.com/icon.jpg', + publishable: true, + }, + }, + }); + let realmPath = join(context.dir.name, 'realm_server_1', owner, endpoint); + let realmJSON = readJSONSync(join(realmPath, '.realm.json')); + expect(realmJSON).toEqual({ + name: 'Test Realm', + backgroundURL: 'http://example.com/background.jpg', + iconURL: 'http://example.com/icon.jpg', + publishable: true, + }); + expect(existsSync(join(realmPath, 'index.json'))).toBeTruthy(); + let job = (await context.dbAdapter.execute(`SELECT priority FROM jobs WHERE job_type = 'from-scratch-index' AND args->>'realmURL' = '${json.data.id}' ORDER BY created_at DESC LIMIT 1`)) as { + priority: number; + }[]; + expect(job[0]).toBeTruthy(); + expect(job[0].priority).toBe(userInitiatedPriority); + let permissions = await fetchRealmPermissions(context.dbAdapter, new URL(json.data.id)); + expect(permissions).toEqual({ + [ownerUserId]: ['read', 'write', 'realm-owner'], + }); + let id: string; + let realm = context.testRealmServer.testingOnlyRealms.find((r) => r.url === json.data.id)!; + { + // owner can create an instance + let response = await context.request + .post(`/${owner}/${endpoint}/`) + .send({ + data: { + type: 'card', + attributes: { cardInfo: { name: 'Test Card' } }, + meta: { + adoptsFrom: { + module: 'https://cardstack.com/base/card-api', + name: 'CardDef', + }, + }, + }, + }) + .set('Accept', 'application/vnd.card+json') + .set('Authorization', `Bearer ${createJWT(realm, ownerUserId, [ + 'read', + 'write', + 'realm-owner', + ])}`); + expect(response.status).toBe(201); + let doc = response.body as SingleCardDocument; + id = doc.data.id!; + } + { + // owner can get an instance + let response = await context.request + .get(new URL(id).pathname) + .set('Accept', 'application/vnd.card+json') + .set('Authorization', `Bearer ${createJWT(realm, ownerUserId, [ + 'read', + 'write', + 'realm-owner', + ])}`); + expect(response.status).toBe(200); + let doc = response.body as SingleCardDocument; + expect(doc.data.attributes?.cardTitle).toBe('Test Card'); + } + { + // owner can search in the realm + let response = await context.request + .post(`${new URL(realm.url).pathname}_search`) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .set('Authorization', `Bearer ${createJWT(realm, ownerUserId, [ + 'read', + 'write', + 'realm-owner', + ])}`) + .send({ + filter: { + on: baseCardRef, + eq: { + cardTitle: 'Test Card', + }, + }, + } as Query); + expect(response.status).toBe(200); + let results = response.body as CardCollectionDocument; + (expect(results.data.length).toBe(1), + 'correct number of search results'); + } + }); + it('dynamically created realms are not publicly readable or writable', async function () { + let endpoint = `test-realm-${uuidv4()}`; + let owner = 'mango'; + let ownerUserId = '@mango:localhost'; + let response = await context.request + .post('/_create-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send(JSON.stringify({ + data: { + type: 'realm', + attributes: { + name: 'Test Realm', + endpoint, + }, + }, + })); + let realmURL = response.body.data.id; + expect(response.status).toBe(201); + let realm = context.testRealmServer.testingOnlyRealms.find((r) => r.url === realmURL)!; + { + let response = await context.request + .post(`${new URL(realmURL).pathname}_search`) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .set('Authorization', `Bearer ${createJWT(realm, 'rando')}`) + .send({ + filter: { + on: baseCardRef, + eq: { + cardTitle: 'Test Card', + }, + }, + } as Query); + expect(response.status).toBe(403); + response = await context.request + .post(`/${owner}/${endpoint}/`) + .send({ + data: { + type: 'card', + attributes: { + cardTitle: 'Test Card', + }, + meta: { + adoptsFrom: { + module: 'https://cardstack.com/base/card-api', + name: 'CardDef', + }, + }, + }, + }) + .set('Accept', 'application/vnd.card+json') + .set('Authorization', `Bearer ${createJWT(realm, 'rando')}`); + expect(response.status).toBe(403); + } + }); + it('can restart a realm that was created dynamically', async function () { + let endpoint = `test-realm-${uuidv4()}`; + let owner = 'mango'; + let ownerUserId = '@mango:localhost'; + let realmURL: string; + { + let response = await context.request + .post('/_create-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${createRealmServerJWT({ + user: '@mango:localhost', + sessionRoom: 'session-room-test', + }, realmSecretSeed)}`) + .send(JSON.stringify({ + data: { + type: 'realm', + attributes: { + name: 'Test Realm', + endpoint, + }, + }, + })); + expect(response.status).toBe(201); + realmURL = response.body.data.id; + } + let id: string; + let realm = context.testRealmServer.testingOnlyRealms.find((r) => r.url === realmURL)!; + { + let response = await context.request + .post(`/${owner}/${endpoint}/`) + .send({ + data: { + type: 'card', + attributes: { cardInfo: { name: 'Test Card' } }, + meta: { + adoptsFrom: { + module: 'https://cardstack.com/base/card-api', + name: 'CardDef', + }, + }, + }, + }) + .set('Accept', 'application/vnd.card+json') + .set('Authorization', `Bearer ${createJWT(realm, ownerUserId, [ + 'read', + 'write', + 'realm-owner', + ])}`); + expect(response.status).toBe(201); + id = response.body.data.id; + } + let jobsBeforeRestart = await context.dbAdapter.execute('select * from jobs'); + context.testRealmServer.testingOnlyUnmountRealms(); + await closeServer(context.testRealmHttpServer); + let restartedServer = await runTestRealmServer({ + virtualNetwork: context.virtualNetwork, + testRealmDir: context.testRealmDir, + realmsRootPath: join(context.dir.name, 'realm_server_1'), + realmURL: testRealmURL, + permissions: { + '*': ['read', 'write'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + dbAdapter: context.dbAdapter, + publisher: context.publisher, + runner: context.runner, + matrixURL, + }); + try { + let jobsAfterRestart = await context.dbAdapter.execute('select * from jobs'); + expect(jobsBeforeRestart.length).toBe(jobsAfterRestart.length); + let restartedRealm = restartedServer.testRealmServer.testingOnlyRealms.find((r) => r.url === realmURL); + expect(restartedRealm).toBeTruthy(); + let restartedRequest = supertest(restartedServer.testRealmHttpServer); + let response = await restartedRequest + .get(new URL(id).pathname) + .set('Accept', 'application/vnd.card+json') + .set('Authorization', `Bearer ${createJWT(restartedRealm!, ownerUserId, [ + 'read', + 'write', + 'realm-owner', + ])}`); + expect(response.status).toBe(200); + let doc = response.body as SingleCardDocument; + expect(doc.data.attributes?.cardTitle).toBe('Test Card'); + } + finally { + restartedServer.testRealmServer.testingOnlyUnmountRealms(); + await closeServer(restartedServer.testRealmHttpServer); + } + }); + it('POST /_create-realm without JWT', async function () { + let endpoint = `test-realm-${uuidv4()}`; + let response = await context.request + .post('/_create-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .send(JSON.stringify({ + data: { + type: 'realm', + attributes: { + name: 'Test Realm', + endpoint, + }, + }, + })); + expect(response.status).toBe(401); + let error = response.body.errors[0]; + expect(error).toBe('Missing Authorization header'); + }); + it('POST /_create-realm with invalid JWT', async function () { + let endpoint = `test-realm-${uuidv4()}`; + let response = await context.request + .post('/_create-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', 'Bearer invalid-jwt') + .send(JSON.stringify({ + data: { + type: 'realm', + attributes: { + name: 'Test Realm', + endpoint, + }, + }, + })); + expect(response.status).toBe(401); + let error = response.body.errors[0]; + expect(error).toBe('Token invalid'); + }); + it('POST /_create-realm with invalid JSON', async function () { + let response = await context.request + .post('/_create-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: '@mango:localhost', sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send('make a new realm please!'); + expect(response.status).toBe(400); + let error = response.body.errors[0]; + expect(error.match(/not valid JSON-API/)).toBeTruthy(); + }); + it('POST /_create-realm with bad JSON-API', async function () { + let response = await context.request + .post('/_create-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: '@mango:localhost', sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send(JSON.stringify({ + name: 'mango-realm', + })); + expect(response.status).toBe(400); + let error = response.body.errors[0]; + expect(error.match(/not valid JSON-API/)).toBeTruthy(); + }); + it('POST /_create-realm without a realm endpoint', async function () { + let response = await context.request + .post('/_create-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: '@mango:localhost', sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send(JSON.stringify({ + data: { + type: 'realm', + attributes: { + name: 'Test Realm', + }, + }, + })); + expect(response.status).toBe(400); + let error = response.body.errors[0]; + expect(error.match(/endpoint is required and must be a string/)).toBeTruthy(); + }); + it('POST /_create-realm without a realm name', async function () { + let endpoint = `test-realm-${uuidv4()}`; + let response = await context.request + .post('/_create-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: '@mango:localhost', sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send(JSON.stringify({ + data: { + type: 'realm', + attributes: { + endpoint, + }, + }, + })); + expect(response.status).toBe(400); + let error = response.body.errors[0]; + expect(error.match(/name is required and must be a string/)).toBeTruthy(); + }); + it('cannot create a new realm that collides with an existing realm', async function () { + let endpoint = `test-realm-${uuidv4()}`; + let ownerUserId = '@mango:localhost'; + let response = await context.request + .post('/_create-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send(JSON.stringify({ + data: { + type: 'realm', + attributes: { + endpoint, + name: 'Test Realm', + }, + }, + })); + expect(response.status).toBe(201); + { + let response = await context.request + .post('/_create-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send(JSON.stringify({ + data: { + type: 'realm', + attributes: { + endpoint, + name: 'Another Test Realm', + }, + }, + })); + expect(response.status).toBe(400); + let error = response.body.errors[0]; + expect(error.match(/already exists on this server/)).toBeTruthy(); + } + }); + it('cannot create a realm with invalid characters in endpoint', async function () { + let ownerUserId = '@mango:localhost'; + { + let response = await context.request + .post('/_create-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send(JSON.stringify({ + data: { + type: 'realm', + attributes: { + endpoint: 'invalid_realm_endpoint', + name: 'Test Realm', + }, + }, + })); + expect(response.status).toBe(400); + let error = response.body.errors[0]; + expect(error.match(/contains invalid characters/)).toBeTruthy(); + } + { + let response = await context.request + .post('/_create-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send(JSON.stringify({ + data: { + type: 'realm', + attributes: { + endpoint: 'invalid realm endpoint', + name: 'Test Realm', + }, + }, + })); + expect(response.status).toBe(400); + let error = response.body.errors[0]; + expect(error.match(/contains invalid characters/)).toBeTruthy(); + } + }); + it('can create instance in private realm using card def from a different private realm both owned by the same user', async function () { + let owner = 'mango'; + let ownerUserId = '@mango:localhost'; + let providerEndpoint = `test-realm-provider-${uuidv4()}`; + let providerRealmURL: string; + { + let response = await context.request + .post('/_create-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${createRealmServerJWT({ + user: '@mango:localhost', + sessionRoom: 'session-room-test', + }, realmSecretSeed)}`) + .send(JSON.stringify({ + data: { + type: 'realm', + attributes: { + name: 'Test Provider Realm', + endpoint: providerEndpoint, + }, + }, + })); + expect(response.status).toBe(201); + providerRealmURL = response.body.data.id; + } + let providerRealm = context.testRealmServer.testingOnlyRealms.find((r) => r.url === providerRealmURL)!; + { + // create a card def + let response = await context.request + .post(`/${owner}/${providerEndpoint}/test-card.gts`) + .set('Accept', 'application/vnd.card+source') + .set('Authorization', `Bearer ${createJWT(providerRealm, ownerUserId, [ + 'read', + 'write', + 'realm-owner', + ])}`) + .send(cardSrc); + expect(response.status).toBe(204); + } + let consumerEndpoint = `test-realm-consumer-${uuidv4()}`; + let consumerRealmURL: string; + { + let response = await context.request + .post('/_create-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${createRealmServerJWT({ + user: '@mango:localhost', + sessionRoom: 'session-room-test', + }, realmSecretSeed)}`) + .send(JSON.stringify({ + data: { + type: 'realm', + attributes: { + name: 'Test Consumer Realm', + endpoint: consumerEndpoint, + }, + }, + })); + expect(response.status).toBe(201); + consumerRealmURL = response.body.data.id; + } + let consumerRealm = context.testRealmServer.testingOnlyRealms.find((r) => r.url === consumerRealmURL)!; + let id: string; + { + // create an instance using card def in different private realm + let response = await context.request + .post(`/${owner}/${consumerEndpoint}/`) + .send({ + data: { + type: 'card', + attributes: { + firstName: 'Mango', + }, + meta: { + adoptsFrom: { + module: `${providerRealmURL}test-card`, + name: 'Person', + }, + }, + }, + }) + .set('Accept', 'application/vnd.card+json') + .set('Authorization', `Bearer ${createJWT(consumerRealm, ownerUserId, [ + 'read', + 'write', + 'realm-owner', + ])}`); + expect(response.status).toBe(201); + let doc = response.body as SingleCardDocument; + id = doc.data.id!; + } + { + // get the instance + let response = await context.request + .get(new URL(id).pathname) + .set('Accept', 'application/vnd.card+json') + .set('Authorization', `Bearer ${createJWT(consumerRealm, ownerUserId, [ + 'read', + 'write', + 'realm-owner', + ])}`); + expect(response.status).toBe(200); + let doc = response.body as SingleCardDocument; + expect(doc.data.attributes?.firstName).toBe('Mango'); + } + }); + }); + describe('Realm creation when a realm is mounted at server origin', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let request!: SuperTest; + setupPermissionedRealmCached(hooks, { + realmURL: rootTestRealmURL, + permissions: { + '*': ['read', 'write'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + onRealmSetup(args) { + request = args.request; + }, + }); + it('cannot create a realm on a realm server that has a realm mounted at the origin', async function () { + let response = await request + .post('/_create-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: '@mango:localhost', sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send(JSON.stringify({ + data: { + type: 'realm', + attributes: { + endpoint: 'mango-realm', + name: 'Test Realm', + }, + }, + })); + expect(response.status).toBe(400); + let error = response.body.errors[0]; + expect(error.match(/a realm is already mounted at the origin of this server/)).toBeTruthy(); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/server-endpoints/search-prerendered.test.ts b/packages/realm-server/tests-vitest/server-endpoints/search-prerendered.test.ts new file mode 100644 index 00000000000..3dc07914242 --- /dev/null +++ b/packages/realm-server/tests-vitest/server-endpoints/search-prerendered.test.ts @@ -0,0 +1,263 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import supertest from 'supertest'; +import type { Test, SuperTest } from 'supertest'; +import { join } from 'path'; +import { dirSync } from 'tmp'; +import type { LooseSingleCardDocument, QueuePublisher, QueueRunner, Realm, } from '@cardstack/runtime-common'; +import { baseCardRef, buildQueryParamValue } from '@cardstack/runtime-common'; +import type { Query } from '@cardstack/runtime-common/query'; +import type { PgAdapter } from '@cardstack/postgres'; +import { resetCatalogRealms } from '../../handlers/handle-fetch-catalog-realms'; +import { closeServer, createVirtualNetwork, setupDB, matrixURL, realmSecretSeed, runTestRealmServerWithRealms, } from '../helpers'; +import { createJWT as createRealmServerJWT } from '../../utils/jwt'; +import type { Server } from 'http'; +describe("server-endpoints/search-prerendered-test.ts", function () { + const _hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + describe('Realm Server Endpoints | /_federated-search-prerendered', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let testRealm: Realm; + let secondaryRealm: Realm; + let request: SuperTest; + let dbAdapter: PgAdapter; + let testRealmHttpServer: Server; + let ownerUserId = '@mango:localhost'; + let realmFileSystem: Record = { + 'test-card.json': { + data: { + type: 'card', + attributes: { + cardInfo: { + name: 'Shared Card', + }, + }, + meta: { + adoptsFrom: { + module: 'https://cardstack.com/base/card-api', + name: 'CardDef', + }, + }, + }, + }, + 'other-card.json': { + data: { + type: 'card', + attributes: { + cardInfo: { + name: 'Other Card', + }, + }, + meta: { + adoptsFrom: { + module: 'https://cardstack.com/base/card-api', + name: 'CardDef', + }, + }, + }, + }, + }; + async function startSearchRealmServer({ dbAdapter, publisher, runner, }: { + dbAdapter: PgAdapter; + publisher: QueuePublisher; + runner: QueueRunner; + }) { + let virtualNetwork = createVirtualNetwork(); + let dir = dirSync(); + let testRealmURL = new URL('http://127.0.0.1:4444/test/'); + let secondaryRealmURL = new URL('http://127.0.0.1:4444/secondary/'); + let result = await runTestRealmServerWithRealms({ + virtualNetwork, + realmsRootPath: join(dir.name, 'realm_server_1'), + realms: [ + { + realmURL: testRealmURL, + fileSystem: realmFileSystem, + permissions: { + [ownerUserId]: ['read', 'write', 'realm-owner'], + }, + }, + { + realmURL: secondaryRealmURL, + fileSystem: realmFileSystem, + permissions: { + [ownerUserId]: ['read', 'write', 'realm-owner'], + }, + }, + ], + dbAdapter, + publisher, + runner, + matrixURL, + }); + testRealmHttpServer = result.testRealmHttpServer; + request = supertest(result.testRealmHttpServer); + testRealm = result.realms.find((realm) => realm.url === testRealmURL.href)!; + secondaryRealm = result.realms.find((realm) => realm.url === secondaryRealmURL.href)!; + } + async function stopSearchRealmServer() { + testRealm.unsubscribe(); + secondaryRealm.unsubscribe(); + await closeServer(testRealmHttpServer); + resetCatalogRealms(); + } + setupDB(hooks, { + beforeEach: async (_dbAdapter, publisher, runner) => { + dbAdapter = _dbAdapter; + await startSearchRealmServer({ + dbAdapter, + publisher, + runner, + }); + }, + afterEach: async () => { + await stopSearchRealmServer(); + }, + }); + it('QUERY /_federated-search-prerendered federates results across realms', async function () { + let realmServerToken = createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed); + let query: Query = { + filter: { + on: baseCardRef, + eq: { + cardTitle: 'Shared Card', + }, + }, + }; + let searchURL = new URL('/_federated-search-prerendered', testRealm.url); + let searchResponse = await request + .post(`${searchURL.pathname}${searchURL.search}`) + .set('Accept', 'application/vnd.card+json') + .set('Content-Type', 'application/json') + .set('X-HTTP-Method-Override', 'QUERY') + .set('Authorization', `Bearer ${realmServerToken}`) + .send({ + ...query, + realms: [testRealm.url, secondaryRealm.url], + prerenderedHtmlFormat: 'embedded', + }); + expect(searchResponse.status).toBe(200); + let results = searchResponse.body; + expect(results.data.length).toBe(2); + expect(results.meta.page.total).toBe(2); + let ids: string[] = results.data.map((entry: { + id: string; + }) => entry.id); + expect(ids[0]?.startsWith(testRealm.url)).toBeTruthy(); + expect(ids[1]?.startsWith(secondaryRealm.url)).toBeTruthy(); + expect(ids.every((id) => id.includes('test-card'))).toBe(true); + }); + it('GET /_federated-search-prerendered returns 400 for unsupported method', async function () { + let realmServerToken = createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed); + let query: Query = { + filter: { + on: baseCardRef, + eq: { + cardTitle: 'Shared Card', + }, + }, + }; + let searchURL = new URL('/_federated-search-prerendered', testRealm.url); + searchURL.searchParams.append('realms', testRealm.url); + searchURL.searchParams.set('query', buildQueryParamValue(query)); + searchURL.searchParams.set('prerenderedHtmlFormat', 'embedded'); + let response = await request + .get(`${searchURL.pathname}${searchURL.search}`) + .set('Accept', 'application/vnd.card+json') + .set('Authorization', `Bearer ${realmServerToken}`); + expect(response.status).toBe(400); + expect(response.body.errors?.[0]?.includes('method must be QUERY')).toBeTruthy(); + }); + it('QUERY /_federated-search-prerendered returns 403 when user lacks read access', async function () { + let realmServerToken = createRealmServerJWT({ user: '@rando:localhost', sessionRoom: 'session-room-test' }, realmSecretSeed); + let query: Query = { + filter: { + on: baseCardRef, + eq: { + cardTitle: 'Test Card', + }, + }, + }; + let searchURL = new URL('/_federated-search-prerendered', testRealm.url); + let response = await request + .post(`${searchURL.pathname}${searchURL.search}`) + .set('Accept', 'application/vnd.card+json') + .set('Content-Type', 'application/json') + .set('X-HTTP-Method-Override', 'QUERY') + .set('Authorization', `Bearer ${realmServerToken}`) + .send({ + ...query, + realms: [testRealm.url], + prerenderedHtmlFormat: 'embedded', + }); + expect(response.status).toBe(403); + expect(response.body.errors?.[0]?.includes(testRealm.url)).toBeTruthy(); + }); + it('QUERY /_federated-search-prerendered returns 401 when unauthenticated user requests non-public realm', async function () { + let query: Query = { + filter: { + on: baseCardRef, + eq: { + cardTitle: 'Test Card', + }, + }, + }; + let searchURL = new URL('/_federated-search-prerendered', testRealm.url); + let response = await request + .post(`${searchURL.pathname}${searchURL.search}`) + .set('Accept', 'application/vnd.card+json') + .set('Content-Type', 'application/json') + .set('X-HTTP-Method-Override', 'QUERY') + .send({ + ...query, + realms: [testRealm.url], + prerenderedHtmlFormat: 'embedded', + }); + expect(response.status).toBe(401); + expect(response.body.errors?.[0]?.includes(testRealm.url)).toBeTruthy(); + }); + it('QUERY /_federated-search-prerendered returns 400 for invalid query', async function () { + let realmServerToken = createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed); + let searchURL = new URL('/_federated-search-prerendered', testRealm.url); + let response = await request + .post(`${searchURL.pathname}${searchURL.search}`) + .set('Accept', 'application/vnd.card+json') + .set('Content-Type', 'application/json') + .set('X-HTTP-Method-Override', 'QUERY') + .set('Authorization', `Bearer ${realmServerToken}`) + .send({ + realms: [testRealm.url], + invalid: 'query structure', + prerenderedHtmlFormat: 'embedded', + }); + expect(response.status).toBe(400); + }); + it('QUERY /_federated-search-prerendered returns 400 when realms param is missing', async function () { + let query: Query = { + filter: { + on: baseCardRef, + eq: { + cardTitle: 'Test Card', + }, + }, + }; + let response = await request + .post('/_federated-search-prerendered') + .set('Accept', 'application/vnd.card+json') + .set('Content-Type', 'application/json') + .set('X-HTTP-Method-Override', 'QUERY') + .send({ ...query, prerenderedHtmlFormat: 'embedded' }); + expect(response.status).toBe(400); + expect(response.body.errors?.[0]?.includes('realms must be supplied in request body')).toBeTruthy(); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/server-endpoints/search.test.ts b/packages/realm-server/tests-vitest/server-endpoints/search.test.ts new file mode 100644 index 00000000000..8ea88dae80d --- /dev/null +++ b/packages/realm-server/tests-vitest/server-endpoints/search.test.ts @@ -0,0 +1,265 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import supertest from 'supertest'; +import type { Test, SuperTest } from 'supertest'; +import { join } from 'path'; +import { dirSync } from 'tmp'; +import type { LooseSingleCardDocument, QueuePublisher, QueueRunner, Realm, } from '@cardstack/runtime-common'; +import { baseCardRef } from '@cardstack/runtime-common'; +import type { Query } from '@cardstack/runtime-common/query'; +import type { PgAdapter } from '@cardstack/postgres'; +import { stringify } from 'qs'; +import { resetCatalogRealms } from '../../handlers/handle-fetch-catalog-realms'; +import { closeServer, createVirtualNetwork, setupDB, matrixURL, realmSecretSeed, runTestRealmServerWithRealms, } from '../helpers'; +import { createJWT as createRealmServerJWT } from '../../utils/jwt'; +import type { Server } from 'http'; +describe("server-endpoints/search-test.ts", function () { + const _hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + describe('Realm Server Endpoints | /_federated-search', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let testRealm: Realm; + let secondaryRealm: Realm; + let request: SuperTest; + let dbAdapter: PgAdapter; + let testRealmHttpServer: Server; + let ownerUserId = '@mango:localhost'; + let realmFileSystem: Record = { + 'test-card.json': { + data: { + type: 'card', + attributes: { + cardInfo: { + name: 'Shared Card', + }, + }, + meta: { + adoptsFrom: { + module: 'https://cardstack.com/base/card-api', + name: 'CardDef', + }, + }, + }, + }, + 'other-card.json': { + data: { + type: 'card', + attributes: { + cardInfo: { + name: 'Other Card', + }, + }, + meta: { + adoptsFrom: { + module: 'https://cardstack.com/base/card-api', + name: 'CardDef', + }, + }, + }, + }, + }; + async function startSearchRealmServer({ dbAdapter, publisher, runner, }: { + dbAdapter: PgAdapter; + publisher: QueuePublisher; + runner: QueueRunner; + }) { + let virtualNetwork = createVirtualNetwork(); + let dir = dirSync(); + let testRealmURL = new URL('http://127.0.0.1:4444/test/'); + let secondaryRealmURL = new URL('http://127.0.0.1:4444/secondary/'); + let result = await runTestRealmServerWithRealms({ + virtualNetwork, + realmsRootPath: join(dir.name, 'realm_server_1'), + realms: [ + { + realmURL: testRealmURL, + fileSystem: realmFileSystem, + permissions: { + [ownerUserId]: ['read', 'write', 'realm-owner'], + }, + }, + { + realmURL: secondaryRealmURL, + fileSystem: realmFileSystem, + permissions: { + [ownerUserId]: ['read', 'write', 'realm-owner'], + }, + }, + ], + dbAdapter, + publisher, + runner, + matrixURL, + }); + testRealmHttpServer = result.testRealmHttpServer; + request = supertest(result.testRealmHttpServer); + testRealm = result.realms.find((realm) => realm.url === testRealmURL.href)!; + secondaryRealm = result.realms.find((realm) => realm.url === secondaryRealmURL.href)!; + } + async function stopSearchRealmServer() { + testRealm.unsubscribe(); + secondaryRealm.unsubscribe(); + await closeServer(testRealmHttpServer); + resetCatalogRealms(); + } + setupDB(hooks, { + beforeEach: async (_dbAdapter, publisher, runner) => { + dbAdapter = _dbAdapter; + await startSearchRealmServer({ + dbAdapter, + publisher, + runner, + }); + }, + afterEach: async () => { + await stopSearchRealmServer(); + }, + }); + it('QUERY /_federated-search federates results across realms', async function () { + let realmServerToken = createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed); + let query: Query = { + filter: { + on: baseCardRef, + eq: { + cardTitle: 'Shared Card', + }, + }, + }; + let searchURL = new URL('/_federated-search', testRealm.url); + let searchResponse = await request + .post(`${searchURL.pathname}${searchURL.search}`) + .set('Accept', 'application/vnd.card+json') + .set('Content-Type', 'application/json') + .set('X-HTTP-Method-Override', 'QUERY') + .set('Authorization', `Bearer ${realmServerToken}`) + .send({ ...query, realms: [testRealm.url, secondaryRealm.url] }); + expect(searchResponse.status).toBe(200); + let results = searchResponse.body; + expect(results.data.length).toBe(2); + expect(results.meta.page.total).toBe(2); + let ids: string[] = results.data.map((entry: { + id: string; + }) => entry.id); + expect(ids).toEqual([`${testRealm.url}test-card`, `${secondaryRealm.url}test-card`]); + }); + it('QUERY /_federated-search supports query body', async function () { + let realmServerToken = createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed); + let query: Query = { + filter: { + on: baseCardRef, + eq: { + cardTitle: 'Shared Card', + }, + }, + }; + let searchURL = new URL('/_federated-search', testRealm.url); + let response = await request + .post(`${searchURL.pathname}${searchURL.search}`) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .set('Authorization', `Bearer ${realmServerToken}`) + .send({ ...query, realms: [testRealm.url] }); + expect(response.status).toBe(200); + expect(response.body.data.length).toBe(1); + }); + it('GET /_federated-search returns 400 for unsupported method', async function () { + let realmServerToken = createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed); + let query: Query = { + filter: { + on: baseCardRef, + eq: { + cardTitle: 'Shared Card', + }, + }, + }; + let searchURL = new URL('/_federated-search', testRealm.url); + searchURL.searchParams.append('realms', testRealm.url); + searchURL.searchParams.set('query', stringify(query, { encode: false })); + let response = await request + .get(`${searchURL.pathname}${searchURL.search}`) + .set('Accept', 'application/vnd.card+json') + .set('Authorization', `Bearer ${realmServerToken}`); + expect(response.status).toBe(400); + expect(response.body.errors?.[0]?.includes('method must be QUERY')).toBeTruthy(); + }); + it('QUERY /_federated-search returns 403 when user lacks read access', async function () { + let realmServerToken = createRealmServerJWT({ user: '@rando:localhost', sessionRoom: 'session-room-test' }, realmSecretSeed); + let query: Query = { + filter: { + on: baseCardRef, + eq: { + cardTitle: 'Test Card', + }, + }, + }; + let searchURL = new URL('/_federated-search', testRealm.url); + let response = await request + .post(`${searchURL.pathname}${searchURL.search}`) + .set('Accept', 'application/vnd.card+json') + .set('Content-Type', 'application/json') + .set('X-HTTP-Method-Override', 'QUERY') + .set('Authorization', `Bearer ${realmServerToken}`) + .send({ ...query, realms: [testRealm.url] }); + expect(response.status).toBe(403); + expect(response.body.errors?.[0]?.includes(testRealm.url)).toBeTruthy(); + }); + it('QUERY /_federated-search returns 401 when unauthenticated user requests non-public realm', async function () { + let query: Query = { + filter: { + on: baseCardRef, + eq: { + cardTitle: 'Test Card', + }, + }, + }; + let searchURL = new URL('/_federated-search', testRealm.url); + let response = await request + .post(`${searchURL.pathname}${searchURL.search}`) + .set('Accept', 'application/vnd.card+json') + .set('Content-Type', 'application/json') + .set('X-HTTP-Method-Override', 'QUERY') + .send({ ...query, realms: [testRealm.url] }); + expect(response.status).toBe(401); + expect(response.body.errors?.[0]?.includes(testRealm.url)).toBeTruthy(); + }); + it('QUERY /_federated-search returns 400 for invalid query', async function () { + let realmServerToken = createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed); + let searchURL = new URL('/_federated-search', testRealm.url); + let response = await request + .post(`${searchURL.pathname}${searchURL.search}`) + .set('Accept', 'application/vnd.card+json') + .set('Content-Type', 'application/json') + .set('X-HTTP-Method-Override', 'QUERY') + .set('Authorization', `Bearer ${realmServerToken}`) + .send({ realms: [testRealm.url], invalid: 'query structure' }); + expect(response.status).toBe(400); + }); + it('QUERY /_federated-search returns 400 when realms param is missing', async function () { + let query: Query = { + filter: { + on: baseCardRef, + eq: { + cardTitle: 'Test Card', + }, + }, + }; + let response = await request + .post('/_federated-search') + .set('Accept', 'application/vnd.card+json') + .set('Content-Type', 'application/json') + .set('X-HTTP-Method-Override', 'QUERY') + .send(query); + expect(response.status).toBe(400); + expect(response.body.errors?.[0]?.includes('realms must be supplied in request body')).toBeTruthy(); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/server-endpoints/stripe-session.test.ts b/packages/realm-server/tests-vitest/server-endpoints/stripe-session.test.ts new file mode 100644 index 00000000000..1b4ba2eb1c2 --- /dev/null +++ b/packages/realm-server/tests-vitest/server-endpoints/stripe-session.test.ts @@ -0,0 +1,256 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import type { Test, SuperTest } from 'supertest'; +import sinon from 'sinon'; +import { getStripe } from '@cardstack/billing/stripe-webhook-handlers/stripe'; +import type { PgAdapter } from '@cardstack/postgres'; +import { getUserByMatrixUserId } from '@cardstack/billing/billing-queries'; +import { createJWT, insertPlan, insertUser, setupPermissionedRealmCached, } from '../helpers'; +import '@cardstack/runtime-common/helpers/code-equality-assertion'; +import type { Realm } from '@cardstack/runtime-common'; +describe("server-endpoints/stripe-session-test.ts", function () { + describe('Realm Server Endpoints (not specific to one realm)', function () { + describe('stripe session handler', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let createCustomerStub: sinon.SinonStub; + let createCheckoutSessionStub: sinon.SinonStub; + let listSubscriptionsStub: sinon.SinonStub; + let retrieveProductStub: sinon.SinonStub; + let createBillingPortalSessionStub: sinon.SinonStub; + let userId = '@test_realm:localhost'; + let jwtToken: string; + let request: SuperTest; + let dbAdapter: PgAdapter; + function onRealmSetup(args: { + request: SuperTest; + dbAdapter: PgAdapter; + testRealm: Realm; + }) { + request = args.request; + dbAdapter = args.dbAdapter; + jwtToken = createJWT(args.testRealm, userId); + } + setupPermissionedRealmCached(hooks, { + fileSystem: {}, + permissions: { + '*': ['read', 'write'], + }, + onRealmSetup, + }); + hooks.beforeEach(async function () { + let stripe = getStripe(); + createCustomerStub = sinon.stub(stripe.customers, 'create'); + createCheckoutSessionStub = sinon.stub(stripe.checkout.sessions, 'create'); + listSubscriptionsStub = sinon.stub(stripe.subscriptions, 'list'); + retrieveProductStub = sinon.stub(stripe.products, 'retrieve'); + createBillingPortalSessionStub = sinon.stub(stripe.billingPortal.sessions, 'create'); + }); + hooks.afterEach(async function () { + createCustomerStub.restore(); + createCheckoutSessionStub.restore(); + listSubscriptionsStub.restore(); + retrieveProductStub.restore(); + createBillingPortalSessionStub.restore(); + }); + it('creates checkout session for AI tokens when user has no Stripe customer', async function () { + let user = await insertUser(dbAdapter, userId, '', // no stripe customer id + ''); + const mockCustomer = { + id: 'cus_test123', + email: 'test@example.com', + }; + const mockSession = { + id: 'cs_test123', + url: 'https://checkout.stripe.com/test123', + }; + createCustomerStub.resolves(mockCustomer); + createCheckoutSessionStub.resolves(mockSession); + let response = await request + .post('/_stripe-session?returnUrl=http%3A//example.com/return&email=test@example.com&aiTokenAmount=2500') + .set('Accept', 'application/json') + .set('Content-Type', 'application/json') + .set('Authorization', jwtToken); + expect(response.status).toBe(200); + let json = response.body; + expect(json).toEqual({ + url: 'https://checkout.stripe.com/test123', + sessionId: 'cs_test123', + type: 'checkout', + }); + // Verify Stripe customer was created + expect(createCustomerStub.calledOnce).toBeTruthy(); + expect(createCustomerStub.firstCall.args[0]).toEqual({ email: 'test@example.com' }); + // Verify checkout session was created + expect(createCheckoutSessionStub.calledOnce).toBeTruthy(); + let sessionArgs = createCheckoutSessionStub.firstCall.args[0]; + expect(sessionArgs.customer).toBe('cus_test123'); + expect(sessionArgs.mode).toBe('payment'); + expect(sessionArgs.success_url).toBe('http://example.com/return'); + expect(sessionArgs.cancel_url).toBe('http://example.com/return'); + expect(sessionArgs.line_items[0].price_data.unit_amount).toBe(500); + expect(sessionArgs.line_items[0].price_data.product_data.name).toBe('2,500 AI credits'); + expect(sessionArgs.metadata.credit_reload_amount).toBe('2500'); + expect(sessionArgs.metadata.user_id).toBe(user.id); + // Verify user was updated with Stripe customer info + let updatedUser = await getUserByMatrixUserId(dbAdapter, userId); + expect(updatedUser?.stripeCustomerId).toBe('cus_test123'); + expect(updatedUser?.stripeCustomerEmail).toBe('test@example.com'); + }); + it('creates checkout session for AI tokens when user already has Stripe customer', async function () { + let user = await insertUser(dbAdapter, userId, 'cus_existing123', // existing stripe customer id + 'existing@example.com'); + const mockSession = { + id: 'cs_test456', + url: 'https://checkout.stripe.com/test456', + }; + createCheckoutSessionStub.resolves(mockSession); + let response = await request + .post('/_stripe-session?returnUrl=http%3A//example.com/return&email=test@example.com&aiTokenAmount=20000') + .set('Accept', 'application/json') + .set('Content-Type', 'application/json') + .set('Authorization', jwtToken); + expect(response.status).toBe(200); + let json = response.body; + expect(json).toEqual({ + url: 'https://checkout.stripe.com/test456', + sessionId: 'cs_test456', + type: 'checkout', + }); + // Verify Stripe customer was NOT created (since user already has one) + expect(createCustomerStub.notCalled).toBeTruthy(); + // Verify checkout session was created with existing customer + expect(createCheckoutSessionStub.calledOnce).toBeTruthy(); + let sessionArgs = createCheckoutSessionStub.firstCall.args[0]; + expect(sessionArgs.customer).toBe('cus_existing123'); + expect(sessionArgs.mode).toBe('payment'); + expect(sessionArgs.success_url).toBe('http://example.com/return'); + expect(sessionArgs.cancel_url).toBe('http://example.com/return'); + expect(sessionArgs.line_items[0].price_data.unit_amount).toBe(3000); + expect(sessionArgs.line_items[0].price_data.product_data.name).toBe('20,000 AI credits'); + expect(sessionArgs.metadata.credit_reload_amount).toBe('20000'); + expect(sessionArgs.metadata.user_id).toBe(user.id); + // Verify user info remains unchanged + let updatedUser = await getUserByMatrixUserId(dbAdapter, userId); + expect(updatedUser?.stripeCustomerId).toBe('cus_existing123'); + expect(updatedUser?.stripeCustomerEmail).toBe('existing@example.com'); + }); + it('creates checkout session for subscription when user has no active subscription', async function () { + let user = await insertUser(dbAdapter, userId, 'cus_existing123', // existing stripe customer id + 'existing@example.com'); + // Create a test plan + let plan = await insertPlan(dbAdapter, 'TestPlan', 12, 5000, 'prod_test_plan'); + const mockSession = { + id: 'cs_subscription_test', + url: 'https://checkout.stripe.com/subscription', + }; + const mockProduct = { + id: 'prod_test_plan', + default_price: 'price_123', + }; + // User has no active subscriptions + listSubscriptionsStub.resolves({ data: [] }); + retrieveProductStub.resolves(mockProduct); + createCheckoutSessionStub.resolves(mockSession); + let response = await request + .post('/_stripe-session?returnUrl=http%3A//example.com/return&plan=TestPlan') + .set('Accept', 'application/json') + .set('Content-Type', 'application/json') + .set('Authorization', jwtToken); + expect(response.status).toBe(200); + let json = response.body; + expect(json).toEqual({ + url: 'https://checkout.stripe.com/subscription', + sessionId: 'cs_subscription_test', + type: 'checkout', + }); + // Verify subscriptions.list was called to check for active subscriptions + expect(listSubscriptionsStub.calledOnce).toBeTruthy(); + expect(listSubscriptionsStub.firstCall.args[0]).toEqual({ + customer: 'cus_existing123', + status: 'active', + limit: 1, + }); + // Verify product was retrieved + expect(retrieveProductStub.calledOnce).toBeTruthy(); + expect(retrieveProductStub.firstCall.args[0]).toBe('prod_test_plan'); + // Verify checkout session was created for subscription + expect(createCheckoutSessionStub.calledOnce).toBeTruthy(); + let sessionArgs = createCheckoutSessionStub.firstCall.args[0]; + expect(sessionArgs.customer).toBe('cus_existing123'); + expect(sessionArgs.mode).toBe('subscription'); + expect(sessionArgs.success_url).toBe('http://example.com/return'); + expect(sessionArgs.cancel_url).toBe('http://example.com/return'); + expect(sessionArgs.line_items).toEqual([ + { + price: 'price_123', + quantity: 1, + }, + ]); + expect(sessionArgs.payment_method_data).toEqual({ + allow_redisplay: 'always', + }); + expect(sessionArgs.metadata).toEqual({ + plan_name: 'TestPlan', + plan_id: plan.id, + user_id: user.id, + }); + // Verify Stripe customer was NOT created (since user already has one) + expect(createCustomerStub.notCalled).toBeTruthy(); + }); + it('creates billing portal session when user already has active subscription', async function () { + // Create a test plan + await insertPlan(dbAdapter, 'ExistingPlan', 15, 7500, 'prod_existing_plan'); + await insertUser(dbAdapter, userId, 'cus_existing456', 'existing@example.com'); + const mockPortalSession = { + url: 'https://billing.stripe.com/portal123', + }; + // User has an active subscription + listSubscriptionsStub.resolves({ + data: [ + { + id: 'sub_active123', + status: 'active', + customer: 'cus_existing456', + }, + ], + }); + createBillingPortalSessionStub.resolves(mockPortalSession); + let response = await request + .post('/_stripe-session?returnUrl=http%3A//example.com/return&plan=ExistingPlan') + .set('Accept', 'application/json') + .set('Content-Type', 'application/json') + .set('Authorization', jwtToken); + expect(response.status).toBe(200); + let json = response.body; + expect(json).toEqual({ + url: 'https://billing.stripe.com/portal123', + type: 'portal', + message: 'You already have an active subscription. Redirecting to manage your subscription...', + }); + // Verify subscriptions.list was called to check for active subscriptions + expect(listSubscriptionsStub.calledOnce).toBeTruthy(); + expect(listSubscriptionsStub.firstCall.args[0]).toEqual({ + customer: 'cus_existing456', + status: 'active', + limit: 1, + }); + // Verify billing portal session was created + expect(createBillingPortalSessionStub.calledOnce).toBeTruthy(); + expect(createBillingPortalSessionStub.firstCall.args[0]).toEqual({ + customer: 'cus_existing456', + return_url: 'http://example.com/return', + }); + // Verify product retrieval and checkout session creation were NOT called + expect(retrieveProductStub.notCalled).toBeTruthy(); + expect(createCheckoutSessionStub.notCalled).toBeTruthy(); + // Verify Stripe customer was NOT created + expect(createCustomerStub.notCalled).toBeTruthy(); + }); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/server-endpoints/stripe-webhook.test.ts b/packages/realm-server/tests-vitest/server-endpoints/stripe-webhook.test.ts new file mode 100644 index 00000000000..86ddcde6529 --- /dev/null +++ b/packages/realm-server/tests-vitest/server-endpoints/stripe-webhook.test.ts @@ -0,0 +1,543 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import type { Test, SuperTest } from 'supertest'; +import type { Realm, User } from '@cardstack/runtime-common'; +import { Deferred } from '@cardstack/runtime-common'; +import { MatrixClient } from '@cardstack/runtime-common/matrix-client'; +import Stripe from 'stripe'; +import sinon from 'sinon'; +import { getStripe } from '@cardstack/billing/stripe-webhook-handlers/stripe'; +import type { PgAdapter } from '@cardstack/postgres'; +import { createJWT, fetchSubscriptionsByUserId, insertPlan, insertUser, realmSecretSeed, realmServerTestMatrix, setupPermissionedRealmCached, } from '../helpers'; +import { createRealmServerSession } from './helpers'; +import { APP_BOXEL_REALM_SERVER_EVENT_MSGTYPE } from '@cardstack/runtime-common/matrix-constants'; +import type { MatrixEvent, RealmServerEventContent, } from 'https://cardstack.com/base/matrix-event'; +import '@cardstack/runtime-common/helpers/code-equality-assertion'; +describe("server-endpoints/stripe-webhook-test.ts", function () { + describe('Realm Server Endpoints (not specific to one realm)', function () { + describe('stripe webhook handler', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let testRealm: Realm; + let request: SuperTest; + let dbAdapter: PgAdapter; + let createSubscriptionStub: sinon.SinonStub; + let fetchPriceListStub: sinon.SinonStub; + let matrixClient: MatrixClient; + let roomId: string; + let userId = '@test_realm:localhost'; + let user: User; + let originalLowCreditThreshold: string | undefined; + let waitForBillingNotification = async function () { + let messages = await matrixClient.roomMessages(roomId); + let firstMessageContent = messages[0].content; + if (messageEventContentIsRealmServerEvent(firstMessageContent)) { + expect((firstMessageContent as RealmServerEventContent).body).toBe(JSON.stringify({ eventType: 'billing-notification' })); + return; + } + else { + await new Promise(resolve => setTimeout(resolve, 1)); + return await waitForBillingNotification(); + } + }; + function onRealmSetup(args: { + testRealm: Realm; + request: SuperTest; + dbAdapter: PgAdapter; + }) { + testRealm = args.testRealm; + request = args.request; + dbAdapter = args.dbAdapter; + } + setupPermissionedRealmCached(hooks, { + permissions: { + '*': ['read', 'write'], + }, + onRealmSetup, + }); + hooks.beforeEach(async function () { + originalLowCreditThreshold = process.env.LOW_CREDIT_THRESHOLD; + process.env.LOW_CREDIT_THRESHOLD = '2000'; + let stripe = getStripe(); + createSubscriptionStub = sinon.stub(stripe.subscriptions, 'create'); + fetchPriceListStub = sinon.stub(stripe.prices, 'list'); + user = await insertUser(dbAdapter, userId!, 'cus_123', 'user@test.com'); + matrixClient = new MatrixClient({ + matrixURL: realmServerTestMatrix.url, + username: 'test_realm', + seed: realmSecretSeed, + }); + await matrixClient.login(); + let { sessionRoom } = await createRealmServerSession(matrixClient, request); + let { joined_rooms: rooms } = await matrixClient.getJoinedRooms(); + if (!rooms.includes(sessionRoom)) { + await matrixClient.joinRoom(sessionRoom); + } + roomId = sessionRoom; + }); + hooks.afterEach(async function () { + createSubscriptionStub.restore(); + fetchPriceListStub.restore(); + if (originalLowCreditThreshold == null) { + delete process.env.LOW_CREDIT_THRESHOLD; + } + else { + process.env.LOW_CREDIT_THRESHOLD = originalLowCreditThreshold; + } + }); + it('subscribes user back to free plan when the current subscription is expired', async function () { + const secret = process.env.STRIPE_WEBHOOK_SECRET; + let freePlan = await insertPlan(dbAdapter, 'Free plan', 0, 100, 'prod_free'); + let creatorPlan = await insertPlan(dbAdapter, 'Creator', 12, 5000, 'prod_creator'); + if (!secret) { + throw new Error('STRIPE_WEBHOOK_SECRET is not set'); + } + let stripeInvoicePaymentSucceededEvent = { + id: 'evt_1234567890', + object: 'event', + type: 'invoice.payment_succeeded', + data: { + object: { + id: 'in_1234567890', + object: 'invoice', + amount_paid: 12, + billing_reason: 'subscription_create', + period_end: 1638465600, + period_start: 1635873600, + subscription: 'sub_1234567890', + customer: 'cus_123', + lines: { + data: [ + { + amount: 12, + type: 'subscription', + proration: false, + price: { product: 'prod_creator' }, + period: { start: 1635873600, end: 1638465600 }, + }, + ], + }, + }, + }, + }; + let timestamp = Math.floor(Date.now() / 1000); + let stripeInvoicePaymentSucceededPayload = JSON.stringify(stripeInvoicePaymentSucceededEvent); + let stripeInvoicePaymentSucceededSignature = Stripe.webhooks.generateTestHeaderString({ + payload: stripeInvoicePaymentSucceededPayload, + secret, + timestamp, + }); + await request + .post('/_stripe-webhook') + .send(stripeInvoicePaymentSucceededPayload) + .set('Accept', 'application/json') + .set('Content-Type', 'application/json') + .set('stripe-signature', stripeInvoicePaymentSucceededSignature); + let subscriptions = await fetchSubscriptionsByUserId(dbAdapter, user.id); + expect(subscriptions.length).toBe(1); + expect(subscriptions[0].status).toBe('active'); + expect(subscriptions[0].planId).toBe(creatorPlan.id); + let waitForSubscriptionExpiryProcessed = new Deferred(); + let waitForFreePlanSubscriptionProcessed = new Deferred(); + // A function to simulate webhook call from stripe after we call 'stripe.subscription.create' endpoint + let subscribeToFreePlan = async function () { + await waitForSubscriptionExpiryProcessed.promise; + let stripeInvoicePaymentSucceededEvent = { + id: 'evt_1234567892', + object: 'event', + type: 'invoice.payment_succeeded', + data: { + object: { + id: 'in_1234567890', + object: 'invoice', + amount_paid: 0, // free plan + billing_reason: 'subscription_create', + period_end: 1638465600, + period_start: 1635873600, + subscription: 'sub_1234567890', + customer: 'cus_123', + lines: { + data: [ + { + amount: 0, + type: 'subscription', + proration: false, + price: { product: 'prod_free' }, + period: { start: 1635873600, end: 1638465600 }, + }, + ], + }, + }, + }, + }; + let stripeInvoicePaymentSucceededPayload = JSON.stringify(stripeInvoicePaymentSucceededEvent); + let stripeInvoicePaymentSucceededSignature = Stripe.webhooks.generateTestHeaderString({ + payload: stripeInvoicePaymentSucceededPayload, + secret, + timestamp, + }); + await request + .post('/_stripe-webhook') + .send(stripeInvoicePaymentSucceededPayload) + .set('Accept', 'application/json') + .set('Content-Type', 'application/json') + .set('stripe-signature', stripeInvoicePaymentSucceededSignature); + waitForFreePlanSubscriptionProcessed.fulfill(); + }; + const createSubscriptionResponse = { + id: 'sub_1MowQVLkdIwHu7ixeRlqHVzs', + object: 'subscription', + automatic_tax: { + enabled: false, + }, + billing_cycle_anchor: 1679609767, + cancel_at_period_end: false, + collection_method: 'charge_automatically', + created: 1679609767, + currency: 'usd', + current_period_end: 1682288167, + current_period_start: 1679609767, + customer: 'cus_123', + invoice_settings: { + issuer: { + type: 'self', + }, + }, + }; + createSubscriptionStub.callsFake(() => { + subscribeToFreePlan(); + return createSubscriptionResponse; + }); + let fetchPriceListResponse = { + object: 'list', + data: [ + { + id: 'price_1QMRCxH9rBd1yAHRD4BXhAHW', + object: 'price', + active: true, + billing_scheme: 'per_unit', + created: 1731921923, + currency: 'usd', + custom_unit_amount: null, + livemode: false, + lookup_key: null, + metadata: {}, + nickname: null, + product: 'prod_REv3E69DbAPv4K', + recurring: { + aggregate_usage: null, + interval: 'month', + interval_count: 1, + meter: null, + trial_period_days: null, + usage_type: 'licensed', + }, + tax_behavior: 'unspecified', + tiers_mode: null, + transform_quantity: null, + type: 'recurring', + unit_amount: 0, + unit_amount_decimal: '0', + }, + ], + has_more: false, + url: '/v1/prices', + }; + fetchPriceListStub.resolves(fetchPriceListResponse); + let stripeSubscriptionDeletedEvent = { + id: 'evt_sub_deleted_1', + object: 'event', + type: 'customer.subscription.deleted', + data: { + object: { + id: 'sub_1234567890', + canceled_at: 2, + cancellation_details: { + reason: 'payment_failure', + }, + customer: 'cus_123', + }, + }, + }; + let stripeSubscriptionDeletedPayload = JSON.stringify(stripeSubscriptionDeletedEvent); + let stripeSubscriptionDeletedSignature = Stripe.webhooks.generateTestHeaderString({ + payload: stripeSubscriptionDeletedPayload, + secret, + timestamp, + }); + await request + .post('/_stripe-webhook') + .send(stripeSubscriptionDeletedPayload) + .set('Accept', 'application/json') + .set('Content-Type', 'application/json') + .set('stripe-signature', stripeSubscriptionDeletedSignature); + waitForSubscriptionExpiryProcessed.fulfill(); + await waitForFreePlanSubscriptionProcessed.promise; + subscriptions = await fetchSubscriptionsByUserId(dbAdapter, user.id); + expect(subscriptions.length).toBe(2); + expect(subscriptions[0].status).toBe('expired'); + expect(subscriptions[0].planId).toBe(creatorPlan.id); + expect(subscriptions[1].status).toBe('active'); + expect(subscriptions[1].planId).toBe(freePlan.id); + await waitForBillingNotification(); + }); + it('ensures the current subscription expires when free plan subscription fails', async function () { + const secret = process.env.STRIPE_WEBHOOK_SECRET; + await insertPlan(dbAdapter, 'Free plan', 0, 100, 'prod_free'); + let creatorPlan = await insertPlan(dbAdapter, 'Creator', 12, 5000, 'prod_creator'); + if (!secret) { + throw new Error('STRIPE_WEBHOOK_SECRET is not set'); + } + let stripeInvoicePaymentSucceededEvent = { + id: 'evt_1234567890', + object: 'event', + type: 'invoice.payment_succeeded', + data: { + object: { + id: 'in_1234567890', + object: 'invoice', + amount_paid: 12, + billing_reason: 'subscription_create', + period_end: 1638465600, + period_start: 1635873600, + subscription: 'sub_1234567890', + customer: 'cus_123', + lines: { + data: [ + { + amount: 12, + type: 'subscription', + proration: false, + price: { product: 'prod_creator' }, + period: { start: 1635873600, end: 1638465600 }, + }, + ], + }, + }, + }, + }; + let timestamp = Math.floor(Date.now() / 1000); + let stripeInvoicePaymentSucceededPayload = JSON.stringify(stripeInvoicePaymentSucceededEvent); + let stripeInvoicePaymentSucceededSignature = Stripe.webhooks.generateTestHeaderString({ + payload: stripeInvoicePaymentSucceededPayload, + secret, + timestamp, + }); + await request + .post('/_stripe-webhook') + .send(stripeInvoicePaymentSucceededPayload) + .set('Accept', 'application/json') + .set('Content-Type', 'application/json') + .set('stripe-signature', stripeInvoicePaymentSucceededSignature); + let subscriptions = await fetchSubscriptionsByUserId(dbAdapter, user.id); + expect(subscriptions.length).toBe(1); + expect(subscriptions[0].status).toBe('active'); + expect(subscriptions[0].planId).toBe(creatorPlan.id); + createSubscriptionStub.throws({ + message: 'Failed subscribing to free plan', + }); + let fetchPriceListResponse = { + object: 'list', + data: [ + { + id: 'price_1QMRCxH9rBd1yAHRD4BXhAHW', + object: 'price', + active: true, + billing_scheme: 'per_unit', + created: 1731921923, + currency: 'usd', + custom_unit_amount: null, + livemode: false, + lookup_key: null, + metadata: {}, + nickname: null, + product: 'prod_REv3E69DbAPv4K', + recurring: { + aggregate_usage: null, + interval: 'month', + interval_count: 1, + meter: null, + trial_period_days: null, + usage_type: 'licensed', + }, + tax_behavior: 'unspecified', + tiers_mode: null, + transform_quantity: null, + type: 'recurring', + unit_amount: 0, + unit_amount_decimal: '0', + }, + ], + has_more: false, + url: '/v1/prices', + }; + fetchPriceListStub.resolves(fetchPriceListResponse); + let stripeSubscriptionDeletedEvent = { + id: 'evt_sub_deleted_1', + object: 'event', + type: 'customer.subscription.deleted', + data: { + object: { + id: 'sub_1234567890', + canceled_at: 2, + cancellation_details: { + reason: 'payment_failure', + }, + customer: 'cus_123', + }, + }, + }; + let stripeSubscriptionDeletedPayload = JSON.stringify(stripeSubscriptionDeletedEvent); + let stripeSubscriptionDeletedSignature = Stripe.webhooks.generateTestHeaderString({ + payload: stripeSubscriptionDeletedPayload, + secret, + timestamp, + }); + await request + .post('/_stripe-webhook') + .send(stripeSubscriptionDeletedPayload) + .set('Accept', 'application/json') + .set('Content-Type', 'application/json') + .set('stripe-signature', stripeSubscriptionDeletedSignature); + subscriptions = await fetchSubscriptionsByUserId(dbAdapter, user.id); + expect(subscriptions.length).toBe(1); + expect(subscriptions[0].status).toBe('expired'); + expect(subscriptions[0].planId).toBe(creatorPlan.id); + let response = await request + .get(`/_user`) + .set('Accept', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createJWT(testRealm, '@test_realm:localhost', [ + 'read', + 'write', + ])}`); + expect(response.status).toBe(200); + let json = response.body; + expect(json).toEqual({ + data: { + type: 'user', + id: user.id, + attributes: { + matrixUserId: user.matrixUserId, + stripeCustomerId: user.stripeCustomerId, + stripeCustomerEmail: user.stripeCustomerEmail, + creditsAvailableInPlanAllowance: null, + creditsIncludedInPlanAllowance: null, + extraCreditsAvailableInBalance: 0, + lowCreditThreshold: 2000, + lastDailyCreditGrantAt: null, + nextDailyCreditGrantAt: json.data.attributes.nextDailyCreditGrantAt, + dailyCreditGrantCount: 0, + }, + relationships: { + subscription: null, + }, + }, + included: [ + { + type: 'plan', + id: 'free', + attributes: { + name: 'Free', + monthlyPrice: 0, + creditsIncluded: 0, + }, + }, + ], + }); + }); + it('sends billing notification on invoice payment succeeded event', async function () { + const secret = process.env.STRIPE_WEBHOOK_SECRET; + await insertPlan(dbAdapter, 'Free plan', 0, 100, 'prod_free'); + if (!secret) { + throw new Error('STRIPE_WEBHOOK_SECRET is not set'); + } + let event = { + id: 'evt_1234567890', + object: 'event', + type: 'invoice.payment_succeeded', + data: { + object: { + id: 'in_1234567890', + object: 'invoice', + amount_paid: 0, // free plan + billing_reason: 'subscription_create', + period_end: 1638465600, + period_start: 1635873600, + subscription: 'sub_1234567890', + customer: 'cus_123', + lines: { + data: [ + { + amount: 0, + type: 'subscription', + proration: false, + price: { product: 'prod_free' }, + period: { start: 1635873600, end: 1638465600 }, + }, + ], + }, + }, + }, + }; + let payload = JSON.stringify(event); + let timestamp = Math.floor(Date.now() / 1000); + let signature = Stripe.webhooks.generateTestHeaderString({ + payload, + secret, + timestamp, + }); + await request + .post('/_stripe-webhook') + .send(payload) + .set('Accept', 'application/json') + .set('Content-Type', 'application/json') + .set('stripe-signature', signature); + await waitForBillingNotification(); + }); + it('sends billing notification on checkout session completed event', async function () { + const secret = process.env.STRIPE_WEBHOOK_SECRET; + await insertPlan(dbAdapter, 'Free plan', 0, 100, 'prod_free'); + if (!secret) { + throw new Error('STRIPE_WEBHOOK_SECRET is not set'); + } + let event = { + id: 'evt_1234567890', + object: 'event', + data: { + object: { + id: 'cs_test_1234567890', + object: 'checkout.session', + customer: 'cus_123', + metadata: { + user_id: user.id, + }, + }, + }, + type: 'checkout.session.completed', + }; + let payload = JSON.stringify(event); + let timestamp = Math.floor(Date.now() / 1000); + let signature = Stripe.webhooks.generateTestHeaderString({ + payload, + secret, + timestamp, + }); + await request + .post('/_stripe-webhook') + .send(payload) + .set('Accept', 'application/json') + .set('Content-Type', 'application/json') + .set('stripe-signature', signature); + await waitForBillingNotification(); + }); + }); + }); +}); +function messageEventContentIsRealmServerEvent(content: MatrixEvent['content']): content is RealmServerEventContent { + return ('msgtype' in content && + (content.msgtype as string) === APP_BOXEL_REALM_SERVER_EVENT_MSGTYPE); +} diff --git a/packages/realm-server/tests-vitest/server-endpoints/user-and-catalog.test.ts b/packages/realm-server/tests-vitest/server-endpoints/user-and-catalog.test.ts new file mode 100644 index 00000000000..31b714e569b --- /dev/null +++ b/packages/realm-server/tests-vitest/server-endpoints/user-and-catalog.test.ts @@ -0,0 +1,95 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import { getUserByMatrixUserId } from '@cardstack/billing/billing-queries'; +import { realmSecretSeed, testRealmInfo } from '../helpers'; +import { createJWT as createRealmServerJWT } from '../../utils/jwt'; +import { setupServerEndpointsTest, testRealmURL } from './helpers'; +import '@cardstack/runtime-common/helpers/code-equality-assertion'; +describe("server-endpoints/user-and-catalog-test.ts", function () { + describe('Realm Server Endpoints (not specific to one realm)', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let context = setupServerEndpointsTest(hooks); + let originalLowCreditThreshold: string | undefined; + hooks.beforeEach(function () { + originalLowCreditThreshold = process.env.LOW_CREDIT_THRESHOLD; + process.env.LOW_CREDIT_THRESHOLD = '2000'; + }); + hooks.afterEach(function () { + if (originalLowCreditThreshold == null) { + delete process.env.LOW_CREDIT_THRESHOLD; + } + else { + process.env.LOW_CREDIT_THRESHOLD = originalLowCreditThreshold; + } + }); + it('can create a user', async function () { + let ownerUserId = '@mango-new:localhost'; + let response = await context.request + .post('/_user') + .set('Accept', 'application/json') + .set('Content-Type', 'application/json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: ownerUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'user', + attributes: { + registrationToken: 'reg_token_123', + }, + }, + }); + expect(response.status).toBe(200); + expect(response.text).toBe('ok'); + let user = await getUserByMatrixUserId(context.dbAdapter, ownerUserId); + if (!user) { + throw new Error('user does not exist in db'); + } + expect(user.matrixUserId).toBe(ownerUserId); + expect(user.matrixRegistrationToken).toBe('reg_token_123'); + }); + it('can not create a user without a jwt', async function () { + let response = await context.request.post('/_user').send({}); + expect(response.status).toBe(401); + }); + it('can fetch catalog realms', async function () { + let response = await context.request + .get('/_catalog-realms') + .set('Accept', 'application/json'); + expect(response.status).toBe(200); + expect(response.body).toEqual({ + data: [ + { + type: 'catalog-realm', + id: `${testRealmURL}`, + attributes: { + ...testRealmInfo, + }, + }, + ], + }); + }); + it(`returns 200 with empty data if failed to fetch catalog realm's info`, async function () { + let failedRealmInfoMock = async (req: Request) => { + if (req.url.includes('_info')) { + return new Response('Failed to fetch realm info', { + status: 500, + statusText: 'Internal Server Error', + }); + } + return null; + }; + context.virtualNetwork.mount(failedRealmInfoMock, { prepend: true }); + let response = await context.request + .get('/_catalog-realms') + .set('Accept', 'application/json'); + expect(response.status).toBe(200); + expect(response.body).toEqual({ + data: [], + }); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/server-endpoints/webhook-commands.test.ts b/packages/realm-server/tests-vitest/server-endpoints/webhook-commands.test.ts new file mode 100644 index 00000000000..c3b7fc10e4f --- /dev/null +++ b/packages/realm-server/tests-vitest/server-endpoints/webhook-commands.test.ts @@ -0,0 +1,520 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import { createJWT as createRealmServerJWT } from '../../utils/jwt'; +import { realmSecretSeed, insertUser } from '../helpers'; +import { param, query, uuidv4 } from '@cardstack/runtime-common'; +import { setupServerEndpointsTest } from './helpers'; +describe("server-endpoints/webhook-commands-test.ts", function () { + describe('Webhook Command Endpoints', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let context = setupServerEndpointsTest(hooks); + it('requires auth to add webhook command', async function () { + let response = await context.request.post('/_webhook-commands').send({}); + expect(response.status).toBe(401); + }); + it('requires auth to list webhook commands', async function () { + let response = await context.request.get('/_webhook-commands'); + expect(response.status).toBe(401); + }); + it('requires auth to delete webhook command', async function () { + let response = await context.request.delete('/_webhook-commands').send({ + data: { + type: 'webhook-command', + id: uuidv4(), + }, + }); + expect(response.status).toBe(401); + }); + it('can add webhook command for own webhook', async function () { + let matrixUserId = '@user:localhost'; + await insertUser(context.dbAdapter, matrixUserId, 'cus_123', 'user@example.com'); + let incomingWebhookId = uuidv4(); + await query(context.dbAdapter, [ + `INSERT INTO incoming_webhooks (id, username, webhook_path, verification_type, verification_config, signing_secret, created_at, updated_at) VALUES (`, + param(incomingWebhookId), + `,`, + param(matrixUserId), + `,`, + param('whk_test1'), + `,`, + param('HMAC_SHA256_HEADER'), + `,`, + `'{"header": "X-Hub-Signature-256", "encoding": "hex"}'::jsonb`, + `,`, + param('testsecret'), + `,`, + `CURRENT_TIMESTAMP`, + `,`, + `CURRENT_TIMESTAMP`, + `)`, + ]); + let response = await context.request + .post('/_webhook-commands') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'webhook-command', + attributes: { + incomingWebhookId, + command: 'https://example.com/webhook-handler', + filter: { + eventType: 'pull_request', + }, + }, + }, + }); + expect(response.status).toBe(201); + expect(response.body.data.id).toBeTruthy(); + expect(response.body.data.attributes.incomingWebhookId).toBe(incomingWebhookId); + expect(response.body.data.attributes.command).toBe('https://example.com/webhook-handler'); + expect(response.body.data.attributes.filter).toEqual({ eventType: 'pull_request' }); + expect(response.body.data.attributes.createdAt).toBeTruthy(); + expect(response.body.data.attributes.updatedAt).toBeTruthy(); + let rows = await context.dbAdapter.execute(`SELECT id, incoming_webhook_id, command, command_filter, created_at, updated_at FROM webhook_commands`); + expect(rows.length).toBe(1); + expect(rows[0].incoming_webhook_id).toBe(incomingWebhookId); + expect(rows[0].command).toBe('https://example.com/webhook-handler'); + }); + it('can add webhook command with null filter', async function () { + let matrixUserId = '@user:localhost'; + await insertUser(context.dbAdapter, matrixUserId, 'cus_123', 'user@example.com'); + let incomingWebhookId = uuidv4(); + await query(context.dbAdapter, [ + `INSERT INTO incoming_webhooks (id, username, webhook_path, verification_type, verification_config, signing_secret, created_at, updated_at) VALUES (`, + param(incomingWebhookId), + `,`, + param(matrixUserId), + `,`, + param('whk_test2'), + `,`, + param('HMAC_SHA256_HEADER'), + `,`, + `'{"header": "X-Hub-Signature-256", "encoding": "hex"}'::jsonb`, + `,`, + param('testsecret'), + `,`, + `CURRENT_TIMESTAMP`, + `,`, + `CURRENT_TIMESTAMP`, + `)`, + ]); + let response = await context.request + .post('/_webhook-commands') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'webhook-command', + attributes: { + incomingWebhookId, + command: 'https://example.com/webhook-handler', + }, + }, + }); + expect(response.status).toBe(201); + expect(response.body.data.attributes.filter).toBe(null); + }); + it('rejects webhook command for another user webhook', async function () { + let matrixUserId = '@user:localhost'; + let otherMatrixUserId = '@other-user:localhost'; + await insertUser(context.dbAdapter, matrixUserId, 'cus_123', 'user@example.com'); + await insertUser(context.dbAdapter, otherMatrixUserId, 'cus_124', 'other@example.com'); + let incomingWebhookId = uuidv4(); + await query(context.dbAdapter, [ + `INSERT INTO incoming_webhooks (id, username, webhook_path, verification_type, verification_config, signing_secret, created_at, updated_at) VALUES (`, + param(incomingWebhookId), + `,`, + param(otherMatrixUserId), + `,`, + param('whk_other1'), + `,`, + param('HMAC_SHA256_HEADER'), + `,`, + `'{"header": "X-Hub-Signature-256", "encoding": "hex"}'::jsonb`, + `,`, + param('testsecret'), + `,`, + `CURRENT_TIMESTAMP`, + `,`, + `CURRENT_TIMESTAMP`, + `)`, + ]); + let response = await context.request + .post('/_webhook-commands') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'webhook-command', + attributes: { + incomingWebhookId, + command: 'https://example.com/webhook-handler', + filter: { eventType: 'push' }, + }, + }, + }); + expect(response.status).toBe(403); + }); + it('rejects webhook command when webhook is not found', async function () { + let matrixUserId = '@user:localhost'; + await insertUser(context.dbAdapter, matrixUserId, 'cus_123', 'user@example.com'); + let response = await context.request + .post('/_webhook-commands') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'webhook-command', + attributes: { + incomingWebhookId: uuidv4(), + command: 'https://example.com/webhook-handler', + filter: { eventType: 'push' }, + }, + }, + }); + expect(response.status).toBe(404); + }); + it('rejects webhook command with invalid incomingWebhookId', async function () { + let matrixUserId = '@user:localhost'; + await insertUser(context.dbAdapter, matrixUserId, 'cus_123', 'user@example.com'); + let response = await context.request + .post('/_webhook-commands') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'webhook-command', + attributes: { + incomingWebhookId: 'not-a-uuid', + command: 'https://example.com/webhook-handler', + filter: { eventType: 'push' }, + }, + }, + }); + expect(response.status).toBe(400); + }); + it('rejects invalid command URL', async function () { + let matrixUserId = '@user:localhost'; + await insertUser(context.dbAdapter, matrixUserId, 'cus_123', 'user@example.com'); + let incomingWebhookId = uuidv4(); + await query(context.dbAdapter, [ + `INSERT INTO incoming_webhooks (id, username, webhook_path, verification_type, verification_config, signing_secret, created_at, updated_at) VALUES (`, + param(incomingWebhookId), + `,`, + param(matrixUserId), + `,`, + param('whk_test3'), + `,`, + param('HMAC_SHA256_HEADER'), + `,`, + `'{"header": "X-Hub-Signature-256", "encoding": "hex"}'::jsonb`, + `,`, + param('testsecret'), + `,`, + `CURRENT_TIMESTAMP`, + `,`, + `CURRENT_TIMESTAMP`, + `)`, + ]); + let response = await context.request + .post('/_webhook-commands') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'webhook-command', + attributes: { + incomingWebhookId, + command: ' ', + filter: { eventType: 'push' }, + }, + }, + }); + expect(response.status).toBe(400); + }); + it('lists webhook commands for authenticated user', async function () { + let matrixUserId = '@user:localhost'; + let otherMatrixUserId = '@other-user:localhost'; + await insertUser(context.dbAdapter, matrixUserId, 'cus_123', 'user@example.com'); + await insertUser(context.dbAdapter, otherMatrixUserId, 'cus_124', 'other@example.com'); + let incomingWebhookId = uuidv4(); + let otherIncomingWebhookId = uuidv4(); + await query(context.dbAdapter, [ + `INSERT INTO incoming_webhooks (id, username, webhook_path, verification_type, verification_config, signing_secret, created_at, updated_at) VALUES (`, + param(incomingWebhookId), + `,`, + param(matrixUserId), + `,`, + param('whk_list1'), + `,`, + param('HMAC_SHA256_HEADER'), + `,`, + `'{"header": "X-Hub-Signature-256", "encoding": "hex"}'::jsonb`, + `,`, + param('testsecret'), + `,`, + `CURRENT_TIMESTAMP`, + `,`, + `CURRENT_TIMESTAMP`, + `)`, + ]); + await query(context.dbAdapter, [ + `INSERT INTO incoming_webhooks (id, username, webhook_path, verification_type, verification_config, signing_secret, created_at, updated_at) VALUES (`, + param(otherIncomingWebhookId), + `,`, + param(otherMatrixUserId), + `,`, + param('whk_list2'), + `,`, + param('HMAC_SHA256_HEADER'), + `,`, + `'{"header": "X-Hub-Signature-256", "encoding": "hex"}'::jsonb`, + `,`, + param('testsecret'), + `,`, + `CURRENT_TIMESTAMP`, + `,`, + `CURRENT_TIMESTAMP`, + `)`, + ]); + await query(context.dbAdapter, [ + `INSERT INTO webhook_commands (id, incoming_webhook_id, command, command_filter, created_at, updated_at) VALUES (`, + param(uuidv4()), + `,`, + param(incomingWebhookId), + `,`, + param('https://example.com/handler-1'), + `,`, + `'{"eventType": "pull_request"}'::jsonb`, + `,`, + `CURRENT_TIMESTAMP`, + `,`, + `CURRENT_TIMESTAMP`, + `)`, + ]); + await query(context.dbAdapter, [ + `INSERT INTO webhook_commands (id, incoming_webhook_id, command, command_filter, created_at, updated_at) VALUES (`, + param(uuidv4()), + `,`, + param(otherIncomingWebhookId), + `,`, + param('https://example.com/handler-2'), + `,`, + `'{"eventType": "push"}'::jsonb`, + `,`, + `CURRENT_TIMESTAMP`, + `,`, + `CURRENT_TIMESTAMP`, + `)`, + ]); + let response = await context.request + .get('/_webhook-commands') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`); + expect(response.status).toBe(200); + expect(response.body.data.length).toBe(1); + expect(response.body.data[0].attributes.incomingWebhookId).toBe(incomingWebhookId); + }); + it('can filter webhook commands by incomingWebhookId', async function () { + let matrixUserId = '@user:localhost'; + await insertUser(context.dbAdapter, matrixUserId, 'cus_123', 'user@example.com'); + let webhookId1 = uuidv4(); + let webhookId2 = uuidv4(); + await query(context.dbAdapter, [ + `INSERT INTO incoming_webhooks (id, username, webhook_path, verification_type, verification_config, signing_secret, created_at, updated_at) VALUES (`, + param(webhookId1), + `,`, + param(matrixUserId), + `,`, + param('whk_filter1'), + `,`, + param('HMAC_SHA256_HEADER'), + `,`, + `'{"header": "X-Hub-Signature-256", "encoding": "hex"}'::jsonb`, + `,`, + param('testsecret'), + `,`, + `CURRENT_TIMESTAMP`, + `,`, + `CURRENT_TIMESTAMP`, + `)`, + ]); + await query(context.dbAdapter, [ + `INSERT INTO incoming_webhooks (id, username, webhook_path, verification_type, verification_config, signing_secret, created_at, updated_at) VALUES (`, + param(webhookId2), + `,`, + param(matrixUserId), + `,`, + param('whk_filter2'), + `,`, + param('HMAC_SHA256_HEADER'), + `,`, + `'{"header": "X-Hub-Signature-256", "encoding": "hex"}'::jsonb`, + `,`, + param('testsecret'), + `,`, + `CURRENT_TIMESTAMP`, + `,`, + `CURRENT_TIMESTAMP`, + `)`, + ]); + await query(context.dbAdapter, [ + `INSERT INTO webhook_commands (id, incoming_webhook_id, command, command_filter, created_at, updated_at) VALUES (`, + param(uuidv4()), + `,`, + param(webhookId1), + `,`, + param('https://example.com/handler-1'), + `,`, + `'{"eventType": "pull_request"}'::jsonb`, + `,`, + `CURRENT_TIMESTAMP`, + `,`, + `CURRENT_TIMESTAMP`, + `)`, + ]); + await query(context.dbAdapter, [ + `INSERT INTO webhook_commands (id, incoming_webhook_id, command, command_filter, created_at, updated_at) VALUES (`, + param(uuidv4()), + `,`, + param(webhookId2), + `,`, + param('https://example.com/handler-2'), + `,`, + `'{"eventType": "push"}'::jsonb`, + `,`, + `CURRENT_TIMESTAMP`, + `,`, + `CURRENT_TIMESTAMP`, + `)`, + ]); + let response = await context.request + .get(`/_webhook-commands?incomingWebhookId=${webhookId1}`) + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`); + expect(response.status).toBe(200); + expect(response.body.data.length).toBe(1); + expect(response.body.data[0].attributes.incomingWebhookId).toBe(webhookId1); + }); + it('can delete own webhook command', async function () { + let matrixUserId = '@user:localhost'; + await insertUser(context.dbAdapter, matrixUserId, 'cus_123', 'user@example.com'); + let incomingWebhookId = uuidv4(); + await query(context.dbAdapter, [ + `INSERT INTO incoming_webhooks (id, username, webhook_path, verification_type, verification_config, signing_secret, created_at, updated_at) VALUES (`, + param(incomingWebhookId), + `,`, + param(matrixUserId), + `,`, + param('whk_del1'), + `,`, + param('HMAC_SHA256_HEADER'), + `,`, + `'{"header": "X-Hub-Signature-256", "encoding": "hex"}'::jsonb`, + `,`, + param('testsecret'), + `,`, + `CURRENT_TIMESTAMP`, + `,`, + `CURRENT_TIMESTAMP`, + `)`, + ]); + let webhookCommandId = uuidv4(); + await query(context.dbAdapter, [ + `INSERT INTO webhook_commands (id, incoming_webhook_id, command, command_filter, created_at, updated_at) VALUES (`, + param(webhookCommandId), + `,`, + param(incomingWebhookId), + `,`, + param('https://example.com/handler'), + `,`, + `'{"eventType": "push"}'::jsonb`, + `,`, + `CURRENT_TIMESTAMP`, + `,`, + `CURRENT_TIMESTAMP`, + `)`, + ]); + let response = await context.request + .delete('/_webhook-commands') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'webhook-command', + id: webhookCommandId, + }, + }); + expect(response.status).toBe(204); + let rows = await context.dbAdapter.execute(`SELECT id FROM webhook_commands WHERE id = '${webhookCommandId}'`); + expect(rows.length).toBe(0); + }); + it('rejects deleting another user webhook command', async function () { + let matrixUserId = '@user:localhost'; + let otherMatrixUserId = '@other-user:localhost'; + await insertUser(context.dbAdapter, matrixUserId, 'cus_123', 'user@example.com'); + await insertUser(context.dbAdapter, otherMatrixUserId, 'cus_124', 'other@example.com'); + let incomingWebhookId = uuidv4(); + await query(context.dbAdapter, [ + `INSERT INTO incoming_webhooks (id, username, webhook_path, verification_type, verification_config, signing_secret, created_at, updated_at) VALUES (`, + param(incomingWebhookId), + `,`, + param(otherMatrixUserId), + `,`, + param('whk_delforbid1'), + `,`, + param('HMAC_SHA256_HEADER'), + `,`, + `'{"header": "X-Hub-Signature-256", "encoding": "hex"}'::jsonb`, + `,`, + param('testsecret'), + `,`, + `CURRENT_TIMESTAMP`, + `,`, + `CURRENT_TIMESTAMP`, + `)`, + ]); + let webhookCommandId = uuidv4(); + await query(context.dbAdapter, [ + `INSERT INTO webhook_commands (id, incoming_webhook_id, command, command_filter, created_at, updated_at) VALUES (`, + param(webhookCommandId), + `,`, + param(incomingWebhookId), + `,`, + param('https://example.com/handler'), + `,`, + `'{"eventType": "push"}'::jsonb`, + `,`, + `CURRENT_TIMESTAMP`, + `,`, + `CURRENT_TIMESTAMP`, + `)`, + ]); + let response = await context.request + .delete('/_webhook-commands') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'webhook-command', + id: webhookCommandId, + }, + }); + expect(response.status).toBe(403); + let rows = await context.dbAdapter.execute(`SELECT id FROM webhook_commands WHERE id = '${webhookCommandId}'`); + expect(rows.length).toBe(1); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/server-endpoints/webhook-receiver.test.ts b/packages/realm-server/tests-vitest/server-endpoints/webhook-receiver.test.ts new file mode 100644 index 00000000000..3acc1c9ea29 --- /dev/null +++ b/packages/realm-server/tests-vitest/server-endpoints/webhook-receiver.test.ts @@ -0,0 +1,374 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import { createHmac } from 'crypto'; +import { createJWT as createRealmServerJWT } from '../../utils/jwt'; +import { realmSecretSeed, insertUser } from '../helpers'; +import { param, query, uuidv4 } from '@cardstack/runtime-common'; +import { setupServerEndpointsTest } from './helpers'; +describe("server-endpoints/webhook-receiver-test.ts", function () { + describe('Webhook Receiver Endpoint', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let context = setupServerEndpointsTest(hooks); + it('returns 404 for unknown webhook path', async function () { + let response = await context.request + .post('/_webhooks/whk_nonexistent') + .set('Content-Type', 'application/json') + .send(JSON.stringify({ event: 'test' })); + expect(response.status).toBe(404); + }); + it('returns 401 for invalid HMAC signature', async function () { + let matrixUserId = '@user:localhost'; + await insertUser(context.dbAdapter, matrixUserId, 'cus_123', 'user@example.com'); + let createResponse = await context.request + .post('/_incoming-webhooks') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'incoming-webhook', + attributes: { + verificationType: 'HMAC_SHA256_HEADER', + verificationConfig: { + header: 'X-Hub-Signature-256', + encoding: 'hex', + }, + }, + }, + }); + let webhookPath = createResponse.body.data.attributes.webhookPath; + let payload = JSON.stringify({ event: 'push', ref: 'refs/heads/main' }); + let response = await context.request + .post(`/_webhooks/${webhookPath}`) + .set('Content-Type', 'application/json') + .set('X-Hub-Signature-256', 'sha256=invalidsignature') + .send(payload); + expect(response.status).toBe(401); + }); + it('returns 200 for valid HMAC_SHA256_HEADER signature with hex encoding', async function () { + let matrixUserId = '@user:localhost'; + await insertUser(context.dbAdapter, matrixUserId, 'cus_123', 'user@example.com'); + let createResponse = await context.request + .post('/_incoming-webhooks') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'incoming-webhook', + attributes: { + verificationType: 'HMAC_SHA256_HEADER', + verificationConfig: { + header: 'X-Hub-Signature-256', + encoding: 'hex', + }, + }, + }, + }); + let webhookPath = createResponse.body.data.attributes.webhookPath; + let signingSecret = createResponse.body.data.attributes.signingSecret; + let payload = JSON.stringify({ event: 'push', ref: 'refs/heads/main' }); + let signature = 'sha256=' + + createHmac('sha256', signingSecret) + .update(payload, 'utf8') + .digest('hex'); + let response = await context.request + .post(`/_webhooks/${webhookPath}`) + .set('Content-Type', 'application/json') + .set('X-Hub-Signature-256', signature) + .send(payload); + expect(response.status).toBe(200); + expect(response.body).toEqual({ status: 'received', commandsExecuted: 0 }); + }); + it('returns 200 for valid signature with base64 encoding', async function () { + let matrixUserId = '@user:localhost'; + await insertUser(context.dbAdapter, matrixUserId, 'cus_123', 'user@example.com'); + let createResponse = await context.request + .post('/_incoming-webhooks') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'incoming-webhook', + attributes: { + verificationType: 'HMAC_SHA256_HEADER', + verificationConfig: { + header: 'X-Shopify-Hmac-SHA256', + encoding: 'base64', + }, + }, + }, + }); + let webhookPath = createResponse.body.data.attributes.webhookPath; + let signingSecret = createResponse.body.data.attributes.signingSecret; + let payload = JSON.stringify({ + event: 'order_created', + id: 12345, + }); + let signature = createHmac('sha256', signingSecret) + .update(payload, 'utf8') + .digest('base64'); + let response = await context.request + .post(`/_webhooks/${webhookPath}`) + .set('Content-Type', 'application/json') + .set('X-Shopify-Hmac-SHA256', signature) + .send(payload); + expect(response.status).toBe(200); + }); + it('returns 401 when signature header is missing', async function () { + let matrixUserId = '@user:localhost'; + await insertUser(context.dbAdapter, matrixUserId, 'cus_123', 'user@example.com'); + let webhookId = uuidv4(); + await query(context.dbAdapter, [ + `INSERT INTO incoming_webhooks (id, username, webhook_path, verification_type, verification_config, signing_secret, created_at, updated_at) VALUES (`, + param(webhookId), + `,`, + param(matrixUserId), + `,`, + param('whk_noheader'), + `,`, + param('HMAC_SHA256_HEADER'), + `,`, + `'{"header": "X-Hub-Signature-256", "encoding": "hex"}'::jsonb`, + `,`, + param('testsecret123'), + `,`, + `CURRENT_TIMESTAMP`, + `,`, + `CURRENT_TIMESTAMP`, + `)`, + ]); + let payload = JSON.stringify({ event: 'test' }); + let response = await context.request + .post('/_webhooks/whk_noheader') + .set('Content-Type', 'application/json') + .send(payload); + expect(response.status).toBe(401); + }); + it('is a public endpoint (no JWT required)', async function () { + let matrixUserId = '@user:localhost'; + await insertUser(context.dbAdapter, matrixUserId, 'cus_123', 'user@example.com'); + let createResponse = await context.request + .post('/_incoming-webhooks') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'incoming-webhook', + attributes: { + verificationType: 'HMAC_SHA256_HEADER', + verificationConfig: { + header: 'X-Hub-Signature-256', + encoding: 'hex', + }, + }, + }, + }); + let webhookPath = createResponse.body.data.attributes.webhookPath; + let signingSecret = createResponse.body.data.attributes.signingSecret; + let payload = JSON.stringify({ test: true }); + let signature = 'sha256=' + + createHmac('sha256', signingSecret) + .update(payload, 'utf8') + .digest('hex'); + // No Authorization header set - this should still work + let response = await context.request + .post(`/_webhooks/${webhookPath}`) + .set('Content-Type', 'application/json') + .set('X-Hub-Signature-256', signature) + .send(payload); + // Should be 200 (not 401 from JWT middleware) + expect(response.status).toBe(200); + }); + it('executes webhook command when signature is valid', async function () { + let matrixUserId = '@user:localhost'; + await insertUser(context.dbAdapter, matrixUserId, 'cus_123', 'user@example.com'); + // Create webhook + let createWebhookResponse = await context.request + .post('/_incoming-webhooks') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'incoming-webhook', + attributes: { + verificationType: 'HMAC_SHA256_HEADER', + verificationConfig: { + header: 'X-Hub-Signature-256', + encoding: 'hex', + }, + }, + }, + }); + let webhookId = createWebhookResponse.body.data.id; + let webhookPath = createWebhookResponse.body.data.attributes.webhookPath; + let signingSecret = createWebhookResponse.body.data.attributes.signingSecret; + // Register webhook command + await context.request + .post('/_webhook-commands') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'webhook-command', + attributes: { + incomingWebhookId: webhookId, + command: `http://test-realm/commands/process-github-event`, + filter: null, + }, + }, + }); + // Send webhook with valid signature + let payload = JSON.stringify({ + action: 'opened', + pull_request: { number: 123 }, + }); + let signature = 'sha256=' + + createHmac('sha256', signingSecret) + .update(payload, 'utf8') + .digest('hex'); + let response = await context.request + .post(`/_webhooks/${webhookPath}`) + .set('Content-Type', 'application/json') + .set('X-Hub-Signature-256', signature) + .set('X-GitHub-Event', 'pull_request') + .send(payload); + expect(response.status).toBe(200); + expect(response.body.status).toBe('received'); + expect(response.body.commandsExecuted).toBe(1); + }); + it('filters commands by event type', async function () { + let matrixUserId = '@user:localhost'; + await insertUser(context.dbAdapter, matrixUserId, 'cus_123', 'user@example.com'); + // Create webhook + let createWebhookResponse = await context.request + .post('/_incoming-webhooks') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'incoming-webhook', + attributes: { + verificationType: 'HMAC_SHA256_HEADER', + verificationConfig: { + header: 'X-Hub-Signature-256', + encoding: 'hex', + }, + }, + }, + }); + let webhookId = createWebhookResponse.body.data.id; + let webhookPath = createWebhookResponse.body.data.attributes.webhookPath; + let signingSecret = createWebhookResponse.body.data.attributes.signingSecret; + // Register command filtered to 'push' events only + await context.request + .post('/_webhook-commands') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`) + .send({ + data: { + type: 'webhook-command', + attributes: { + incomingWebhookId: webhookId, + command: `http://test-realm/commands/process-github-event`, + filter: { type: 'github-event', eventType: 'push' }, + }, + }, + }); + // Send 'pull_request' event (should NOT execute command) + let payload = JSON.stringify({ + action: 'opened', + pull_request: { number: 123 }, + }); + let signature = 'sha256=' + + createHmac('sha256', signingSecret) + .update(payload, 'utf8') + .digest('hex'); + let response = await context.request + .post(`/_webhooks/${webhookPath}`) + .set('Content-Type', 'application/json') + .set('X-Hub-Signature-256', signature) + .set('X-GitHub-Event', 'pull_request') + .send(payload); + expect(response.status).toBe(200); + expect(response.body.commandsExecuted).toBe(0); + }); + it('command matched by eventType filter is pending execution', async function () { + let matrixUserId = '@user:localhost'; + await insertUser(context.dbAdapter, matrixUserId, 'cus_123', 'user@example.com'); + let jwt = `Bearer ${createRealmServerJWT({ user: matrixUserId, sessionRoom: 'session-room-test' }, realmSecretSeed)}`; + let createWebhookResponse = await context.request + .post('/_incoming-webhooks') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', jwt) + .send({ + data: { + type: 'incoming-webhook', + attributes: { + verificationType: 'HMAC_SHA256_HEADER', + verificationConfig: { + header: 'X-Hub-Signature-256', + encoding: 'hex', + }, + }, + }, + }); + let webhookId = createWebhookResponse.body.data.id; + let webhookPath = createWebhookResponse.body.data.attributes.webhookPath; + let signingSecret = createWebhookResponse.body.data.attributes.signingSecret; + // Register command with roomId and realm in filter + await context.request + .post('/_webhook-commands') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', jwt) + .send({ + data: { + type: 'webhook-command', + attributes: { + incomingWebhookId: webhookId, + command: `http://test-realm/commands/process-github-event`, + filter: { + type: 'github-event', + eventType: 'pull_request', + roomId: '!room:localhost', + realm: 'http://localhost:4201/submissions/', + }, + }, + }, + }); + let payload = JSON.stringify({ + action: 'opened', + pull_request: { + number: 42, + html_url: 'https://github.com/test/repo/pull/42', + }, + sender: { login: 'testuser' }, + }); + let signature = 'sha256=' + + createHmac('sha256', signingSecret) + .update(payload, 'utf8') + .digest('hex'); + let response = await context.request + .post(`/_webhooks/${webhookPath}`) + .set('Content-Type', 'application/json') + .set('X-Hub-Signature-256', signature) + .set('X-GitHub-Event', 'pull_request') + .send(payload); + expect(response.status).toBe(200); + expect(response.body.commandsExecuted).toBe(1); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/session-room-queries.test.ts b/packages/realm-server/tests-vitest/session-room-queries.test.ts new file mode 100644 index 00000000000..b8d404a2b1c --- /dev/null +++ b/packages/realm-server/tests-vitest/session-room-queries.test.ts @@ -0,0 +1,186 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import type { PgAdapter } from '@cardstack/postgres'; +import { insertPermissions } from '@cardstack/runtime-common'; +import { clearSessionRoom, fetchSessionRoom, fetchRealmSessionRooms, upsertSessionRoom, } from '@cardstack/runtime-common/db-queries/session-room-queries'; +import { setupDB, insertUser } from './helpers'; +describe("session-room-queries-test.ts", function () { + describe('fetchRealmSessionRooms', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let dbAdapter: PgAdapter; + const realmURL = new URL('http://127.0.0.1:4444/test/'); + setupDB(hooks, { + beforeEach: async (_dbAdapter) => { + dbAdapter = _dbAdapter; + }, + }); + it('returns users with explicit read permission for the realm', async function () { + await insertUser(dbAdapter, '@alice:localhost', 'cus_alice', 'alice@example.com'); + await upsertSessionRoom(dbAdapter, '@alice:localhost', '!room-alice:localhost'); + await insertPermissions(dbAdapter, realmURL, { + '@alice:localhost': ['read'], + }); + let result = await fetchRealmSessionRooms(dbAdapter, realmURL.href); + expect(result).toEqual({ + '@alice:localhost': '!room-alice:localhost', + }); + }); + it('returns users with explicit write permission for the realm', async function () { + await insertUser(dbAdapter, '@bob:localhost', 'cus_bob', 'bob@example.com'); + await upsertSessionRoom(dbAdapter, '@bob:localhost', '!room-bob:localhost'); + await insertPermissions(dbAdapter, realmURL, { + '@bob:localhost': ['write'], + }); + let result = await fetchRealmSessionRooms(dbAdapter, realmURL.href); + expect(result).toEqual({ + '@bob:localhost': '!room-bob:localhost', + }); + }); + it('returns multiple users with different permissions', async function () { + await insertUser(dbAdapter, '@alice:localhost', 'cus_alice', 'alice@example.com'); + await upsertSessionRoom(dbAdapter, '@alice:localhost', '!room-alice:localhost'); + await insertUser(dbAdapter, '@bob:localhost', 'cus_bob', 'bob@example.com'); + await upsertSessionRoom(dbAdapter, '@bob:localhost', '!room-bob:localhost'); + await insertPermissions(dbAdapter, realmURL, { + '@alice:localhost': ['read'], + '@bob:localhost': ['read', 'write'], + }); + let result = await fetchRealmSessionRooms(dbAdapter, realmURL.href); + expect(result).toEqual({ + '@alice:localhost': '!room-alice:localhost', + '@bob:localhost': '!room-bob:localhost', + }); + }); + it('excludes users without permission on a non-world-readable realm', async function () { + await insertUser(dbAdapter, '@alice:localhost', 'cus_alice', 'alice@example.com'); + await upsertSessionRoom(dbAdapter, '@alice:localhost', '!room-alice:localhost'); + await insertUser(dbAdapter, '@eve:localhost', 'cus_eve', 'eve@example.com'); + await upsertSessionRoom(dbAdapter, '@eve:localhost', '!room-eve:localhost'); + // Only alice has permission, not eve + await insertPermissions(dbAdapter, realmURL, { + '@alice:localhost': ['read'], + }); + let result = await fetchRealmSessionRooms(dbAdapter, realmURL.href); + expect(result).toEqual({ + '@alice:localhost': '!room-alice:localhost', + }); + }); + it('excludes users without a session room even when they have permissions', async function () { + // alice has a session room + await insertUser(dbAdapter, '@alice:localhost', 'cus_alice', 'alice@example.com'); + await upsertSessionRoom(dbAdapter, '@alice:localhost', '!room-alice:localhost'); + // bob does NOT have a session room (never authenticated via realm-auth) + await insertUser(dbAdapter, '@bob:localhost', 'cus_bob', 'bob@example.com'); + await insertPermissions(dbAdapter, realmURL, { + '@alice:localhost': ['read'], + '@bob:localhost': ['read'], + }); + let result = await fetchRealmSessionRooms(dbAdapter, realmURL.href); + expect(result).toEqual({ + '@alice:localhost': '!room-alice:localhost', + }); + }); + it('returns all users with session rooms when the realm is world-readable', async function () { + // Create multiple users with session rooms + await insertUser(dbAdapter, '@alice:localhost', 'cus_alice', 'alice@example.com'); + await upsertSessionRoom(dbAdapter, '@alice:localhost', '!room-alice:localhost'); + await insertUser(dbAdapter, '@bob:localhost', 'cus_bob', 'bob@example.com'); + await upsertSessionRoom(dbAdapter, '@bob:localhost', '!room-bob:localhost'); + await insertUser(dbAdapter, '@charlie:localhost', 'cus_charlie', 'charlie@example.com'); + await upsertSessionRoom(dbAdapter, '@charlie:localhost', '!room-charlie:localhost'); + // World-readable realm: username='*' with read=true + // No per-user permission rows for alice, bob, or charlie + await insertPermissions(dbAdapter, realmURL, { + '*': ['read'], + }); + let result = await fetchRealmSessionRooms(dbAdapter, realmURL.href); + expect(result).toEqual({ + '@alice:localhost': '!room-alice:localhost', + '@bob:localhost': '!room-bob:localhost', + '@charlie:localhost': '!room-charlie:localhost', + }); + }); + it('world-readable realm still excludes users without session rooms', async function () { + await insertUser(dbAdapter, '@alice:localhost', 'cus_alice', 'alice@example.com'); + await upsertSessionRoom(dbAdapter, '@alice:localhost', '!room-alice:localhost'); + // bob has no session room + await insertUser(dbAdapter, '@bob:localhost', 'cus_bob', 'bob@example.com'); + await insertPermissions(dbAdapter, realmURL, { + '*': ['read'], + }); + let result = await fetchRealmSessionRooms(dbAdapter, realmURL.href); + expect(result).toEqual({ + '@alice:localhost': '!room-alice:localhost', + }); + }); + it('returns empty result when no users have permissions', async function () { + await insertUser(dbAdapter, '@alice:localhost', 'cus_alice', 'alice@example.com'); + await upsertSessionRoom(dbAdapter, '@alice:localhost', '!room-alice:localhost'); + // No permissions set for this realm at all + let result = await fetchRealmSessionRooms(dbAdapter, realmURL.href); + expect(result).toEqual({}); + }); + it('does not return users from a different realm', async function () { + let otherRealmURL = new URL('http://127.0.0.1:4444/other/'); + await insertUser(dbAdapter, '@alice:localhost', 'cus_alice', 'alice@example.com'); + await upsertSessionRoom(dbAdapter, '@alice:localhost', '!room-alice:localhost'); + // alice has permission on the OTHER realm, not on our test realm + await insertPermissions(dbAdapter, otherRealmURL, { + '@alice:localhost': ['read'], + }); + let result = await fetchRealmSessionRooms(dbAdapter, realmURL.href); + expect(result).toEqual({}); + }); + it('combines explicit permissions and world-readable access', async function () { + await insertUser(dbAdapter, '@alice:localhost', 'cus_alice', 'alice@example.com'); + await upsertSessionRoom(dbAdapter, '@alice:localhost', '!room-alice:localhost'); + await insertUser(dbAdapter, '@bob:localhost', 'cus_bob', 'bob@example.com'); + await upsertSessionRoom(dbAdapter, '@bob:localhost', '!room-bob:localhost'); + // alice has explicit write permission AND the realm is world-readable + await insertPermissions(dbAdapter, realmURL, { + '*': ['read'], + '@alice:localhost': ['read', 'write'], + }); + let result = await fetchRealmSessionRooms(dbAdapter, realmURL.href); + // Both users should be returned: alice via explicit + world-readable, bob via world-readable + expect(Object.keys(result).length).toBe(2); + expect(result['@alice:localhost']).toBe('!room-alice:localhost'); + expect(result['@bob:localhost']).toBe('!room-bob:localhost'); + }); + }); + describe('clearSessionRoom', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let dbAdapter: PgAdapter; + setupDB(hooks, { + beforeEach: async (_dbAdapter) => { + dbAdapter = _dbAdapter; + }, + }); + it('clears the stored session room when it matches', async function () { + await insertUser(dbAdapter, '@alice:localhost', 'cus_alice', 'alice@example.com'); + await upsertSessionRoom(dbAdapter, '@alice:localhost', '!room-alice:localhost'); + let cleared = await clearSessionRoom(dbAdapter, '@alice:localhost', '!room-alice:localhost'); + expect(cleared).toBe(true); + let remainingRoom = await fetchSessionRoom(dbAdapter, '@alice:localhost'); + expect(remainingRoom).toBe(null); + }); + it('does not clear the stored session room when the room id does not match', async function () { + await insertUser(dbAdapter, '@alice:localhost', 'cus_alice', 'alice@example.com'); + await upsertSessionRoom(dbAdapter, '@alice:localhost', '!room-alice:localhost'); + let cleared = await clearSessionRoom(dbAdapter, '@alice:localhost', '!different-room:localhost'); + expect(cleared).toBe(false); + let remainingRoom = await fetchSessionRoom(dbAdapter, '@alice:localhost'); + expect(remainingRoom).toBe('!room-alice:localhost'); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/setup.ts b/packages/realm-server/tests-vitest/setup.ts new file mode 100644 index 00000000000..8747bbd7dd6 --- /dev/null +++ b/packages/realm-server/tests-vitest/setup.ts @@ -0,0 +1,97 @@ +import { afterAll } from 'vitest'; +import * as ContentTagGlobal from 'content-tag'; +import 'decorator-transforms/globals'; +import '../setup-logger'; + +(globalThis as any).__environment = 'test'; +(globalThis as any).ContentTagGlobal = ContentTagGlobal; + +// Match the QUnit test entrypoint behavior so timers don't keep Vitest workers alive. +{ + const originalSetTimeout = global.setTimeout; + const originalSetInterval = global.setInterval; + global.setTimeout = ((...args: Parameters) => { + const handle = originalSetTimeout(...args); + if (typeof (handle as any)?.unref === 'function') { + (handle as any).unref(); + } + return handle; + }) as typeof setTimeout; + global.setInterval = ((...args: Parameters) => { + const handle = originalSetInterval(...args); + if (typeof (handle as any)?.unref === 'function') { + (handle as any).unref(); + } + return handle; + }) as typeof setInterval; +} + +afterAll(async () => { + const helpers = await import('./helpers'); + + await helpers.stopTrackedPrerenderers(); + await helpers.closeTrackedServers(); + await helpers.destroyTrackedQueueRunners(); + await helpers.destroyTrackedQueuePublishers(); + await helpers.closeTrackedDbAdapters(); + + try { + const undici = (await import('undici')) as { + getGlobalDispatcher?: () => { close?: () => Promise }; + }; + await undici.getGlobalDispatcher?.()?.close?.(); + } catch { + // best-effort cleanup + } + + let handles = (process as any)._getActiveHandles?.() ?? []; + for (let handle of handles) { + if ( + handle && + typeof handle.kill === 'function' && + typeof handle.spawnfile === 'string' && + /chrome|chromium/i.test(handle.spawnfile) + ) { + try { + handle.kill('SIGKILL'); + handle.unref?.(); + } catch { + // best-effort cleanup + } + } + } + + handles = (process as any)._getActiveHandles?.() ?? []; + for (let handle of handles) { + if (!handle || typeof handle.destroy !== 'function') { + continue; + } + let websocketSymbol = Object.getOwnPropertySymbols(handle).find( + (symbol) => symbol.description === 'websocket', + ); + if (websocketSymbol) { + try { + handle[websocketSymbol]?.terminate?.(); + handle.destroy(); + } catch { + // best-effort cleanup + } + } + } + + handles = (process as any)._getActiveHandles?.() ?? []; + for (let handle of handles) { + if (!handle || typeof handle.destroy !== 'function') { + continue; + } + if ((handle as any)._isStdio || (handle as any)._type === 'pipe') { + continue; + } + try { + handle.unref?.(); + handle.destroy(); + } catch { + // best-effort cleanup + } + } +}); diff --git a/packages/realm-server/tests-vitest/transpile.test.ts b/packages/realm-server/tests-vitest/transpile.test.ts new file mode 100644 index 00000000000..2a4ecfd4451 --- /dev/null +++ b/packages/realm-server/tests-vitest/transpile.test.ts @@ -0,0 +1,38 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect } from "vitest"; +import { transpileJS } from '@cardstack/runtime-common/transpile'; +import '@cardstack/runtime-common/helpers/code-equality-assertion'; +describe("transpile-test.ts", function () { + describe('Transpile', function () { + it('can rewrite fetch()', async function () { + let transpiled = await transpileJS(` + async function test() { + return await fetch('http://test.com'); + }`, 'test-module.ts'); + expect(transpiled).toEqual(` + async function test() { + return await import.meta.loader.fetch('http://test.com'); + }`); + }); + }); + it('can rewrite import() that has url like argument', async function () { + let transpiled = await transpileJS(` + async function test() { + return await import('./x'); + }`, 'test-module.ts'); + expect(transpiled).toEqual(` + async function test() { + return await import.meta.loader.import(new URL('./x', import.meta.url).href); + }`); + }); + it('can rewrite import() that has module specifier argument', async function () { + let transpiled = await transpileJS(` + async function test() { + return await import('lodash'); + }`, 'test-module.ts'); + expect(transpiled).toEqual(` + async function test() { + return await import.meta.loader.import('lodash'); + }`); + }); +}); diff --git a/packages/realm-server/tests-vitest/types-endpoint.test.ts b/packages/realm-server/tests-vitest/types-endpoint.test.ts new file mode 100644 index 00000000000..8fb8bc55b12 --- /dev/null +++ b/packages/realm-server/tests-vitest/types-endpoint.test.ts @@ -0,0 +1,195 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { fileURLToPath } from "url"; +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; +import type { Test, SuperTest } from 'supertest'; +import { join, dirname } from 'path'; +import type { Server } from 'http'; +import type { DirResult } from 'tmp'; +import { copySync, ensureDirSync } from 'fs-extra'; +import type { Realm } from '@cardstack/runtime-common'; +import type { QueuePublisher, QueueRunner } from '@cardstack/runtime-common'; +import { setupPermissionedRealmCached, runTestRealmServer, setupDB, setupMatrixRoom, createVirtualNetwork, matrixURL, closeServer, type RealmRequest, withRealmPath, } from './helpers'; +import '@cardstack/runtime-common/helpers/code-equality-assertion'; +import type { PgAdapter } from '@cardstack/postgres'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const testRealm2URL = new URL('http://127.0.0.1:4445/test/'); +describe("types-endpoint-test.ts", function () { + describe('Realm-specific Endpoints | GET _types', function () { + const hooks = { + before: beforeAll, + after: afterAll, + beforeEach, + afterEach + }; + let realmURL = new URL('http://127.0.0.1:4444/test/'); + let testRealm: Realm; + let testRealmHttpServer: Server; + let request: RealmRequest; + let serverRequest: SuperTest; + let dir: DirResult; + let dbAdapter: PgAdapter; + let testRealmHttpServer2: Server; + let testRealm2: Realm; + let dbAdapter2: PgAdapter; + let publisher: QueuePublisher; + let runner: QueueRunner; + let testRealmDir: string; + function onRealmSetup(args: { + testRealm: Realm; + testRealmHttpServer: Server; + request: SuperTest; + dir: DirResult; + dbAdapter: PgAdapter; + }) { + testRealm = args.testRealm; + testRealmHttpServer = args.testRealmHttpServer; + serverRequest = args.request; + request = withRealmPath(args.request, realmURL); + dir = args.dir; + dbAdapter = args.dbAdapter; + } + function getRealmSetup() { + return { + testRealm, + testRealmHttpServer, + request, + serverRequest, + dir, + dbAdapter, + }; + } + setupPermissionedRealmCached(hooks, { + permissions: { + '*': ['read', 'write'], + '@node-test_realm:localhost': ['read', 'write', 'realm-owner'], + }, + realmURL, + onRealmSetup, + }); + setupMatrixRoom(hooks, getRealmSetup); + let virtualNetwork = createVirtualNetwork(); + async function startRealmServer(dbAdapter: PgAdapter, publisher: QueuePublisher, runner: QueueRunner) { + if (testRealm2) { + virtualNetwork.unmount(testRealm2.handle); + } + ({ testRealm: testRealm2, testRealmHttpServer: testRealmHttpServer2 } = + await runTestRealmServer({ + virtualNetwork, + testRealmDir, + realmsRootPath: join(dir.name, 'realm_server_2'), + realmURL: testRealm2URL, + dbAdapter, + publisher, + runner, + matrixURL, + })); + await testRealm.logInToMatrix(); + } + setupDB(hooks, { + beforeEach: async (_dbAdapter, _publisher, _runner) => { + dbAdapter2 = _dbAdapter; + publisher = _publisher; + runner = _runner; + testRealmDir = join(dir.name, 'realm_server_2', 'test'); + ensureDirSync(testRealmDir); + copySync(join(__dirname, 'cards'), testRealmDir); + await startRealmServer(dbAdapter2, publisher, runner); + }, + afterEach: async () => { + await closeServer(testRealmHttpServer2); + }, + }); + it('can fetch card type summary', async function () { + let response = await request + .get('/_types') + .set('Accept', 'application/json'); + let iconHTML = ''; + let chessIconHTML = ''; + let sortCardTypeSummaries = (summaries: any[]) => [...summaries].sort((a, b) => { + let aName = a.attributes.displayName; + let bName = b.attributes.displayName; + if (aName === bName) { + return a.id.localeCompare(b.id); + } + return aName.localeCompare(bName); + }); + expect(response.status).toBe(200); + let expectedData = [ + { + type: 'card-type-summary', + id: `${testRealm.url}chess-gallery/ChessGallery`, + attributes: { + displayName: 'Chess Gallery', + total: 3, + iconHTML: chessIconHTML, + }, + }, + { + type: 'card-type-summary', + id: `${testRealm.url}family_photo_card/FamilyPhotoCard`, + attributes: { + displayName: 'Family Photo Card', + total: 2, + iconHTML, + }, + }, + { + type: 'card-type-summary', + id: `${testRealm.url}friend/Friend`, + attributes: { + displayName: 'Friend', + total: 2, + iconHTML, + }, + }, + { + type: 'card-type-summary', + id: 'http://localhost:4202/node-test/friend-with-used-link/FriendWithUsedLink', + attributes: { + displayName: 'FriendWithUsedLink', + total: 2, + iconHTML, + }, + }, + { + type: 'card-type-summary', + id: `${testRealm.url}home/Home`, + attributes: { + displayName: 'Home', + total: 1, + iconHTML, + }, + }, + { + type: 'card-type-summary', + id: `${testRealm.url}person/Person`, + attributes: { + displayName: 'Person', + total: 3, + iconHTML, + }, + }, + { + type: 'card-type-summary', + id: `${testRealm.url}person-with-error/PersonCard`, + attributes: { + displayName: 'Person', + total: 4, + iconHTML, + }, + }, + { + type: 'card-type-summary', + id: `${testRealm.url}timers-card/TimersCard`, + attributes: { + displayName: 'TimersCard', + total: 1, + iconHTML, + }, + }, + ]; + expect(sortCardTypeSummaries(response.body.data)).toEqual(sortCardTypeSummaries(expectedData)); + }); + }); +}); diff --git a/packages/realm-server/tests-vitest/virtual-network.test.ts b/packages/realm-server/tests-vitest/virtual-network.test.ts new file mode 100644 index 00000000000..5edf8ebb0d8 --- /dev/null +++ b/packages/realm-server/tests-vitest/virtual-network.test.ts @@ -0,0 +1,48 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, it, expect } from "vitest"; +import type { ResponseWithNodeStream } from '@cardstack/runtime-common'; +import { VirtualNetwork } from '@cardstack/runtime-common'; +describe("virtual-network-test.ts", function () { + describe('virtual-network', function () { + it('will respond with real (not virtual) url when handler makes a redirect', async function () { + let virtualNetwork = new VirtualNetwork(); + virtualNetwork.addURLMapping(new URL('https://cardstack.com/base/'), new URL('http://localhost:4201/base/')); + virtualNetwork.mount(async (_request: Request) => { + // Normally there would be some redirection logic here, but for this test we just want to make sure that the redirect is handled correctly + return new Response(null, { + status: 302, + headers: { + Location: 'https://cardstack.com/base/__boxel/assets/', // This virtual url should be converted to a real url so that the client can follow the redirect + }, + }) as ResponseWithNodeStream; + }); + let response = await virtualNetwork.handle(new Request('http://localhost:4201/__boxel/assets/')); + expect(response.status).toBe(302); + expect(response.headers.get('Location')).toBe('http://localhost:4201/base/__boxel/assets/'); + }); + it('is able to follow redirects', async function () { + let virtualNetwork = new VirtualNetwork(); + virtualNetwork.mount(async (request: Request) => { + // Normally there would be some redirection logic here, but for this test we just want to make sure that the redirect is handled correctly + if (request.url == 'http://test-realm/test/person') { + return new Response(null, { + status: 302, + headers: { + Location: 'http://test-realm/test/person.gts', + }, + }) as ResponseWithNodeStream; + } + return null; + }); + virtualNetwork.mount(async (request: Request) => { + if (request.url == 'http://test-realm/test/person.gts') { + return new Response(null, { status: 200 }); + } + return null; + }); + let response = await virtualNetwork.fetch(`http://test-realm/test/person`); + expect(response.url).toBe('http://test-realm/test/person.gts'); + expect(response.redirected).toBe(true); + }); + }); +}); diff --git a/packages/realm-server/vitest.config.mjs b/packages/realm-server/vitest.config.mjs new file mode 100644 index 00000000000..113119fd6b3 --- /dev/null +++ b/packages/realm-server/vitest.config.mjs @@ -0,0 +1,21 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + globals: false, + include: ['tests-vitest/**/*.test.ts'], + setupFiles: ['tests-vitest/setup.ts'], + testTimeout: 60000, + hookTimeout: 120000, + fileParallelism: false, + server: { + deps: { + external: ['pg'], + }, + }, + sequence: { + hooks: 'list', + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9fe600ce09c..41126dd61c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2581,6 +2581,9 @@ importers: uuid: specifier: 'catalog:' version: 9.0.1 + vitest: + specifier: 'catalog:' + version: 2.1.9(@types/node@24.10.8)(jsdom@21.1.2)(terser@5.44.1) wait-for-localhost-cli: specifier: 'catalog:' version: 3.2.0 @@ -17702,6 +17705,14 @@ snapshots: chai: 5.3.3 tinyrainbow: 1.2.0 + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@24.10.8)(terser@5.44.1))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@24.10.8)(terser@5.44.1) + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@25.0.8)(terser@5.44.1))': dependencies: '@vitest/spy': 2.1.9 @@ -27213,6 +27224,24 @@ snapshots: version-range@4.15.0: {} + vite-node@2.1.9(@types/node@24.10.8)(terser@5.44.1): + dependencies: + cac: 6.7.14 + debug: 4.4.3(supports-color@8.1.1) + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@24.10.8)(terser@5.44.1) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vite-node@2.1.9(@types/node@25.0.8)(terser@5.44.1): dependencies: cac: 6.7.14 @@ -27231,6 +27260,16 @@ snapshots: - supports-color - terser + vite@5.4.21(@types/node@24.10.8)(terser@5.44.1): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.6 + rollup: 4.55.1 + optionalDependencies: + '@types/node': 24.10.8 + fsevents: 2.3.3 + terser: 5.44.1 + vite@5.4.21(@types/node@25.0.8)(terser@5.44.1): dependencies: esbuild: 0.21.5 @@ -27256,6 +27295,42 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 + vitest@2.1.9(@types/node@24.10.8)(jsdom@21.1.2)(terser@5.44.1): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@24.10.8)(terser@5.44.1)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3(supports-color@8.1.1) + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@24.10.8)(terser@5.44.1) + vite-node: 2.1.9(@types/node@24.10.8)(terser@5.44.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.10.8 + jsdom: 21.1.2 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vitest@2.1.9(@types/node@25.0.8)(jsdom@25.0.1)(terser@5.44.1): dependencies: '@vitest/expect': 2.1.9