@app-vitals/agent-admin 0.0.1 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +43 -0
- package/README.md +72 -2
- package/e2e/agents-page.e2e.ts +338 -0
- package/e2e/chat-degraded.e2e.ts +153 -0
- package/e2e/chat-page.e2e.ts +169 -0
- package/e2e/chat-progress.e2e.ts +206 -0
- package/e2e/chat-thread-page.e2e.ts +444 -0
- package/e2e/login-page.e2e.ts +148 -0
- package/e2e/test-server.ts +427 -0
- package/openapi.json +6098 -0
- package/openapi.platform.json +5553 -0
- package/package.json +62 -4
- package/playwright.config.ts +25 -0
- package/prisma/client/browser.ts +134 -0
- package/prisma/client/client.ts +158 -0
- package/prisma/client/commonInputTypes.ts +578 -0
- package/prisma/client/enums.ts +15 -0
- package/prisma/client/internal/class.ts +424 -0
- package/prisma/client/internal/prismaNamespace.ts +2898 -0
- package/prisma/client/internal/prismaNamespaceBrowser.ts +476 -0
- package/prisma/client/models/Account.ts +1774 -0
- package/prisma/client/models/AccountInvite.ts +1386 -0
- package/prisma/client/models/AccountMember.ts +1351 -0
- package/prisma/client/models/Agent.ts +4554 -0
- package/prisma/client/models/AgentAsk.ts +1373 -0
- package/prisma/client/models/AgentChatTokenUsageDailyByModel.ts +1638 -0
- package/prisma/client/models/AgentCronJob.ts +2416 -0
- package/prisma/client/models/AgentCronRun.ts +2858 -0
- package/prisma/client/models/AgentCronRunModelBreakdown.ts +1532 -0
- package/prisma/client/models/AgentCronRunSkillUsage.ts +1642 -0
- package/prisma/client/models/AgentEnv.ts +1421 -0
- package/prisma/client/models/AgentGitHubInstallationsSnapshot.ts +1289 -0
- package/prisma/client/models/AgentMember.ts +1316 -0
- package/prisma/client/models/AgentPhaseMethodology.ts +1355 -0
- package/prisma/client/models/AgentPlugin.ts +1421 -0
- package/prisma/client/models/AgentToken.ts +1380 -0
- package/prisma/client/models/AgentTool.ts +1351 -0
- package/prisma/client/models/AgentWorkQueueSnapshot.ts +1297 -0
- package/prisma/client/models/ChatThreadWatch.ts +1183 -0
- package/prisma/client/models/PushSubscription.ts +1233 -0
- package/prisma/client/models/SessionAlertState.ts +1187 -0
- package/prisma/client/models/SessionFollow.ts +1187 -0
- package/prisma/client/models/UserNotificationPrefs.ts +1221 -0
- package/prisma/client/models.ts +34 -0
- package/prisma/migrations/20260608000000_init/migration.sql +112 -0
- package/prisma/migrations/20260616000000_agent_members/migration.sql +18 -0
- package/prisma/migrations/20260624000000_add_agent_self_hosted/migration.sql +2 -0
- package/prisma/migrations/20260624120000_add_agent_repos/migration.sql +3 -0
- package/prisma/migrations/20260625000000_add_agent_cron_run/migration.sql +26 -0
- package/prisma/migrations/20260626000000_add_cron_run_token_fields/migration.sql +10 -0
- package/prisma/migrations/20260627000000_add_agent_chat_token_usage_daily/migration.sql +18 -0
- package/prisma/migrations/20260627010000_add_agent_chat_token_usage_fk/migration.sql +2 -0
- package/prisma/migrations/20260701000000_add_chat_threads_messages/migration.sql +37 -0
- package/prisma/migrations/20260701010000_drop_chat_threads_messages/migration.sql +17 -0
- package/prisma/migrations/20260701020000_add_cron_run_model_breakdown/migration.sql +23 -0
- package/prisma/migrations/20260701020000_chat_token_by_model/migration.sql +25 -0
- package/prisma/migrations/20260701030000_add_secret_flag_to_agent_env/migration.sql +2 -0
- package/prisma/migrations/20260701030000_drop_cron_run_legacy_columns/migration.sql +5 -0
- package/prisma/migrations/20260706000000_consolidate_cron_run_token_accounting/migration.sql +54 -0
- package/prisma/migrations/20260709000000_add_phase_to_agent_cron_run/migration.sql +8 -0
- package/prisma/migrations/20260717000000_add_item_tracking_to_agent_cron_run/migration.sql +10 -0
- package/prisma/migrations/20260717010000_add_agent_work_queue_snapshot/migration.sql +20 -0
- package/prisma/migrations/20260718000000_add_cron_phase_columns/migration.sql +25 -0
- package/prisma/migrations/20260718100000_drop_agent_cron_run_phase_column/migration.sql +8 -0
- package/prisma/migrations/20260724000000_add_agent_type_name/migration.sql +3 -0
- package/prisma/migrations/20260725232433_add_agent_author_allowlist/migration.sql +2 -0
- package/prisma/migrations/20260727120000_add_not_null_to_agent_author_allowlist/migration.sql +3 -0
- package/prisma/migrations/20260731000000_add_session_id_to_agent_cron_run/migration.sql +2 -0
- package/prisma/migrations/20260821000000_add_agent_restrict_slack_to_members/migration.sql +3 -0
- package/prisma/migrations/20260901000000_add_push_subscription_and_chat_thread_watch/migration.sql +40 -0
- package/prisma/migrations/20260903000000_add_agent_patch_author_allowlist/migration.sql +2 -0
- package/prisma/migrations/20260903120000_add_agent_review_author_allowlist/migration.sql +18 -0
- package/prisma/migrations/20260903150000_drop_agent_author_allowlist/migration.sql +14 -0
- package/prisma/migrations/20260905000000_add_started_at_index_to_agent_cron_run/migration.sql +11 -0
- package/prisma/migrations/20260906000000_add_session_follow_notification_prefs_alert_state/migration.sql +47 -0
- package/prisma/migrations/20260911000000_backfill_auto_follow_since/migration.sql +16 -0
- package/prisma/migrations/20260923115814_add_last_heartbeat_at_to_agent_cron_run/migration.sql +2 -0
- package/prisma/migrations/20260924120000_add_trial_expiry_fields_to_agent/migration.sql +6 -0
- package/prisma/migrations/20260929000000_add_agent_phase_methodology/migration.sql +21 -0
- package/prisma/migrations/20260929120000_add_agent_policy_fields/migration.sql +15 -0
- package/prisma/migrations/20261005000000_add_agent_github_installations_snapshot/migration.sql +21 -0
- package/prisma/migrations/20261008000000_add_cron_run_telemetry/migration.sql +50 -0
- package/prisma/migrations/20261008010000_add_account_models/migration.sql +48 -0
- package/prisma/migrations/20261008020000_add_cron_lockdown_disabled_at/migration.sql +3 -0
- package/prisma/migrations/20261009000000_chat_token_usage_bigint/migration.sql +7 -0
- package/prisma/migrations/20261010000000_add_cron_run_command_variant/migration.sql +2 -0
- package/prisma/migrations/20261019000000_add_agent_anthropic_identity/migration.sql +9 -0
- package/prisma/migrations/20261025000000_add_agent_ask/migration.sql +23 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema/coding.prisma +85 -0
- package/prisma/schema/schema.prisma +506 -0
- package/prisma.config.ts +34 -0
- package/pwa-assets/icons/apple-touch-icon.png +0 -0
- package/pwa-assets/icons/favicon-32.png +0 -0
- package/pwa-assets/icons/icon-192.png +0 -0
- package/pwa-assets/icons/icon-512.png +0 -0
- package/pwa-assets/icons/icon-maskable-192.png +0 -0
- package/pwa-assets/icons/icon-maskable-512.png +0 -0
- package/src/account-agent-create.ts +42 -0
- package/src/account-agent-create.unit.test.ts +28 -0
- package/src/account-created-notifier.ts +71 -0
- package/src/account-created-notifier.unit.test.ts +70 -0
- package/src/account-email.ts +9 -0
- package/src/account-email.unit.test.ts +21 -0
- package/src/account-invites.ts +70 -0
- package/src/account-lifecycle.integration.test.ts +329 -0
- package/src/account-lifecycle.ts +206 -0
- package/src/account-lifecycle.unit.test.ts +108 -0
- package/src/account-members.ts +128 -0
- package/src/account-onboarding.integration.test.ts +101 -0
- package/src/account-onboarding.ts +95 -0
- package/src/account-trial-expiry-sweeper.ts +85 -0
- package/src/account-trial-warning-sweeper.integration.test.ts +228 -0
- package/src/account-trial-warning-sweeper.ts +216 -0
- package/src/accounts-api.integration.test.ts +196 -0
- package/src/accounts-api.smoke.test.ts +292 -0
- package/src/accounts-api.ts +289 -0
- package/src/accounts-api.unit.test.ts +93 -0
- package/src/accounts.integration.test.ts +178 -0
- package/src/accounts.ts +208 -0
- package/src/admin-boot-config.ts +574 -0
- package/src/admin-boot-config.unit.test.ts +28 -0
- package/src/admin-boot.boot-order.unit.test.ts +91 -0
- package/src/admin-boot.ts +690 -0
- package/src/admin-dev-login.smoke.test.ts +317 -0
- package/src/admin-extension.ts +432 -0
- package/src/admin-extension.unit.test.ts +532 -0
- package/src/admin-local-agent.smoke.test.ts +1643 -0
- package/src/admin-spec.smoke.test.ts +96 -0
- package/src/admin-spec.ts +186 -0
- package/src/admin-ui-account-nav.ts +20 -0
- package/src/admin-ui-account-pages.ts +131 -0
- package/src/admin-ui-account-pages.unit.test.ts +112 -0
- package/src/admin-ui-account.smoke.test.ts +286 -0
- package/src/admin-ui-account.ts +185 -0
- package/src/admin-ui-accounts-pages.ts +202 -0
- package/src/admin-ui-accounts-pages.unit.test.ts +138 -0
- package/src/admin-ui-accounts.smoke.test.ts +434 -0
- package/src/admin-ui-accounts.ts +266 -0
- package/src/admin-ui-layout.ts +60 -0
- package/src/admin-ui-layout.unit.test.ts +140 -0
- package/src/admin-ui-pages.ts +3489 -0
- package/src/admin-ui-pages.unit.test.ts +4355 -0
- package/src/admin-ui-push-test.smoke.test.ts +125 -0
- package/src/admin-ui-push-test.ts +102 -0
- package/src/admin-ui-styles.ts +656 -0
- package/src/admin-ui-styles.unit.test.ts +103 -0
- package/src/admin-ui.fixtures.ts +457 -0
- package/src/admin-ui.smoke.test.ts +7227 -0
- package/src/admin-ui.ts +3824 -0
- package/src/agent-account-assignment.ts +49 -0
- package/src/agent-account-assignment.unit.test.ts +99 -0
- package/src/agent-anthropic-identity.integration.test.ts +99 -0
- package/src/agent-anthropic-identity.ts +78 -0
- package/src/agent-anthropic-identity.unit.test.ts +77 -0
- package/src/agent-ask-config.ts +59 -0
- package/src/agent-ask-config.unit.test.ts +70 -0
- package/src/agent-ask.integration.test.ts +175 -0
- package/src/agent-ask.smoke.test.ts +457 -0
- package/src/agent-ask.ts +378 -0
- package/src/agent-chat-tokens.integration.test.ts +420 -0
- package/src/agent-chat-tokens.smoke.test.ts +551 -0
- package/src/agent-chat-tokens.ts +259 -0
- package/src/agent-cron-jobs.integration.test.ts +582 -0
- package/src/agent-cron-jobs.ts +615 -0
- package/src/agent-cron-jobs.unit.test.ts +420 -0
- package/src/agent-cron-run-stats.integration.test.ts +1551 -0
- package/src/agent-cron-run-stats.smoke.test.ts +953 -0
- package/src/agent-cron-run-stats.ts +752 -0
- package/src/agent-cron-runs.integration.test.ts +1491 -0
- package/src/agent-cron-runs.ts +489 -0
- package/src/agent-deletion-checklist.ts +128 -0
- package/src/agent-deletion-checklist.unit.test.ts +201 -0
- package/src/agent-deletion-per-agent.integration.test.ts +323 -0
- package/src/agent-deletion.integration.test.ts +323 -0
- package/src/agent-deletion.ts +329 -0
- package/src/agent-deletion.unit.test.ts +412 -0
- package/src/agent-envs.integration.test.ts +323 -0
- package/src/agent-envs.ts +244 -0
- package/src/agent-github-installations.integration.test.ts +115 -0
- package/src/agent-github-installations.ts +52 -0
- package/src/agent-manifest.ts +685 -0
- package/src/agent-manifest.unit.test.ts +870 -0
- package/src/agent-members.ts +69 -0
- package/src/agent-members.unit.test.ts +250 -0
- package/src/agent-patch-author-allowlist.integration.test.ts +218 -0
- package/src/agent-plugins.integration.test.ts +157 -0
- package/src/agent-plugins.ts +95 -0
- package/src/agent-plugins.unit.test.ts +43 -0
- package/src/agent-provisioner-per-agent.integration.test.ts +949 -0
- package/src/agent-provisioner.integration.test.ts +1245 -0
- package/src/agent-provisioner.ts +1650 -0
- package/src/agent-provisioner.unit.test.ts +1852 -0
- package/src/agent-tokens.integration.test.ts +150 -0
- package/src/agent-tokens.ts +112 -0
- package/src/agent-tools.integration.test.ts +129 -0
- package/src/agent-tools.ts +111 -0
- package/src/agents-api.integration.test.ts +705 -0
- package/src/agents-api.sentry.smoke.test.ts +478 -0
- package/src/agents-api.smoke.test.ts +5986 -0
- package/src/agents-api.ts +2826 -0
- package/src/agents.integration.test.ts +512 -0
- package/src/agents.ts +1097 -0
- package/src/agents.unit.test.ts +1736 -0
- package/src/anthropic-credential-guard.ts +44 -0
- package/src/anthropic-credential-guard.unit.test.ts +88 -0
- package/src/anthropic-federated-token-provider.integration.test.ts +248 -0
- package/src/anthropic-federated-token-provider.ts +249 -0
- package/src/anthropic-identity-client.fixtures.ts +237 -0
- package/src/anthropic-identity-client.integration.test.ts +342 -0
- package/src/anthropic-identity-client.ts +473 -0
- package/src/anthropic-identity-fixture-spec.integration.test.ts +169 -0
- package/src/anthropic-identity-name.integration.test.ts +83 -0
- package/src/anthropic-identity-name.ts +163 -0
- package/src/anthropic-identity-name.unit.test.ts +345 -0
- package/src/anthropic-openapi-contract.fixtures.ts +192 -0
- package/src/api-auth.ts +383 -0
- package/src/api-auth.unit.test.ts +837 -0
- package/src/api.smoke.test.ts +1225 -0
- package/src/api.ts +376 -0
- package/src/attachment-validation.ts +124 -0
- package/src/attachment-validation.unit.test.ts +218 -0
- package/src/caller-scope.ts +131 -0
- package/src/caller-scope.unit.test.ts +116 -0
- package/src/chat-credential-config.ts +22 -0
- package/src/chat-credentials-logging.unit.test.ts +92 -0
- package/src/chat-markers.ts +114 -0
- package/src/chat-markers.unit.test.ts +116 -0
- package/src/chat-service-provisioning-client.integration.test.ts +412 -0
- package/src/chat-service-provisioning-client.ts +221 -0
- package/src/chat.smoke.test.ts +1875 -0
- package/src/clock.ts +47 -0
- package/src/coding-task-store-provisioning-client.integration.test.ts +372 -0
- package/src/coding-task-store-provisioning-client.ts +156 -0
- package/src/crypto.ts +83 -0
- package/src/dev-auth-guard.ts +29 -0
- package/src/dev-auth-guard.unit.test.ts +51 -0
- package/src/errors.ts +77 -0
- package/src/errors.unit.test.ts +52 -0
- package/src/fixtures/anthropic-identity-recorded-fixtures.json +850 -0
- package/src/fixtures/anthropic-openapi/SDK_VERSION +1 -0
- package/src/fixtures/anthropic-openapi/mock-spec.json.gz +0 -0
- package/src/fixtures/chat-service-provisioning-cassette.json +156 -0
- package/src/fixtures/github-app-manifest-cassette.json +44 -0
- package/src/fixtures/google-auth-cassette.json +36 -0
- package/src/fixtures/http-chat-client-cassette.json +226 -0
- package/src/fixtures/k8s-cassette.json +470 -0
- package/src/fixtures/okta-auth-cassette.json +36 -0
- package/src/fixtures/slack-provision-cassette.json +12 -0
- package/src/fixtures/slack-provisioning-http-cassette.json +98 -0
- package/src/fixtures/task-store-provisioning-cassette.json +85 -0
- package/src/github-app-provisioning-client.integration.test.ts +149 -0
- package/src/github-app-provisioning-client.ts +161 -0
- package/src/github-app-provisioning-client.unit.test.ts +114 -0
- package/src/github-provisioning-service.ts +569 -0
- package/src/github-provisioning-service.unit.test.ts +123 -0
- package/src/google-auth-client.integration.test.ts +195 -0
- package/src/google-auth-client.ts +121 -0
- package/src/http-chat-client.integration.test.ts +554 -0
- package/src/http-chat-client.ts +533 -0
- package/src/http-chat-client.unit.test.ts +119 -0
- package/src/import-boundary.unit.test.ts +240 -0
- package/src/kubernetes-client.integration.test.ts +697 -0
- package/src/kubernetes-client.ts +1027 -0
- package/src/kubernetes-client.unit.test.ts +726 -0
- package/src/main.smoke.test.ts +91 -0
- package/src/main.ts +21 -0
- package/src/main.unit.test.ts +801 -0
- package/src/okta-auth-client.integration.test.ts +263 -0
- package/src/okta-auth-client.ts +152 -0
- package/src/openapi-schemas.ts +1310 -0
- package/src/openapi-schemas.unit.test.ts +348 -0
- package/src/per-agent-identity.fixtures.ts +305 -0
- package/src/platform-doc-route.ts +40 -0
- package/src/prisma-client.integration.test.ts +57 -0
- package/src/prisma-client.ts +187 -0
- package/src/prisma-client.unit.test.ts +178 -0
- package/src/prisma-tx.ts +23 -0
- package/src/push-content.ts +177 -0
- package/src/push-content.unit.test.ts +213 -0
- package/src/push-crypto.ts +135 -0
- package/src/push-crypto.unit.test.ts +66 -0
- package/src/push-sender.ts +169 -0
- package/src/push-sender.unit.test.ts +107 -0
- package/src/push-service.ts +332 -0
- package/src/push-service.unit.test.ts +679 -0
- package/src/push-toggle.ts +135 -0
- package/src/push-toggle.unit.test.ts +78 -0
- package/src/pwa-https-gating.smoke.test.ts +229 -0
- package/src/pwa-icons.unit.test.ts +41 -0
- package/src/pwa-routes.smoke.test.ts +276 -0
- package/src/pwa.ts +380 -0
- package/src/pwa.unit.test.ts +321 -0
- package/src/schema.unit.test.ts +122 -0
- package/src/self-serve-config.ts +52 -0
- package/src/self-serve-config.unit.test.ts +79 -0
- package/src/server.smoke.test.ts +273 -0
- package/src/server.ts +50 -0
- package/src/service-integration.ts +67 -0
- package/src/service-integration.unit.test.ts +129 -0
- package/src/slack-provisioning-client.integration.test.ts +308 -0
- package/src/slack-provisioning-client.ts +411 -0
- package/src/slack-provisioning-client.unit.test.ts +80 -0
- package/src/slack-provisioning-service.integration.test.ts +637 -0
- package/src/slack-provisioning-service.ts +451 -0
- package/src/slack-provisioning.integration.test.ts +395 -0
- package/src/specialty-manifest-loader.fixture.integration.test.ts +63 -0
- package/src/specialty-manifest-loader.ts +255 -0
- package/src/specialty-manifest-loader.unit.test.ts +301 -0
- package/src/specialty-registry.schema.unit.test.ts +35 -0
- package/src/specialty-registry.ts +236 -0
- package/src/specialty-registry.unit.test.ts +272 -0
- package/src/startup-policy-reconcile.ts +77 -0
- package/src/startup-policy-reconcile.unit.test.ts +251 -0
- package/src/theme-tokens.json +12 -0
- package/src/token-crypto.ts +60 -0
- package/src/token-crypto.unit.test.ts +182 -0
- package/src/trial-expiry-sweeper.integration.test.ts +318 -0
- package/src/trial-expiry-sweeper.ts +132 -0
- package/src/trial-expiry-sweeper.unit.test.ts +391 -0
- package/src/trial-expiry-warning-sweeper.integration.test.ts +468 -0
- package/src/trial-expiry-warning-sweeper.ts +447 -0
- package/src/trial-expiry-warning-sweeper.unit.test.ts +148 -0
- package/src/voice-contract.unit.test.ts +102 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/agents-api.integration.test.ts
|
|
3
|
+
* Integration tests for the admin CRUD API against a real PostgreSQL DB.
|
|
4
|
+
*
|
|
5
|
+
* Requires DATABASE_URL_ADMIN_TEST to be set; skips otherwise.
|
|
6
|
+
*
|
|
7
|
+
* Key assertions:
|
|
8
|
+
* - POST /agents/:id/envs encrypts values at rest
|
|
9
|
+
* - POST /agents/:id/tokens stores hash not raw token
|
|
10
|
+
* - GET /tokens shows hash metadata only (no rawToken)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
14
|
+
import { sign } from "hono/jwt";
|
|
15
|
+
import type { PrismaClient } from "../prisma/client/client.ts";
|
|
16
|
+
import { AgentChatTokenService } from "./agent-chat-tokens.ts";
|
|
17
|
+
import { AgentCronJobService } from "./agent-cron-jobs.ts";
|
|
18
|
+
import { AgentCronRunStatsService } from "./agent-cron-run-stats.ts";
|
|
19
|
+
import { AgentCronRunService } from "./agent-cron-runs.ts";
|
|
20
|
+
import { AgentEnvService } from "./agent-envs.ts";
|
|
21
|
+
import { AgentGitHubInstallationsService } from "./agent-github-installations.ts";
|
|
22
|
+
import { AgentMemberService } from "./agent-members.ts";
|
|
23
|
+
import { AgentPluginService } from "./agent-plugins.ts";
|
|
24
|
+
import type { AgentProvisioner } from "./agent-provisioner.ts";
|
|
25
|
+
import { NoopAgentProvisioner } from "./agent-provisioner.ts";
|
|
26
|
+
import { AgentTokenService } from "./agent-tokens.ts";
|
|
27
|
+
import { AgentToolService } from "./agent-tools.ts";
|
|
28
|
+
import type { SpecialtyManifestResolver } from "./specialty-manifest-loader.ts";
|
|
29
|
+
import type { SpecialtyManifest } from "./specialty-registry.ts";
|
|
30
|
+
import { AgentService } from "./agents.ts";
|
|
31
|
+
import type { AdminDeps } from "./agents-api.ts";
|
|
32
|
+
import { createAdminApp } from "./agents-api.ts";
|
|
33
|
+
import { NoopChatServiceProvisioningClient } from "./chat-service-provisioning-client.ts";
|
|
34
|
+
import { createAdminPrismaClient } from "./prisma-client.ts";
|
|
35
|
+
import { makeTokenCrypto } from "./token-crypto.ts";
|
|
36
|
+
|
|
37
|
+
const TEST_DB = process.env.DATABASE_URL_ADMIN_TEST;
|
|
38
|
+
const describeOrSkip = TEST_DB ? describe : describe.skip;
|
|
39
|
+
|
|
40
|
+
const SESSION_SECRET = "test-admin-session-secret-32-bytes!";
|
|
41
|
+
const REAL_KEY =
|
|
42
|
+
"0000000000000000000000000000000000000000000000000000000000000001";
|
|
43
|
+
|
|
44
|
+
async function makeSessionCookie(): Promise<string> {
|
|
45
|
+
return sign(
|
|
46
|
+
{
|
|
47
|
+
isAdmin: true,
|
|
48
|
+
userId: "user-123",
|
|
49
|
+
email: "admin@example.com",
|
|
50
|
+
name: "Admin User",
|
|
51
|
+
iat: Math.floor(Date.now() / 1000),
|
|
52
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
53
|
+
},
|
|
54
|
+
SESSION_SECRET,
|
|
55
|
+
"HS256",
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function makePrisma(): PrismaClient {
|
|
60
|
+
// TEST_DB is guaranteed set — the describe block is skipped otherwise.
|
|
61
|
+
return createAdminPrismaClient(TEST_DB as string);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function createAgent(
|
|
65
|
+
prisma: PrismaClient,
|
|
66
|
+
name = "Test Agent",
|
|
67
|
+
): Promise<string> {
|
|
68
|
+
const agent = await prisma.agent.create({ data: { name } });
|
|
69
|
+
return agent.id;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A minimal valid "coding" manifest used to drive seeding assertions below. */
|
|
73
|
+
const CODING_MANIFEST: SpecialtyManifest = {
|
|
74
|
+
apiVersion: "shipwright.dev/v1alpha1",
|
|
75
|
+
kind: "Specialty",
|
|
76
|
+
metadata: {
|
|
77
|
+
name: "coding",
|
|
78
|
+
displayName: "Coding Agent",
|
|
79
|
+
description: "test manifest",
|
|
80
|
+
version: "1.0.0",
|
|
81
|
+
skills: [],
|
|
82
|
+
},
|
|
83
|
+
identity: { templatesDir: "workspace/" },
|
|
84
|
+
crons: [],
|
|
85
|
+
plugins: ["shipwright"],
|
|
86
|
+
tools: [
|
|
87
|
+
"Read",
|
|
88
|
+
"Write",
|
|
89
|
+
"Edit",
|
|
90
|
+
"Glob",
|
|
91
|
+
"Grep",
|
|
92
|
+
"Bash",
|
|
93
|
+
"WebSearch",
|
|
94
|
+
"WebFetch",
|
|
95
|
+
"Skill",
|
|
96
|
+
"Agent",
|
|
97
|
+
],
|
|
98
|
+
env: { required: [], optional: [] },
|
|
99
|
+
members: [],
|
|
100
|
+
repos: [],
|
|
101
|
+
chat: true,
|
|
102
|
+
voice: true,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Fake SpecialtyManifestResolver — resolves a fixed map of manifests by
|
|
107
|
+
* typeName. tryGetManifest() returns undefined (no fallback) for anything
|
|
108
|
+
* not in the map, mirroring SpecialtyRegistry's real contract.
|
|
109
|
+
*/
|
|
110
|
+
function fakeSpecialtyRegistry(
|
|
111
|
+
byType: Record<string, SpecialtyManifest> = { coding: CODING_MANIFEST },
|
|
112
|
+
): SpecialtyManifestResolver {
|
|
113
|
+
return {
|
|
114
|
+
getManifest(typeName: string): SpecialtyManifest {
|
|
115
|
+
return byType[typeName] ?? (byType.coding as SpecialtyManifest);
|
|
116
|
+
},
|
|
117
|
+
tryGetManifest(typeName: string): SpecialtyManifest | undefined {
|
|
118
|
+
return byType[typeName];
|
|
119
|
+
},
|
|
120
|
+
listTypes() {
|
|
121
|
+
return Object.entries(byType).map(([name, manifest]) => ({
|
|
122
|
+
name,
|
|
123
|
+
displayName: manifest.metadata.displayName,
|
|
124
|
+
}));
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function makeDeps(
|
|
130
|
+
prisma: PrismaClient,
|
|
131
|
+
provisioner: AgentProvisioner = new NoopAgentProvisioner(),
|
|
132
|
+
specialtyRegistry: SpecialtyManifestResolver = fakeSpecialtyRegistry(),
|
|
133
|
+
): AdminDeps {
|
|
134
|
+
const savedKey = process.env.SHIPWRIGHT_ENCRYPTION_KEY;
|
|
135
|
+
process.env.SHIPWRIGHT_ENCRYPTION_KEY = REAL_KEY;
|
|
136
|
+
const crypto = makeTokenCrypto();
|
|
137
|
+
if (savedKey === undefined) {
|
|
138
|
+
delete process.env.SHIPWRIGHT_ENCRYPTION_KEY;
|
|
139
|
+
} else {
|
|
140
|
+
process.env.SHIPWRIGHT_ENCRYPTION_KEY = savedKey;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
agentService: new AgentService(prisma),
|
|
145
|
+
agentEnvService: new AgentEnvService(prisma, crypto),
|
|
146
|
+
agentCronJobService: new AgentCronJobService(prisma),
|
|
147
|
+
agentCronRunService: new AgentCronRunService(prisma),
|
|
148
|
+
agentCronRunStatsService: new AgentCronRunStatsService(prisma),
|
|
149
|
+
agentToolService: new AgentToolService(prisma),
|
|
150
|
+
agentTokenService: new AgentTokenService(prisma),
|
|
151
|
+
agentPluginService: new AgentPluginService(prisma),
|
|
152
|
+
agentMemberService: new AgentMemberService(prisma),
|
|
153
|
+
specialtyRegistry,
|
|
154
|
+
agentChatTokenService: new AgentChatTokenService(prisma),
|
|
155
|
+
agentGitHubInstallationsService: new AgentGitHubInstallationsService(
|
|
156
|
+
prisma,
|
|
157
|
+
),
|
|
158
|
+
prisma,
|
|
159
|
+
provisioner,
|
|
160
|
+
serviceIntegrations: [
|
|
161
|
+
{
|
|
162
|
+
name: "task-store",
|
|
163
|
+
listTokensForAgent: async () => [],
|
|
164
|
+
revokeToken: async () => {},
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
chatService: new NoopChatServiceProvisioningClient(),
|
|
168
|
+
slack: {
|
|
169
|
+
deleteApp: async () => {},
|
|
170
|
+
},
|
|
171
|
+
decrypt: (value: string) => crypto.decrypt(value),
|
|
172
|
+
sessionSecret: SESSION_SECRET,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
describeOrSkip("admin CRUD API (integration)", () => {
|
|
177
|
+
let prisma: PrismaClient;
|
|
178
|
+
let agentId: string;
|
|
179
|
+
let cookie: string;
|
|
180
|
+
let app: ReturnType<typeof createAdminApp>;
|
|
181
|
+
|
|
182
|
+
beforeEach(async () => {
|
|
183
|
+
prisma = makePrisma();
|
|
184
|
+
|
|
185
|
+
// Clean all tables in FK dependency order
|
|
186
|
+
await prisma.agentWorkQueueSnapshot.deleteMany();
|
|
187
|
+
await prisma.agentPlugin.deleteMany();
|
|
188
|
+
await prisma.agentPhaseMethodology.deleteMany();
|
|
189
|
+
await prisma.agentToken.deleteMany();
|
|
190
|
+
await prisma.agentCronJob.deleteMany();
|
|
191
|
+
await prisma.agentTool.deleteMany();
|
|
192
|
+
await prisma.agentEnv.deleteMany();
|
|
193
|
+
await prisma.agentMember.deleteMany();
|
|
194
|
+
await prisma.agent.deleteMany();
|
|
195
|
+
|
|
196
|
+
agentId = await createAgent(prisma);
|
|
197
|
+
cookie = await makeSessionCookie();
|
|
198
|
+
|
|
199
|
+
app = createAdminApp(makeDeps(prisma));
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
afterEach(async () => {
|
|
203
|
+
await prisma.$disconnect();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// ─── Env var encryption ───────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
it("POST /envs encrypts values at rest — raw DB value differs from input", async () => {
|
|
209
|
+
const savedKey = process.env.SHIPWRIGHT_ENCRYPTION_KEY;
|
|
210
|
+
process.env.SHIPWRIGHT_ENCRYPTION_KEY = REAL_KEY;
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
const res = await app.request(`/agents/${agentId}/envs`, {
|
|
214
|
+
method: "POST",
|
|
215
|
+
body: JSON.stringify({ SECRET: "my-api-key" }),
|
|
216
|
+
headers: {
|
|
217
|
+
"Content-Type": "application/json",
|
|
218
|
+
Cookie: `admin_session=${cookie}`,
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
expect(res.status).toBe(201);
|
|
222
|
+
|
|
223
|
+
// Raw DB value should be encrypted (not plain text)
|
|
224
|
+
const raw = await prisma.agentEnv.findFirst({
|
|
225
|
+
where: { agentId, key: "SECRET" },
|
|
226
|
+
});
|
|
227
|
+
expect(raw?.value).not.toBe("my-api-key");
|
|
228
|
+
expect(raw?.value).toContain(":"); // iv:ciphertext:authTag format
|
|
229
|
+
} finally {
|
|
230
|
+
if (savedKey === undefined) {
|
|
231
|
+
delete process.env.SHIPWRIGHT_ENCRYPTION_KEY;
|
|
232
|
+
} else {
|
|
233
|
+
process.env.SHIPWRIGHT_ENCRYPTION_KEY = savedKey;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("GET /envs returns decrypted values", async () => {
|
|
239
|
+
const savedKey = process.env.SHIPWRIGHT_ENCRYPTION_KEY;
|
|
240
|
+
process.env.SHIPWRIGHT_ENCRYPTION_KEY = REAL_KEY;
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
// First write via POST
|
|
244
|
+
await app.request(`/agents/${agentId}/envs`, {
|
|
245
|
+
method: "POST",
|
|
246
|
+
body: JSON.stringify({ SECRET: "my-api-key" }),
|
|
247
|
+
headers: {
|
|
248
|
+
"Content-Type": "application/json",
|
|
249
|
+
Cookie: `admin_session=${cookie}`,
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// Then read via GET
|
|
254
|
+
const res = await app.request(`/agents/${agentId}/envs`, {
|
|
255
|
+
headers: { Cookie: `admin_session=${cookie}` },
|
|
256
|
+
});
|
|
257
|
+
expect(res.status).toBe(200);
|
|
258
|
+
const body = await res.json();
|
|
259
|
+
expect(body.env.SECRET).toBe("my-api-key");
|
|
260
|
+
} finally {
|
|
261
|
+
if (savedKey === undefined) {
|
|
262
|
+
delete process.env.SHIPWRIGHT_ENCRYPTION_KEY;
|
|
263
|
+
} else {
|
|
264
|
+
process.env.SHIPWRIGHT_ENCRYPTION_KEY = savedKey;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// ─── Token hash storage ───────────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
it("POST /tokens stores hash not raw token; GET /tokens shows hash metadata only", async () => {
|
|
272
|
+
const res = await app.request(`/agents/${agentId}/tokens`, {
|
|
273
|
+
method: "POST",
|
|
274
|
+
body: JSON.stringify({ label: "ci-token" }),
|
|
275
|
+
headers: {
|
|
276
|
+
"Content-Type": "application/json",
|
|
277
|
+
Cookie: `admin_session=${cookie}`,
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
expect(res.status).toBe(201);
|
|
281
|
+
|
|
282
|
+
const createBody = await res.json();
|
|
283
|
+
const rawToken: string = createBody.rawToken;
|
|
284
|
+
expect(typeof rawToken).toBe("string");
|
|
285
|
+
expect(rawToken.length).toBeGreaterThan(0);
|
|
286
|
+
|
|
287
|
+
// Verify the stored token is a hash, not the raw token
|
|
288
|
+
const dbRecord = await prisma.agentToken.findFirst({
|
|
289
|
+
where: { agentId },
|
|
290
|
+
});
|
|
291
|
+
expect(dbRecord).not.toBeNull();
|
|
292
|
+
expect(dbRecord?.token).not.toBe(rawToken);
|
|
293
|
+
// SHA-256 hash is 64 hex chars
|
|
294
|
+
expect(dbRecord?.token).toHaveLength(64);
|
|
295
|
+
|
|
296
|
+
// GET /tokens should not expose rawToken
|
|
297
|
+
const listRes = await app.request(`/agents/${agentId}/tokens`, {
|
|
298
|
+
headers: { Cookie: `admin_session=${cookie}` },
|
|
299
|
+
});
|
|
300
|
+
expect(listRes.status).toBe(200);
|
|
301
|
+
const listBody = await listRes.json();
|
|
302
|
+
expect(Array.isArray(listBody.tokens)).toBe(true);
|
|
303
|
+
expect(listBody.tokens).toHaveLength(1);
|
|
304
|
+
// rawToken must NOT be present in the list response
|
|
305
|
+
const listedToken = listBody.tokens[0];
|
|
306
|
+
expect(listedToken.rawToken).toBeUndefined();
|
|
307
|
+
// The stored hash value should also not be exposed
|
|
308
|
+
expect(listedToken.token).toBeUndefined();
|
|
309
|
+
expect(listedToken.id).toBe(dbRecord?.id);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// ─── typeName field ─────────────────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
it("GET /agents includes typeName:'coding' for an agent created before typeName existed", async () => {
|
|
315
|
+
const res = await app.request("/agents", {
|
|
316
|
+
headers: { Cookie: `admin_session=${cookie}` },
|
|
317
|
+
});
|
|
318
|
+
expect(res.status).toBe(200);
|
|
319
|
+
const body = await res.json();
|
|
320
|
+
const entry = body.find((a: { id: string }) => a.id === agentId);
|
|
321
|
+
expect(entry).toBeDefined();
|
|
322
|
+
expect(entry.typeName).toBe("coding");
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("GET /agents/:id includes typeName:'coding' by default", async () => {
|
|
326
|
+
const res = await app.request(`/agents/${agentId}`, {
|
|
327
|
+
headers: { Cookie: `admin_session=${cookie}` },
|
|
328
|
+
});
|
|
329
|
+
expect(res.status).toBe(200);
|
|
330
|
+
const body = await res.json();
|
|
331
|
+
expect(body.typeName).toBe("coding");
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// ─── Migration backfill read-back (pre-existing rows + child rows) ────────────
|
|
335
|
+
//
|
|
336
|
+
// Simulates the exact scenario the 20260724000000_add_agent_type_name
|
|
337
|
+
// migration must handle: rows that existed *before* the typeName column was
|
|
338
|
+
// added (never received an explicit value) must read back typeName="coding"
|
|
339
|
+
// via the column DEFAULT, and every child row (cron/plugin/tool/env/member)
|
|
340
|
+
// must be completely untouched — same count, same updatedAt — since the
|
|
341
|
+
// migration only ALTERs the Agent table.
|
|
342
|
+
|
|
343
|
+
it("migration backfill: pre-existing agent rows read back typeName='coding' and child rows (cron/plugin/tool/env/member) are untouched", async () => {
|
|
344
|
+
// Seed a second "pre-existing" agent with one row in every child table,
|
|
345
|
+
// simulating data that existed prior to the typeName migration landing.
|
|
346
|
+
const preExisting = await prisma.agent.create({
|
|
347
|
+
data: { name: "Pre-Existing Agent" },
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
const cron = await prisma.agentCronJob.create({
|
|
351
|
+
data: {
|
|
352
|
+
agentId: preExisting.id,
|
|
353
|
+
schedule: "0 9 * * 1-5",
|
|
354
|
+
prompt: "daily standup",
|
|
355
|
+
channel: "C123",
|
|
356
|
+
},
|
|
357
|
+
});
|
|
358
|
+
const plugin = await prisma.agentPlugin.create({
|
|
359
|
+
data: { agentId: preExisting.id, name: "shipwright@shipwright" },
|
|
360
|
+
});
|
|
361
|
+
const tool = await prisma.agentTool.create({
|
|
362
|
+
data: { agentId: preExisting.id, pattern: "Read" },
|
|
363
|
+
});
|
|
364
|
+
const env = await prisma.agentEnv.create({
|
|
365
|
+
data: { agentId: preExisting.id, key: "FOO", value: "encrypted-value" },
|
|
366
|
+
});
|
|
367
|
+
const member = await prisma.agentMember.create({
|
|
368
|
+
data: { agentId: preExisting.id, email: "member@example.com" },
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// Re-running `prisma migrate deploy` against an already-migrated schema
|
|
372
|
+
// (which is exactly the state this test DB is in — beforeEach only
|
|
373
|
+
// truncates rows, it never re-runs migrations) is the idempotency
|
|
374
|
+
// scenario: the additive ALTER TABLE ... ADD COLUMN has already been
|
|
375
|
+
// applied once when the schema was first provisioned, and asserting the
|
|
376
|
+
// column's default here — without any application-layer backfill code —
|
|
377
|
+
// is the black-box proof that a second `migrate deploy` is a no-op that
|
|
378
|
+
// still leaves every row readable with the column default intact.
|
|
379
|
+
|
|
380
|
+
// Read back via the raw Agent row (bypasses the API layer entirely) —
|
|
381
|
+
// this is the direct DB-level assertion that the column DEFAULT applies
|
|
382
|
+
// to a row that never set typeName explicitly.
|
|
383
|
+
const rawAgent = await prisma.agent.findUniqueOrThrow({
|
|
384
|
+
where: { id: preExisting.id },
|
|
385
|
+
});
|
|
386
|
+
expect(rawAgent.typeName).toBe("coding");
|
|
387
|
+
|
|
388
|
+
// Read back via the HTTP API too — end-to-end confirmation.
|
|
389
|
+
const res = await app.request(`/agents/${preExisting.id}`, {
|
|
390
|
+
headers: { Cookie: `admin_session=${cookie}` },
|
|
391
|
+
});
|
|
392
|
+
expect(res.status).toBe(200);
|
|
393
|
+
const body = await res.json();
|
|
394
|
+
expect(body.typeName).toBe("coding");
|
|
395
|
+
|
|
396
|
+
// Child row counts are unchanged — exactly one of each, still linked to
|
|
397
|
+
// the same agent.
|
|
398
|
+
expect(
|
|
399
|
+
await prisma.agentCronJob.count({ where: { agentId: preExisting.id } }),
|
|
400
|
+
).toBe(1);
|
|
401
|
+
expect(
|
|
402
|
+
await prisma.agentPlugin.count({ where: { agentId: preExisting.id } }),
|
|
403
|
+
).toBe(1);
|
|
404
|
+
expect(
|
|
405
|
+
await prisma.agentTool.count({ where: { agentId: preExisting.id } }),
|
|
406
|
+
).toBe(1);
|
|
407
|
+
expect(
|
|
408
|
+
await prisma.agentEnv.count({ where: { agentId: preExisting.id } }),
|
|
409
|
+
).toBe(1);
|
|
410
|
+
expect(
|
|
411
|
+
await prisma.agentMember.count({ where: { agentId: preExisting.id } }),
|
|
412
|
+
).toBe(1);
|
|
413
|
+
|
|
414
|
+
// Child row updatedAt values are unchanged — the typeName column only
|
|
415
|
+
// touches the Agent table, so nothing here should have been rewritten.
|
|
416
|
+
const cronAfter = await prisma.agentCronJob.findUniqueOrThrow({
|
|
417
|
+
where: { id: cron.id },
|
|
418
|
+
});
|
|
419
|
+
const pluginAfter = await prisma.agentPlugin.findUniqueOrThrow({
|
|
420
|
+
where: { id: plugin.id },
|
|
421
|
+
});
|
|
422
|
+
const toolAfter = await prisma.agentTool.findUniqueOrThrow({
|
|
423
|
+
where: { id: tool.id },
|
|
424
|
+
});
|
|
425
|
+
const envAfter = await prisma.agentEnv.findUniqueOrThrow({
|
|
426
|
+
where: { id: env.id },
|
|
427
|
+
});
|
|
428
|
+
const memberAfter = await prisma.agentMember.findUniqueOrThrow({
|
|
429
|
+
where: { id: member.id },
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
expect(cronAfter.updatedAt.getTime()).toBe(cron.updatedAt.getTime());
|
|
433
|
+
expect(pluginAfter.updatedAt.getTime()).toBe(plugin.updatedAt.getTime());
|
|
434
|
+
expect(envAfter.updatedAt.getTime()).toBe(env.updatedAt.getTime());
|
|
435
|
+
// AgentTool and AgentMember have no updatedAt column — createdAt stands
|
|
436
|
+
// in as the "untouched" signal for those two models.
|
|
437
|
+
expect(toolAfter.createdAt.getTime()).toBe(tool.createdAt.getTime());
|
|
438
|
+
expect(memberAfter.createdAt.getTime()).toBe(member.createdAt.getTime());
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it("prisma migrate deploy is idempotent — running it twice does not error or duplicate the typeName column", async () => {
|
|
442
|
+
// The migration has already been applied once (this test DB was
|
|
443
|
+
// provisioned via `prisma migrate deploy` before the suite ran). Re-run
|
|
444
|
+
// deploy here and confirm the column is still readable with a single,
|
|
445
|
+
// unambiguous default — a duplicate-column error would surface as a
|
|
446
|
+
// thrown exception from execSync, and a broken default would surface as
|
|
447
|
+
// a failed read-back.
|
|
448
|
+
const { execSync } = await import("node:child_process");
|
|
449
|
+
execSync("bunx prisma migrate deploy --schema=prisma/schema", {
|
|
450
|
+
cwd: new URL("..", import.meta.url).pathname,
|
|
451
|
+
env: { ...process.env, DATABASE_URL_SHIPWRIGHT_ADMIN: TEST_DB },
|
|
452
|
+
stdio: "pipe",
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
const rawAgent = await prisma.agent.findUniqueOrThrow({
|
|
456
|
+
where: { id: agentId },
|
|
457
|
+
});
|
|
458
|
+
expect(rawAgent.typeName).toBe("coding");
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
// ─── repos field round-trip ───────────────────────────────────────────────────
|
|
462
|
+
|
|
463
|
+
it("PATCH /agents/:id with repos sets the field; GET /agents/:id returns it", async () => {
|
|
464
|
+
// PATCH to set repos
|
|
465
|
+
const patchRes = await app.request(`/agents/${agentId}`, {
|
|
466
|
+
method: "PATCH",
|
|
467
|
+
body: JSON.stringify({ repos: ["my-org/my-repo"] }),
|
|
468
|
+
headers: {
|
|
469
|
+
"Content-Type": "application/json",
|
|
470
|
+
Cookie: `admin_session=${cookie}`,
|
|
471
|
+
},
|
|
472
|
+
});
|
|
473
|
+
expect(patchRes.status).toBe(200);
|
|
474
|
+
const patchBody = await patchRes.json();
|
|
475
|
+
expect(patchBody.repos).toEqual(["my-org/my-repo"]);
|
|
476
|
+
|
|
477
|
+
// GET to confirm persisted value
|
|
478
|
+
const getRes = await app.request(`/agents/${agentId}`, {
|
|
479
|
+
headers: { Cookie: `admin_session=${cookie}` },
|
|
480
|
+
});
|
|
481
|
+
expect(getRes.status).toBe(200);
|
|
482
|
+
const getBody = await getRes.json();
|
|
483
|
+
expect(getBody.repos).toEqual(["my-org/my-repo"]);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it("POST then DELETE /agents/:id/repos persists the expected set and preserves order", async () => {
|
|
487
|
+
const hdr = {
|
|
488
|
+
"Content-Type": "application/json",
|
|
489
|
+
Cookie: `admin_session=${cookie}`,
|
|
490
|
+
};
|
|
491
|
+
const setRes = await app.request(`/agents/${agentId}`, {
|
|
492
|
+
method: "PATCH",
|
|
493
|
+
body: JSON.stringify({ repos: ["org/alpha", "org/beta"] }),
|
|
494
|
+
headers: hdr,
|
|
495
|
+
});
|
|
496
|
+
expect(setRes.status).toBe(200);
|
|
497
|
+
|
|
498
|
+
const addRes = await app.request(`/agents/${agentId}/repos`, {
|
|
499
|
+
method: "POST",
|
|
500
|
+
body: JSON.stringify({ repos: ["org/gamma", "org/alpha"] }),
|
|
501
|
+
headers: hdr,
|
|
502
|
+
});
|
|
503
|
+
expect(addRes.status).toBe(200);
|
|
504
|
+
|
|
505
|
+
const delRes = await app.request(`/agents/${agentId}/repos/org/beta`, {
|
|
506
|
+
method: "DELETE",
|
|
507
|
+
headers: hdr,
|
|
508
|
+
});
|
|
509
|
+
expect(delRes.status).toBe(200);
|
|
510
|
+
|
|
511
|
+
const row = await prisma.agent.findUniqueOrThrow({
|
|
512
|
+
where: { id: agentId },
|
|
513
|
+
});
|
|
514
|
+
expect(row.repos).toEqual(["org/alpha", "org/gamma"]);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
// ─── reviewAuthorAllowlist field round-trip ────────────────────────────────
|
|
518
|
+
|
|
519
|
+
it("PATCH /agents/:id with reviewAuthorAllowlist sets the field; GET /agents/:id returns it", async () => {
|
|
520
|
+
const patchRes = await app.request(`/agents/${agentId}`, {
|
|
521
|
+
method: "PATCH",
|
|
522
|
+
body: JSON.stringify({ reviewAuthorAllowlist: ["octocat"] }),
|
|
523
|
+
headers: {
|
|
524
|
+
"Content-Type": "application/json",
|
|
525
|
+
Cookie: `admin_session=${cookie}`,
|
|
526
|
+
},
|
|
527
|
+
});
|
|
528
|
+
expect(patchRes.status).toBe(200);
|
|
529
|
+
const patchBody = await patchRes.json();
|
|
530
|
+
expect(patchBody.reviewAuthorAllowlist).toEqual(["octocat"]);
|
|
531
|
+
|
|
532
|
+
// GET to confirm persisted value
|
|
533
|
+
const getRes = await app.request(`/agents/${agentId}`, {
|
|
534
|
+
headers: { Cookie: `admin_session=${cookie}` },
|
|
535
|
+
});
|
|
536
|
+
expect(getRes.status).toBe(200);
|
|
537
|
+
const getBody = await getRes.json();
|
|
538
|
+
expect(getBody.reviewAuthorAllowlist).toEqual(["octocat"]);
|
|
539
|
+
|
|
540
|
+
const rawAgent = await prisma.agent.findUniqueOrThrow({
|
|
541
|
+
where: { id: agentId },
|
|
542
|
+
});
|
|
543
|
+
expect(rawAgent.reviewAuthorAllowlist).toEqual(["octocat"]);
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
// DBR-2.4 regression: the legacy authorAllowlist column is gone for good.
|
|
547
|
+
it("GET /agents/:id response does not include the removed authorAllowlist field", async () => {
|
|
548
|
+
const getRes = await app.request(`/agents/${agentId}`, {
|
|
549
|
+
headers: { Cookie: `admin_session=${cookie}` },
|
|
550
|
+
});
|
|
551
|
+
expect(getRes.status).toBe(200);
|
|
552
|
+
const getBody = await getRes.json();
|
|
553
|
+
expect(getBody).not.toHaveProperty("authorAllowlist");
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
// ─── restrictSlackToMembers field round-trip ───────────────────────────────────
|
|
557
|
+
|
|
558
|
+
it("PATCH /agents/:id with restrictSlackToMembers:true sets the field; GET /agents/:id returns it", async () => {
|
|
559
|
+
const patchRes = await app.request(`/agents/${agentId}`, {
|
|
560
|
+
method: "PATCH",
|
|
561
|
+
body: JSON.stringify({ restrictSlackToMembers: true }),
|
|
562
|
+
headers: {
|
|
563
|
+
"Content-Type": "application/json",
|
|
564
|
+
Cookie: `admin_session=${cookie}`,
|
|
565
|
+
},
|
|
566
|
+
});
|
|
567
|
+
expect(patchRes.status).toBe(200);
|
|
568
|
+
const patchBody = await patchRes.json();
|
|
569
|
+
expect(patchBody.restrictSlackToMembers).toBe(true);
|
|
570
|
+
|
|
571
|
+
const getRes = await app.request(`/agents/${agentId}`, {
|
|
572
|
+
headers: { Cookie: `admin_session=${cookie}` },
|
|
573
|
+
});
|
|
574
|
+
expect(getRes.status).toBe(200);
|
|
575
|
+
const getBody = await getRes.json();
|
|
576
|
+
expect(getBody.restrictSlackToMembers).toBe(true);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it("PATCH /agents/:id omitting restrictSlackToMembers leaves the existing value unchanged", async () => {
|
|
580
|
+
// First set it to true.
|
|
581
|
+
await app.request(`/agents/${agentId}`, {
|
|
582
|
+
method: "PATCH",
|
|
583
|
+
body: JSON.stringify({ restrictSlackToMembers: true }),
|
|
584
|
+
headers: {
|
|
585
|
+
"Content-Type": "application/json",
|
|
586
|
+
Cookie: `admin_session=${cookie}`,
|
|
587
|
+
},
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
// A subsequent PATCH that omits the field entirely must not reset it.
|
|
591
|
+
const patchRes = await app.request(`/agents/${agentId}`, {
|
|
592
|
+
method: "PATCH",
|
|
593
|
+
body: JSON.stringify({ selfHosted: false }),
|
|
594
|
+
headers: {
|
|
595
|
+
"Content-Type": "application/json",
|
|
596
|
+
Cookie: `admin_session=${cookie}`,
|
|
597
|
+
},
|
|
598
|
+
});
|
|
599
|
+
expect(patchRes.status).toBe(200);
|
|
600
|
+
const patchBody = await patchRes.json();
|
|
601
|
+
expect(patchBody.restrictSlackToMembers).toBe(true);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
// ─── restrictSlackToMembers zero-members warning ───────────────────────────────
|
|
605
|
+
|
|
606
|
+
it("PATCH /agents/:id to restrictSlackToMembers:true with zero AgentMember rows returns a warning; with members present returns no warning", async () => {
|
|
607
|
+
// No members on this agent — expect a warning.
|
|
608
|
+
const patchRes = await app.request(`/agents/${agentId}`, {
|
|
609
|
+
method: "PATCH",
|
|
610
|
+
body: JSON.stringify({ restrictSlackToMembers: true }),
|
|
611
|
+
headers: {
|
|
612
|
+
"Content-Type": "application/json",
|
|
613
|
+
Cookie: `admin_session=${cookie}`,
|
|
614
|
+
},
|
|
615
|
+
});
|
|
616
|
+
expect(patchRes.status).toBe(200);
|
|
617
|
+
const patchBody = await patchRes.json();
|
|
618
|
+
expect(typeof patchBody.warning).toBe("string");
|
|
619
|
+
|
|
620
|
+
// Reset, add a member, then flip the flag again — expect no warning.
|
|
621
|
+
await app.request(`/agents/${agentId}`, {
|
|
622
|
+
method: "PATCH",
|
|
623
|
+
body: JSON.stringify({ restrictSlackToMembers: false }),
|
|
624
|
+
headers: {
|
|
625
|
+
"Content-Type": "application/json",
|
|
626
|
+
Cookie: `admin_session=${cookie}`,
|
|
627
|
+
},
|
|
628
|
+
});
|
|
629
|
+
await prisma.agentMember.create({
|
|
630
|
+
data: { agentId, email: "member@example.com" },
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
const secondPatchRes = await app.request(`/agents/${agentId}`, {
|
|
634
|
+
method: "PATCH",
|
|
635
|
+
body: JSON.stringify({ restrictSlackToMembers: true }),
|
|
636
|
+
headers: {
|
|
637
|
+
"Content-Type": "application/json",
|
|
638
|
+
Cookie: `admin_session=${cookie}`,
|
|
639
|
+
},
|
|
640
|
+
});
|
|
641
|
+
expect(secondPatchRes.status).toBe(200);
|
|
642
|
+
const secondPatchBody = await secondPatchRes.json();
|
|
643
|
+
expect(secondPatchBody.warning).toBeUndefined();
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
it("GET /agents/:id/chat-tokens/daily/stats is scoped to the caller's own agent", async () => {
|
|
647
|
+
const otherId = await createAgent(prisma);
|
|
648
|
+
const chatTokens = new AgentChatTokenService(prisma);
|
|
649
|
+
const usage = (n: number) => ({
|
|
650
|
+
inputTokens: n,
|
|
651
|
+
outputTokens: n,
|
|
652
|
+
cacheReadTokens: 0,
|
|
653
|
+
cacheCreationTokens: 0,
|
|
654
|
+
costUsd: 0,
|
|
655
|
+
});
|
|
656
|
+
await chatTokens.upsertDailyByModel(
|
|
657
|
+
agentId,
|
|
658
|
+
"2026-01-10",
|
|
659
|
+
"model-a",
|
|
660
|
+
usage(10),
|
|
661
|
+
);
|
|
662
|
+
await chatTokens.upsertDailyByModel(
|
|
663
|
+
otherId,
|
|
664
|
+
"2026-01-10",
|
|
665
|
+
"model-b",
|
|
666
|
+
usage(500),
|
|
667
|
+
);
|
|
668
|
+
const { rawToken: tokenA } = await new AgentTokenService(prisma).create(
|
|
669
|
+
agentId,
|
|
670
|
+
);
|
|
671
|
+
const own = await app.request(
|
|
672
|
+
`/agents/${agentId}/chat-tokens/daily/stats`,
|
|
673
|
+
{
|
|
674
|
+
headers: { Authorization: `Bearer ${tokenA}` },
|
|
675
|
+
},
|
|
676
|
+
);
|
|
677
|
+
expect(own.status).toBe(200);
|
|
678
|
+
const body = await own.json();
|
|
679
|
+
expect(body.totals.input).toBe(10);
|
|
680
|
+
expect(body.byAgent.map((r: { key: string }) => r.key)).toEqual([agentId]);
|
|
681
|
+
expect(
|
|
682
|
+
body.byModel.map((r: { key1: string; key2: string }) => [r.key1, r.key2]),
|
|
683
|
+
).toEqual([[agentId, "model-a"]]);
|
|
684
|
+
expect(body.daily).toHaveLength(1);
|
|
685
|
+
expect(body.daily[0].input).toBe(10);
|
|
686
|
+
|
|
687
|
+
const cross = await app.request(
|
|
688
|
+
`/agents/${otherId}/chat-tokens/daily/stats`,
|
|
689
|
+
{ headers: { Authorization: `Bearer ${tokenA}` } },
|
|
690
|
+
);
|
|
691
|
+
expect(cross.status).toBe(403);
|
|
692
|
+
|
|
693
|
+
const anon = await app.request(
|
|
694
|
+
`/agents/${agentId}/chat-tokens/daily/stats`,
|
|
695
|
+
);
|
|
696
|
+
expect(anon.status).toBe(401);
|
|
697
|
+
|
|
698
|
+
// Fleet-wide admin route is unchanged: sees both agents.
|
|
699
|
+
const fleet = await app.request("/agents/chat-tokens/daily/stats", {
|
|
700
|
+
headers: { Cookie: `admin_session=${cookie}` },
|
|
701
|
+
});
|
|
702
|
+
expect(fleet.status).toBe(200);
|
|
703
|
+
expect((await fleet.json()).totals.input).toBe(510);
|
|
704
|
+
});
|
|
705
|
+
});
|