@thotischner/observability-mcp 3.7.0 → 3.8.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.
Files changed (53) hide show
  1. package/dist/auth/policy/loader.js +1 -1
  2. package/dist/auth/rbac.d.ts +1 -1
  3. package/dist/auth/rbac.js +3 -1
  4. package/dist/auth/rbac.test.js +5 -3
  5. package/dist/conformance/inspect-e2e.test.d.ts +1 -0
  6. package/dist/conformance/inspect-e2e.test.js +104 -0
  7. package/dist/connectors/loader.js +30 -0
  8. package/dist/connectors/loader.test.js +11 -0
  9. package/dist/enterprise-gate.d.ts +28 -0
  10. package/dist/enterprise-gate.js +51 -0
  11. package/dist/enterprise-gate.test.js +21 -1
  12. package/dist/index.js +285 -6
  13. package/dist/inspect/enforcer.d.ts +19 -0
  14. package/dist/inspect/enforcer.js +69 -0
  15. package/dist/inspect/enforcer.test.d.ts +1 -0
  16. package/dist/inspect/enforcer.test.js +76 -0
  17. package/dist/inspect/graph.d.ts +33 -0
  18. package/dist/inspect/graph.js +0 -0
  19. package/dist/inspect/graph.test.d.ts +1 -0
  20. package/dist/inspect/graph.test.js +74 -0
  21. package/dist/inspect/index.d.ts +8 -0
  22. package/dist/inspect/index.js +13 -0
  23. package/dist/inspect/mode.d.ts +20 -0
  24. package/dist/inspect/mode.js +57 -0
  25. package/dist/inspect/mode.test.d.ts +1 -0
  26. package/dist/inspect/mode.test.js +53 -0
  27. package/dist/inspect/profile-store.d.ts +42 -0
  28. package/dist/inspect/profile-store.js +139 -0
  29. package/dist/inspect/profile-store.test.d.ts +1 -0
  30. package/dist/inspect/profile-store.test.js +82 -0
  31. package/dist/inspect/profile.d.ts +51 -0
  32. package/dist/inspect/profile.js +111 -0
  33. package/dist/inspect/profile.test.d.ts +1 -0
  34. package/dist/inspect/profile.test.js +96 -0
  35. package/dist/inspect/recorder.d.ts +42 -0
  36. package/dist/inspect/recorder.js +72 -0
  37. package/dist/inspect/recorder.test.d.ts +1 -0
  38. package/dist/inspect/recorder.test.js +112 -0
  39. package/dist/inspect/signature.d.ts +32 -0
  40. package/dist/inspect/signature.js +200 -0
  41. package/dist/inspect/signature.test.d.ts +1 -0
  42. package/dist/inspect/signature.test.js +136 -0
  43. package/dist/inspect/store.d.ts +62 -0
  44. package/dist/inspect/store.js +76 -0
  45. package/dist/inspect/store.test.d.ts +1 -0
  46. package/dist/inspect/store.test.js +78 -0
  47. package/dist/metrics/self.d.ts +3 -0
  48. package/dist/metrics/self.js +19 -0
  49. package/dist/tenancy/context.d.ts +7 -0
  50. package/dist/tenancy/context.js +15 -0
  51. package/dist/tenancy/context.test.js +18 -1
  52. package/dist/ui/index.html +742 -0
  53. package/package.json +7 -3
@@ -27,7 +27,7 @@ import { BuiltinPolicyEngine } from "./engine.js";
27
27
  export const VALID_RESOURCES = new Set([
28
28
  "sources", "services", "health", "topology", "settings",
29
29
  "connectors", "audit", "catalog", "users", "redaction",
30
- "products",
30
+ "products", "inspection",
31
31
  ]);
32
32
  export const VALID_ACTIONS = new Set(["read", "write", "delete", "bypass"]);
33
33
  export class PolicyLoadError extends Error {
@@ -18,7 +18,7 @@ import type { RequestHandler } from "express";
18
18
  import type { AuthRuntime } from "./middleware.js";
19
19
  import type { PolicyEngine } from "./policy/engine.js";
20
20
  export type Action = "read" | "write" | "delete" | "bypass";
21
- export type Resource = "sources" | "services" | "health" | "topology" | "settings" | "connectors" | "audit" | "catalog" | "users" | "redaction" | "products";
21
+ export type Resource = "sources" | "services" | "health" | "topology" | "settings" | "connectors" | "audit" | "catalog" | "users" | "redaction" | "products" | "inspection";
22
22
  export interface Permission {
23
23
  resource: Resource;
24
24
  action: Action;
package/dist/auth/rbac.js CHANGED
@@ -30,6 +30,7 @@ export const DEFAULT_POLICY = {
30
30
  { resource: "audit", action: "read" },
31
31
  { resource: "catalog", action: "read" },
32
32
  { resource: "products", action: "read" },
33
+ { resource: "inspection", action: "read" },
33
34
  ],
34
35
  operator: [
35
36
  // Inherits viewer's read set + write on operational resources.
@@ -46,10 +47,11 @@ export const DEFAULT_POLICY = {
46
47
  { resource: "catalog", action: "read" },
47
48
  { resource: "products", action: "read" },
48
49
  { resource: "products", action: "write" },
50
+ { resource: "inspection", action: "read" },
49
51
  ],
50
52
  admin: [
51
53
  // Full surface — readable + writable + deletable.
52
- ...["sources", "services", "health", "topology", "settings", "connectors", "audit", "catalog", "users", "products"]
54
+ ...["sources", "services", "health", "topology", "settings", "connectors", "audit", "catalog", "users", "products", "inspection"]
53
55
  .flatMap((r) => ["read", "write", "delete"].map((a) => ({ resource: r, action: a }))),
54
56
  // Special: admins may bypass log-redaction on per-call MCP tool
55
57
  // invocations (when the bearer credential ALSO opts in via
@@ -108,11 +108,13 @@ test("listGrantedPermissions — deduplicates across overlapping roles", () => {
108
108
  });
109
109
  test("listGrantedPermissions — admin lists every (resource, action) once", () => {
110
110
  const p = listGrantedPermissions(["admin"]);
111
- // 10 resources (added 'products' in the products-RBAC phase) * 3 actions
112
- // = 30, plus the special redaction:bypass entry = 31.
113
- assert.equal(p.length, 31);
111
+ // 11 resources (added 'products' in the products-RBAC phase, 'inspection'
112
+ // in the Inspect phase) * 3 actions = 33, plus the special redaction:bypass
113
+ // entry = 34.
114
+ assert.equal(p.length, 34);
114
115
  assert.ok(p.some((g) => g.resource === "redaction" && g.action === "bypass"));
115
116
  assert.ok(p.some((g) => g.resource === "products" && g.action === "delete"));
117
+ assert.ok(p.some((g) => g.resource === "inspection" && g.action === "write"));
116
118
  });
117
119
  test("DEFAULT_POLICY shape — has the three built-in roles", () => {
118
120
  assert.ok(DEFAULT_POLICY.viewer);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,104 @@
1
+ // Inspect observe-path E2E (live).
2
+ //
3
+ // Proves the full chain: a real tools/call over the Streamable HTTP transport
4
+ // is captured by the observe recorder and surfaces on /api/inspect/events +
5
+ // /api/inspect/flows. Runs against a booted gateway via OMCP_CONFORMANCE_URL
6
+ // (the same env the spec-conformance harness uses); skips entirely when unset
7
+ // so a plain unit-test run stays hermetic.
8
+ //
9
+ // OMCP_CONFORMANCE_URL=http://localhost:3000/mcp \
10
+ // npx tsx --test src/conformance/inspect-e2e.test.ts
11
+ //
12
+ // integration.yml runs this after booting the demo stack.
13
+ import { test } from "node:test";
14
+ import assert from "node:assert/strict";
15
+ const URL_ENV = process.env.OMCP_CONFORMANCE_URL;
16
+ const skip = !URL_ENV;
17
+ const opts = skip ? { skip: "OMCP_CONFORMANCE_URL not set" } : {};
18
+ const base = (URL_ENV ?? "").replace(/\/mcp\/?$/, "");
19
+ async function rpc(method, params, session) {
20
+ const headers = {
21
+ "content-type": "application/json",
22
+ accept: "application/json, text/event-stream",
23
+ };
24
+ if (session)
25
+ headers["mcp-session-id"] = session;
26
+ const res = await fetch(URL_ENV, {
27
+ method: "POST",
28
+ headers,
29
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
30
+ });
31
+ return { text: await res.text(), session: res.headers.get("mcp-session-id") ?? session };
32
+ }
33
+ test("observe: a real tools/call surfaces on /api/inspect/events and /flows", opts, async () => {
34
+ // 1. Handshake — open an MCP session.
35
+ const init = await rpc("initialize", {
36
+ protocolVersion: "2025-11-25",
37
+ capabilities: {},
38
+ clientInfo: { name: "inspect-e2e", version: "0" },
39
+ });
40
+ const session = init.session;
41
+ assert.ok(session, "server returned a session id");
42
+ await fetch(URL_ENV, {
43
+ method: "POST",
44
+ headers: { "content-type": "application/json", accept: "application/json, text/event-stream", "mcp-session-id": session },
45
+ body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized", params: {} }),
46
+ });
47
+ // 2. Make an observable tool call. list_sources is always registered and
48
+ // needs no backend data, so it's a stable probe.
49
+ await rpc("tools/call", { name: "list_sources", arguments: {} }, session);
50
+ // 3. The observe recorder is synchronous on the post-invoke hook, but give
51
+ // the event loop a tick before reading back.
52
+ await new Promise((r) => setTimeout(r, 250));
53
+ // 4. /api/inspect/events shows the call.
54
+ const evRes = await fetch(`${base}/api/inspect/events?tool=list_sources&limit=50`);
55
+ assert.equal(evRes.status, 200, "events endpoint reachable");
56
+ const ev = (await evRes.json());
57
+ assert.ok(["observe", "dryrun", "enforce"].includes(ev.mode), `recording mode active (got ${ev.mode})`);
58
+ assert.ok(ev.events.some((e) => e.tool === "list_sources" && e.decision === "allow"), "list_sources call was observed");
59
+ // 5. /api/inspect/flows includes the tool node + an edge into it.
60
+ const flRes = await fetch(`${base}/api/inspect/flows?window=1h`);
61
+ assert.equal(flRes.status, 200, "flows endpoint reachable");
62
+ const fl = (await flRes.json());
63
+ assert.ok(fl.total >= 1, "flow graph has traffic");
64
+ assert.ok(fl.nodes.some((n) => n.id === "tool:list_sources"), "tool node present in the flow graph");
65
+ });
66
+ test("/api/inspect/mode reports a recording mode", opts, async () => {
67
+ const res = await fetch(`${base}/api/inspect/mode`);
68
+ assert.equal(res.status, 200);
69
+ const m = (await res.json());
70
+ assert.ok(["off", "observe", "dryrun", "enforce"].includes(m.mode));
71
+ });
72
+ test("enforce is gated by entitlement; observe/dry-run are free", opts, async () => {
73
+ // CSRF double-submit: grab the issued token, echo it on every mutation.
74
+ const probe = await fetch(`${base}/api/inspect/mode`);
75
+ const sc = probe.headers.get("set-cookie") || "";
76
+ const tok = (sc.match(/omcp-csrf=([^;]+)/) || [])[1];
77
+ const csrf = tok ? { "x-csrf-token": decodeURIComponent(tok), cookie: `omcp-csrf=${tok}` } : {};
78
+ const write = (path, method, body) => fetch(`${base}${path}`, { method, headers: { "content-type": "application/json", ...csrf }, body: JSON.stringify(body) });
79
+ const modeBody = (await probe.json());
80
+ // Dry-run is always available (OSS).
81
+ const dry = await write("/api/inspect/mode", "PUT", { mode: "dryrun" });
82
+ assert.equal(dry.status, 200, "dry-run is free");
83
+ try {
84
+ const enf = await write("/api/inspect/mode", "PUT", { mode: "enforce" });
85
+ if (modeBody.enforceEntitled) {
86
+ // Licensed: enforce switches on and blocks out-of-profile calls.
87
+ assert.equal(enf.status, 200, "enforce accepted when entitled");
88
+ const init = await rpc("initialize", { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "inspect-e2e", version: "0" } });
89
+ const session = init.session;
90
+ await fetch(URL_ENV, { method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream", "mcp-session-id": session }, body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized", params: {} }) });
91
+ const blocked = await rpc("tools/call", { name: "list_services", arguments: {} }, session);
92
+ assert.match(blocked.text, /Blocked by the inspection profile|inspection profile/, "out-of-profile call blocked when entitled");
93
+ }
94
+ else {
95
+ // OSS default: enforce is refused with a clear entitlement error.
96
+ assert.equal(enf.status, 403, "enforce refused without entitlement");
97
+ const body = (await enf.json());
98
+ assert.equal(body.code, "OMCP_ENTITLEMENT_REQUIRED");
99
+ }
100
+ }
101
+ finally {
102
+ await write("/api/inspect/mode", "PUT", { mode: "observe" }).catch(() => { });
103
+ }
104
+ });
@@ -96,19 +96,49 @@ export class PluginLoader {
96
96
  return instrumentConnector(c);
97
97
  }
98
98
  loadBuiltins() {
99
+ // Builtins carry inline manifest metadata so the Installed Connectors UI
100
+ // shows a name/description/version like filesystem plugins do (without it,
101
+ // describeInstalled() falls back to an empty description). Mirrors
102
+ // plugins/<name>/manifest.json — keep the description text in sync.
99
103
  this.register({
100
104
  name: "prometheus",
101
105
  source: "builtin",
106
+ manifest: {
107
+ schemaVersion: 1,
108
+ name: "prometheus",
109
+ displayName: "Prometheus",
110
+ version: "1.0.0",
111
+ description: "PromQL-based metrics backend with prom-client default scrape support and dynamic service-label resolution.",
112
+ signalTypes: ["metrics"],
113
+ capabilities: { queryMetrics: true, listServices: true, listAvailableMetrics: true },
114
+ },
102
115
  factory: () => new PrometheusConnector(),
103
116
  });
104
117
  this.register({
105
118
  name: "loki",
106
119
  source: "builtin",
120
+ manifest: {
121
+ schemaVersion: 1,
122
+ name: "loki",
123
+ displayName: "Loki",
124
+ version: "1.0.0",
125
+ description: "LogQL-based log backend with dynamic service-label discovery (service_name / service / job / app / container).",
126
+ signalTypes: ["logs"],
127
+ capabilities: { queryLogs: true, listServices: true },
128
+ },
107
129
  factory: () => new LokiConnector(),
108
130
  });
109
131
  this.register({
110
132
  name: "kubernetes",
111
133
  source: "builtin",
134
+ manifest: {
135
+ schemaVersion: 1,
136
+ name: "kubernetes",
137
+ displayName: "Kubernetes",
138
+ version: "0.1.0",
139
+ description: "Watches a Kubernetes cluster (in-cluster or via kubeconfig) and exposes pods, nodes, deployments, replicasets and namespaces as an infrastructure topology graph. Edges: RUNS_ON (pod→node), OWNED_BY (pod→rs→deployment), IN_NAMESPACE.",
140
+ signalTypes: ["topology"],
141
+ },
112
142
  factory: () => new KubernetesConnector(),
113
143
  });
114
144
  }
@@ -76,3 +76,14 @@ test("PluginLoader.load(): with verify on + no trust root → builtins still loa
76
76
  assert.ok(names.includes("kubernetes"), "kubernetes builtin must remain available");
77
77
  });
78
78
  });
79
+ test("PluginLoader.load(): builtins carry manifest metadata (description shows in Installed Connectors)", async () => {
80
+ const loader = new PluginLoader({ pluginsDir: tmp() });
81
+ await loader.load();
82
+ for (const name of ["prometheus", "loki", "kubernetes"]) {
83
+ const c = loader.get(name);
84
+ assert.ok(c, `${name} builtin present`);
85
+ assert.ok(c.manifest, `${name} builtin has a manifest`);
86
+ assert.ok((c.manifest.description || "").length > 10, `${name} builtin has a non-empty description`);
87
+ assert.equal(c.manifest.name, name);
88
+ }
89
+ });
@@ -13,6 +13,8 @@ type GateState = {
13
13
  mode: "active";
14
14
  claims: Record<string, unknown>;
15
15
  accessControl: boolean;
16
+ inspectEnforce: boolean;
17
+ hasFeature: (feature: string) => boolean;
16
18
  enforceRbac?: (policy: unknown, ctx: unknown, req: unknown) => unknown;
17
19
  enforceCatalog?: (catalog: unknown, ctx: unknown, req: unknown) => unknown;
18
20
  rbacPolicy?: unknown;
@@ -25,6 +27,32 @@ type GateState = {
25
27
  export declare function _resetEnterpriseAudit(): void;
26
28
  /** Reset memoised state (tests only). */
27
29
  export declare function _resetEnterpriseGate(): void;
30
+ /**
31
+ * Is the Inspect ENFORCE control entitled? Inspect observe/dry-run are free
32
+ * (OSS); only blocking enforcement requires the "inspect-enforce" feature on a
33
+ * valid entitlement token. Default-OFF (no token) → false, so OSS deployments
34
+ * keep observe/dry-run and simply can't switch to blocking enforce.
35
+ */
36
+ export declare function inspectEnforceEntitled(): Promise<boolean>;
37
+ /**
38
+ * Is a named entitlement feature active? True only with a valid entitlement
39
+ * token that carries the feature. Default-OFF (no token / enterprise modules
40
+ * absent) → false, never throws — so OSS deployments keep their free surface
41
+ * and a feature is only gated when the operator has actively configured it.
42
+ * Used to license SSO/OIDC ("sso"), SCIM ("scim"), multi-tenancy ("tenancy").
43
+ */
44
+ export declare function featureEntitled(feature: string): Promise<boolean>;
45
+ /** Every entitlement feature the product knows about — the closed vocabulary
46
+ * the UI renders lock badges for. Keep in sync with the gates in index.ts. */
47
+ export declare const ENTITLEABLE_FEATURES: readonly ["access-control", "audit", "inspect-enforce", "sso", "scim", "tenancy"];
48
+ /**
49
+ * Resolve every known feature to whether it is currently entitled — one flat
50
+ * map for the UI to drive a consistent lock optic. Default-OFF (no token /
51
+ * enterprise modules absent) → all false; never throws. This is purely
52
+ * informational (visibility is free); the real enforcement lives in each
53
+ * feature's own gate.
54
+ */
55
+ export declare function entitledFeatures(): Promise<Record<string, boolean>>;
28
56
  /** Gate mode — for diagnostics (/api/info). */
29
57
  export declare function enterpriseGateStatus(): Promise<{
30
58
  active: boolean;
@@ -109,6 +109,8 @@ async function buildGate() {
109
109
  mode: "active",
110
110
  claims,
111
111
  accessControl: has("access-control"),
112
+ inspectEnforce: has("inspect-enforce"),
113
+ hasFeature: has,
112
114
  };
113
115
  // Audit (best-effort; only if entitled and the module loads). The log
114
116
  // is a PROCESS singleton, deliberately decoupled from the gate memo:
@@ -137,6 +139,55 @@ async function buildGate() {
137
139
  export function _resetEnterpriseGate() {
138
140
  gatePromise = null;
139
141
  }
142
+ /**
143
+ * Is the Inspect ENFORCE control entitled? Inspect observe/dry-run are free
144
+ * (OSS); only blocking enforcement requires the "inspect-enforce" feature on a
145
+ * valid entitlement token. Default-OFF (no token) → false, so OSS deployments
146
+ * keep observe/dry-run and simply can't switch to blocking enforce.
147
+ */
148
+ export async function inspectEnforceEntitled() {
149
+ return featureEntitled("inspect-enforce");
150
+ }
151
+ /**
152
+ * Is a named entitlement feature active? True only with a valid entitlement
153
+ * token that carries the feature. Default-OFF (no token / enterprise modules
154
+ * absent) → false, never throws — so OSS deployments keep their free surface
155
+ * and a feature is only gated when the operator has actively configured it.
156
+ * Used to license SSO/OIDC ("sso"), SCIM ("scim"), multi-tenancy ("tenancy").
157
+ */
158
+ export async function featureEntitled(feature) {
159
+ if (!gatePromise)
160
+ gatePromise = buildGate();
161
+ const g = await gatePromise;
162
+ return g.mode === "active" && g.hasFeature(feature);
163
+ }
164
+ /** Every entitlement feature the product knows about — the closed vocabulary
165
+ * the UI renders lock badges for. Keep in sync with the gates in index.ts. */
166
+ export const ENTITLEABLE_FEATURES = [
167
+ "access-control",
168
+ "audit",
169
+ "inspect-enforce",
170
+ "sso",
171
+ "scim",
172
+ "tenancy",
173
+ ];
174
+ /**
175
+ * Resolve every known feature to whether it is currently entitled — one flat
176
+ * map for the UI to drive a consistent lock optic. Default-OFF (no token /
177
+ * enterprise modules absent) → all false; never throws. This is purely
178
+ * informational (visibility is free); the real enforcement lives in each
179
+ * feature's own gate.
180
+ */
181
+ export async function entitledFeatures() {
182
+ if (!gatePromise)
183
+ gatePromise = buildGate();
184
+ const g = await gatePromise;
185
+ const active = g.mode === "active";
186
+ const out = {};
187
+ for (const f of ENTITLEABLE_FEATURES)
188
+ out[f] = active && g.hasFeature(f);
189
+ return out;
190
+ }
140
191
  /** Gate mode — for diagnostics (/api/info). */
141
192
  export async function enterpriseGateStatus() {
142
193
  if (!gatePromise)
@@ -4,7 +4,7 @@ import { writeFileSync, mkdtempSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { defaultContext } from "./context.js";
7
- import { enforceEntitledAccess, enterpriseGateStatus, enterpriseGateInfo, enterprisePolicyView, enterpriseCatalogView, enterpriseAuditTail, validatePolicyShape, validateCatalogShape, authorizeAdmin, _resetEnterpriseGate, } from "./enterprise-gate.js";
7
+ import { enforceEntitledAccess, enterpriseGateStatus, enterpriseGateInfo, enterprisePolicyView, enterpriseCatalogView, enterpriseAuditTail, validatePolicyShape, validateCatalogShape, authorizeAdmin, featureEntitled, inspectEnforceEntitled, entitledFeatures, ENTITLEABLE_FEATURES, _resetEnterpriseGate, } from "./enterprise-gate.js";
8
8
  // These tests run in the mcp-server sandbox where enterprise/ is ABSENT
9
9
  // (it is excluded from the npm package and the Docker build context) —
10
10
  // exactly the published-artifact state. They pin the security contract:
@@ -55,6 +55,26 @@ describe("enterprise-gate — OFF (no opt-in, published-artifact state)", () =>
55
55
  await assert.doesNotReject(enforceEntitledAccess(defaultContext(), { tool }));
56
56
  }
57
57
  });
58
+ it("featureEntitled is false for every feature when OFF (OSS default)", async () => {
59
+ clearEnv();
60
+ // Default-OFF means no entitled feature is active — SSO/SCIM/tenancy/
61
+ // inspect-enforce all stay locked, so the OSS surface is unchanged and
62
+ // a feature only ever gates when the operator has actively licensed it.
63
+ for (const feature of ["sso", "scim", "tenancy", "inspect-enforce", "anything"]) {
64
+ assert.equal(await featureEntitled(feature), false, `feature ${feature} must be locked when OFF`);
65
+ }
66
+ assert.equal(await inspectEnforceEntitled(), false, "inspectEnforceEntitled mirrors featureEntitled");
67
+ });
68
+ it("entitledFeatures returns the full vocabulary, all false when OFF", async () => {
69
+ clearEnv();
70
+ const map = await entitledFeatures();
71
+ // Every known feature is present as a key (so the UI can render a badge
72
+ // for each) and false on the OSS default.
73
+ for (const f of ENTITLEABLE_FEATURES) {
74
+ assert.equal(map[f], false, `feature ${f} must be false when OFF`);
75
+ }
76
+ assert.deepEqual(Object.keys(map).sort(), [...ENTITLEABLE_FEATURES].sort());
77
+ });
58
78
  it("gate state is memoised across calls", async () => {
59
79
  clearEnv();
60
80
  assert.deepEqual(await enterpriseGateStatus(), await enterpriseGateStatus());