@app-vitals/agent-admin 1.13.0 → 1.15.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@app-vitals/agent-admin",
3
- "version": "1.13.0",
3
+ "version": "1.15.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/app-vitals/agent.git",
@@ -42,7 +42,7 @@
42
42
  },
43
43
  "dependencies": {
44
44
  "@anthropic-ai/sdk": "0.135.0",
45
- "@app-vitals/agent-lib": "1.13.0",
45
+ "@app-vitals/agent-lib": "1.15.0",
46
46
  "@hono/zod-openapi": "^1.0.0",
47
47
  "@prisma/adapter-pg": "^7.10.0",
48
48
  "@prisma/client": "^7.10.0",
package/src/admin-boot.ts CHANGED
@@ -99,6 +99,8 @@ import { CronRunNowService } from "./cron-run-now.ts";
99
99
  import { parseCronRunNowConfig } from "./cron-run-now-config.ts";
100
100
  import { CronRunTranscriptService } from "./cron-run-transcripts.ts";
101
101
  import { isDevAuthAllowed } from "./dev-auth-guard.ts";
102
+ import { createGithubEnterpriseApp } from "./github-enterprise-api.ts";
103
+ import { GitHubEnterpriseAppService } from "./github-enterprise-apps.ts";
102
104
  import { HttpGithubAppProvisioningClient } from "./github-app-provisioning-client.ts";
103
105
  import { HttpGoogleAuthClient } from "./google-auth-client.ts";
104
106
  import { HttpChatClient } from "./http-chat-client.ts";
@@ -113,7 +115,7 @@ import { parseSelfServeConfig } from "./self-serve-config.ts";
113
115
  import { HttpSlackProvisioningClient } from "./slack-provisioning-client.ts";
114
116
  import { SpecialtyTypes } from "./specialty-types.ts";
115
117
  import { runStartupPolicyReconcile } from "./startup-policy-reconcile.ts";
116
- import { makeTokenCrypto } from "./token-crypto.ts";
118
+ import { makeStrictTokenCrypto, makeTokenCrypto } from "./token-crypto.ts";
117
119
  import { parseTranscriptConfig } from "./transcript-config.ts";
118
120
  import {
119
121
  resolveTrialExpirySweepIntervalMs,
@@ -530,6 +532,19 @@ export async function startAdmin(
530
532
  }),
531
533
  );
532
534
 
535
+ // 3b'. GitHub Enterprise Apps API — /github-enterprise/* — admin-only,
536
+ // optional (GHE-1.3). With no rows configured it reports unconfigured
537
+ // and does nothing else. Strict crypto: no plain-text key storage.
538
+ const githubEnterpriseApp = createGithubEnterpriseApp({
539
+ appService: new GitHubEnterpriseAppService(prisma, makeStrictTokenCrypto()),
540
+ agentService,
541
+ agentEnvService,
542
+ sessionSecret,
543
+ adminApiKeys,
544
+ agentTokenService,
545
+ });
546
+ root.route("/", githubEnterpriseApp);
547
+
533
548
  // 3c. Extension API routes — after every platform API, before the admin UI
534
549
  // (same no-shadowing rule as the admin CRUD API above).
535
550
  mountRouteContributions(root, resolvedExtensions.apiRoutes);
@@ -594,6 +609,8 @@ export async function startAdmin(
594
609
  });
595
610
  return accountCreatedNotifier ? { accountCreatedNotifier } : {};
596
611
  })(),
612
+ githubEnterpriseApi: (path, init) =>
613
+ githubEnterpriseApp.request(path, init),
597
614
  accountServices: {
598
615
  accounts: new AccountService(prisma),
599
616
  members: new AccountMemberService(prisma),
package/src/admin-spec.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  parseAdminApiKeys,
25
25
  } from "./agents-api.ts";
26
26
  import { createAgentRuntimeApp } from "./api.ts";
27
+ import { createGithubEnterpriseApp } from "./github-enterprise-api.ts";
27
28
 
28
29
  /** The admin OpenAPI document shape the spec builders and generators share. */
29
30
  export interface AdminOpenApiDocument {
@@ -172,6 +173,23 @@ export function buildPlatformAdminSpec(): AdminOpenApiDocument {
172
173
  }
173
174
  }
174
175
 
176
+ // GHE-1.3: the /github-enterprise routes live in their own app (mounted at
177
+ // the root in admin-boot.ts); fold their paths and schemas in.
178
+ const gheSpec = createGithubEnterpriseApp({
179
+ appService: {} as never,
180
+ agentService: {} as never,
181
+ agentEnvService: {} as never,
182
+ sessionSecret: SESSION_SECRET,
183
+ adminApiKeys,
184
+ agentTokenService: { validate: async () => null },
185
+ }).getOpenAPI31Document({
186
+ openapi: "3.1.0",
187
+ info: { title: "github-enterprise (internal)", version: "0.1.0" },
188
+ });
189
+ for (const [path, def] of Object.entries(gheSpec.paths ?? {})) {
190
+ mergedPaths[path] = def as Record<string, unknown>;
191
+ }
192
+
175
193
  return {
176
194
  ...adminSpec,
177
195
  paths: mergedPaths,
@@ -180,6 +198,7 @@ export function buildPlatformAdminSpec(): AdminOpenApiDocument {
180
198
  schemas: {
181
199
  ...(adminSpec.components?.schemas ?? {}),
182
200
  ...(runtimeSpec.components?.schemas ?? {}),
201
+ ...(gheSpec.components?.schemas ?? {}),
183
202
  },
184
203
  },
185
204
  };
@@ -9,12 +9,29 @@
9
9
 
10
10
  import { AsyncLocalStorage } from "node:async_hooks";
11
11
 
12
- const storage = new AsyncLocalStorage<{ showAccount: boolean }>();
12
+ interface NavFlags {
13
+ showAccount: boolean;
14
+ /** GHE-1.4: the admin-only "Settings" entry. */
15
+ showSettings: boolean;
16
+ }
17
+
18
+ const storage = new AsyncLocalStorage<NavFlags>();
13
19
 
14
20
  export function runWithAccountNav<T>(showAccount: boolean, fn: () => T): T {
15
- return storage.run({ showAccount }, fn);
21
+ const showSettings = storage.getStore()?.showSettings === true;
22
+ return storage.run({ showAccount, showSettings }, fn);
16
23
  }
17
24
 
18
25
  export function shouldShowAccountNav(): boolean {
19
26
  return storage.getStore()?.showAccount === true;
20
27
  }
28
+
29
+ /** Wrap a request so the toolbar shows (or hides) the admin Settings entry. */
30
+ export function runWithSettingsNav<T>(showSettings: boolean, fn: () => T): T {
31
+ const showAccount = storage.getStore()?.showAccount === true;
32
+ return storage.run({ showAccount, showSettings }, fn);
33
+ }
34
+
35
+ export function shouldShowSettingsNav(): boolean {
36
+ return storage.getStore()?.showSettings === true;
37
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * admin/src/admin-ui-github-enterprise-pages.ts
3
+ * Render function for the enterprise GitHub apps settings page (GHE-1.4).
4
+ * Pure string -> string; routes live in admin-ui-github-enterprise.ts.
5
+ *
6
+ * Credentials are paste-only: the forms accept the private key but the page
7
+ * never renders a key (the status API only reports `keySet`).
8
+ */
9
+
10
+ import { renderAdminPage } from "./admin-ui-layout.ts";
11
+ import { escapeHtml, renderAdminToolbar } from "./admin-ui-styles.ts";
12
+
13
+ export const ADMIN_GITHUB_ENTERPRISE_PATH = "/admin/settings/github-enterprise";
14
+
15
+ /** GithubEnterpriseApp shape returned by GET /github-enterprise/apps. */
16
+ export interface GheApp {
17
+ role: string;
18
+ appId: string;
19
+ slug: string;
20
+ enterpriseSlug: string | null;
21
+ keySet: boolean;
22
+ lastValidationStatus: string | null;
23
+ lastValidatedAt: string | null;
24
+ lastValidationError: string | null;
25
+ createdAt: string;
26
+ updatedAt: string;
27
+ }
28
+
29
+ export interface GheStatus {
30
+ configured: boolean;
31
+ installer: GheApp | null;
32
+ lookup: GheApp | null;
33
+ }
34
+
35
+ export interface GheOrg {
36
+ id: number;
37
+ login: string;
38
+ lookupAppInstalled: boolean | null;
39
+ agentAppInstalled: boolean | null;
40
+ }
41
+
42
+ export interface GheOrgs {
43
+ configured: boolean;
44
+ agentAppSlug?: string | null;
45
+ orgs: GheOrg[];
46
+ }
47
+
48
+ export interface GithubEnterpriseSettingsPageOpts {
49
+ userEmail: string;
50
+ status: GheStatus;
51
+ /** Present when the installer App is configured and the org list loaded. */
52
+ orgs?: GheOrgs;
53
+ /** Readable message when the org list could not be loaded. */
54
+ orgsError?: string;
55
+ /** Agent whose App the install actions target (carried via ?agentId=). */
56
+ agentId?: string;
57
+ error?: string;
58
+ notice?: string;
59
+ }
60
+
61
+ const BASE = ADMIN_GITHUB_ENTERPRISE_PATH;
62
+
63
+ /** "2026-11-09T12:00:00.000Z" -> "2026-11-09 12:00 UTC". */
64
+ function formatTime(iso: string): string {
65
+ return `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`;
66
+ }
67
+
68
+ function validationBadge(status: string | null): string {
69
+ if (!status) return `<span class="badge badge-gray">Not validated yet</span>`;
70
+ const cls = status === "valid" ? "badge-green" : "badge-warning";
71
+ return `<span class="badge ${cls}">${escapeHtml(status)}</span>`;
72
+ }
73
+
74
+ const ROLE_COPY = {
75
+ installer: {
76
+ title: "Installer app",
77
+ help: "Installs agents' GitHub Apps on the enterprise's organizations. Needs the enterprise_organization_installations (write) permission.",
78
+ },
79
+ lookup: {
80
+ title: "Lookup app",
81
+ help: "Read-only App used to see which organizations the platform can look up. Needs the metadata (read) permission.",
82
+ },
83
+ } as const;
84
+
85
+ function statusLine(app: GheApp): string {
86
+ const validated = app.lastValidatedAt
87
+ ? ` &middot; ${escapeHtml(formatTime(app.lastValidatedAt))}`
88
+ : "";
89
+ const error = app.lastValidationError
90
+ ? `<div class="help-text" style="font-size:12px;margin-top:4px">${escapeHtml(app.lastValidationError)}</div>`
91
+ : "";
92
+ return `<div class="gh-ent-status">
93
+ <span class="badge badge-green">Configured</span>
94
+ <span class="badge ${app.keySet ? "badge-green" : "badge-warning"}">${app.keySet ? "Key set" : "Key not set"}</span>
95
+ ${validationBadge(app.lastValidationStatus)}${validated}
96
+ ${error}
97
+ </div>`;
98
+ }
99
+
100
+ function postButton(
101
+ action: string,
102
+ label: string,
103
+ cls = "btn-secondary",
104
+ ): string {
105
+ return `<form method="POST" action="${action}" style="display:inline;margin:0"><button type="submit" class="btn ${cls}" style="font-size:12px;padding:4px 10px">${label}</button></form>`;
106
+ }
107
+
108
+ function appCard(role: "installer" | "lookup", app: GheApp | null): string {
109
+ const copy = ROLE_COPY[role];
110
+ const action = `${BASE}/apps/${role}`;
111
+ const summary = app
112
+ ? `<p style="margin:8px 0">App ID <strong>${escapeHtml(app.appId)}</strong> &middot; slug <strong>${escapeHtml(app.slug)}</strong>${app.enterpriseSlug ? ` &middot; enterprise <strong>${escapeHtml(app.enterpriseSlug)}</strong>` : ""}</p>
113
+ ${statusLine(app)}
114
+ <div style="display:flex;gap:8px;margin:12px 0">${postButton(`${action}/validate`, "Validate")}${postButton(`${action}/delete`, "Delete", "btn-danger")}</div>`
115
+ : `<p class="help-text" style="font-size:12px;margin:8px 0">Not configured.</p>`;
116
+ const enterprise =
117
+ role === "installer"
118
+ ? `<label>Enterprise slug <input name="enterpriseSlug" class="form-input" required placeholder="my-enterprise" /></label>`
119
+ : "";
120
+ return `<div class="card">
121
+ <div class="card-title">${copy.title}</div>
122
+ <p class="help-text" style="font-size:12px">${copy.help}</p>
123
+ ${summary}
124
+ <form method="POST" action="${action}" style="display:grid;gap:8px;max-width:480px">
125
+ <label>App ID <input name="appId" class="form-input" required inputmode="numeric" pattern="[0-9]+" /></label>
126
+ <label>App slug <input name="slug" class="form-input" required /></label>
127
+ ${enterprise}
128
+ <label>Private key (PEM) <textarea name="privateKey" class="form-input" rows="4" required autocomplete="off" spellcheck="false" placeholder="Paste the private key. It is stored encrypted and never shown again."></textarea></label>
129
+ <div><button type="submit" class="btn btn-primary">${app ? "Replace credentials" : "Save credentials"}</button></div>
130
+ </form>
131
+ </div>`;
132
+ }
133
+
134
+ function installedCell(v: boolean | null): string {
135
+ if (v === null) return "&mdash;";
136
+ return v
137
+ ? `<span class="badge badge-green">Installed</span>`
138
+ : `<span class="badge badge-gray">Not installed</span>`;
139
+ }
140
+
141
+ function orgsCard(opts: GithubEnterpriseSettingsPageOpts): string {
142
+ const { status } = opts;
143
+ if (!status.installer) return "";
144
+ const agentId = opts.agentId ?? "";
145
+ const lookupSlug = status.lookup?.slug;
146
+
147
+ const filter = `<form method="GET" action="${BASE}" style="display:flex;gap:8px;margin:8px 0">
148
+ <input name="agentId" class="form-input" placeholder="Agent ID (to check its App)" value="${escapeHtml(agentId)}" style="max-width:280px" />
149
+ <button type="submit" class="btn btn-secondary">Check agent</button>
150
+ </form>`;
151
+
152
+ let body: string;
153
+ if (opts.orgsError) {
154
+ body = `<div class="alert alert-error">${escapeHtml(opts.orgsError)}</div>`;
155
+ } else {
156
+ const orgs = opts.orgs?.orgs ?? [];
157
+ const rows =
158
+ orgs.length === 0
159
+ ? `<tr><td colspan="4" class="empty-state">No installable organizations.</td></tr>`
160
+ : orgs
161
+ .map((o) => {
162
+ const login = escapeHtml(o.login);
163
+ const lookupAction = !lookupSlug
164
+ ? ""
165
+ : o.lookupAppInstalled
166
+ ? ""
167
+ : `<a class="btn btn-secondary" style="font-size:12px;padding:4px 10px" href="https://github.com/apps/${escapeHtml(lookupSlug)}/installations/new" target="_blank" rel="noopener noreferrer">Install lookup app</a>`;
168
+ const agentAction = `<form method="POST" action="${BASE}/orgs/${encodeURIComponent(o.login)}/install" style="display:inline-flex;gap:4px;margin:0">
169
+ <input name="agentId" class="form-input" required placeholder="Agent ID" value="${escapeHtml(agentId)}" style="max-width:160px;padding:4px 8px" />
170
+ <button type="submit" class="btn btn-primary" style="font-size:12px;padding:4px 10px">Install agent app</button>
171
+ </form>`;
172
+ return `<tr>
173
+ <td>${login}</td>
174
+ <td>${installedCell(o.lookupAppInstalled)}</td>
175
+ <td>${installedCell(o.agentAppInstalled)}</td>
176
+ <td style="text-align:right">${lookupAction} ${agentAction}</td>
177
+ </tr>`;
178
+ })
179
+ .join("\n");
180
+ body = `<table class="data-table">
181
+ <thead><tr><th>Organization</th><th>Lookup app</th><th>Agent app</th><th></th></tr></thead>
182
+ <tbody>${rows}</tbody>
183
+ </table>`;
184
+ }
185
+ return `<div class="card">
186
+ <div class="card-title">Organizations</div>
187
+ ${filter}
188
+ ${body}
189
+ </div>`;
190
+ }
191
+
192
+ export function renderGithubEnterpriseSettingsPage(
193
+ opts: GithubEnterpriseSettingsPageOpts,
194
+ ): string {
195
+ const { status } = opts;
196
+ const error = opts.error
197
+ ? `<div class="alert alert-error">${escapeHtml(opts.error)}</div>`
198
+ : "";
199
+ const notice = opts.notice
200
+ ? `<div class="alert alert-success">${escapeHtml(opts.notice)}</div>`
201
+ : "";
202
+ const emptyState = status.configured
203
+ ? ""
204
+ : `<div class="card"><div class="card-title">Not configured</div>
205
+ <p>This feature is optional. Without it, agents are provisioned exactly as before. Configure an installer app to install agents' GitHub Apps on your enterprise's organizations from here, and a lookup app to see which organizations are reachable. Paste the credentials of Apps you created in GitHub below.</p>
206
+ </div>`;
207
+
208
+ return renderAdminPage({
209
+ title: "GitHub Enterprise — Settings — Shipwright Admin",
210
+ body: `${renderAdminToolbar(opts.userEmail, BASE)}
211
+ <div class="vos-page">
212
+ <div class="page-header"><h1 class="page-title">GitHub Enterprise apps</h1></div>
213
+ ${error}${notice}${emptyState}
214
+ ${appCard("installer", status.installer)}
215
+ ${appCard("lookup", status.lookup)}
216
+ ${orgsCard(opts)}
217
+ </div>`,
218
+ });
219
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * admin/src/admin-ui-github-enterprise-pages.unit.test.ts
3
+ * Pure render tests for the enterprise GitHub apps settings page (GHE-1.4):
4
+ * empty, configured, validation-failed, and the never-render-the-key rule.
5
+ */
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import { runWithSettingsNav } from "./admin-ui-account-nav.ts";
9
+ import {
10
+ type GheApp,
11
+ renderGithubEnterpriseSettingsPage,
12
+ } from "./admin-ui-github-enterprise-pages.ts";
13
+ import { renderAdminToolbar } from "./admin-ui-styles.ts";
14
+
15
+ const PEM_MARKER = "-----BEGIN PRIVATE KEY-----";
16
+
17
+ function app(over: Partial<GheApp> = {}): GheApp {
18
+ return {
19
+ role: "installer",
20
+ appId: "12345",
21
+ slug: "acme-installer",
22
+ enterpriseSlug: "acme",
23
+ keySet: true,
24
+ lastValidationStatus: "valid",
25
+ lastValidatedAt: "2026-11-09T12:00:00.000Z",
26
+ lastValidationError: null,
27
+ createdAt: "2026-11-09T11:00:00.000Z",
28
+ updatedAt: "2026-11-09T11:00:00.000Z",
29
+ ...over,
30
+ };
31
+ }
32
+
33
+ const base = { userEmail: "admin@x.com" };
34
+ const empty = { configured: false, installer: null, lookup: null };
35
+
36
+ describe("renderGithubEnterpriseSettingsPage", () => {
37
+ test("empty state explains the feature is optional and shows both paste forms", () => {
38
+ const html = renderGithubEnterpriseSettingsPage({ ...base, status: empty });
39
+ expect(html).toContain("optional");
40
+ expect(html).toContain("Installer app");
41
+ expect(html).toContain("Lookup app");
42
+ expect(html).toContain(
43
+ 'action="/admin/settings/github-enterprise/apps/installer"',
44
+ );
45
+ expect(html).toContain(
46
+ 'action="/admin/settings/github-enterprise/apps/lookup"',
47
+ );
48
+ expect(html).toContain('name="enterpriseSlug"');
49
+ expect(html).toContain('name="privateKey"');
50
+ expect(html).not.toContain("/validate");
51
+ expect(html).not.toContain("Organizations</div>");
52
+ });
53
+
54
+ test("only the installer form asks for an enterprise slug", () => {
55
+ const html = renderGithubEnterpriseSettingsPage({ ...base, status: empty });
56
+ expect(html.match(/name="enterpriseSlug"/g)?.length).toBe(1);
57
+ });
58
+
59
+ test("configured card shows status only: key set, result, time, no key", () => {
60
+ const html = renderGithubEnterpriseSettingsPage({
61
+ ...base,
62
+ status: { configured: true, installer: app(), lookup: null },
63
+ });
64
+ expect(html).toContain("acme-installer");
65
+ expect(html).toContain("12345");
66
+ expect(html).toContain("Key set");
67
+ expect(html).toContain("valid");
68
+ expect(html).toContain("2026-11-09 12:00");
69
+ expect(html).toContain("/apps/installer/validate");
70
+ expect(html).toContain("/apps/installer/delete");
71
+ expect(html).not.toContain(PEM_MARKER);
72
+ expect(html).not.toContain('type="password" value=');
73
+ expect(html).not.toMatch(/<textarea[^>]*>[^<]+<\/textarea>/);
74
+ });
75
+
76
+ test("never-validated app says so", () => {
77
+ const html = renderGithubEnterpriseSettingsPage({
78
+ ...base,
79
+ status: {
80
+ configured: true,
81
+ installer: app({ lastValidationStatus: null, lastValidatedAt: null }),
82
+ lookup: null,
83
+ },
84
+ });
85
+ expect(html).toContain("Not validated yet");
86
+ });
87
+
88
+ test("validation failure renders the stored error, escaped", () => {
89
+ const html = renderGithubEnterpriseSettingsPage({
90
+ ...base,
91
+ status: {
92
+ configured: true,
93
+ installer: app({
94
+ lastValidationStatus: "missing_permission",
95
+ lastValidationError: "the App does not hold <b>x</b>",
96
+ }),
97
+ lookup: null,
98
+ },
99
+ });
100
+ expect(html).toContain("missing_permission");
101
+ expect(html).toContain("the App does not hold &lt;b&gt;x&lt;/b&gt;");
102
+ expect(html).not.toContain("<b>x</b>");
103
+ });
104
+
105
+ test("key not set shows 'Key not set'", () => {
106
+ const html = renderGithubEnterpriseSettingsPage({
107
+ ...base,
108
+ status: {
109
+ configured: true,
110
+ installer: app({ keySet: false }),
111
+ lookup: null,
112
+ },
113
+ });
114
+ expect(html).toContain("Key not set");
115
+ });
116
+
117
+ test("org list renders install actions and lookup state", () => {
118
+ const html = renderGithubEnterpriseSettingsPage({
119
+ ...base,
120
+ agentId: "agent-1",
121
+ status: {
122
+ configured: true,
123
+ installer: app(),
124
+ lookup: app({
125
+ role: "lookup",
126
+ slug: "acme-lookup",
127
+ enterpriseSlug: null,
128
+ }),
129
+ },
130
+ orgs: {
131
+ configured: true,
132
+ agentAppSlug: "agent-app",
133
+ orgs: [
134
+ {
135
+ id: 1,
136
+ login: "org-a",
137
+ lookupAppInstalled: false,
138
+ agentAppInstalled: false,
139
+ },
140
+ {
141
+ id: 2,
142
+ login: "org-b",
143
+ lookupAppInstalled: true,
144
+ agentAppInstalled: true,
145
+ },
146
+ ],
147
+ },
148
+ });
149
+ expect(html).toContain("org-a");
150
+ expect(html).toContain("org-b");
151
+ expect(html).toContain(
152
+ "https://github.com/apps/acme-lookup/installations/new",
153
+ );
154
+ expect(html).toContain(
155
+ 'action="/admin/settings/github-enterprise/orgs/org-a/install"',
156
+ );
157
+ expect(html).toContain('value="agent-1"');
158
+ expect(html).toContain("Install agent app");
159
+ expect(html).toContain("Installed");
160
+ });
161
+
162
+ test("org list failure and top-level error/notice are escaped messages", () => {
163
+ const html = renderGithubEnterpriseSettingsPage({
164
+ ...base,
165
+ status: { configured: true, installer: app(), lookup: null },
166
+ orgsError: "GitHub could not be reached <x>",
167
+ error: "bad <script>",
168
+ notice: "Saved.",
169
+ });
170
+ expect(html).toContain("GitHub could not be reached &lt;x&gt;");
171
+ expect(html).toContain("bad &lt;script&gt;");
172
+ expect(html).toContain("Saved.");
173
+ expect(html).not.toContain("<script>alert");
174
+ });
175
+ });
176
+
177
+ describe("toolbar Settings entry", () => {
178
+ test("hidden outside a settings-nav context, shown inside one", () => {
179
+ expect(renderAdminToolbar("a@x.com")).not.toContain("/admin/settings");
180
+ const html = runWithSettingsNav(true, () => renderAdminToolbar("a@x.com"));
181
+ expect(html).toContain('href="/admin/settings/github-enterprise"');
182
+ });
183
+ });