-
- Target Environment
-
-
-
-
-
- {availableEnvironments.map((env) => (
-
- {env.name}
-
- ))}
-
-
-
-
-
- Pipeline Name
- setName(e.target.value)}
- />
-
+
+ {result?.pendingApproval ? (
+
+
+
+
+
Promotion request submitted for approval
+
+ An administrator must approve before the pipeline appears in{" "}
+ {selectedEnv?.name ?? "the target environment"}.
+
+
+
+
+ ) : (
+
+
+
+
+
Pipeline promoted successfully
+
+ The pipeline has been deployed to{" "}
+ {selectedEnv?.name ?? "the target environment"}.
+
+
+
+
+ )}
- handleClose(false)}>
- Cancel
-
-
- promoteMutation.mutate({
- pipelineId: pipeline.id,
- targetEnvironmentId: targetEnvId,
- name: name || undefined,
- })
- }
- >
- {promoteMutation.isPending ? (
- <>
-
- Promoting...
- >
- ) : (
- "Promote"
- )}
-
+ handleClose(false)}>Close
diff --git a/src/components/settings-sidebar-nav.tsx b/src/components/settings-sidebar-nav.tsx
index 7de46153..10d7296b 100644
--- a/src/components/settings-sidebar-nav.tsx
+++ b/src/components/settings-sidebar-nav.tsx
@@ -10,6 +10,7 @@ import {
KeyRound,
Bot,
Sparkles,
+ Webhook,
} from "lucide-react";
export const settingsNavGroups = [
@@ -34,6 +35,7 @@ export const settingsNavGroups = [
{ title: "Teams", href: "/settings/teams", icon: Building2, requiredSuperAdmin: true },
{ title: "Team Settings", href: "/settings/team", icon: Users, requiredSuperAdmin: false },
{ title: "Service Accounts", href: "/settings/service-accounts", icon: Bot, requiredSuperAdmin: false },
+ { title: "Outbound Webhooks", href: "/settings/webhooks", icon: Webhook, requiredSuperAdmin: false },
{ title: "AI", href: "/settings/ai", icon: Sparkles, requiredSuperAdmin: false },
],
},
diff --git a/src/lib/__tests__/node-group-utils.test.ts b/src/lib/__tests__/node-group-utils.test.ts
new file mode 100644
index 00000000..7e6dc6d6
--- /dev/null
+++ b/src/lib/__tests__/node-group-utils.test.ts
@@ -0,0 +1,21 @@
+import { describe, it, expect } from "vitest";
+import { nodeMatchesGroup } from "@/lib/node-group-utils";
+
+describe("nodeMatchesGroup", () => {
+ it("Test 13: Empty criteria matches any labels (returns true)", () => {
+ expect(nodeMatchesGroup({ region: "us-east", role: "web" }, {})).toBe(true);
+ expect(nodeMatchesGroup({}, {})).toBe(true);
+ });
+
+ it("Test 14: Criteria {region: 'us-east'} matches node with {region: 'us-east', role: 'web'} (subset match)", () => {
+ expect(
+ nodeMatchesGroup({ region: "us-east", role: "web" }, { region: "us-east" }),
+ ).toBe(true);
+ });
+
+ it("Test 15: Criteria {region: 'us-east'} does NOT match node with {region: 'eu-west'}", () => {
+ expect(
+ nodeMatchesGroup({ region: "eu-west" }, { region: "us-east" }),
+ ).toBe(false);
+ });
+});
diff --git a/src/lib/node-group-utils.ts b/src/lib/node-group-utils.ts
new file mode 100644
index 00000000..6abfa530
--- /dev/null
+++ b/src/lib/node-group-utils.ts
@@ -0,0 +1,11 @@
+/**
+ * Returns true if the node's labels match all criteria key-value pairs.
+ * Empty criteria {} is a catch-all that matches any node.
+ */
+export function nodeMatchesGroup(
+ nodeLabels: Record
,
+ criteria: Record,
+): boolean {
+ if (Object.keys(criteria).length === 0) return true;
+ return Object.entries(criteria).every(([k, v]) => nodeLabels[k] === v);
+}
diff --git a/src/lib/vector/__tests__/catalog.test.ts b/src/lib/vector/__tests__/catalog.test.ts
new file mode 100644
index 00000000..2938bd07
--- /dev/null
+++ b/src/lib/vector/__tests__/catalog.test.ts
@@ -0,0 +1,27 @@
+import { describe, it, expect } from "vitest";
+import { getVectorCatalog, findComponentDef } from "@/lib/vector/catalog";
+
+describe("Vector Catalog (PERF-04)", () => {
+ it("getVectorCatalog returns a non-empty array", () => {
+ const catalog = getVectorCatalog();
+ expect(Array.isArray(catalog)).toBe(true);
+ expect(catalog.length).toBeGreaterThan(0);
+ });
+
+ it("getVectorCatalog returns same reference on repeated calls (singleton)", () => {
+ const first = getVectorCatalog();
+ const second = getVectorCatalog();
+ expect(first).toBe(second); // same reference, not just equal
+ });
+
+ it("findComponentDef finds a known component", () => {
+ const httpSource = findComponentDef("http_server", "source");
+ expect(httpSource).toBeDefined();
+ expect(httpSource?.type).toBe("http_server");
+ });
+
+ it("findComponentDef returns undefined for unknown type", () => {
+ const result = findComponentDef("nonexistent_component_xyz");
+ expect(result).toBeUndefined();
+ });
+});
diff --git a/src/lib/vector/catalog.ts b/src/lib/vector/catalog.ts
index 38a6237e..cb84837d 100644
--- a/src/lib/vector/catalog.ts
+++ b/src/lib/vector/catalog.ts
@@ -3,11 +3,15 @@ import { ALL_SOURCES } from "./schemas/sources";
import { ALL_TRANSFORMS } from "./schemas/transforms";
import { ALL_SINKS } from "./schemas/sinks";
-export const VECTOR_CATALOG: VectorComponentDef[] = [
- ...ALL_SOURCES,
- ...ALL_TRANSFORMS,
- ...ALL_SINKS,
-];
+let _catalog: VectorComponentDef[] | null = null;
+
+/** PERF-04: Lazy singleton — catalog is built on first access, not at module load. */
+export function getVectorCatalog(): VectorComponentDef[] {
+ if (!_catalog) {
+ _catalog = [...ALL_SOURCES, ...ALL_TRANSFORMS, ...ALL_SINKS];
+ }
+ return _catalog;
+}
/**
* Find a component definition by type and optionally kind.
@@ -18,8 +22,9 @@ export function findComponentDef(
type: string,
kind?: VectorComponentDef["kind"],
): VectorComponentDef | undefined {
+ const catalog = getVectorCatalog();
if (kind) {
- return VECTOR_CATALOG.find((c) => c.type === type && c.kind === kind);
+ return catalog.find((c) => c.type === type && c.kind === kind);
}
- return VECTOR_CATALOG.find((c) => c.type === type);
+ return catalog.find((c) => c.type === type);
}
diff --git a/src/server/routers/__tests__/fleet-list.test.ts b/src/server/routers/__tests__/fleet-list.test.ts
index e097dd04..6daba667 100644
--- a/src/server/routers/__tests__/fleet-list.test.ts
+++ b/src/server/routers/__tests__/fleet-list.test.ts
@@ -81,6 +81,8 @@ function makeNode(overrides: Partial<{
describe("fleet.list", () => {
beforeEach(() => {
mockReset(prismaMock);
+ // Default: no node groups (vacuously compliant)
+ prismaMock.nodeGroup.findMany.mockResolvedValue([]);
});
it("returns all nodes when no filters", async () => {
@@ -168,4 +170,40 @@ describe("fleet.list", () => {
expect(result[0]).toHaveProperty("pushConnected", false);
});
+
+ // ── label compliance ────────────────────────────────────────────────────
+
+ it("returns labelCompliant=true when node has all required labels", async () => {
+ const nodes = [makeNode({ id: "n1", labels: { region: "us-east", role: "worker" } })];
+ prismaMock.vectorNode.findMany.mockResolvedValue(nodes as never);
+ prismaMock.nodeGroup.findMany.mockResolvedValue([
+ { requiredLabels: ["region", "role"] },
+ ] as never);
+
+ const result = await caller.list({ environmentId: "env-1" });
+
+ expect(result[0]).toHaveProperty("labelCompliant", true);
+ });
+
+ it("returns labelCompliant=false when node is missing a required label", async () => {
+ const nodes = [makeNode({ id: "n1", labels: { region: "us-east" } })];
+ prismaMock.vectorNode.findMany.mockResolvedValue(nodes as never);
+ prismaMock.nodeGroup.findMany.mockResolvedValue([
+ { requiredLabels: ["region", "role"] },
+ ] as never);
+
+ const result = await caller.list({ environmentId: "env-1" });
+
+ expect(result[0]).toHaveProperty("labelCompliant", false);
+ });
+
+ it("returns labelCompliant=true when no NodeGroups have required labels (vacuously compliant)", async () => {
+ const nodes = [makeNode({ id: "n1", labels: {} })];
+ prismaMock.vectorNode.findMany.mockResolvedValue(nodes as never);
+ prismaMock.nodeGroup.findMany.mockResolvedValue([]);
+
+ const result = await caller.list({ environmentId: "env-1" });
+
+ expect(result[0]).toHaveProperty("labelCompliant", true);
+ });
});
diff --git a/src/server/routers/__tests__/node-group.test.ts b/src/server/routers/__tests__/node-group.test.ts
new file mode 100644
index 00000000..a1b9b65a
--- /dev/null
+++ b/src/server/routers/__tests__/node-group.test.ts
@@ -0,0 +1,498 @@
+import { vi, describe, it, expect, beforeEach } from "vitest";
+import { mockDeep, mockReset, type DeepMockProxy } from "vitest-mock-extended";
+import type { PrismaClient } from "@/generated/prisma";
+
+// ─── vi.hoisted so `t` is available inside vi.mock factories ────────────────
+
+const { t } = vi.hoisted(() => {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const { initTRPC } = require("@trpc/server");
+ const t = initTRPC.context().create();
+ return { t };
+});
+
+vi.mock("@/trpc/init", () => {
+ const passthrough = () =>
+ t.middleware(({ next, ctx }: { next: (opts: { ctx: unknown }) => unknown; ctx: unknown }) => next({ ctx }));
+ return {
+ router: t.router,
+ protectedProcedure: t.procedure,
+ withTeamAccess: passthrough,
+ middleware: t.middleware,
+ };
+});
+
+vi.mock("@/server/middleware/audit", () => ({
+ withAudit: () =>
+ t.middleware(({ next, ctx }: { next: (opts: { ctx: unknown }) => unknown; ctx: unknown }) => next({ ctx })),
+}));
+
+vi.mock("@/lib/prisma", () => ({
+ prisma: mockDeep(),
+}));
+
+// ─── Import SUT + mocks after vi.mock ───────────────────────────────────────
+
+import { prisma } from "@/lib/prisma";
+import { nodeGroupRouter } from "@/server/routers/node-group";
+
+const prismaMock = prisma as unknown as DeepMockProxy;
+const caller = t.createCallerFactory(nodeGroupRouter)({
+ session: { user: { id: "user-1" } },
+});
+
+// ─── Fixtures ────────────────────────────────────────────────────────────────
+
+function makeNodeGroup(overrides: Partial<{
+ id: string;
+ name: string;
+ environmentId: string;
+ criteria: Record;
+ labelTemplate: Record;
+ requiredLabels: string[];
+}> = {}) {
+ return {
+ id: overrides.id ?? "ng-1",
+ name: overrides.name ?? "US East",
+ environmentId: overrides.environmentId ?? "env-1",
+ criteria: overrides.criteria ?? { region: "us-east" },
+ labelTemplate: overrides.labelTemplate ?? { env: "prod" },
+ requiredLabels: overrides.requiredLabels ?? ["region", "role"],
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+}
+
+function makeNode(overrides: Partial<{
+ id: string;
+ name: string;
+ status: "HEALTHY" | "DEGRADED" | "UNREACHABLE" | "UNKNOWN";
+ labels: Record;
+ lastSeen: Date | null;
+ nodeMetrics: Array<{ loadAvg1: number }>;
+}> = {}) {
+ return {
+ id: overrides.id ?? "node-1",
+ name: overrides.name ?? "node-1",
+ status: overrides.status ?? "HEALTHY",
+ labels: overrides.labels ?? {},
+ lastSeen: overrides.lastSeen !== undefined ? overrides.lastSeen : new Date(),
+ nodeMetrics: overrides.nodeMetrics ?? [],
+ };
+}
+
+function makeAlertEvent(overrides: Partial<{
+ id: string;
+ nodeId: string | null;
+ status: "firing" | "resolved" | "acknowledged";
+}> = {}) {
+ return {
+ id: overrides.id ?? "alert-1",
+ nodeId: overrides.nodeId !== undefined ? overrides.nodeId : "node-1",
+ status: overrides.status ?? "firing",
+ };
+}
+
+// ─── Tests ──────────────────────────────────────────────────────────────────
+
+describe("nodeGroupRouter", () => {
+ beforeEach(() => {
+ mockReset(prismaMock);
+ });
+
+ // ── list ────────────────────────────────────────────────────────────────
+
+ describe("list", () => {
+ it("returns node groups for an environment ordered by name", async () => {
+ const groups = [
+ makeNodeGroup({ id: "ng-1", name: "EU West" }),
+ makeNodeGroup({ id: "ng-2", name: "US East" }),
+ ];
+ prismaMock.nodeGroup.findMany.mockResolvedValue(groups as never);
+
+ const result = await caller.list({ environmentId: "env-1" });
+
+ expect(result).toEqual(groups);
+ expect(prismaMock.nodeGroup.findMany).toHaveBeenCalledWith({
+ where: { environmentId: "env-1" },
+ orderBy: { name: "asc" },
+ });
+ });
+
+ it("returns empty array when no groups exist", async () => {
+ prismaMock.nodeGroup.findMany.mockResolvedValue([]);
+
+ const result = await caller.list({ environmentId: "env-1" });
+
+ expect(result).toEqual([]);
+ });
+ });
+
+ // ── create ──────────────────────────────────────────────────────────────
+
+ describe("create", () => {
+ it("creates a node group with name, criteria, labelTemplate, requiredLabels", async () => {
+ prismaMock.nodeGroup.findUnique.mockResolvedValue(null);
+ const created = makeNodeGroup({ id: "ng-new", name: "Asia Pacific" });
+ prismaMock.nodeGroup.create.mockResolvedValue(created as never);
+
+ const result = await caller.create({
+ environmentId: "env-1",
+ name: "Asia Pacific",
+ criteria: { region: "ap-southeast" },
+ labelTemplate: { env: "prod", tier: "1" },
+ requiredLabels: ["region", "role"],
+ });
+
+ expect(result).toEqual(created);
+ expect(prismaMock.nodeGroup.create).toHaveBeenCalledWith({
+ data: {
+ name: "Asia Pacific",
+ environmentId: "env-1",
+ criteria: { region: "ap-southeast" },
+ labelTemplate: { env: "prod", tier: "1" },
+ requiredLabels: ["region", "role"],
+ },
+ });
+ });
+
+ it("throws CONFLICT when duplicate name in same environment", async () => {
+ prismaMock.nodeGroup.findUnique.mockResolvedValue(makeNodeGroup() as never);
+
+ await expect(
+ caller.create({ environmentId: "env-1", name: "US East" }),
+ ).rejects.toMatchObject({ code: "CONFLICT" });
+
+ expect(prismaMock.nodeGroup.create).not.toHaveBeenCalled();
+ });
+
+ it("rejects empty name (Zod validation)", async () => {
+ await expect(
+ caller.create({ environmentId: "env-1", name: "" }),
+ ).rejects.toThrow();
+ });
+ });
+
+ // ── update ──────────────────────────────────────────────────────────────
+
+ describe("update", () => {
+ it("updates group name", async () => {
+ prismaMock.nodeGroup.findUnique
+ .mockResolvedValueOnce(makeNodeGroup({ id: "ng-1", name: "Old Name" }) as never)
+ .mockResolvedValueOnce(null); // no conflict
+
+ const updated = makeNodeGroup({ id: "ng-1", name: "New Name" });
+ prismaMock.nodeGroup.update.mockResolvedValue(updated as never);
+
+ const result = await caller.update({ id: "ng-1", name: "New Name" });
+
+ expect(result.name).toBe("New Name");
+ });
+
+ it("throws NOT_FOUND for non-existent group", async () => {
+ prismaMock.nodeGroup.findUnique.mockResolvedValue(null);
+
+ await expect(
+ caller.update({ id: "nonexistent", name: "Foo" }),
+ ).rejects.toMatchObject({ code: "NOT_FOUND" });
+ });
+
+ it("throws CONFLICT when renaming to existing name", async () => {
+ prismaMock.nodeGroup.findUnique
+ .mockResolvedValueOnce(makeNodeGroup({ id: "ng-1", name: "Alpha" }) as never)
+ .mockResolvedValueOnce(makeNodeGroup({ id: "ng-2", name: "Beta" }) as never); // conflict!
+
+ await expect(
+ caller.update({ id: "ng-1", name: "Beta" }),
+ ).rejects.toMatchObject({ code: "CONFLICT" });
+ });
+
+ it("skips uniqueness check when name is unchanged", async () => {
+ prismaMock.nodeGroup.findUnique.mockResolvedValueOnce(
+ makeNodeGroup({ id: "ng-1", name: "Same Name" }) as never,
+ );
+
+ prismaMock.nodeGroup.update.mockResolvedValue(
+ makeNodeGroup({ id: "ng-1", name: "Same Name" }) as never,
+ );
+
+ await caller.update({ id: "ng-1", name: "Same Name" });
+
+ // findUnique called only once (to fetch the group), not twice
+ expect(prismaMock.nodeGroup.findUnique).toHaveBeenCalledTimes(1);
+ });
+
+ it("updates labelTemplate", async () => {
+ prismaMock.nodeGroup.findUnique.mockResolvedValueOnce(
+ makeNodeGroup({ id: "ng-1" }) as never,
+ );
+
+ const updated = makeNodeGroup({ id: "ng-1", labelTemplate: { env: "staging", tier: "2" } });
+ prismaMock.nodeGroup.update.mockResolvedValue(updated as never);
+
+ const result = await caller.update({ id: "ng-1", labelTemplate: { env: "staging", tier: "2" } });
+
+ expect(prismaMock.nodeGroup.update).toHaveBeenCalledWith({
+ where: { id: "ng-1" },
+ data: { labelTemplate: { env: "staging", tier: "2" } },
+ });
+ expect(result).toEqual(updated);
+ });
+ });
+
+ // ── delete ──────────────────────────────────────────────────────────────
+
+ describe("delete", () => {
+ it("deletes an existing group", async () => {
+ prismaMock.nodeGroup.findUnique.mockResolvedValue({ id: "ng-1" } as never);
+ prismaMock.nodeGroup.delete.mockResolvedValue(makeNodeGroup({ id: "ng-1" }) as never);
+
+ const result = await caller.delete({ id: "ng-1" });
+
+ expect(result.id).toBe("ng-1");
+ expect(prismaMock.nodeGroup.delete).toHaveBeenCalledWith({
+ where: { id: "ng-1" },
+ });
+ });
+
+ it("throws NOT_FOUND for non-existent group", async () => {
+ prismaMock.nodeGroup.findUnique.mockResolvedValue(null);
+
+ await expect(
+ caller.delete({ id: "nonexistent" }),
+ ).rejects.toMatchObject({ code: "NOT_FOUND" });
+ });
+ });
+
+ // ── groupHealthStats ─────────────────────────────────────────────────────
+
+ describe("groupHealthStats", () => {
+ it("Test 1: Returns per-group stats (onlineCount, alertCount, complianceRate, totalNodes) for two groups", async () => {
+ const groups = [
+ makeNodeGroup({ id: "ng-1", name: "US East", criteria: { region: "us-east" }, requiredLabels: ["region"] }),
+ makeNodeGroup({ id: "ng-2", name: "EU West", criteria: { region: "eu-west" }, requiredLabels: ["region"] }),
+ ];
+ const nodes = [
+ makeNode({ id: "n-1", status: "HEALTHY", labels: { region: "us-east" } }),
+ makeNode({ id: "n-2", status: "DEGRADED", labels: { region: "us-east" } }),
+ makeNode({ id: "n-3", status: "HEALTHY", labels: { region: "eu-west" } }),
+ ];
+ const firingAlerts = [makeAlertEvent({ nodeId: "n-2", status: "firing" })];
+
+ prismaMock.vectorNode.findMany.mockResolvedValue(nodes as never);
+ prismaMock.nodeGroup.findMany.mockResolvedValue(groups as never);
+ prismaMock.alertEvent.findMany.mockResolvedValue(firingAlerts as never);
+
+ const result = await caller.groupHealthStats({ environmentId: "env-1" });
+
+ const usEast = result.find((r) => r.id === "ng-1");
+ const euWest = result.find((r) => r.id === "ng-2");
+
+ expect(usEast).toBeDefined();
+ expect(usEast!.totalNodes).toBe(2);
+ expect(usEast!.onlineCount).toBe(1); // only HEALTHY
+ expect(usEast!.alertCount).toBe(1); // n-2 has firing alert
+ expect(usEast!.complianceRate).toBe(100); // both have 'region' label
+
+ expect(euWest).toBeDefined();
+ expect(euWest!.totalNodes).toBe(1);
+ expect(euWest!.onlineCount).toBe(1);
+ expect(euWest!.alertCount).toBe(0);
+ });
+
+ it("Test 2: Group with empty criteria {} matches all nodes (catch-all) — totalNodes equals total environment nodes", async () => {
+ const groups = [
+ makeNodeGroup({ id: "ng-all", name: "All Nodes", criteria: {}, requiredLabels: [] }),
+ ];
+ const nodes = [
+ makeNode({ id: "n-1", labels: { region: "us-east" } }),
+ makeNode({ id: "n-2", labels: { region: "eu-west" } }),
+ makeNode({ id: "n-3", labels: {} }),
+ ];
+
+ prismaMock.vectorNode.findMany.mockResolvedValue(nodes as never);
+ prismaMock.nodeGroup.findMany.mockResolvedValue(groups as never);
+ prismaMock.alertEvent.findMany.mockResolvedValue([] as never);
+
+ const result = await caller.groupHealthStats({ environmentId: "env-1" });
+
+ const allGroup = result.find((r) => r.id === "ng-all");
+ expect(allGroup).toBeDefined();
+ expect(allGroup!.totalNodes).toBe(3); // matches all
+ // No ungrouped since all matched
+ expect(result.find((r) => r.id === "__ungrouped__")).toBeUndefined();
+ });
+
+ it("Test 3: Includes synthetic 'Ungrouped' entry for nodes matching no group", async () => {
+ const groups = [
+ makeNodeGroup({ id: "ng-1", name: "US East", criteria: { region: "us-east" }, requiredLabels: [] }),
+ ];
+ const nodes = [
+ makeNode({ id: "n-1", labels: { region: "us-east" } }),
+ makeNode({ id: "n-2", labels: { region: "eu-west" } }), // no matching group
+ makeNode({ id: "n-3", labels: {} }), // no matching group
+ ];
+
+ prismaMock.vectorNode.findMany.mockResolvedValue(nodes as never);
+ prismaMock.nodeGroup.findMany.mockResolvedValue(groups as never);
+ prismaMock.alertEvent.findMany.mockResolvedValue([] as never);
+
+ const result = await caller.groupHealthStats({ environmentId: "env-1" });
+
+ const ungrouped = result.find((r) => r.id === "__ungrouped__");
+ expect(ungrouped).toBeDefined();
+ expect(ungrouped!.name).toBe("Ungrouped");
+ expect(ungrouped!.totalNodes).toBe(2); // n-2 and n-3
+ });
+
+ it("Test 4: complianceRate is 100 when requiredLabels is empty (vacuous truth)", async () => {
+ const groups = [
+ makeNodeGroup({ id: "ng-1", name: "Any", criteria: {}, requiredLabels: [] }),
+ ];
+ const nodes = [
+ makeNode({ id: "n-1", labels: {} }), // no labels at all
+ makeNode({ id: "n-2", labels: { random: "value" } }),
+ ];
+
+ prismaMock.vectorNode.findMany.mockResolvedValue(nodes as never);
+ prismaMock.nodeGroup.findMany.mockResolvedValue(groups as never);
+ prismaMock.alertEvent.findMany.mockResolvedValue([] as never);
+
+ const result = await caller.groupHealthStats({ environmentId: "env-1" });
+
+ const group = result.find((r) => r.id === "ng-1");
+ expect(group!.complianceRate).toBe(100);
+ });
+
+ it("Test 5: alertCount only counts AlertStatus.firing, not resolved/acknowledged", async () => {
+ const groups = [
+ makeNodeGroup({ id: "ng-1", criteria: {}, requiredLabels: [] }),
+ ];
+ const nodes = [
+ makeNode({ id: "n-1" }),
+ makeNode({ id: "n-2" }),
+ makeNode({ id: "n-3" }),
+ ];
+ // Only n-1 has a firing alert; n-2 has resolved, n-3 has acknowledged
+ const alerts = [
+ makeAlertEvent({ nodeId: "n-1", status: "firing" }),
+ // resolved and acknowledged should not appear since we filter for firing only
+ ];
+
+ prismaMock.vectorNode.findMany.mockResolvedValue(nodes as never);
+ prismaMock.nodeGroup.findMany.mockResolvedValue(groups as never);
+ prismaMock.alertEvent.findMany.mockResolvedValue(alerts as never);
+
+ const result = await caller.groupHealthStats({ environmentId: "env-1" });
+
+ const group = result.find((r) => r.id === "ng-1");
+ expect(group!.alertCount).toBe(1); // only the firing one
+ });
+
+ it("Test 6: Returns empty array when no groups and no nodes exist (no ungrouped entry)", async () => {
+ prismaMock.vectorNode.findMany.mockResolvedValue([] as never);
+ prismaMock.nodeGroup.findMany.mockResolvedValue([] as never);
+ prismaMock.alertEvent.findMany.mockResolvedValue([] as never);
+
+ const result = await caller.groupHealthStats({ environmentId: "env-1" });
+
+ expect(result).toEqual([]);
+ });
+ });
+
+ // ── nodesInGroup ─────────────────────────────────────────────────────────
+
+ describe("nodesInGroup", () => {
+ it("Test 7: Returns nodes matching criteria sorted by status (UNREACHABLE first, then DEGRADED, then HEALTHY), then by name", async () => {
+ const group = makeNodeGroup({
+ id: "ng-1",
+ criteria: { region: "us-east" },
+ requiredLabels: [],
+ });
+ const nodes = [
+ makeNode({ id: "n-healthy", name: "alpha", status: "HEALTHY", labels: { region: "us-east" } }),
+ makeNode({ id: "n-unreachable", name: "beta", status: "UNREACHABLE", labels: { region: "us-east" } }),
+ makeNode({ id: "n-degraded", name: "gamma", status: "DEGRADED", labels: { region: "us-east" } }),
+ ];
+
+ prismaMock.nodeGroup.findUnique.mockResolvedValue(group as never);
+ prismaMock.vectorNode.findMany.mockResolvedValue(nodes as never);
+
+ const result = await caller.nodesInGroup({ groupId: "ng-1", environmentId: "env-1" });
+
+ expect(result[0].status).toBe("UNREACHABLE");
+ expect(result[1].status).toBe("DEGRADED");
+ expect(result[2].status).toBe("HEALTHY");
+ });
+
+ it("Test 8: Attaches cpuLoad from latest NodeMetric (nodeMetrics[0].loadAvg1) — null when no metrics", async () => {
+ const group = makeNodeGroup({ id: "ng-1", criteria: {}, requiredLabels: [] });
+ const nodes = [
+ makeNode({ id: "n-with-metrics", name: "a", nodeMetrics: [{ loadAvg1: 0.75 }] }),
+ makeNode({ id: "n-no-metrics", name: "b", nodeMetrics: [] }),
+ ];
+
+ prismaMock.nodeGroup.findUnique.mockResolvedValue(group as never);
+ prismaMock.vectorNode.findMany.mockResolvedValue(nodes as never);
+
+ const result = await caller.nodesInGroup({ groupId: "ng-1", environmentId: "env-1" });
+
+ const withMetrics = result.find((n) => n.id === "n-with-metrics");
+ const noMetrics = result.find((n) => n.id === "n-no-metrics");
+
+ expect(withMetrics!.cpuLoad).toBe(0.75);
+ expect(noMetrics!.cpuLoad).toBeNull();
+ });
+
+ it("Test 9: Attaches labelCompliant=true when requiredLabels is empty", async () => {
+ const group = makeNodeGroup({ id: "ng-1", criteria: {}, requiredLabels: [] });
+ const nodes = [makeNode({ id: "n-1", labels: {} })]; // no labels, but requiredLabels is empty
+
+ prismaMock.nodeGroup.findUnique.mockResolvedValue(group as never);
+ prismaMock.vectorNode.findMany.mockResolvedValue(nodes as never);
+
+ const result = await caller.nodesInGroup({ groupId: "ng-1", environmentId: "env-1" });
+
+ expect(result[0].labelCompliant).toBe(true);
+ });
+
+ it("Test 10: Attaches labelCompliant=false when node is missing a required label key", async () => {
+ const group = makeNodeGroup({
+ id: "ng-1",
+ criteria: { region: "us-east" },
+ requiredLabels: ["region", "role"], // requires both
+ });
+ const nodes = [
+ makeNode({ id: "n-missing-role", labels: { region: "us-east" } }), // missing 'role'
+ ];
+
+ prismaMock.nodeGroup.findUnique.mockResolvedValue(group as never);
+ prismaMock.vectorNode.findMany.mockResolvedValue(nodes as never);
+
+ const result = await caller.nodesInGroup({ groupId: "ng-1", environmentId: "env-1" });
+
+ expect(result[0].labelCompliant).toBe(false);
+ });
+
+ it("Test 11: Throws NOT_FOUND for non-existent groupId", async () => {
+ prismaMock.nodeGroup.findUnique.mockResolvedValue(null);
+
+ await expect(
+ caller.nodesInGroup({ groupId: "nonexistent", environmentId: "env-1" }),
+ ).rejects.toMatchObject({ code: "NOT_FOUND" });
+ });
+
+ it("Test 12: Returns lastSeen timestamp for recency display", async () => {
+ const group = makeNodeGroup({ id: "ng-1", criteria: {}, requiredLabels: [] });
+ const lastSeen = new Date("2026-01-15T10:00:00Z");
+ const nodes = [makeNode({ id: "n-1", lastSeen })];
+
+ prismaMock.nodeGroup.findUnique.mockResolvedValue(group as never);
+ prismaMock.vectorNode.findMany.mockResolvedValue(nodes as never);
+
+ const result = await caller.nodesInGroup({ groupId: "ng-1", environmentId: "env-1" });
+
+ expect(result[0].lastSeen).toEqual(lastSeen);
+ });
+ });
+});
diff --git a/src/server/routers/__tests__/pipeline-bulk-tags.test.ts b/src/server/routers/__tests__/pipeline-bulk-tags.test.ts
new file mode 100644
index 00000000..8a549f7a
--- /dev/null
+++ b/src/server/routers/__tests__/pipeline-bulk-tags.test.ts
@@ -0,0 +1,320 @@
+import { vi, describe, it, expect, beforeEach } from "vitest";
+import { mockDeep, mockReset, type DeepMockProxy } from "vitest-mock-extended";
+import type { PrismaClient } from "@/generated/prisma";
+
+// ─── vi.hoisted so `t` is available inside vi.mock factories ────────────────
+
+const { t } = vi.hoisted(() => {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const { initTRPC } = require("@trpc/server");
+ const t = initTRPC.context().create();
+ return { t };
+});
+
+vi.mock("@/trpc/init", () => {
+ const passthrough = () =>
+ t.middleware(({ next, ctx }: { next: (opts: { ctx: unknown }) => unknown; ctx: unknown }) => next({ ctx }));
+ return {
+ router: t.router,
+ protectedProcedure: t.procedure,
+ withTeamAccess: passthrough,
+ requireSuperAdmin: passthrough,
+ middleware: t.middleware,
+ };
+});
+
+vi.mock("@/server/middleware/audit", () => ({
+ withAudit: () =>
+ t.middleware(({ next, ctx }: { next: (opts: { ctx: unknown }) => unknown; ctx: unknown }) => next({ ctx })),
+}));
+
+vi.mock("@/lib/prisma", () => ({
+ prisma: mockDeep(),
+}));
+
+vi.mock("@/server/services/deploy-agent", () => ({
+ deployAgent: vi.fn(),
+ undeployAgent: vi.fn(),
+}));
+
+vi.mock("@/server/services/pipeline-graph", () => ({
+ saveGraphComponents: vi.fn(),
+ promotePipeline: vi.fn(),
+ discardPipelineChanges: vi.fn(),
+ detectConfigChanges: vi.fn(),
+ listPipelinesForEnvironment: vi.fn(),
+}));
+
+vi.mock("@/server/services/pipeline-version", () => ({
+ createVersion: vi.fn(),
+ listVersions: vi.fn(),
+ listVersionsSummary: vi.fn(),
+ getVersion: vi.fn(),
+ rollback: vi.fn(),
+}));
+
+vi.mock("@/server/services/config-crypto", () => ({
+ decryptNodeConfig: vi.fn((_, c: unknown) => c),
+}));
+
+vi.mock("@/server/services/system-environment", () => ({
+ getOrCreateSystemEnvironment: vi.fn(),
+}));
+
+vi.mock("@/server/services/copy-pipeline-graph", () => ({
+ copyPipelineGraph: vi.fn(),
+}));
+
+vi.mock("@/server/services/git-sync", () => ({
+ gitSyncDeletePipeline: vi.fn(),
+}));
+
+vi.mock("@/server/services/sli-evaluator", () => ({
+ evaluatePipelineHealth: vi.fn(),
+}));
+
+vi.mock("@/server/services/batch-health", () => ({
+ batchEvaluatePipelineHealth: vi.fn(),
+}));
+
+vi.mock("@/server/services/push-broadcast", () => ({
+ relayPush: vi.fn(),
+}));
+
+vi.mock("@/server/services/sse-broadcast", () => ({
+ broadcastSSE: vi.fn(),
+}));
+
+vi.mock("@/server/services/event-alerts", () => ({
+ fireEventAlert: vi.fn(),
+}));
+
+// ─── Import SUT + mocks ────────────────────────────────────────────────────
+
+import { prisma } from "@/lib/prisma";
+import { pipelineRouter } from "@/server/routers/pipeline";
+
+const prismaMock = prisma as unknown as DeepMockProxy;
+const caller = t.createCallerFactory(pipelineRouter)({
+ session: { user: { id: "user-1" } },
+});
+
+// ─── Fixtures ───────────────────────────────────────────────────────────────
+
+function makePipeline(overrides: Record = {}) {
+ return {
+ id: "p1",
+ tags: ["existing-tag"],
+ environment: { teamId: "team-1" },
+ ...overrides,
+ };
+}
+
+function makeTeam(overrides: Record = {}) {
+ return {
+ id: "team-1",
+ availableTags: ["tag-a", "tag-b", "existing-tag"],
+ ...overrides,
+ };
+}
+
+// ─── Tests ──────────────────────────────────────────────────────────────────
+
+describe("bulk tag operations", () => {
+ beforeEach(() => {
+ mockReset(prismaMock);
+ });
+
+ // ── bulkAddTags ──────────────────────────────────────────────────────────
+
+ describe("bulkAddTags", () => {
+ it("adds tags to multiple pipelines successfully", async () => {
+ prismaMock.pipeline.findUnique
+ .mockResolvedValueOnce(makePipeline({ id: "p1", tags: [] }) as never) // first pipeline (team lookup)
+ .mockResolvedValueOnce(makePipeline({ id: "p1", tags: [] }) as never) // loop iteration 1
+ .mockResolvedValueOnce(makePipeline({ id: "p2", tags: ["old-tag"] }) as never); // loop iteration 2
+ prismaMock.team.findUnique.mockResolvedValue(makeTeam({ availableTags: [] }) as never); // empty = no validation
+ prismaMock.pipeline.update.mockResolvedValue({} as never);
+
+ const result = await caller.bulkAddTags({
+ pipelineIds: ["p1", "p2"],
+ tags: ["tag-a"],
+ });
+
+ expect(result.total).toBe(2);
+ expect(result.succeeded).toBe(2);
+ expect(result.results).toHaveLength(2);
+ expect(result.results.every((r) => r.success)).toBe(true);
+ });
+
+ it("validates tags against team.availableTags before the loop", async () => {
+ prismaMock.pipeline.findUnique.mockResolvedValue(makePipeline({ id: "p1" }) as never);
+ prismaMock.team.findUnique.mockResolvedValue(makeTeam({ availableTags: ["tag-a", "tag-b"] }) as never);
+
+ await expect(
+ caller.bulkAddTags({
+ pipelineIds: ["p1"],
+ tags: ["invalid-tag"],
+ }),
+ ).rejects.toMatchObject({
+ code: "BAD_REQUEST",
+ message: expect.stringContaining("Invalid tags"),
+ });
+ });
+
+ it("throws BAD_REQUEST for tags not in availableTags", async () => {
+ prismaMock.pipeline.findUnique.mockResolvedValue(makePipeline() as never);
+ prismaMock.team.findUnique.mockResolvedValue(makeTeam({ availableTags: ["allowed"] }) as never);
+
+ await expect(
+ caller.bulkAddTags({
+ pipelineIds: ["p1"],
+ tags: ["not-allowed"],
+ }),
+ ).rejects.toMatchObject({ code: "BAD_REQUEST" });
+ });
+
+ it("handles partial failure when some pipelines are not found", async () => {
+ prismaMock.pipeline.findUnique
+ .mockResolvedValueOnce(makePipeline({ id: "p1" }) as never) // first pipeline (team lookup)
+ .mockResolvedValueOnce(makePipeline({ id: "p1", tags: [] }) as never) // loop: p1 found
+ .mockResolvedValueOnce(null); // loop: p2 not found
+ prismaMock.team.findUnique.mockResolvedValue(makeTeam({ availableTags: [] }) as never);
+ prismaMock.pipeline.update.mockResolvedValue({} as never);
+
+ const result = await caller.bulkAddTags({
+ pipelineIds: ["p1", "p2"],
+ tags: ["tag-a"],
+ });
+
+ expect(result.total).toBe(2);
+ expect(result.succeeded).toBe(1);
+ const failedResult = result.results.find((r) => r.pipelineId === "p2");
+ expect(failedResult?.success).toBe(false);
+ expect(failedResult?.error).toBe("Pipeline not found");
+ });
+
+ it("deduplicates tags — adding an existing tag does not create duplicates", async () => {
+ prismaMock.pipeline.findUnique
+ .mockResolvedValueOnce(makePipeline({ id: "p1" }) as never) // team lookup
+ .mockResolvedValueOnce(makePipeline({ id: "p1", tags: ["existing-tag"] }) as never); // loop
+ prismaMock.team.findUnique.mockResolvedValue(makeTeam({ availableTags: [] }) as never);
+ prismaMock.pipeline.update.mockResolvedValue({} as never);
+
+ await caller.bulkAddTags({
+ pipelineIds: ["p1"],
+ tags: ["existing-tag"],
+ });
+
+ // Update should be called with deduplicated tags (no duplicates)
+ expect(prismaMock.pipeline.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: { tags: ["existing-tag"] }, // only one instance
+ }),
+ );
+ });
+
+ it("enforces max 100 pipeline limit (rejects more than 100)", async () => {
+ const tooMany = Array.from({ length: 101 }, (_, i) => `p${i}`);
+
+ await expect(
+ caller.bulkAddTags({
+ pipelineIds: tooMany,
+ tags: ["tag-a"],
+ }),
+ ).rejects.toThrow(); // Zod max(100) validation
+ });
+
+ it("throws NOT_FOUND when first pipeline for team lookup is not found", async () => {
+ prismaMock.pipeline.findUnique.mockResolvedValueOnce(null);
+
+ await expect(
+ caller.bulkAddTags({
+ pipelineIds: ["nonexistent"],
+ tags: ["tag-a"],
+ }),
+ ).rejects.toMatchObject({ code: "NOT_FOUND" });
+ });
+ });
+
+ // ── bulkRemoveTags ───────────────────────────────────────────────────────
+
+ describe("bulkRemoveTags", () => {
+ it("removes specified tags from multiple pipelines", async () => {
+ prismaMock.pipeline.findUnique
+ .mockResolvedValueOnce(makePipeline({ id: "p1", tags: ["tag-a", "tag-b"] }) as never)
+ .mockResolvedValueOnce(makePipeline({ id: "p2", tags: ["tag-a", "tag-c"] }) as never);
+ prismaMock.pipeline.update.mockResolvedValue({} as never);
+
+ const result = await caller.bulkRemoveTags({
+ pipelineIds: ["p1", "p2"],
+ tags: ["tag-a"],
+ });
+
+ expect(result.total).toBe(2);
+ expect(result.succeeded).toBe(2);
+ // p1 should have tag-b remaining, p2 should have tag-c remaining
+ expect(prismaMock.pipeline.update).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({ data: { tags: ["tag-b"] } }),
+ );
+ expect(prismaMock.pipeline.update).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({ data: { tags: ["tag-c"] } }),
+ );
+ });
+
+ it("handles pipelines that don't have the tag (no-op, still success)", async () => {
+ prismaMock.pipeline.findUnique.mockResolvedValue(
+ makePipeline({ id: "p1", tags: ["unrelated-tag"] }) as never,
+ );
+ prismaMock.pipeline.update.mockResolvedValue({} as never);
+
+ const result = await caller.bulkRemoveTags({
+ pipelineIds: ["p1"],
+ tags: ["nonexistent-tag"],
+ });
+
+ expect(result.succeeded).toBe(1);
+ // Tags should remain unchanged
+ expect(prismaMock.pipeline.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: { tags: ["unrelated-tag"] },
+ }),
+ );
+ });
+
+ it("handles partial failure when pipeline is not found", async () => {
+ prismaMock.pipeline.findUnique
+ .mockResolvedValueOnce(makePipeline({ id: "p1", tags: ["tag-a"] }) as never) // p1 found
+ .mockResolvedValueOnce(null); // p2 not found
+ prismaMock.pipeline.update.mockResolvedValue({} as never);
+
+ const result = await caller.bulkRemoveTags({
+ pipelineIds: ["p1", "p2"],
+ tags: ["tag-a"],
+ });
+
+ expect(result.total).toBe(2);
+ expect(result.succeeded).toBe(1);
+ const failedResult = result.results.find((r) => r.pipelineId === "p2");
+ expect(failedResult?.success).toBe(false);
+ });
+
+ it("returns correct succeeded count", async () => {
+ prismaMock.pipeline.findUnique
+ .mockResolvedValueOnce(makePipeline({ id: "p1", tags: ["tag-a"] }) as never)
+ .mockResolvedValueOnce(null) // p2 not found
+ .mockResolvedValueOnce(makePipeline({ id: "p3", tags: ["tag-a"] }) as never);
+ prismaMock.pipeline.update.mockResolvedValue({} as never);
+
+ const result = await caller.bulkRemoveTags({
+ pipelineIds: ["p1", "p2", "p3"],
+ tags: ["tag-a"],
+ });
+
+ expect(result.total).toBe(3);
+ expect(result.succeeded).toBe(2);
+ });
+ });
+});
diff --git a/src/server/routers/__tests__/pipeline-group.test.ts b/src/server/routers/__tests__/pipeline-group.test.ts
index 3a492c20..334cba8c 100644
--- a/src/server/routers/__tests__/pipeline-group.test.ts
+++ b/src/server/routers/__tests__/pipeline-group.test.ts
@@ -43,6 +43,22 @@ const caller = t.createCallerFactory(pipelineGroupRouter)({
session: { user: { id: "user-1" } },
});
+// ─── Fixtures ───────────────────────────────────────────────────────────────
+
+function makeGroup(overrides: Record = {}) {
+ return {
+ id: "g1",
+ name: "Backend",
+ color: "#ff0000",
+ environmentId: "env-1",
+ parentId: null,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ _count: { pipelines: 0, children: 0 },
+ ...overrides,
+ };
+}
+
// ─── Tests ──────────────────────────────────────────────────────────────────
describe("pipelineGroupRouter", () => {
@@ -55,8 +71,8 @@ describe("pipelineGroupRouter", () => {
describe("list", () => {
it("returns groups ordered by name with pipeline counts", async () => {
const groups = [
- { id: "g1", name: "Backend", color: "#ff0000", environmentId: "env-1", createdAt: new Date(), updatedAt: new Date(), _count: { pipelines: 3 } },
- { id: "g2", name: "Frontend", color: null, environmentId: "env-1", createdAt: new Date(), updatedAt: new Date(), _count: { pipelines: 0 } },
+ makeGroup({ id: "g1", name: "Backend", _count: { pipelines: 3, children: 1 } }),
+ makeGroup({ id: "g2", name: "Frontend", color: null, _count: { pipelines: 0, children: 0 } }),
];
prismaMock.pipelineGroup.findMany.mockResolvedValue(groups as never);
@@ -65,11 +81,23 @@ describe("pipelineGroupRouter", () => {
expect(result).toEqual(groups);
expect(prismaMock.pipelineGroup.findMany).toHaveBeenCalledWith({
where: { environmentId: "env-1" },
- include: { _count: { select: { pipelines: true } } },
+ include: { _count: { select: { pipelines: true, children: true } } },
orderBy: { name: "asc" },
});
});
+ it("returns groups with parentId field", async () => {
+ const groups = [
+ makeGroup({ id: "g1", name: "Parent", parentId: null }),
+ makeGroup({ id: "g2", name: "Child", parentId: "g1" }),
+ ];
+ prismaMock.pipelineGroup.findMany.mockResolvedValue(groups as never);
+
+ const result = await caller.list({ environmentId: "env-1" });
+
+ expect(result[1]).toMatchObject({ parentId: "g1" });
+ });
+
it("returns empty array when no groups exist", async () => {
prismaMock.pipelineGroup.findMany.mockResolvedValue([]);
@@ -83,11 +111,8 @@ describe("pipelineGroupRouter", () => {
describe("create", () => {
it("creates a group with name and color", async () => {
- prismaMock.pipelineGroup.findUnique.mockResolvedValue(null);
- const created = {
- id: "g-new", name: "Infra", color: "#00ff00",
- environmentId: "env-1", createdAt: new Date(), updatedAt: new Date(),
- };
+ prismaMock.pipelineGroup.findFirst.mockResolvedValue(null);
+ const created = makeGroup({ id: "g-new", name: "Infra", color: "#00ff00" });
prismaMock.pipelineGroup.create.mockResolvedValue(created as never);
const result = await caller.create({
@@ -98,16 +123,13 @@ describe("pipelineGroupRouter", () => {
expect(result).toEqual(created);
expect(prismaMock.pipelineGroup.create).toHaveBeenCalledWith({
- data: { name: "Infra", color: "#00ff00", environmentId: "env-1" },
+ data: { name: "Infra", color: "#00ff00", environmentId: "env-1", parentId: null },
});
});
it("creates a group without color", async () => {
- prismaMock.pipelineGroup.findUnique.mockResolvedValue(null);
- prismaMock.pipelineGroup.create.mockResolvedValue({
- id: "g-new", name: "Logs", color: null,
- environmentId: "env-1", createdAt: new Date(), updatedAt: new Date(),
- } as never);
+ prismaMock.pipelineGroup.findFirst.mockResolvedValue(null);
+ prismaMock.pipelineGroup.create.mockResolvedValue(makeGroup({ name: "Logs", color: null }) as never);
const result = await caller.create({
environmentId: "env-1",
@@ -117,21 +139,120 @@ describe("pipelineGroupRouter", () => {
expect(result.color).toBeNull();
});
- it("throws CONFLICT when duplicate name in same environment", async () => {
+ it("creates a child group with parentId", async () => {
+ prismaMock.pipelineGroup.findFirst.mockResolvedValue(null);
+ // parent at depth 1 (root), no grandparent
+ prismaMock.pipelineGroup.findUnique.mockResolvedValue({
+ id: "parent-1",
+ parentId: null,
+ parent: null,
+ } as never);
+ const created = makeGroup({ id: "child-1", name: "Child", parentId: "parent-1" });
+ prismaMock.pipelineGroup.create.mockResolvedValue(created as never);
+
+ const result = await caller.create({
+ environmentId: "env-1",
+ name: "Child",
+ parentId: "parent-1",
+ });
+
+ expect(result.parentId).toBe("parent-1");
+ expect(prismaMock.pipelineGroup.create).toHaveBeenCalledWith({
+ data: { name: "Child", color: undefined, environmentId: "env-1", parentId: "parent-1" },
+ });
+ });
+
+ it("creates a group at depth 3 (parent at depth 2) successfully", async () => {
+ prismaMock.pipelineGroup.findFirst.mockResolvedValue(null);
+ // parent is at depth 2 (has a parent at depth 1 with no grandparent)
prismaMock.pipelineGroup.findUnique.mockResolvedValue({
- id: "existing", name: "Infra", color: null,
- environmentId: "env-1", createdAt: new Date(), updatedAt: new Date(),
+ id: "depth2-group",
+ parentId: "depth1-group",
+ parent: { parentId: null },
} as never);
+ const created = makeGroup({ id: "depth3-group", name: "Deep", parentId: "depth2-group" });
+ prismaMock.pipelineGroup.create.mockResolvedValue(created as never);
+
+ const result = await caller.create({
+ environmentId: "env-1",
+ name: "Deep",
+ parentId: "depth2-group",
+ });
+
+ expect(result.id).toBe("depth3-group");
+ });
+
+ it("rejects creating a group at depth 4 (Maximum group nesting depth exceeded)", async () => {
+ prismaMock.pipelineGroup.findFirst.mockResolvedValue(null);
+ // parent is at depth 3 (has parentId and parent.parentId is non-null)
+ prismaMock.pipelineGroup.findUnique.mockResolvedValue({
+ id: "depth3-group",
+ parentId: "depth2-group",
+ parent: { parentId: "depth1-group" },
+ } as never);
+
+ await expect(
+ caller.create({
+ environmentId: "env-1",
+ name: "TooDeep",
+ parentId: "depth3-group",
+ }),
+ ).rejects.toMatchObject({
+ code: "BAD_REQUEST",
+ message: expect.stringContaining("Maximum group nesting depth (3) exceeded"),
+ });
+ });
+
+ it("throws NOT_FOUND when parentId does not exist", async () => {
+ prismaMock.pipelineGroup.findFirst.mockResolvedValue(null);
+ prismaMock.pipelineGroup.findUnique.mockResolvedValue(null);
await expect(
- caller.create({ environmentId: "env-1", name: "Infra" }),
- ).rejects.toThrow(TRPCError);
+ caller.create({
+ environmentId: "env-1",
+ name: "Orphan",
+ parentId: "nonexistent",
+ }),
+ ).rejects.toMatchObject({ code: "NOT_FOUND" });
+ });
+
+ it("throws CONFLICT when duplicate name under the same parent", async () => {
+ // findFirst returns existing group with same name + parentId
+ prismaMock.pipelineGroup.findFirst.mockResolvedValue(makeGroup({ name: "Infra", parentId: "parent-1" }) as never);
await expect(
- caller.create({ environmentId: "env-1", name: "Infra" }),
+ caller.create({ environmentId: "env-1", name: "Infra", parentId: "parent-1" }),
).rejects.toMatchObject({ code: "CONFLICT" });
});
+ it("throws CONFLICT when duplicate name at root level in same environment", async () => {
+ prismaMock.pipelineGroup.findFirst.mockResolvedValue(makeGroup({ name: "Root Group", parentId: null }) as never);
+
+ await expect(
+ caller.create({ environmentId: "env-1", name: "Root Group" }),
+ ).rejects.toMatchObject({ code: "CONFLICT" });
+ });
+
+ it("allows duplicate names under different parents", async () => {
+ // findFirst returns null (no conflict since different parent)
+ prismaMock.pipelineGroup.findFirst.mockResolvedValue(null);
+ prismaMock.pipelineGroup.findUnique.mockResolvedValue({
+ id: "parent-2",
+ parentId: null,
+ parent: null,
+ } as never);
+ const created = makeGroup({ id: "g-dup", name: "Shared Name", parentId: "parent-2" });
+ prismaMock.pipelineGroup.create.mockResolvedValue(created as never);
+
+ const result = await caller.create({
+ environmentId: "env-1",
+ name: "Shared Name",
+ parentId: "parent-2",
+ });
+
+ expect(result.name).toBe("Shared Name");
+ });
+
it("rejects empty name", async () => {
await expect(
caller.create({ environmentId: "env-1", name: "" }),
@@ -149,17 +270,14 @@ describe("pipelineGroupRouter", () => {
describe("update", () => {
it("updates group name", async () => {
- prismaMock.pipelineGroup.findUnique
- .mockResolvedValueOnce({
- id: "g1", name: "Old Name", environmentId: "env-1",
- color: null, createdAt: new Date(), updatedAt: new Date(),
- } as never)
- .mockResolvedValueOnce(null); // no conflict
-
- prismaMock.pipelineGroup.update.mockResolvedValue({
- id: "g1", name: "New Name", color: null,
- environmentId: "env-1", createdAt: new Date(), updatedAt: new Date(),
- } as never);
+ prismaMock.pipelineGroup.findUnique.mockResolvedValueOnce(
+ makeGroup({ id: "g1", name: "Old Name", parentId: null }) as never,
+ );
+ prismaMock.pipelineGroup.findFirst.mockResolvedValueOnce(null); // no conflict
+
+ prismaMock.pipelineGroup.update.mockResolvedValue(
+ makeGroup({ id: "g1", name: "New Name" }) as never,
+ );
const result = await caller.update({ id: "g1", name: "New Name" });
@@ -167,15 +285,13 @@ describe("pipelineGroupRouter", () => {
});
it("updates group color to null", async () => {
- prismaMock.pipelineGroup.findUnique.mockResolvedValueOnce({
- id: "g1", name: "Infra", environmentId: "env-1",
- color: "#ff0000", createdAt: new Date(), updatedAt: new Date(),
- } as never);
+ prismaMock.pipelineGroup.findUnique.mockResolvedValueOnce(
+ makeGroup({ id: "g1", name: "Infra", color: "#ff0000", parentId: null }) as never,
+ );
- prismaMock.pipelineGroup.update.mockResolvedValue({
- id: "g1", name: "Infra", color: null,
- environmentId: "env-1", createdAt: new Date(), updatedAt: new Date(),
- } as never);
+ prismaMock.pipelineGroup.update.mockResolvedValue(
+ makeGroup({ id: "g1", name: "Infra", color: null }) as never,
+ );
const result = await caller.update({ id: "g1", color: null });
@@ -194,16 +310,13 @@ describe("pipelineGroupRouter", () => {
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
- it("throws CONFLICT when renaming to an existing name", async () => {
- prismaMock.pipelineGroup.findUnique
- .mockResolvedValueOnce({
- id: "g1", name: "Alpha", environmentId: "env-1",
- color: null, createdAt: new Date(), updatedAt: new Date(),
- } as never)
- .mockResolvedValueOnce({
- id: "g2", name: "Beta", environmentId: "env-1",
- color: null, createdAt: new Date(), updatedAt: new Date(),
- } as never); // conflict!
+ it("throws CONFLICT when renaming to an existing name in same parent", async () => {
+ prismaMock.pipelineGroup.findUnique.mockResolvedValueOnce(
+ makeGroup({ id: "g1", name: "Alpha", parentId: null }) as never,
+ );
+ prismaMock.pipelineGroup.findFirst.mockResolvedValueOnce(
+ makeGroup({ id: "g2", name: "Beta", parentId: null }) as never, // conflict
+ );
await expect(
caller.update({ id: "g1", name: "Beta" }),
@@ -211,20 +324,36 @@ describe("pipelineGroupRouter", () => {
});
it("skips uniqueness check when name is unchanged", async () => {
- prismaMock.pipelineGroup.findUnique.mockResolvedValueOnce({
- id: "g1", name: "Same Name", environmentId: "env-1",
- color: null, createdAt: new Date(), updatedAt: new Date(),
- } as never);
+ prismaMock.pipelineGroup.findUnique.mockResolvedValueOnce(
+ makeGroup({ id: "g1", name: "Same Name", parentId: null }) as never,
+ );
- prismaMock.pipelineGroup.update.mockResolvedValue({
- id: "g1", name: "Same Name", color: "#000",
- environmentId: "env-1", createdAt: new Date(), updatedAt: new Date(),
- } as never);
+ prismaMock.pipelineGroup.update.mockResolvedValue(
+ makeGroup({ id: "g1", name: "Same Name", color: "#000" }) as never,
+ );
await caller.update({ id: "g1", name: "Same Name", color: "#000" });
- // findUnique called only once (to fetch the group), not twice (no conflict check)
- expect(prismaMock.pipelineGroup.findUnique).toHaveBeenCalledTimes(1);
+ // findFirst should NOT be called (no name change, skip uniqueness check)
+ expect(prismaMock.pipelineGroup.findFirst).not.toHaveBeenCalled();
+ });
+
+ it("enforces depth guard when updating parentId", async () => {
+ prismaMock.pipelineGroup.findUnique
+ .mockResolvedValueOnce(makeGroup({ id: "g1", name: "Group", parentId: null }) as never) // fetch group
+ .mockResolvedValueOnce({
+ id: "depth3-group",
+ parentId: "depth2-group",
+ parent: { parentId: "depth1-group" },
+ } as never); // depth guard: parent at depth 3
+ prismaMock.pipelineGroup.findFirst.mockResolvedValueOnce(null);
+
+ await expect(
+ caller.update({ id: "g1", parentId: "depth3-group" }),
+ ).rejects.toMatchObject({
+ code: "BAD_REQUEST",
+ message: expect.stringContaining("Maximum group nesting depth (3) exceeded"),
+ });
});
});
@@ -235,10 +364,9 @@ describe("pipelineGroupRouter", () => {
prismaMock.pipelineGroup.findUnique.mockResolvedValue({
id: "g1",
} as never);
- prismaMock.pipelineGroup.delete.mockResolvedValue({
- id: "g1", name: "Deleted", color: null,
- environmentId: "env-1", createdAt: new Date(), updatedAt: new Date(),
- } as never);
+ prismaMock.pipelineGroup.delete.mockResolvedValue(
+ makeGroup({ id: "g1", name: "Deleted" }) as never,
+ );
const result = await caller.delete({ id: "g1" });
@@ -255,5 +383,17 @@ describe("pipelineGroupRouter", () => {
caller.delete({ id: "nonexistent" }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
+
+ it("deletes group with children (SetNull cascade handles children parentId)", async () => {
+ // onDelete:SetNull handles this in DB — we just verify delete is called
+ prismaMock.pipelineGroup.findUnique.mockResolvedValue({ id: "parent-g" } as never);
+ prismaMock.pipelineGroup.delete.mockResolvedValue(
+ makeGroup({ id: "parent-g", name: "Parent" }) as never,
+ );
+
+ const result = await caller.delete({ id: "parent-g" });
+
+ expect(result.id).toBe("parent-g");
+ });
});
});
diff --git a/src/server/routers/__tests__/promotion.test.ts b/src/server/routers/__tests__/promotion.test.ts
new file mode 100644
index 00000000..8ade26fb
--- /dev/null
+++ b/src/server/routers/__tests__/promotion.test.ts
@@ -0,0 +1,723 @@
+import { vi, describe, it, expect, beforeEach } from "vitest";
+import { mockDeep, mockReset, type DeepMockProxy } from "vitest-mock-extended";
+import type { PrismaClient } from "@/generated/prisma";
+
+// ─── vi.hoisted so `t` is available inside vi.mock factories ────────────────
+
+const { t } = vi.hoisted(() => {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const { initTRPC } = require("@trpc/server");
+ const t = initTRPC.context().create();
+ return { t };
+});
+
+vi.mock("@/trpc/init", () => {
+ const passthrough = () =>
+ t.middleware(({ next, ctx }: { next: (opts: { ctx: unknown }) => unknown; ctx: unknown }) => next({ ctx }));
+ return {
+ router: t.router,
+ protectedProcedure: t.procedure,
+ withTeamAccess: passthrough,
+ requireSuperAdmin: passthrough,
+ middleware: t.middleware,
+ };
+});
+
+vi.mock("@/server/middleware/audit", () => ({
+ withAudit: () =>
+ t.middleware(({ next, ctx }: { next: (opts: { ctx: unknown }) => unknown; ctx: unknown }) => next({ ctx })),
+}));
+
+vi.mock("@/lib/prisma", () => ({
+ prisma: mockDeep(),
+}));
+
+vi.mock("@/server/services/promotion-service", () => ({
+ preflightSecrets: vi.fn(),
+ executePromotion: vi.fn(),
+ generateDiffPreview: vi.fn(),
+}));
+
+vi.mock("@/server/services/secret-resolver", () => ({
+ collectSecretRefs: vi.fn(),
+ convertSecretRefsToEnvVars: vi.fn(),
+ secretNameToEnvVar: vi.fn(),
+}));
+
+vi.mock("@/server/services/copy-pipeline-graph", () => ({
+ copyPipelineGraph: vi.fn(),
+}));
+
+vi.mock("@/server/services/outbound-webhook", () => ({
+ fireOutboundWebhooks: vi.fn(),
+}));
+
+vi.mock("@/server/services/config-crypto", () => ({
+ decryptNodeConfig: vi.fn((_: unknown, c: unknown) => c),
+ encryptNodeConfig: vi.fn((_: unknown, c: unknown) => c),
+}));
+
+vi.mock("@/lib/config-generator", () => ({
+ generateVectorYaml: vi.fn().mockReturnValue("sources:\n my_source:\n type: stdin\n"),
+}));
+
+vi.mock("@/server/services/audit", () => ({
+ writeAuditLog: vi.fn(),
+}));
+
+vi.mock("@/server/services/event-alerts", () => ({
+ fireEventAlert: vi.fn(),
+}));
+
+vi.mock("@/server/services/gitops-promotion", () => ({
+ createPromotionPR: vi.fn(),
+}));
+
+// ─── Import SUT + mocks ─────────────────────────────────────────────────────
+
+import { prisma } from "@/lib/prisma";
+import { promotionRouter } from "@/server/routers/promotion";
+import * as promotionService from "@/server/services/promotion-service";
+import * as gitopsPromotion from "@/server/services/gitops-promotion";
+
+const prismaMock = prisma as unknown as DeepMockProxy;
+const caller = t.createCallerFactory(promotionRouter)({
+ session: { user: { id: "user-1", email: "test@test.com" } },
+});
+
+// ─── Helpers ────────────────────────────────────────────────────────────────
+
+function makePipeline(overrides: Record = {}) {
+ return {
+ id: "pipeline-1",
+ name: "My Pipeline",
+ description: null,
+ environmentId: "env-source",
+ globalConfig: null,
+ isDraft: true,
+ isSystem: false,
+ nodes: [],
+ edges: [],
+ environment: { teamId: "team-1", id: "env-source", name: "Development" },
+ ...overrides,
+ };
+}
+
+function makeEnvironment(overrides: Record = {}) {
+ return {
+ id: "env-target",
+ name: "Production",
+ teamId: "team-1",
+ requireDeployApproval: true,
+ gitOpsMode: "off",
+ gitRepoUrl: null,
+ gitToken: null,
+ gitBranch: "main",
+ ...overrides,
+ };
+}
+
+function makePromotionRequest(overrides: Record = {}) {
+ return {
+ id: "req-1",
+ sourcePipelineId: "pipeline-1",
+ targetPipelineId: null,
+ sourceEnvironmentId: "env-source",
+ targetEnvironmentId: "env-target",
+ status: "PENDING",
+ promotedById: "user-2",
+ approvedById: null,
+ targetPipelineName: "My Pipeline",
+ nodesSnapshot: null,
+ edgesSnapshot: null,
+ globalConfigSnapshot: null,
+ reviewNote: null,
+ createdAt: new Date(),
+ reviewedAt: null,
+ deployedAt: null,
+ ...overrides,
+ };
+}
+
+// ─── Tests ──────────────────────────────────────────────────────────────────
+
+describe("promotion router", () => {
+ beforeEach(() => {
+ mockReset(prismaMock);
+ vi.clearAllMocks();
+ });
+
+ // ─── preflight ─────────────────────────────────────────────────────────────
+
+ describe("preflight", () => {
+ it("preflight blocks when secrets are missing in target env", async () => {
+ prismaMock.pipeline.findUnique.mockResolvedValue(makePipeline() as never);
+ prismaMock.pipeline.findFirst.mockResolvedValue(null);
+ prismaMock.environment.findUnique.mockResolvedValue(makeEnvironment() as never);
+ vi.mocked(promotionService.preflightSecrets).mockResolvedValue({
+ missing: ["api_key"],
+ present: ["db_password"],
+ canProceed: false,
+ });
+
+ const result = await caller.preflight({
+ pipelineId: "pipeline-1",
+ targetEnvironmentId: "env-target",
+ });
+
+ expect(result.canProceed).toBe(false);
+ expect(result.missing).toContain("api_key");
+ expect(result.present).toContain("db_password");
+ });
+
+ it("preflight passes when all secrets present", async () => {
+ prismaMock.pipeline.findUnique.mockResolvedValue(makePipeline() as never);
+ prismaMock.pipeline.findFirst.mockResolvedValue(null);
+ prismaMock.environment.findUnique.mockResolvedValue(makeEnvironment() as never);
+ vi.mocked(promotionService.preflightSecrets).mockResolvedValue({
+ missing: [],
+ present: ["api_key", "db_password"],
+ canProceed: true,
+ });
+
+ const result = await caller.preflight({
+ pipelineId: "pipeline-1",
+ targetEnvironmentId: "env-target",
+ });
+
+ expect(result.canProceed).toBe(true);
+ expect(result.missing).toHaveLength(0);
+ expect(result.present).toContain("api_key");
+ expect(result.present).toContain("db_password");
+ });
+
+ it("preflight passes with no secret refs", async () => {
+ prismaMock.pipeline.findUnique.mockResolvedValue(makePipeline() as never);
+ prismaMock.pipeline.findFirst.mockResolvedValue(null);
+ prismaMock.environment.findUnique.mockResolvedValue(makeEnvironment() as never);
+ vi.mocked(promotionService.preflightSecrets).mockResolvedValue({
+ missing: [],
+ present: [],
+ canProceed: true,
+ });
+
+ const result = await caller.preflight({
+ pipelineId: "pipeline-1",
+ targetEnvironmentId: "env-target",
+ });
+
+ expect(result.canProceed).toBe(true);
+ expect(result.missing).toHaveLength(0);
+ expect(result.present).toHaveLength(0);
+ });
+
+ it("preflight reports name collision when pipeline exists in target env", async () => {
+ prismaMock.pipeline.findUnique.mockResolvedValue(makePipeline() as never);
+ prismaMock.pipeline.findFirst.mockResolvedValue({ id: "existing-pipeline" } as never);
+ prismaMock.environment.findUnique.mockResolvedValue(makeEnvironment() as never);
+ vi.mocked(promotionService.preflightSecrets).mockResolvedValue({
+ missing: [],
+ present: [],
+ canProceed: true,
+ });
+
+ const result = await caller.preflight({
+ pipelineId: "pipeline-1",
+ targetEnvironmentId: "env-target",
+ });
+
+ expect(result.nameCollision).toBe(true);
+ });
+ });
+
+ // ─── diffPreview ────────────────────────────────────────────────────────────
+
+ describe("diffPreview", () => {
+ it("returns source and target YAML", async () => {
+ vi.mocked(promotionService.generateDiffPreview).mockResolvedValue({
+ sourceYaml: "sources:\n stdin: {}\n",
+ targetYaml: "sources:\n stdin: {}\n",
+ });
+
+ const result = await caller.diffPreview({ pipelineId: "pipeline-1" });
+
+ expect(result.sourceYaml).toBeDefined();
+ expect(result.targetYaml).toBeDefined();
+ expect(promotionService.generateDiffPreview).toHaveBeenCalledWith("pipeline-1");
+ });
+ });
+
+ // ─── initiate ──────────────────────────────────────────────────────────────
+
+ describe("initiate", () => {
+ it("creates PENDING request when approval required", async () => {
+ prismaMock.pipeline.findUnique.mockResolvedValue(makePipeline() as never);
+ prismaMock.environment.findUnique.mockResolvedValue(
+ makeEnvironment({ requireDeployApproval: true }) as never,
+ );
+ prismaMock.pipeline.findFirst.mockResolvedValue(null);
+ vi.mocked(promotionService.preflightSecrets).mockResolvedValue({
+ missing: [],
+ present: [],
+ canProceed: true,
+ });
+ prismaMock.promotionRequest.create.mockResolvedValue({
+ ...makePromotionRequest({ promotedById: "user-1" }),
+ } as never);
+
+ const result = await caller.initiate({
+ pipelineId: "pipeline-1",
+ targetEnvironmentId: "env-target",
+ });
+
+ expect(result.status).toBe("PENDING");
+ expect(result.pendingApproval).toBe(true);
+ expect(prismaMock.promotionRequest.create).toHaveBeenCalledOnce();
+ expect(promotionService.executePromotion).not.toHaveBeenCalled();
+ });
+
+ it("auto-executes when approval not required", async () => {
+ prismaMock.pipeline.findUnique.mockResolvedValue(makePipeline() as never);
+ prismaMock.environment.findUnique.mockResolvedValue(
+ makeEnvironment({ requireDeployApproval: false }) as never,
+ );
+ prismaMock.pipeline.findFirst.mockResolvedValue(null);
+ vi.mocked(promotionService.preflightSecrets).mockResolvedValue({
+ missing: [],
+ present: [],
+ canProceed: true,
+ });
+ prismaMock.promotionRequest.create.mockResolvedValue({
+ ...makePromotionRequest({ promotedById: "user-1" }),
+ } as never);
+ vi.mocked(promotionService.executePromotion).mockResolvedValue({
+ pipelineId: "new-pipeline-1",
+ pipelineName: "My Pipeline",
+ });
+
+ const result = await caller.initiate({
+ pipelineId: "pipeline-1",
+ targetEnvironmentId: "env-target",
+ });
+
+ expect(result.status).toBe("DEPLOYED");
+ expect(result.pendingApproval).toBe(false);
+ expect(promotionService.executePromotion).toHaveBeenCalledOnce();
+ });
+
+ it("throws BAD_REQUEST if same environment", async () => {
+ prismaMock.pipeline.findUnique.mockResolvedValue(
+ makePipeline({ environmentId: "env-target" }) as never,
+ );
+ prismaMock.environment.findUnique.mockResolvedValue(makeEnvironment() as never);
+
+ await expect(
+ caller.initiate({
+ pipelineId: "pipeline-1",
+ targetEnvironmentId: "env-target",
+ }),
+ ).rejects.toThrow("Source and target environments must be different");
+ });
+
+ it("throws BAD_REQUEST if different team", async () => {
+ prismaMock.pipeline.findUnique.mockResolvedValue(
+ makePipeline({ environment: { teamId: "team-1", id: "env-source" } }) as never,
+ );
+ prismaMock.environment.findUnique.mockResolvedValue(
+ makeEnvironment({ teamId: "team-2" }) as never,
+ );
+
+ await expect(
+ caller.initiate({
+ pipelineId: "pipeline-1",
+ targetEnvironmentId: "env-target",
+ }),
+ ).rejects.toThrow("same team");
+ });
+
+ it("throws BAD_REQUEST if pipeline name collision", async () => {
+ prismaMock.pipeline.findUnique.mockResolvedValue(makePipeline() as never);
+ prismaMock.environment.findUnique.mockResolvedValue(makeEnvironment() as never);
+ prismaMock.pipeline.findFirst.mockResolvedValue({
+ id: "existing-pipeline",
+ name: "My Pipeline",
+ } as never);
+
+ await expect(
+ caller.initiate({
+ pipelineId: "pipeline-1",
+ targetEnvironmentId: "env-target",
+ }),
+ ).rejects.toThrow("already exists");
+ });
+
+ it("throws BAD_REQUEST if secrets are missing", async () => {
+ prismaMock.pipeline.findUnique.mockResolvedValue(makePipeline() as never);
+ prismaMock.environment.findUnique.mockResolvedValue(makeEnvironment() as never);
+ prismaMock.pipeline.findFirst.mockResolvedValue(null);
+ vi.mocked(promotionService.preflightSecrets).mockResolvedValue({
+ missing: ["api_key"],
+ present: [],
+ canProceed: false,
+ });
+
+ await expect(
+ caller.initiate({
+ pipelineId: "pipeline-1",
+ targetEnvironmentId: "env-target",
+ }),
+ ).rejects.toThrow("Missing secrets");
+ });
+
+ it("stores nodesSnapshot and edgesSnapshot from source pipeline at request time", async () => {
+ const nodes = [
+ {
+ id: "node-1",
+ componentKey: "my_source",
+ componentType: "stdin",
+ kind: "SOURCE",
+ config: { encoding: { codec: "json" } },
+ positionX: 0,
+ positionY: 0,
+ disabled: false,
+ },
+ ];
+ const edges = [
+ { id: "edge-1", sourceNodeId: "node-1", targetNodeId: "node-2", sourcePort: null },
+ ];
+ prismaMock.pipeline.findUnique.mockResolvedValue(
+ makePipeline({ nodes, edges }) as never,
+ );
+ prismaMock.environment.findUnique.mockResolvedValue(
+ makeEnvironment({ requireDeployApproval: true }) as never,
+ );
+ prismaMock.pipeline.findFirst.mockResolvedValue(null);
+ vi.mocked(promotionService.preflightSecrets).mockResolvedValue({
+ missing: [],
+ present: [],
+ canProceed: true,
+ });
+ prismaMock.promotionRequest.create.mockResolvedValue({
+ ...makePromotionRequest({ promotedById: "user-1" }),
+ } as never);
+
+ await caller.initiate({
+ pipelineId: "pipeline-1",
+ targetEnvironmentId: "env-target",
+ });
+
+ const createCall = prismaMock.promotionRequest.create.mock.calls[0][0];
+ expect(createCall.data.nodesSnapshot).toBeDefined();
+ expect(createCall.data.edgesSnapshot).toBeDefined();
+ });
+ });
+
+ // ─── approve ────────────────────────────────────────────────────────────────
+
+ describe("approve", () => {
+ it("self-review blocked — promoter cannot approve own request", async () => {
+ prismaMock.promotionRequest.findUnique.mockResolvedValue(
+ makePromotionRequest({ promotedById: "user-1" }) as never,
+ );
+
+ await expect(
+ caller.approve({ requestId: "req-1" }),
+ ).rejects.toThrow("Cannot approve your own promotion request");
+ });
+
+ it("atomic approve prevents race condition — returns BAD_REQUEST if count 0", async () => {
+ prismaMock.promotionRequest.findUnique.mockResolvedValue(
+ makePromotionRequest({ promotedById: "user-2" }) as never,
+ );
+ prismaMock.promotionRequest.updateMany.mockResolvedValue({ count: 0 } as never);
+
+ await expect(
+ caller.approve({ requestId: "req-1" }),
+ ).rejects.toThrow("no longer pending");
+ });
+
+ it("succeeds for different user and calls executePromotion", async () => {
+ prismaMock.promotionRequest.findUnique.mockResolvedValue(
+ makePromotionRequest({ promotedById: "user-2" }) as never,
+ );
+ prismaMock.promotionRequest.updateMany.mockResolvedValue({ count: 1 } as never);
+ vi.mocked(promotionService.executePromotion).mockResolvedValue({
+ pipelineId: "new-pipeline-1",
+ pipelineName: "My Pipeline",
+ });
+
+ const result = await caller.approve({ requestId: "req-1" });
+
+ expect(result.success).toBe(true);
+ expect(promotionService.executePromotion).toHaveBeenCalledWith("req-1", "user-1");
+ });
+ });
+
+ // ─── reject ──────────────────────────────────────────────────────────────────
+
+ describe("reject", () => {
+ it("sets status REJECTED with review note", async () => {
+ prismaMock.promotionRequest.findUnique.mockResolvedValue(
+ makePromotionRequest({ promotedById: "user-2" }) as never,
+ );
+ prismaMock.promotionRequest.updateMany.mockResolvedValue({ count: 1 } as never);
+
+ const result = await caller.reject({ requestId: "req-1", note: "Not ready" });
+
+ expect(result.rejected).toBe(true);
+ const updateCall = prismaMock.promotionRequest.updateMany.mock.calls[0][0];
+ expect(updateCall.data.status).toBe("REJECTED");
+ expect(updateCall.data.reviewNote).toBe("Not ready");
+ });
+
+ it("throws if request not found or not pending", async () => {
+ prismaMock.promotionRequest.findUnique.mockResolvedValue(null);
+
+ await expect(
+ caller.reject({ requestId: "req-missing" }),
+ ).rejects.toThrow("not found or not pending");
+ });
+ });
+
+ // ─── cancel ──────────────────────────────────────────────────────────────────
+
+ describe("cancel", () => {
+ it("only promoter can cancel — throws FORBIDDEN for different user", async () => {
+ prismaMock.promotionRequest.findUnique.mockResolvedValue(
+ makePromotionRequest({ promotedById: "user-2" }) as never,
+ );
+
+ await expect(
+ caller.cancel({ requestId: "req-1" }),
+ ).rejects.toThrow("Only the original promoter");
+ });
+
+ it("promoter can cancel their own request", async () => {
+ prismaMock.promotionRequest.findUnique.mockResolvedValue(
+ makePromotionRequest({ promotedById: "user-1" }) as never,
+ );
+ prismaMock.promotionRequest.updateMany.mockResolvedValue({ count: 1 } as never);
+
+ const result = await caller.cancel({ requestId: "req-1" });
+
+ expect(result.cancelled).toBe(true);
+ });
+ });
+
+ // ─── history ─────────────────────────────────────────────────────────────────
+
+ describe("history", () => {
+ it("returns records ordered by createdAt desc", async () => {
+ const records = [
+ {
+ ...makePromotionRequest({ createdAt: new Date("2026-03-27") }),
+ promotedBy: { name: "Alice", email: "alice@test.com" },
+ approvedBy: null,
+ sourceEnvironment: { name: "Development" },
+ targetEnvironment: { name: "Production" },
+ },
+ {
+ ...makePromotionRequest({ id: "req-2", createdAt: new Date("2026-03-26") }),
+ promotedBy: { name: "Bob", email: "bob@test.com" },
+ approvedBy: null,
+ sourceEnvironment: { name: "Development" },
+ targetEnvironment: { name: "Staging" },
+ },
+ ];
+ prismaMock.promotionRequest.findMany.mockResolvedValue(records as never);
+
+ const result = await caller.history({ pipelineId: "pipeline-1" });
+
+ expect(result).toHaveLength(2);
+ expect(prismaMock.promotionRequest.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: { sourcePipelineId: "pipeline-1" },
+ orderBy: { createdAt: "desc" },
+ take: 20,
+ }),
+ );
+ });
+ });
+
+ // ─── SECRET[name] ref preservation ───────────────────────────────────────────
+
+ describe("clone preserves SECRET refs", () => {
+ it("executePromotion does not strip SECRET[name] refs from cloned pipeline config", async () => {
+ // This test verifies the behavior is wired correctly: no transformConfig is passed
+ // to copyPipelineGraph, so SECRET[name] refs are preserved intact.
+ // The promotion service is tested here via mocked executePromotion.
+ // The actual preservation is enforced in promotion-service.ts by not passing transformConfig.
+ vi.mocked(promotionService.executePromotion).mockResolvedValue({
+ pipelineId: "new-pipeline-1",
+ pipelineName: "My Pipeline",
+ });
+ prismaMock.promotionRequest.findUnique.mockResolvedValue(
+ makePromotionRequest({ promotedById: "user-2" }) as never,
+ );
+ prismaMock.promotionRequest.updateMany.mockResolvedValue({ count: 1 } as never);
+
+ const result = await caller.approve({ requestId: "req-1" });
+
+ // Verify executePromotion was called (which internally uses copyPipelineGraph without stripping)
+ expect(promotionService.executePromotion).toHaveBeenCalledWith("req-1", "user-1");
+ expect(result.success).toBe(true);
+ });
+
+ it("diffPreview targetYaml uses SECRET ref placeholders (not plaintext)", async () => {
+ // sourceYaml shows SECRET[api_key] as-is, targetYaml converts to ${VF_SECRET_API_KEY}
+ vi.mocked(promotionService.generateDiffPreview).mockResolvedValue({
+ sourceYaml: "password: SECRET[api_key]\n",
+ targetYaml: "password: ${VF_SECRET_API_KEY}\n",
+ });
+
+ const result = await caller.diffPreview({ pipelineId: "pipeline-1" });
+
+ // Source YAML preserves SECRET[name] reference format
+ expect(result.sourceYaml).toContain("SECRET[api_key]");
+ // Target YAML uses env var placeholder format
+ expect(result.targetYaml).toContain("VF_SECRET_API_KEY");
+ });
+ });
+
+ // ─── GitOps initiation path ───────────────────────────────────────────────
+
+ describe("GitOps initiation", () => {
+ it("returns AWAITING_PR_MERGE and prUrl when gitOpsMode is 'promotion'", async () => {
+ prismaMock.pipeline.findUnique.mockResolvedValue(makePipeline() as never);
+ prismaMock.environment.findUnique.mockResolvedValue(
+ makeEnvironment({
+ gitOpsMode: "promotion",
+ gitRepoUrl: "https://github.com/myorg/myrepo",
+ gitToken: "encrypted-token",
+ gitBranch: "main",
+ requireDeployApproval: false,
+ }) as never,
+ );
+ prismaMock.pipeline.findFirst.mockResolvedValue(null);
+ vi.mocked(promotionService.preflightSecrets).mockResolvedValue({
+ missing: [],
+ present: [],
+ canProceed: true,
+ });
+ prismaMock.promotionRequest.create.mockResolvedValue({
+ ...makePromotionRequest({ promotedById: "user-1" }),
+ } as never);
+ vi.mocked(gitopsPromotion.createPromotionPR).mockResolvedValue({
+ prNumber: 42,
+ prUrl: "https://github.com/myorg/myrepo/pull/42",
+ prBranch: "vf-promote/production-my-pipeline-req1",
+ });
+ prismaMock.promotionRequest.update.mockResolvedValue({} as never);
+
+ const result = await caller.initiate({
+ pipelineId: "pipeline-1",
+ targetEnvironmentId: "env-target",
+ });
+
+ expect(result.status).toBe("AWAITING_PR_MERGE");
+ expect(result.prUrl).toBe("https://github.com/myorg/myrepo/pull/42");
+ expect(result.pendingApproval).toBe(false);
+ expect(gitopsPromotion.createPromotionPR).toHaveBeenCalledOnce();
+ expect(promotionService.executePromotion).not.toHaveBeenCalled();
+ });
+
+ it("updates PromotionRequest with prUrl and prNumber after PR creation", async () => {
+ prismaMock.pipeline.findUnique.mockResolvedValue(makePipeline() as never);
+ prismaMock.environment.findUnique.mockResolvedValue(
+ makeEnvironment({
+ gitOpsMode: "promotion",
+ gitRepoUrl: "https://github.com/myorg/myrepo",
+ gitToken: "encrypted-token",
+ gitBranch: "main",
+ }) as never,
+ );
+ prismaMock.pipeline.findFirst.mockResolvedValue(null);
+ vi.mocked(promotionService.preflightSecrets).mockResolvedValue({
+ missing: [],
+ present: [],
+ canProceed: true,
+ });
+ prismaMock.promotionRequest.create.mockResolvedValue({
+ ...makePromotionRequest({ id: "req-gitops-1", promotedById: "user-1" }),
+ } as never);
+ vi.mocked(gitopsPromotion.createPromotionPR).mockResolvedValue({
+ prNumber: 7,
+ prUrl: "https://github.com/myorg/myrepo/pull/7",
+ prBranch: "vf-promote/production-my-pipeline-req-gito",
+ });
+ prismaMock.promotionRequest.update.mockResolvedValue({} as never);
+
+ await caller.initiate({
+ pipelineId: "pipeline-1",
+ targetEnvironmentId: "env-target",
+ });
+
+ expect(prismaMock.promotionRequest.update).toHaveBeenCalledWith({
+ where: { id: "req-gitops-1" },
+ data: {
+ prUrl: "https://github.com/myorg/myrepo/pull/7",
+ prNumber: 7,
+ status: "AWAITING_PR_MERGE",
+ },
+ });
+ });
+
+ it("falls through to UI path when gitOpsMode is 'off'", async () => {
+ prismaMock.pipeline.findUnique.mockResolvedValue(makePipeline() as never);
+ prismaMock.environment.findUnique.mockResolvedValue(
+ makeEnvironment({ gitOpsMode: "off", requireDeployApproval: true }) as never,
+ );
+ prismaMock.pipeline.findFirst.mockResolvedValue(null);
+ vi.mocked(promotionService.preflightSecrets).mockResolvedValue({
+ missing: [],
+ present: [],
+ canProceed: true,
+ });
+ prismaMock.promotionRequest.create.mockResolvedValue({
+ ...makePromotionRequest({ promotedById: "user-1" }),
+ } as never);
+
+ const result = await caller.initiate({
+ pipelineId: "pipeline-1",
+ targetEnvironmentId: "env-target",
+ });
+
+ expect(result.status).toBe("PENDING");
+ expect(gitopsPromotion.createPromotionPR).not.toHaveBeenCalled();
+ });
+
+ it("falls through to UI path when gitOpsMode is 'push'", async () => {
+ prismaMock.pipeline.findUnique.mockResolvedValue(makePipeline() as never);
+ prismaMock.environment.findUnique.mockResolvedValue(
+ makeEnvironment({
+ gitOpsMode: "push",
+ gitRepoUrl: "https://github.com/myorg/myrepo",
+ gitToken: "encrypted-token",
+ requireDeployApproval: false,
+ }) as never,
+ );
+ prismaMock.pipeline.findFirst.mockResolvedValue(null);
+ vi.mocked(promotionService.preflightSecrets).mockResolvedValue({
+ missing: [],
+ present: [],
+ canProceed: true,
+ });
+ prismaMock.promotionRequest.create.mockResolvedValue({
+ ...makePromotionRequest({ promotedById: "user-1" }),
+ } as never);
+ vi.mocked(promotionService.executePromotion).mockResolvedValue({
+ pipelineId: "new-pipeline-1",
+ pipelineName: "My Pipeline",
+ });
+
+ const result = await caller.initiate({
+ pipelineId: "pipeline-1",
+ targetEnvironmentId: "env-target",
+ });
+
+ // push mode should execute directly (no PR)
+ expect(result.status).toBe("DEPLOYED");
+ expect(gitopsPromotion.createPromotionPR).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/server/routers/__tests__/webhook-endpoint.test.ts b/src/server/routers/__tests__/webhook-endpoint.test.ts
new file mode 100644
index 00000000..b6773773
--- /dev/null
+++ b/src/server/routers/__tests__/webhook-endpoint.test.ts
@@ -0,0 +1,258 @@
+import { vi, describe, it, expect, beforeEach } from "vitest";
+import { mockDeep, mockReset, type DeepMockProxy } from "vitest-mock-extended";
+import type { PrismaClient } from "@/generated/prisma";
+import { AlertMetric } from "@/generated/prisma";
+
+// ─── vi.hoisted so `t` is available inside vi.mock factories ────────────────
+
+const { t } = vi.hoisted(() => {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const { initTRPC } = require("@trpc/server");
+ const t = initTRPC.context().create();
+ return { t };
+});
+
+vi.mock("@/trpc/init", () => {
+ const passthrough = () =>
+ t.middleware(({ next, ctx }: { next: (opts: { ctx: unknown }) => unknown; ctx: unknown }) => next({ ctx }));
+ return {
+ router: t.router,
+ protectedProcedure: t.procedure,
+ withTeamAccess: passthrough,
+ middleware: t.middleware,
+ };
+});
+
+vi.mock("@/server/middleware/audit", () => ({
+ withAudit: () =>
+ t.middleware(({ next, ctx }: { next: (opts: { ctx: unknown }) => unknown; ctx: unknown }) => next({ ctx })),
+}));
+
+vi.mock("@/lib/prisma", () => ({
+ prisma: mockDeep(),
+}));
+
+vi.mock("@/server/services/crypto", () => ({
+ encrypt: vi.fn().mockReturnValue("encrypted-secret"),
+ decrypt: vi.fn().mockReturnValue("plaintext-secret"),
+}));
+
+vi.mock("@/server/services/url-validation", () => ({
+ validatePublicUrl: vi.fn().mockResolvedValue(undefined),
+}));
+
+vi.mock("@/server/services/outbound-webhook", () => ({
+ deliverOutboundWebhook: vi.fn().mockResolvedValue({
+ success: true,
+ statusCode: 200,
+ isPermanent: false,
+ }),
+}));
+
+// ─── Import SUT + mocks after vi.mock ───────────────────────────────────────
+
+import { prisma } from "@/lib/prisma";
+import { webhookEndpointRouter } from "@/server/routers/webhook-endpoint";
+import * as cryptoMod from "@/server/services/crypto";
+import * as urlValidation from "@/server/services/url-validation";
+import * as outboundWebhook from "@/server/services/outbound-webhook";
+
+const prismaMock = prisma as unknown as DeepMockProxy;
+const caller = t.createCallerFactory(webhookEndpointRouter)({
+ session: { user: { id: "user-1" } },
+});
+
+// ─── Helpers ────────────────────────────────────────────────────────────────
+
+function makeEndpoint(overrides: Partial<{
+ id: string;
+ teamId: string;
+ name: string;
+ url: string;
+ eventTypes: AlertMetric[];
+ encryptedSecret: string | null;
+ enabled: boolean;
+ createdAt: Date;
+ updatedAt: Date;
+}> = {}) {
+ return {
+ id: "ep-1",
+ teamId: "team-1",
+ name: "My Webhook",
+ url: "https://example.com/hook",
+ eventTypes: [AlertMetric.deploy_completed],
+ encryptedSecret: "encrypted-secret",
+ enabled: true,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ ...overrides,
+ };
+}
+
+// ─── Tests ──────────────────────────────────────────────────────────────────
+
+describe("webhookEndpointRouter", () => {
+ beforeEach(() => {
+ mockReset(prismaMock);
+ vi.clearAllMocks();
+ vi.mocked(urlValidation.validatePublicUrl).mockResolvedValue(undefined);
+ vi.mocked(cryptoMod.encrypt).mockReturnValue("encrypted-secret");
+ });
+
+ // ─── create ────────────────────────────────────────────────────────────
+
+ describe("create", () => {
+ it("encrypts secret before storing", async () => {
+ const endpoint = makeEndpoint();
+ prismaMock.webhookEndpoint.create.mockResolvedValue(endpoint);
+
+ await caller.create({
+ teamId: "team-1",
+ name: "My Webhook",
+ url: "https://example.com/hook",
+ eventTypes: [AlertMetric.deploy_completed],
+ secret: "my-secret",
+ });
+
+ expect(cryptoMod.encrypt).toHaveBeenCalledWith("my-secret");
+ expect(prismaMock.webhookEndpoint.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ encryptedSecret: "encrypted-secret",
+ }),
+ }),
+ );
+ });
+
+ it("validates URL via validatePublicUrl", async () => {
+ const endpoint = makeEndpoint();
+ prismaMock.webhookEndpoint.create.mockResolvedValue(endpoint);
+
+ await caller.create({
+ teamId: "team-1",
+ name: "My Webhook",
+ url: "https://example.com/hook",
+ eventTypes: [AlertMetric.deploy_completed],
+ });
+
+ expect(urlValidation.validatePublicUrl).toHaveBeenCalledWith("https://example.com/hook");
+ });
+
+ it("stores null encryptedSecret when no secret provided", async () => {
+ const endpoint = makeEndpoint({ encryptedSecret: null });
+ prismaMock.webhookEndpoint.create.mockResolvedValue(endpoint);
+
+ await caller.create({
+ teamId: "team-1",
+ name: "My Webhook",
+ url: "https://example.com/hook",
+ eventTypes: [AlertMetric.deploy_completed],
+ });
+
+ expect(prismaMock.webhookEndpoint.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ encryptedSecret: null,
+ }),
+ }),
+ );
+ });
+ });
+
+ // ─── list ──────────────────────────────────────────────────────────────
+
+ describe("list", () => {
+ it("excludes encryptedSecret from response using select", async () => {
+ prismaMock.webhookEndpoint.findMany.mockResolvedValue([]);
+
+ await caller.list({ teamId: "team-1" });
+
+ expect(prismaMock.webhookEndpoint.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ select: expect.not.objectContaining({
+ encryptedSecret: expect.anything(),
+ }),
+ }),
+ );
+ });
+
+ it("orders by createdAt desc", async () => {
+ prismaMock.webhookEndpoint.findMany.mockResolvedValue([]);
+
+ await caller.list({ teamId: "team-1" });
+
+ expect(prismaMock.webhookEndpoint.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ orderBy: { createdAt: "desc" },
+ }),
+ );
+ });
+ });
+
+ // ─── testDelivery ──────────────────────────────────────────────────────
+
+ describe("testDelivery", () => {
+ it("calls deliverOutboundWebhook with endpoint URL and encrypted secret", async () => {
+ const endpoint = makeEndpoint();
+ prismaMock.webhookEndpoint.findFirst.mockResolvedValue(endpoint);
+
+ await caller.testDelivery({ id: "ep-1", teamId: "team-1" });
+
+ expect(outboundWebhook.deliverOutboundWebhook).toHaveBeenCalledWith(
+ expect.objectContaining({
+ url: endpoint.url,
+ encryptedSecret: endpoint.encryptedSecret,
+ }),
+ expect.objectContaining({
+ type: "test",
+ }),
+ );
+ });
+
+ it("returns the delivery result", async () => {
+ const endpoint = makeEndpoint();
+ prismaMock.webhookEndpoint.findFirst.mockResolvedValue(endpoint);
+
+ const result = await caller.testDelivery({ id: "ep-1", teamId: "team-1" });
+
+ expect(result).toMatchObject({
+ success: true,
+ statusCode: 200,
+ });
+ });
+ });
+
+ // ─── listDeliveries ────────────────────────────────────────────────────
+
+ describe("listDeliveries", () => {
+ it("returns deliveries ordered by requestedAt desc", async () => {
+ prismaMock.webhookEndpoint.findFirst.mockResolvedValue(makeEndpoint());
+ prismaMock.webhookDelivery.findMany.mockResolvedValue([]);
+ prismaMock.webhookDelivery.count.mockResolvedValue(0);
+
+ await caller.listDeliveries({
+ webhookEndpointId: "ep-1",
+ teamId: "team-1",
+ });
+
+ expect(prismaMock.webhookDelivery.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ orderBy: { requestedAt: "desc" },
+ }),
+ );
+ });
+
+ it("returns total count for pagination", async () => {
+ prismaMock.webhookEndpoint.findFirst.mockResolvedValue(makeEndpoint());
+ prismaMock.webhookDelivery.findMany.mockResolvedValue([]);
+ prismaMock.webhookDelivery.count.mockResolvedValue(5);
+
+ const result = await caller.listDeliveries({
+ webhookEndpointId: "ep-1",
+ teamId: "team-1",
+ });
+
+ expect(result.total).toBe(5);
+ });
+ });
+});
diff --git a/src/server/routers/environment.ts b/src/server/routers/environment.ts
index 579fee09..8b41e324 100644
--- a/src/server/routers/environment.ts
+++ b/src/server/routers/environment.ts
@@ -103,7 +103,7 @@ export const environmentRouter = router({
gitRepoUrl: z.string().url().optional().nullable(),
gitBranch: z.string().min(1).max(100).optional().nullable(),
gitToken: z.string().optional().nullable(),
- gitOpsMode: z.enum(["off", "push", "bidirectional"]).optional(),
+ gitOpsMode: z.enum(["off", "push", "bidirectional", "promotion"]).optional(),
requireDeployApproval: z.boolean().optional(),
})
)
@@ -147,17 +147,18 @@ export const environmentRouter = router({
data.gitToken = gitToken ? encrypt(gitToken) : null;
}
- // Handle gitOpsMode — auto-generate webhook secret when switching to bidirectional
+ // Handle gitOpsMode — auto-generate webhook secret when switching to bidirectional or promotion
let plaintextWebhookSecret: string | null = null;
if (gitOpsModeInput !== undefined) {
data.gitOpsMode = gitOpsModeInput;
- if (gitOpsModeInput === "bidirectional" && !existing.gitWebhookSecret) {
+ const needsWebhookSecret = gitOpsModeInput === "bidirectional" || gitOpsModeInput === "promotion";
+ if (needsWebhookSecret && !existing.gitWebhookSecret) {
plaintextWebhookSecret = crypto.randomBytes(32).toString("hex");
data.gitWebhookSecret = encrypt(plaintextWebhookSecret);
}
- // Clear webhook secret when disabling bidirectional mode
- if (gitOpsModeInput !== "bidirectional") {
+ // Clear webhook secret when disabling webhook-based modes
+ if (!needsWebhookSecret) {
data.gitWebhookSecret = null;
}
}
diff --git a/src/server/routers/fleet.ts b/src/server/routers/fleet.ts
index 0805f0c3..3990fba5 100644
--- a/src/server/routers/fleet.ts
+++ b/src/server/routers/fleet.ts
@@ -56,9 +56,25 @@ export const fleetRouter = router({
});
}
+ // Label compliance check (NODE-02)
+ const nodeGroups = await prisma.nodeGroup.findMany({
+ where: { environmentId: input.environmentId },
+ select: { requiredLabels: true },
+ });
+ const allRequiredLabels = [
+ ...new Set(nodeGroups.flatMap((g) => g.requiredLabels as string[])),
+ ];
+
return filtered.map((node) => ({
...node,
pushConnected: pushRegistry.isConnected(node.id),
+ labelCompliant: allRequiredLabels.length === 0 ||
+ allRequiredLabels.every((key) =>
+ Object.prototype.hasOwnProperty.call(
+ (node.labels as Record) ?? {},
+ key,
+ ),
+ ),
}));
}),
diff --git a/src/server/routers/node-group.ts b/src/server/routers/node-group.ts
new file mode 100644
index 00000000..f732b3d2
--- /dev/null
+++ b/src/server/routers/node-group.ts
@@ -0,0 +1,358 @@
+import { z } from "zod";
+import { TRPCError } from "@trpc/server";
+import { router, protectedProcedure, withTeamAccess } from "@/trpc/init";
+import { prisma } from "@/lib/prisma";
+import { withAudit } from "@/server/middleware/audit";
+import { nodeMatchesGroup } from "@/lib/node-group-utils";
+
+export const nodeGroupRouter = router({
+ list: protectedProcedure
+ .input(z.object({ environmentId: z.string() }))
+ .use(withTeamAccess("VIEWER"))
+ .query(async ({ input }) => {
+ return prisma.nodeGroup.findMany({
+ where: { environmentId: input.environmentId },
+ orderBy: { name: "asc" },
+ });
+ }),
+
+ create: protectedProcedure
+ .input(
+ z.object({
+ environmentId: z.string(),
+ name: z.string().min(1).max(100),
+ criteria: z.record(z.string(), z.string()).default({}),
+ labelTemplate: z.record(z.string(), z.string()).default({}),
+ requiredLabels: z.array(z.string()).default([]),
+ }),
+ )
+ .use(withTeamAccess("ADMIN"))
+ .use(withAudit("nodeGroup.created", "NodeGroup"))
+ .mutation(async ({ input }) => {
+ // Validate unique name per environment
+ const existing = await prisma.nodeGroup.findUnique({
+ where: {
+ environmentId_name: {
+ environmentId: input.environmentId,
+ name: input.name,
+ },
+ },
+ });
+ if (existing) {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message: `A node group named "${input.name}" already exists in this environment`,
+ });
+ }
+
+ return prisma.nodeGroup.create({
+ data: {
+ name: input.name,
+ environmentId: input.environmentId,
+ criteria: input.criteria,
+ labelTemplate: input.labelTemplate,
+ requiredLabels: input.requiredLabels,
+ },
+ });
+ }),
+
+ update: protectedProcedure
+ .input(
+ z.object({
+ id: z.string(),
+ name: z.string().min(1).max(100).optional(),
+ criteria: z.record(z.string(), z.string()).optional(),
+ labelTemplate: z.record(z.string(), z.string()).optional(),
+ requiredLabels: z.array(z.string()).optional(),
+ }),
+ )
+ .use(withTeamAccess("ADMIN"))
+ .use(withAudit("nodeGroup.updated", "NodeGroup"))
+ .mutation(async ({ input }) => {
+ const group = await prisma.nodeGroup.findUnique({
+ where: { id: input.id },
+ select: { id: true, environmentId: true, name: true },
+ });
+ if (!group) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Node group not found",
+ });
+ }
+
+ // Validate unique name if name is being changed
+ if (input.name && input.name !== group.name) {
+ const existing = await prisma.nodeGroup.findUnique({
+ where: {
+ environmentId_name: {
+ environmentId: group.environmentId,
+ name: input.name,
+ },
+ },
+ });
+ if (existing) {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message: `A node group named "${input.name}" already exists in this environment`,
+ });
+ }
+ }
+
+ const data: Record = {};
+ if (input.name !== undefined) data.name = input.name;
+ if (input.criteria !== undefined) data.criteria = input.criteria;
+ if (input.labelTemplate !== undefined) data.labelTemplate = input.labelTemplate;
+ if (input.requiredLabels !== undefined) data.requiredLabels = input.requiredLabels;
+
+ return prisma.nodeGroup.update({
+ where: { id: input.id },
+ data,
+ });
+ }),
+
+ delete: protectedProcedure
+ .input(z.object({ id: z.string() }))
+ .use(withTeamAccess("ADMIN"))
+ .use(withAudit("nodeGroup.deleted", "NodeGroup"))
+ .mutation(async ({ input }) => {
+ const group = await prisma.nodeGroup.findUnique({
+ where: { id: input.id },
+ select: { id: true },
+ });
+ if (!group) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Node group not found",
+ });
+ }
+
+ return prisma.nodeGroup.delete({
+ where: { id: input.id },
+ });
+ }),
+
+ /**
+ * NODE-04: Aggregated per-group health stats for the fleet dashboard.
+ * Single round trip: 3 parallel queries, application-layer aggregation.
+ */
+ groupHealthStats: protectedProcedure
+ .input(z.object({ environmentId: z.string() }))
+ .use(withTeamAccess("VIEWER"))
+ .query(async ({ input }) => {
+ const { environmentId } = input;
+
+ const [nodes, groups, firingAlerts] = await Promise.all([
+ prisma.vectorNode.findMany({
+ where: { environmentId },
+ select: { id: true, status: true, labels: true },
+ }),
+ prisma.nodeGroup.findMany({
+ where: { environmentId },
+ orderBy: { name: "asc" },
+ }),
+ prisma.alertEvent.findMany({
+ where: { status: "firing", node: { environmentId } },
+ select: { nodeId: true },
+ }),
+ ]);
+
+ const firingNodeIds = new Set(
+ firingAlerts.map((a) => a.nodeId).filter(Boolean) as string[],
+ );
+
+ const assignedNodeIds = new Set();
+
+ const groupStats = groups.map((group) => {
+ const criteria = group.criteria as Record;
+ const requiredLabels = group.requiredLabels as string[];
+
+ const matchedNodes = nodes.filter((n) => {
+ const nodeLabels = (n.labels as Record) ?? {};
+ return nodeMatchesGroup(nodeLabels, criteria);
+ });
+
+ for (const n of matchedNodes) {
+ assignedNodeIds.add(n.id);
+ }
+
+ const totalNodes = matchedNodes.length;
+ const onlineCount = matchedNodes.filter((n) => n.status === "HEALTHY").length;
+ const alertCount = matchedNodes.filter((n) => firingNodeIds.has(n.id)).length;
+
+ let complianceRate = 100;
+ if (requiredLabels.length > 0 && totalNodes > 0) {
+ const compliantCount = matchedNodes.filter((n) => {
+ const nodeLabels = (n.labels as Record) ?? {};
+ return requiredLabels.every((key) =>
+ Object.prototype.hasOwnProperty.call(nodeLabels, key),
+ );
+ }).length;
+ complianceRate = Math.round((compliantCount / totalNodes) * 100);
+ }
+
+ return {
+ ...group,
+ totalNodes,
+ onlineCount,
+ alertCount,
+ complianceRate,
+ };
+ });
+
+ // Synthetic "Ungrouped" entry for nodes not matching any group
+ const ungroupedNodes = nodes.filter((n) => !assignedNodeIds.has(n.id));
+ if (ungroupedNodes.length > 0) {
+ const ungroupedOnlineCount = ungroupedNodes.filter((n) => n.status === "HEALTHY").length;
+ const ungroupedAlertCount = ungroupedNodes.filter((n) => firingNodeIds.has(n.id)).length;
+ groupStats.push({
+ id: "__ungrouped__",
+ name: "Ungrouped",
+ environmentId,
+ criteria: {},
+ labelTemplate: {},
+ requiredLabels: [],
+ createdAt: new Date(0),
+ updatedAt: new Date(0),
+ totalNodes: ungroupedNodes.length,
+ onlineCount: ungroupedOnlineCount,
+ alertCount: ungroupedAlertCount,
+ complianceRate: 100,
+ });
+ }
+
+ return groupStats;
+ }),
+
+ /**
+ * NODE-05: Per-node detail for a group, sorted by health status (worst first).
+ * Used for the drill-down view in the fleet health dashboard.
+ */
+ nodesInGroup: protectedProcedure
+ .input(z.object({ groupId: z.string(), environmentId: z.string() }))
+ .use(withTeamAccess("VIEWER"))
+ .query(async ({ input }) => {
+ const { groupId, environmentId } = input;
+
+ let groupCriteria: Record = {};
+ let requiredLabels: string[] = [];
+
+ if (groupId === "__ungrouped__") {
+ // Fetch all groups to determine which nodes are ungrouped
+ const allGroups = await prisma.nodeGroup.findMany({
+ where: { environmentId },
+ });
+
+ const allNodes = await prisma.vectorNode.findMany({
+ where: { environmentId },
+ select: {
+ id: true,
+ name: true,
+ status: true,
+ labels: true,
+ lastSeen: true,
+ nodeMetrics: {
+ orderBy: { timestamp: "desc" },
+ take: 1,
+ select: { loadAvg1: true },
+ },
+ },
+ });
+
+ const assignedIds = new Set();
+ for (const group of allGroups) {
+ const criteria = group.criteria as Record;
+ for (const n of allNodes) {
+ const nodeLabels = (n.labels as Record) ?? {};
+ if (nodeMatchesGroup(nodeLabels, criteria)) {
+ assignedIds.add(n.id);
+ }
+ }
+ }
+
+ const ungroupedNodes = allNodes.filter((n) => !assignedIds.has(n.id));
+ return sortAndMapNodes(ungroupedNodes, []);
+ }
+
+ // Normal group lookup — scoped to input.environmentId to prevent cross-team data exposure
+ const group = await prisma.nodeGroup.findFirst({
+ where: { id: groupId, environmentId },
+ });
+ if (!group) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Node group not found",
+ });
+ }
+
+ groupCriteria = group.criteria as Record;
+ requiredLabels = group.requiredLabels as string[];
+
+ const allNodes = await prisma.vectorNode.findMany({
+ where: { environmentId },
+ select: {
+ id: true,
+ name: true,
+ status: true,
+ labels: true,
+ lastSeen: true,
+ nodeMetrics: {
+ orderBy: { timestamp: "desc" },
+ take: 1,
+ select: { loadAvg1: true },
+ },
+ },
+ });
+
+ const matchedNodes = allNodes.filter((n) => {
+ const nodeLabels = (n.labels as Record) ?? {};
+ return nodeMatchesGroup(nodeLabels, groupCriteria);
+ });
+
+ return sortAndMapNodes(matchedNodes, requiredLabels);
+ }),
+});
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+const STATUS_ORDER: Record = {
+ UNREACHABLE: 0,
+ DEGRADED: 1,
+ UNKNOWN: 2,
+ HEALTHY: 3,
+};
+
+function sortAndMapNodes(
+ nodes: Array<{
+ id: string;
+ name: string;
+ status: string;
+ labels: unknown;
+ lastSeen: Date | null;
+ nodeMetrics: Array<{ loadAvg1: number }>;
+ }>,
+ requiredLabels: string[],
+) {
+ return nodes
+ .map((n) => ({
+ id: n.id,
+ name: n.name,
+ status: n.status,
+ labels: n.labels,
+ lastSeen: n.lastSeen,
+ cpuLoad: n.nodeMetrics[0]?.loadAvg1 ?? null,
+ labelCompliant:
+ requiredLabels.length === 0 ||
+ requiredLabels.every((key) =>
+ Object.prototype.hasOwnProperty.call(
+ (n.labels as Record) ?? {},
+ key,
+ ),
+ ),
+ }))
+ .sort((a, b) => {
+ const statusDiff =
+ (STATUS_ORDER[a.status] ?? 99) - (STATUS_ORDER[b.status] ?? 99);
+ if (statusDiff !== 0) return statusDiff;
+ return a.name.localeCompare(b.name);
+ });
+}
diff --git a/src/server/routers/pipeline-group.ts b/src/server/routers/pipeline-group.ts
index 031479dd..ee965d4e 100644
--- a/src/server/routers/pipeline-group.ts
+++ b/src/server/routers/pipeline-group.ts
@@ -12,7 +12,7 @@ export const pipelineGroupRouter = router({
return prisma.pipelineGroup.findMany({
where: { environmentId: input.environmentId },
include: {
- _count: { select: { pipelines: true } },
+ _count: { select: { pipelines: true, children: true } },
},
orderBy: { name: "asc" },
});
@@ -24,32 +24,51 @@ export const pipelineGroupRouter = router({
environmentId: z.string(),
name: z.string().min(1).max(100),
color: z.string().max(20).optional(),
+ parentId: z.string().optional(),
}),
)
.use(withTeamAccess("EDITOR"))
.use(withAudit("pipelineGroup.created", "PipelineGroup"))
.mutation(async ({ input }) => {
- // Validate unique name per environment
- const existing = await prisma.pipelineGroup.findUnique({
+ // Check duplicate name under same parent (application-layer uniqueness)
+ const existing = await prisma.pipelineGroup.findFirst({
where: {
- environmentId_name: {
- environmentId: input.environmentId,
- name: input.name,
- },
+ environmentId: input.environmentId,
+ name: input.name,
+ parentId: input.parentId ?? null,
},
});
if (existing) {
throw new TRPCError({
code: "CONFLICT",
- message: `A group named "${input.name}" already exists in this environment`,
+ message: `A group named "${input.name}" already exists ${input.parentId ? "in this parent group" : "at the root level"}`,
});
}
+ // Enforce max 3-level nesting depth
+ if (input.parentId) {
+ const parent = await prisma.pipelineGroup.findUnique({
+ where: { id: input.parentId },
+ select: { parentId: true, parent: { select: { parentId: true } } },
+ });
+ if (!parent) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Parent group not found" });
+ }
+ // If parent has a grandparent that also has a parent, depth would exceed 3
+ if (parent.parentId !== null && parent.parent?.parentId !== null && parent.parent?.parentId !== undefined) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Maximum group nesting depth (3) exceeded",
+ });
+ }
+ }
+
return prisma.pipelineGroup.create({
data: {
name: input.name,
color: input.color,
environmentId: input.environmentId,
+ parentId: input.parentId ?? null,
},
});
}),
@@ -60,6 +79,7 @@ export const pipelineGroupRouter = router({
id: z.string(),
name: z.string().min(1).max(100).optional(),
color: z.string().max(20).nullable().optional(),
+ parentId: z.string().nullable().optional(),
}),
)
.use(withTeamAccess("EDITOR"))
@@ -67,7 +87,7 @@ export const pipelineGroupRouter = router({
.mutation(async ({ input }) => {
const group = await prisma.pipelineGroup.findUnique({
where: { id: input.id },
- select: { id: true, environmentId: true, name: true },
+ select: { id: true, environmentId: true, name: true, parentId: true },
});
if (!group) {
throw new TRPCError({
@@ -78,25 +98,46 @@ export const pipelineGroupRouter = router({
// Validate unique name if name is being changed
if (input.name && input.name !== group.name) {
- const existing = await prisma.pipelineGroup.findUnique({
+ const targetParentId = input.parentId !== undefined ? input.parentId : group.parentId;
+ const existingGroup = await prisma.pipelineGroup.findFirst({
where: {
- environmentId_name: {
- environmentId: group.environmentId,
- name: input.name,
- },
+ environmentId: group.environmentId,
+ name: input.name,
+ parentId: targetParentId,
+ id: { not: input.id },
},
});
- if (existing) {
+ if (existingGroup) {
throw new TRPCError({
code: "CONFLICT",
- message: `A group named "${input.name}" already exists in this environment`,
+ message: `A group named "${input.name}" already exists in this location`,
+ });
+ }
+ }
+
+ // Enforce depth guard when parentId changes
+ if (input.parentId !== undefined && input.parentId !== group.parentId) {
+ if (input.parentId !== null) {
+ const parent = await prisma.pipelineGroup.findUnique({
+ where: { id: input.parentId },
+ select: { parentId: true, parent: { select: { parentId: true } } },
});
+ if (!parent) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Parent group not found" });
+ }
+ if (parent.parentId !== null && parent.parent?.parentId !== null && parent.parent?.parentId !== undefined) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Maximum group nesting depth (3) exceeded",
+ });
+ }
}
}
const data: Record = {};
if (input.name !== undefined) data.name = input.name;
if (input.color !== undefined) data.color = input.color;
+ if (input.parentId !== undefined) data.parentId = input.parentId;
return prisma.pipelineGroup.update({
where: { id: input.id },
@@ -120,7 +161,7 @@ export const pipelineGroupRouter = router({
});
}
- // Prisma onDelete:SetNull automatically unassigns all pipelines
+ // Prisma onDelete:SetNull automatically sets children parentId to null
return prisma.pipelineGroup.delete({
where: { id: input.id },
});
diff --git a/src/server/routers/pipeline.ts b/src/server/routers/pipeline.ts
index 27a28e96..d98af5af 100644
--- a/src/server/routers/pipeline.ts
+++ b/src/server/routers/pipeline.ts
@@ -1040,6 +1040,113 @@ export const pipelineRouter = router({
}
}
+ return { results, total: results.length, succeeded: results.filter((r) => r.success).length };
+ }),
+
+ bulkAddTags: protectedProcedure
+ .input(
+ z.object({
+ pipelineIds: z.array(z.string()).min(1).max(100),
+ tags: z.array(z.string()).min(1),
+ }),
+ )
+ .use(withTeamAccess("EDITOR"))
+ .mutation(async ({ input }) => {
+ // Validate tags against team.availableTags ONCE before the loop
+ // Get the team from the first pipeline's environment
+ const firstPipeline = await prisma.pipeline.findUnique({
+ where: { id: input.pipelineIds[0] },
+ select: { environment: { select: { teamId: true } } },
+ });
+ if (!firstPipeline?.environment.teamId) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Pipeline or team not found" });
+ }
+ const team = await prisma.team.findUnique({
+ where: { id: firstPipeline.environment.teamId },
+ select: { availableTags: true },
+ });
+ if (!team) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Team not found" });
+ }
+ const availableTags = (team.availableTags as string[]) ?? [];
+ if (availableTags.length > 0) {
+ const invalid = input.tags.filter((t) => !availableTags.includes(t));
+ if (invalid.length > 0) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: `Invalid tags: ${invalid.join(", ")}. Tags must be defined in team settings first.`,
+ });
+ }
+ }
+
+ const results: Array<{ pipelineId: string; success: boolean; error?: string }> = [];
+
+ for (const pipelineId of input.pipelineIds) {
+ try {
+ const pipeline = await prisma.pipeline.findUnique({
+ where: { id: pipelineId },
+ select: { id: true, tags: true },
+ });
+ if (!pipeline) {
+ results.push({ pipelineId, success: false, error: "Pipeline not found" });
+ continue;
+ }
+ const existingTags = (pipeline.tags as string[]) ?? [];
+ const merged = [...new Set([...existingTags, ...input.tags])];
+ await prisma.pipeline.update({
+ where: { id: pipelineId },
+ data: { tags: merged },
+ });
+ results.push({ pipelineId, success: true });
+ } catch (err) {
+ results.push({
+ pipelineId,
+ success: false,
+ error: err instanceof Error ? err.message : "Unknown error",
+ });
+ }
+ }
+
+ return { results, total: results.length, succeeded: results.filter((r) => r.success).length };
+ }),
+
+ bulkRemoveTags: protectedProcedure
+ .input(
+ z.object({
+ pipelineIds: z.array(z.string()).min(1).max(100),
+ tags: z.array(z.string()).min(1),
+ }),
+ )
+ .use(withTeamAccess("EDITOR"))
+ .mutation(async ({ input }) => {
+ const results: Array<{ pipelineId: string; success: boolean; error?: string }> = [];
+
+ for (const pipelineId of input.pipelineIds) {
+ try {
+ const pipeline = await prisma.pipeline.findUnique({
+ where: { id: pipelineId },
+ select: { id: true, tags: true },
+ });
+ if (!pipeline) {
+ results.push({ pipelineId, success: false, error: "Pipeline not found" });
+ continue;
+ }
+ const existingTags = (pipeline.tags as string[]) ?? [];
+ const filtered = existingTags.filter((t) => !input.tags.includes(t));
+ await prisma.pipeline.update({
+ where: { id: pipelineId },
+ data: { tags: filtered },
+ });
+ results.push({ pipelineId, success: true });
+ } catch (err) {
+ results.push({
+ pipelineId,
+ success: false,
+ error: err instanceof Error ? err.message : "Unknown error",
+ });
+ }
+ }
+
return { results, total: results.length, succeeded: results.filter((r) => r.success).length };
}),
});
diff --git a/src/server/routers/promotion.ts b/src/server/routers/promotion.ts
new file mode 100644
index 00000000..bd6ad67e
--- /dev/null
+++ b/src/server/routers/promotion.ts
@@ -0,0 +1,428 @@
+import { z } from "zod";
+import { TRPCError } from "@trpc/server";
+import { router, protectedProcedure, withTeamAccess } from "@/trpc/init";
+import { prisma } from "@/lib/prisma";
+import { withAudit } from "@/server/middleware/audit";
+import {
+ preflightSecrets,
+ executePromotion,
+ generateDiffPreview,
+} from "@/server/services/promotion-service";
+import { createPromotionPR } from "@/server/services/gitops-promotion";
+import { generateVectorYaml } from "@/lib/config-generator";
+import { decryptNodeConfig } from "@/server/services/config-crypto";
+
+export const promotionRouter = router({
+ /**
+ * Preflight check: validates all SECRET[name] references in the source pipeline
+ * exist as named secrets in the target environment.
+ * Also checks for pipeline name collisions.
+ */
+ preflight: protectedProcedure
+ .input(
+ z.object({
+ pipelineId: z.string(),
+ targetEnvironmentId: z.string(),
+ name: z.string().optional(),
+ }),
+ )
+ .use(withTeamAccess("VIEWER"))
+ .query(async ({ input }) => {
+ const pipeline = await prisma.pipeline.findUnique({
+ where: { id: input.pipelineId },
+ select: { name: true },
+ });
+ if (!pipeline) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Pipeline not found" });
+ }
+
+ const targetPipelineName = input.name ?? pipeline.name;
+
+ // Check for name collision in target env
+ const nameCollision = await prisma.pipeline.findFirst({
+ where: {
+ environmentId: input.targetEnvironmentId,
+ name: targetPipelineName,
+ },
+ select: { id: true },
+ });
+
+ const targetEnv = await prisma.environment.findUnique({
+ where: { id: input.targetEnvironmentId },
+ select: { name: true },
+ });
+
+ const secretPreflight = await preflightSecrets(input.pipelineId, input.targetEnvironmentId);
+
+ return {
+ ...secretPreflight,
+ nameCollision: nameCollision !== null,
+ targetEnvironmentName: targetEnv?.name ?? input.targetEnvironmentId,
+ targetPipelineName,
+ };
+ }),
+
+ /**
+ * Generates a side-by-side YAML diff preview showing source config
+ * (with SECRET refs visible) vs target config (with SECRET refs as env vars).
+ */
+ diffPreview: protectedProcedure
+ .input(z.object({ pipelineId: z.string() }))
+ .use(withTeamAccess("VIEWER"))
+ .query(async ({ input }) => {
+ return generateDiffPreview(input.pipelineId);
+ }),
+
+ /**
+ * Initiates a pipeline promotion from source to target environment.
+ * - Creates a PromotionRequest with status PENDING (when approval required)
+ * - Or auto-approves and executes when requireDeployApproval is false
+ */
+ initiate: protectedProcedure
+ .input(
+ z.object({
+ pipelineId: z.string(),
+ targetEnvironmentId: z.string(),
+ name: z.string().optional(),
+ }),
+ )
+ .use(withTeamAccess("EDITOR"))
+ .use(withAudit("promotion.initiated", "PromotionRequest"))
+ .mutation(async ({ input, ctx }) => {
+ const userId = ctx.session.user.id;
+
+ // Load source pipeline with environment
+ const sourcePipeline = await prisma.pipeline.findUnique({
+ where: { id: input.pipelineId },
+ include: {
+ nodes: true,
+ edges: true,
+ environment: {
+ select: { teamId: true, id: true, name: true },
+ },
+ },
+ });
+ if (!sourcePipeline) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Pipeline not found" });
+ }
+
+ // Load target environment (including GitOps fields for PR-based promotion)
+ const targetEnv = await prisma.environment.findUnique({
+ where: { id: input.targetEnvironmentId },
+ select: {
+ teamId: true,
+ name: true,
+ requireDeployApproval: true,
+ gitOpsMode: true,
+ gitRepoUrl: true,
+ gitToken: true,
+ gitBranch: true,
+ },
+ });
+ if (!targetEnv) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Target environment not found" });
+ }
+
+ // Validate: source and target must be different environments
+ if (sourcePipeline.environmentId === input.targetEnvironmentId) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Source and target environments must be different",
+ });
+ }
+
+ // Validate: same team constraint
+ if (targetEnv.teamId !== sourcePipeline.environment.teamId) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Target environment must belong to the same team as the source pipeline",
+ });
+ }
+
+ const targetPipelineName = input.name ?? sourcePipeline.name;
+
+ // Check for pipeline name collision in target env
+ const nameCollision = await prisma.pipeline.findFirst({
+ where: {
+ environmentId: input.targetEnvironmentId,
+ name: targetPipelineName,
+ },
+ select: { id: true, name: true },
+ });
+ if (nameCollision) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: `A pipeline named "${targetPipelineName}" already exists in environment "${targetEnv.name}"`,
+ });
+ }
+
+ // Preflight: check all secret refs are present in target env
+ const preflight = await preflightSecrets(input.pipelineId, input.targetEnvironmentId);
+ if (!preflight.canProceed) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: `Missing secrets in target environment: ${preflight.missing.join(", ")}`,
+ });
+ }
+
+ // Capture snapshots from source pipeline
+ const nodesSnapshot = sourcePipeline.nodes.map((n) => ({
+ id: n.id,
+ componentKey: n.componentKey,
+ componentType: n.componentType,
+ kind: n.kind,
+ config: n.config,
+ positionX: n.positionX,
+ positionY: n.positionY,
+ disabled: n.disabled,
+ }));
+ const edgesSnapshot = sourcePipeline.edges.map((e) => ({
+ id: e.id,
+ sourceNodeId: e.sourceNodeId,
+ targetNodeId: e.targetNodeId,
+ sourcePort: e.sourcePort,
+ }));
+
+ // Create the PromotionRequest
+ const promotionRequest = await prisma.promotionRequest.create({
+ data: {
+ sourcePipelineId: input.pipelineId,
+ sourceEnvironmentId: sourcePipeline.environmentId,
+ targetEnvironmentId: input.targetEnvironmentId,
+ status: "PENDING",
+ promotedById: userId,
+ targetPipelineName,
+ nodesSnapshot: nodesSnapshot as unknown as import("@/generated/prisma").Prisma.InputJsonValue,
+ edgesSnapshot: edgesSnapshot as unknown as import("@/generated/prisma").Prisma.InputJsonValue,
+ globalConfigSnapshot: sourcePipeline.globalConfig as import("@/generated/prisma").Prisma.InputJsonValue | null ?? undefined,
+ },
+ });
+
+ // GitOps path: if target env has gitOpsMode="promotion" and a configured repo,
+ // create a GitHub PR instead of directly executing. The PR merge will trigger deployment.
+ if (targetEnv.gitOpsMode === "promotion" && targetEnv.gitRepoUrl && targetEnv.gitToken) {
+ // Build YAML from source pipeline nodes (preserve SECRET[name] refs as-is)
+ const flowEdges = sourcePipeline.edges.map((e) => ({
+ id: e.id,
+ source: e.sourceNodeId,
+ target: e.targetNodeId,
+ ...(e.sourcePort ? { sourceHandle: e.sourcePort } : {}),
+ }));
+ const flowNodes = sourcePipeline.nodes.map((n) => ({
+ id: n.id,
+ type: n.kind.toLowerCase(),
+ position: { x: n.positionX, y: n.positionY },
+ data: {
+ componentDef: { type: n.componentType, kind: n.kind.toLowerCase() },
+ componentKey: n.componentKey,
+ config: decryptNodeConfig(n.componentType, (n.config as Record) ?? {}),
+ disabled: n.disabled,
+ },
+ }));
+ const configYaml = generateVectorYaml(
+ flowNodes as Parameters[0],
+ flowEdges as Parameters[1],
+ sourcePipeline.globalConfig as Record | null,
+ null,
+ );
+
+ const pr = await createPromotionPR({
+ encryptedToken: targetEnv.gitToken,
+ repoUrl: targetEnv.gitRepoUrl,
+ baseBranch: targetEnv.gitBranch ?? "main",
+ requestId: promotionRequest.id,
+ pipelineName: sourcePipeline.name,
+ sourceEnvironmentName: sourcePipeline.environment.name,
+ targetEnvironmentName: targetEnv.name,
+ configYaml,
+ });
+
+ await prisma.promotionRequest.update({
+ where: { id: promotionRequest.id },
+ data: {
+ prUrl: pr.prUrl,
+ prNumber: pr.prNumber,
+ status: "AWAITING_PR_MERGE",
+ },
+ });
+
+ return {
+ requestId: promotionRequest.id,
+ status: "AWAITING_PR_MERGE",
+ prUrl: pr.prUrl,
+ pendingApproval: false,
+ };
+ }
+
+ // UI path (Phase 5): if no approval required, auto-execute
+ if (!targetEnv.requireDeployApproval) {
+ await executePromotion(promotionRequest.id, userId);
+ return { requestId: promotionRequest.id, status: "DEPLOYED", pendingApproval: false };
+ }
+
+ return { requestId: promotionRequest.id, status: "PENDING", pendingApproval: true };
+ }),
+
+ /**
+ * Approves a pending promotion request and executes the promotion.
+ * Self-review is blocked. Uses atomic updateMany to prevent race conditions.
+ */
+ approve: protectedProcedure
+ .input(z.object({ requestId: z.string() }))
+ .use(withTeamAccess("EDITOR"))
+ .use(withAudit("promotion.approved", "PromotionRequest"))
+ .mutation(async ({ input, ctx }) => {
+ const userId = ctx.session.user.id;
+
+ const request = await prisma.promotionRequest.findUnique({
+ where: { id: input.requestId },
+ select: { id: true, status: true, promotedById: true },
+ });
+ if (!request || request.status !== "PENDING") {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Promotion request not found or not pending",
+ });
+ }
+
+ // Self-review guard
+ if (request.promotedById === userId) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Cannot approve your own promotion request",
+ });
+ }
+
+ // Atomic claim — prevents double-approval race condition
+ const updated = await prisma.promotionRequest.updateMany({
+ where: { id: input.requestId, status: "PENDING" },
+ data: {
+ status: "APPROVED",
+ approvedById: userId,
+ reviewedAt: new Date(),
+ },
+ });
+ if (updated.count === 0) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Promotion request is no longer pending",
+ });
+ }
+
+ // Execute the promotion
+ const result = await executePromotion(input.requestId, userId);
+
+ return { success: true, pipelineId: result.pipelineId, pipelineName: result.pipelineName };
+ }),
+
+ /**
+ * Rejects a pending promotion request.
+ */
+ reject: protectedProcedure
+ .input(z.object({ requestId: z.string(), note: z.string().optional() }))
+ .use(withTeamAccess("EDITOR"))
+ .use(withAudit("promotion.rejected", "PromotionRequest"))
+ .mutation(async ({ input, ctx }) => {
+ const request = await prisma.promotionRequest.findUnique({
+ where: { id: input.requestId },
+ select: { id: true, status: true, targetPipelineId: true },
+ });
+ if (!request || request.status !== "PENDING") {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Promotion request not found or not pending",
+ });
+ }
+
+ // Atomically reject — prevents race with concurrent approve
+ const updated = await prisma.promotionRequest.updateMany({
+ where: { id: input.requestId, status: "PENDING" },
+ data: {
+ status: "REJECTED",
+ reviewedAt: new Date(),
+ reviewNote: input.note ?? null,
+ },
+ });
+ if (updated.count === 0) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Promotion request is no longer pending",
+ });
+ }
+
+ // Safety: clean up target pipeline if one was somehow created (shouldn't happen for PENDING)
+ if (request.targetPipelineId) {
+ await prisma.pipeline.delete({ where: { id: request.targetPipelineId } }).catch(() => {
+ // Ignore deletion errors
+ });
+ }
+
+ return { rejected: true };
+ }),
+
+ /**
+ * Cancels a pending promotion request. Only the original promoter can cancel.
+ */
+ cancel: protectedProcedure
+ .input(z.object({ requestId: z.string() }))
+ .use(withTeamAccess("EDITOR"))
+ .use(withAudit("promotion.cancelled", "PromotionRequest"))
+ .mutation(async ({ input, ctx }) => {
+ const userId = ctx.session.user.id;
+
+ const request = await prisma.promotionRequest.findUnique({
+ where: { id: input.requestId },
+ select: { id: true, status: true, promotedById: true },
+ });
+ if (!request || request.status !== "PENDING") {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Promotion request not found or not pending",
+ });
+ }
+
+ // Only the original promoter can cancel
+ if (request.promotedById !== userId) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Only the original promoter can cancel a pending request",
+ });
+ }
+
+ const updated = await prisma.promotionRequest.updateMany({
+ where: { id: input.requestId, status: "PENDING" },
+ data: { status: "CANCELLED" },
+ });
+ if (updated.count === 0) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Promotion request status changed — try again",
+ });
+ }
+
+ return { cancelled: true };
+ }),
+
+ /**
+ * Returns promotion history for a pipeline ordered by createdAt desc.
+ * Includes related user names, emails, and environment names.
+ */
+ history: protectedProcedure
+ .input(z.object({ pipelineId: z.string() }))
+ .use(withTeamAccess("VIEWER"))
+ .query(async ({ input }) => {
+ const records = await prisma.promotionRequest.findMany({
+ where: { sourcePipelineId: input.pipelineId },
+ orderBy: { createdAt: "desc" },
+ take: 20,
+ include: {
+ promotedBy: { select: { name: true, email: true } },
+ approvedBy: { select: { name: true, email: true } },
+ sourceEnvironment: { select: { name: true } },
+ targetEnvironment: { select: { name: true } },
+ },
+ });
+
+ return records;
+ }),
+});
diff --git a/src/server/routers/webhook-endpoint.ts b/src/server/routers/webhook-endpoint.ts
new file mode 100644
index 00000000..763aeb98
--- /dev/null
+++ b/src/server/routers/webhook-endpoint.ts
@@ -0,0 +1,244 @@
+import { z } from "zod";
+import { TRPCError } from "@trpc/server";
+import { router, protectedProcedure, withTeamAccess } from "@/trpc/init";
+import { prisma } from "@/lib/prisma";
+import { AlertMetric } from "@/generated/prisma";
+import { withAudit } from "@/server/middleware/audit";
+import { encrypt } from "@/server/services/crypto";
+import { validatePublicUrl } from "@/server/services/url-validation";
+import { deliverOutboundWebhook } from "@/server/services/outbound-webhook";
+
+// ─── Shared select shape (never includes encryptedSecret) ───────────────────
+
+const ENDPOINT_SELECT = {
+ id: true,
+ name: true,
+ url: true,
+ eventTypes: true,
+ enabled: true,
+ createdAt: true,
+ updatedAt: true,
+} as const;
+
+// ─── Router ─────────────────────────────────────────────────────────────────
+
+export const webhookEndpointRouter = router({
+
+ /**
+ * List all webhook endpoints for a team.
+ * Excludes encryptedSecret — it is never returned after creation.
+ */
+ list: protectedProcedure
+ .input(z.object({ teamId: z.string() }))
+ .use(withTeamAccess("VIEWER"))
+ .query(async ({ input }) => {
+ return prisma.webhookEndpoint.findMany({
+ where: { teamId: input.teamId },
+ select: ENDPOINT_SELECT,
+ orderBy: { createdAt: "desc" },
+ });
+ }),
+
+ /**
+ * Create a new webhook endpoint.
+ * Validates URL against SSRF, encrypts the secret if provided.
+ * Returns the plaintext secret ONCE on creation (never again).
+ */
+ create: protectedProcedure
+ .input(
+ z.object({
+ teamId: z.string(),
+ name: z.string().min(1).max(200),
+ url: z.string().url(),
+ eventTypes: z.array(z.nativeEnum(AlertMetric)).min(1),
+ secret: z.string().min(1).optional(),
+ }),
+ )
+ .use(withTeamAccess("ADMIN"))
+ .use(withAudit("webhookEndpoint.created", "WebhookEndpoint"))
+ .mutation(async ({ input }) => {
+ await validatePublicUrl(input.url);
+
+ const encryptedSecret = input.secret ? encrypt(input.secret) : null;
+
+ const endpoint = await prisma.webhookEndpoint.create({
+ data: {
+ teamId: input.teamId,
+ name: input.name,
+ url: input.url,
+ eventTypes: input.eventTypes,
+ encryptedSecret,
+ },
+ select: ENDPOINT_SELECT,
+ });
+
+ // Return the plaintext secret once so the admin can copy it.
+ // After this response, the secret is never exposed again.
+ return {
+ ...endpoint,
+ secret: input.secret ?? null,
+ };
+ }),
+
+ /**
+ * Update an existing webhook endpoint.
+ * Only provided fields are updated. URL is re-validated if changed.
+ */
+ update: protectedProcedure
+ .input(
+ z.object({
+ id: z.string(),
+ teamId: z.string(),
+ name: z.string().min(1).max(200).optional(),
+ url: z.string().url().optional(),
+ eventTypes: z.array(z.nativeEnum(AlertMetric)).min(1).optional(),
+ secret: z.string().min(1).optional(),
+ }),
+ )
+ .use(withTeamAccess("ADMIN"))
+ .use(withAudit("webhookEndpoint.updated", "WebhookEndpoint"))
+ .mutation(async ({ input }) => {
+ // Verify ownership
+ const existing = await prisma.webhookEndpoint.findFirst({
+ where: { id: input.id, teamId: input.teamId },
+ select: { id: true },
+ });
+ if (!existing) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Webhook endpoint not found" });
+ }
+
+ if (input.url) {
+ await validatePublicUrl(input.url);
+ }
+
+ const updateData: Record = {};
+ if (input.name !== undefined) updateData.name = input.name;
+ if (input.url !== undefined) updateData.url = input.url;
+ if (input.eventTypes !== undefined) updateData.eventTypes = input.eventTypes;
+ if (input.secret !== undefined) updateData.encryptedSecret = encrypt(input.secret);
+
+ return prisma.webhookEndpoint.update({
+ where: { id: input.id },
+ data: updateData,
+ select: ENDPOINT_SELECT,
+ });
+ }),
+
+ /**
+ * Delete a webhook endpoint (and cascade its deliveries).
+ */
+ delete: protectedProcedure
+ .input(z.object({ id: z.string(), teamId: z.string() }))
+ .use(withTeamAccess("ADMIN"))
+ .use(withAudit("webhookEndpoint.deleted", "WebhookEndpoint"))
+ .mutation(async ({ input }) => {
+ // Verify the endpoint belongs to this team before deleting
+ const existing = await prisma.webhookEndpoint.findFirst({
+ where: { id: input.id, teamId: input.teamId },
+ select: { id: true },
+ });
+ if (!existing) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Webhook endpoint not found" });
+ }
+
+ await prisma.webhookEndpoint.delete({ where: { id: input.id } });
+ return { deleted: true };
+ }),
+
+ /**
+ * Toggle the enabled flag on a webhook endpoint.
+ */
+ toggleEnabled: protectedProcedure
+ .input(z.object({ id: z.string(), teamId: z.string() }))
+ .use(withTeamAccess("ADMIN"))
+ .use(withAudit("webhookEndpoint.toggled", "WebhookEndpoint"))
+ .mutation(async ({ input }) => {
+ const existing = await prisma.webhookEndpoint.findFirst({
+ where: { id: input.id, teamId: input.teamId },
+ select: { id: true, enabled: true },
+ });
+ if (!existing) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Webhook endpoint not found" });
+ }
+
+ return prisma.webhookEndpoint.update({
+ where: { id: input.id },
+ data: { enabled: !existing.enabled },
+ select: ENDPOINT_SELECT,
+ });
+ }),
+
+ /**
+ * Send a test delivery to a webhook endpoint.
+ * Returns the OutboundResult directly so the caller can report success/failure.
+ */
+ testDelivery: protectedProcedure
+ .input(z.object({ id: z.string(), teamId: z.string() }))
+ .use(withTeamAccess("ADMIN"))
+ .use(withAudit("webhookEndpoint.testDelivery", "WebhookEndpoint"))
+ .mutation(async ({ input }) => {
+ const endpoint = await prisma.webhookEndpoint.findFirst({
+ where: { id: input.id, teamId: input.teamId },
+ select: {
+ id: true,
+ url: true,
+ encryptedSecret: true,
+ },
+ });
+ if (!endpoint) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Webhook endpoint not found" });
+ }
+
+ const testPayload = {
+ type: "test",
+ timestamp: new Date().toISOString(),
+ data: {
+ message: "Test delivery from VectorFlow",
+ endpointId: input.id,
+ },
+ };
+
+ return deliverOutboundWebhook(
+ { url: endpoint.url, encryptedSecret: endpoint.encryptedSecret, id: endpoint.id },
+ testPayload,
+ );
+ }),
+
+ /**
+ * List delivery history for a webhook endpoint with cursor pagination.
+ */
+ listDeliveries: protectedProcedure
+ .input(
+ z.object({
+ webhookEndpointId: z.string(),
+ teamId: z.string(),
+ take: z.number().min(1).max(100).default(20),
+ skip: z.number().min(0).default(0),
+ }),
+ )
+ .use(withTeamAccess("VIEWER"))
+ .query(async ({ input }) => {
+ // Verify endpoint belongs to the team
+ const endpoint = await prisma.webhookEndpoint.findFirst({
+ where: { id: input.webhookEndpointId, teamId: input.teamId },
+ select: { id: true },
+ });
+ if (!endpoint) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Webhook endpoint not found" });
+ }
+
+ const [deliveries, total] = await Promise.all([
+ prisma.webhookDelivery.findMany({
+ where: { webhookEndpointId: input.webhookEndpointId },
+ orderBy: { requestedAt: "desc" },
+ take: input.take,
+ skip: input.skip,
+ }),
+ prisma.webhookDelivery.count({
+ where: { webhookEndpointId: input.webhookEndpointId },
+ }),
+ ]);
+
+ return { deliveries, total };
+ }),
+});
diff --git a/src/server/services/__tests__/gitops-promotion.test.ts b/src/server/services/__tests__/gitops-promotion.test.ts
new file mode 100644
index 00000000..6d9e31a6
--- /dev/null
+++ b/src/server/services/__tests__/gitops-promotion.test.ts
@@ -0,0 +1,183 @@
+import { vi, describe, it, expect, beforeEach } from "vitest";
+
+// ─── Mocks ───────────────────────────────────────────────────────────────────
+
+vi.mock("@octokit/rest", () => ({
+ Octokit: vi.fn(),
+}));
+
+vi.mock("@/server/services/crypto", () => ({
+ decrypt: vi.fn((encrypted: string) => `decrypted-${encrypted}`),
+}));
+
+vi.mock("@/server/services/git-sync", () => ({
+ toFilenameSlug: vi.fn((name: string) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")),
+}));
+
+// ─── Imports ─────────────────────────────────────────────────────────────────
+
+import { Octokit } from "@octokit/rest";
+import { createPromotionPR, parseGitHubOwnerRepo } from "@/server/services/gitops-promotion";
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+function makeOctokitMock(overrides?: Record) {
+ const getRef = vi.fn().mockResolvedValue({
+ data: { object: { sha: "base-sha-abc123" } },
+ });
+ const createRef = vi.fn().mockResolvedValue({});
+ const getContent = vi.fn().mockRejectedValue(new Error("Not Found")); // Default: file does not exist
+ const createOrUpdateFileContents = vi.fn().mockResolvedValue({});
+ const create = vi.fn().mockResolvedValue({
+ data: { number: 42, html_url: "https://github.com/owner/repo/pull/42" },
+ });
+
+ return {
+ rest: {
+ git: { getRef, createRef },
+ repos: { getContent, createOrUpdateFileContents },
+ pulls: { create },
+ },
+ ...overrides,
+ };
+}
+
+// ─── Tests: parseGitHubOwnerRepo ─────────────────────────────────────────────
+
+describe("parseGitHubOwnerRepo", () => {
+ it("parses HTTPS URL without .git", () => {
+ const result = parseGitHubOwnerRepo("https://github.com/myorg/myrepo");
+ expect(result).toEqual({ owner: "myorg", repo: "myrepo" });
+ });
+
+ it("parses HTTPS URL with .git", () => {
+ const result = parseGitHubOwnerRepo("https://github.com/myorg/myrepo.git");
+ expect(result).toEqual({ owner: "myorg", repo: "myrepo" });
+ });
+
+ it("parses SSH URL", () => {
+ const result = parseGitHubOwnerRepo("git@github.com:myorg/myrepo.git");
+ expect(result).toEqual({ owner: "myorg", repo: "myrepo" });
+ });
+
+ it("parses SSH URL without .git", () => {
+ const result = parseGitHubOwnerRepo("git@github.com:myorg/myrepo");
+ expect(result).toEqual({ owner: "myorg", repo: "myrepo" });
+ });
+
+ it("throws for unrecognized URL format", () => {
+ expect(() => parseGitHubOwnerRepo("https://gitlab.com/myorg/myrepo")).toThrow(
+ "Cannot parse GitHub owner/repo",
+ );
+ });
+});
+
+// ─── Tests: createPromotionPR ─────────────────────────────────────────────────
+
+describe("createPromotionPR", () => {
+ let octokitMock: ReturnType;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ octokitMock = makeOctokitMock();
+ // Must use a function (not arrow) so `new` works correctly in Vitest
+ vi.mocked(Octokit).mockImplementation(function () {
+ return octokitMock as never;
+ });
+ });
+
+ const baseOpts = {
+ encryptedToken: "enc-token",
+ repoUrl: "https://github.com/myorg/myrepo",
+ baseBranch: "main",
+ requestId: "req1234567890",
+ pipelineName: "My Pipeline",
+ sourceEnvironmentName: "Development",
+ targetEnvironmentName: "Production",
+ configYaml: "sources:\n my_source:\n type: stdin\n",
+ };
+
+ it("decrypts token and instantiates Octokit with it", async () => {
+ await createPromotionPR(baseOpts);
+ expect(Octokit).toHaveBeenCalledWith({ auth: "decrypted-enc-token" });
+ });
+
+ it("gets base branch SHA before creating PR branch", async () => {
+ await createPromotionPR(baseOpts);
+ expect(octokitMock.rest.git.getRef).toHaveBeenCalledWith({
+ owner: "myorg",
+ repo: "myrepo",
+ ref: "heads/main",
+ });
+ });
+
+ it("creates a PR branch with unique name including requestId prefix", async () => {
+ await createPromotionPR(baseOpts);
+ expect(octokitMock.rest.git.createRef).toHaveBeenCalledWith({
+ owner: "myorg",
+ repo: "myrepo",
+ ref: "refs/heads/vf-promote/production-my-pipeline-req12345",
+ sha: "base-sha-abc123",
+ });
+ });
+
+ it("commits YAML file at envSlug/pipelineSlug.yaml on the PR branch", async () => {
+ await createPromotionPR(baseOpts);
+ expect(octokitMock.rest.repos.createOrUpdateFileContents).toHaveBeenCalledWith(
+ expect.objectContaining({
+ owner: "myorg",
+ repo: "myrepo",
+ path: "production/my-pipeline.yaml",
+ branch: "vf-promote/production-my-pipeline-req12345",
+ content: Buffer.from(baseOpts.configYaml).toString("base64"),
+ }),
+ );
+ });
+
+ it("opens PR with promotion request ID embedded in body", async () => {
+ await createPromotionPR(baseOpts);
+ const createCall = octokitMock.rest.pulls.create.mock.calls[0][0];
+ expect(createCall.body).toContain("");
+ expect(createCall.title).toContain("My Pipeline");
+ expect(createCall.title).toContain("Production");
+ expect(createCall.head).toBe("vf-promote/production-my-pipeline-req12345");
+ expect(createCall.base).toBe("main");
+ });
+
+ it("returns prNumber, prUrl, and prBranch from GitHub response", async () => {
+ const result = await createPromotionPR(baseOpts);
+ expect(result.prNumber).toBe(42);
+ expect(result.prUrl).toBe("https://github.com/owner/repo/pull/42");
+ expect(result.prBranch).toBe("vf-promote/production-my-pipeline-req12345");
+ });
+
+ it("includes existing file SHA when file already exists on branch", async () => {
+ octokitMock.rest.repos.getContent.mockResolvedValue({
+ data: { sha: "existing-file-sha", type: "file", name: "my-pipeline.yaml" },
+ } as never);
+
+ await createPromotionPR(baseOpts);
+
+ expect(octokitMock.rest.repos.createOrUpdateFileContents).toHaveBeenCalledWith(
+ expect.objectContaining({ sha: "existing-file-sha" }),
+ );
+ });
+
+ it("does not include sha when file does not exist yet (new file creation)", async () => {
+ // Default mock: getContent throws "Not Found"
+ await createPromotionPR(baseOpts);
+
+ const updateCall = octokitMock.rest.repos.createOrUpdateFileContents.mock.calls[0][0];
+ expect(updateCall.sha).toBeUndefined();
+ });
+
+ it("parses SSH URL format correctly", async () => {
+ await createPromotionPR({
+ ...baseOpts,
+ repoUrl: "git@github.com:myorg/myrepo.git",
+ });
+ expect(octokitMock.rest.git.getRef).toHaveBeenCalledWith(
+ expect.objectContaining({ owner: "myorg", repo: "myrepo" }),
+ );
+ });
+});
diff --git a/src/server/services/__tests__/sse-registry.test.ts b/src/server/services/__tests__/sse-registry.test.ts
index 15b06fa5..9ab6e716 100644
--- a/src/server/services/__tests__/sse-registry.test.ts
+++ b/src/server/services/__tests__/sse-registry.test.ts
@@ -193,6 +193,7 @@ describe("SSERegistry", () => {
expect(text).toBe(": keepalive\n\n");
});
+ // PERF-02: Ghost connections detected and evicted within one keepalive interval (30s)
it("keepalive removes dead connections", () => {
const registry = new SSERegistry();
const ctrl = mockController();
diff --git a/src/server/services/alert-evaluator.ts b/src/server/services/alert-evaluator.ts
index 7b84ea70..1e5d3ec1 100644
--- a/src/server/services/alert-evaluator.ts
+++ b/src/server/services/alert-evaluator.ts
@@ -375,6 +375,7 @@ const METRIC_LABELS: Record = {
certificate_expiring: "Certificate expiring",
node_joined: "Node joined",
node_left: "Node left",
+ promotion_completed: "Promotion completed",
};
const CONDITION_LABELS: Record = {
diff --git a/src/server/services/event-alerts.ts b/src/server/services/event-alerts.ts
index 47706fdf..9bbab638 100644
--- a/src/server/services/event-alerts.ts
+++ b/src/server/services/event-alerts.ts
@@ -2,6 +2,7 @@ import { prisma } from "@/lib/prisma";
import type { AlertMetric } from "@/generated/prisma";
import { deliverToChannels } from "@/server/services/channels";
import { deliverWebhooks } from "@/server/services/webhook-delivery";
+import { fireOutboundWebhooks } from "@/server/services/outbound-webhook";
// Re-export from the shared (client-safe) module so existing server imports
// continue to work without changes.
@@ -102,6 +103,25 @@ export async function fireEventAlert(
await deliverWebhooks(rule.environmentId, payload);
await deliverToChannels(rule.environmentId, rule.id, payload);
+ // 4b. Deliver to outbound webhook subscriptions (team-scoped)
+ // void — never blocks the calling operation
+ if (rule.environment.team) {
+ void fireOutboundWebhooks(metric, rule.teamId, {
+ type: metric,
+ timestamp: event.firedAt.toISOString(),
+ data: {
+ alertId: event.id,
+ ruleName: rule.name,
+ environment: rule.environment.name,
+ team: rule.environment.team.name,
+ node: (metadata.nodeId as string) ?? undefined,
+ pipeline: rule.pipeline?.name ?? undefined,
+ message: metadata.message,
+ value: 0,
+ },
+ });
+ }
+
// 5. Update the AlertEvent with notifiedAt timestamp
await prisma.alertEvent.update({
where: { id: event.id },
diff --git a/src/server/services/gitops-promotion.ts b/src/server/services/gitops-promotion.ts
new file mode 100644
index 00000000..a3f687fc
--- /dev/null
+++ b/src/server/services/gitops-promotion.ts
@@ -0,0 +1,152 @@
+import { Octokit } from "@octokit/rest";
+import { decrypt } from "@/server/services/crypto";
+import { toFilenameSlug } from "@/server/services/git-sync";
+
+// ─── Types ──────────────────────────────────────────────────────────────────
+
+export interface CreatePromotionPROptions {
+ /** Encrypted GitHub PAT (stored in Environment.gitToken) */
+ encryptedToken: string;
+ /** GitHub repo URL — https or SSH format */
+ repoUrl: string;
+ /** Target branch in the repo (e.g. "main") */
+ baseBranch: string;
+ /** PromotionRequest.id — used to make branch name unique and embedded in PR body */
+ requestId: string;
+ /** Source pipeline name */
+ pipelineName: string;
+ /** Source environment name */
+ sourceEnvironmentName: string;
+ /** Target environment name */
+ targetEnvironmentName: string;
+ /** Vector YAML config string for the promoted pipeline */
+ configYaml: string;
+}
+
+export interface CreatePromotionPRResult {
+ prNumber: number;
+ prUrl: string;
+ prBranch: string;
+}
+
+// ─── URL Parsing ─────────────────────────────────────────────────────────────
+
+/**
+ * Parses owner and repo from a GitHub URL.
+ * Supports:
+ * - https://github.com/owner/repo
+ * - https://github.com/owner/repo.git
+ * - git@github.com:owner/repo.git
+ */
+export function parseGitHubOwnerRepo(repoUrl: string): { owner: string; repo: string } {
+ // SSH format: git@github.com:owner/repo.git
+ const sshMatch = repoUrl.match(/git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/);
+ if (sshMatch) {
+ return { owner: sshMatch[1], repo: sshMatch[2] };
+ }
+
+ // HTTPS format: https://github.com/owner/repo[.git]
+ const httpsMatch = repoUrl.match(/github\.com\/([^/]+)\/(.+?)(?:\.git)?(?:\/.*)?$/);
+ if (httpsMatch) {
+ return { owner: httpsMatch[1], repo: httpsMatch[2] };
+ }
+
+ throw new Error(
+ `Cannot parse GitHub owner/repo from URL: "${repoUrl}". ` +
+ `Expected format: https://github.com/owner/repo or git@github.com:owner/repo.git`,
+ );
+}
+
+// ─── Service ─────────────────────────────────────────────────────────────────
+
+/**
+ * Creates a GitHub PR for a pipeline promotion using the GitHub REST API.
+ *
+ * Flow:
+ * 1. Decrypt token and authenticate with Octokit
+ * 2. Get the base branch SHA
+ * 3. Create a new PR branch (vf-promote/{envSlug}-{pipelineSlug}-{requestId[:8]})
+ * 4. Commit the pipeline YAML file to {envSlug}/{pipelineSlug}.yaml on the PR branch
+ * 5. Open a PR with the VF promotion request ID embedded in the body
+ *
+ * The promotion request ID in the PR body is used by the merge webhook handler
+ * to look up the PromotionRequest when the PR is merged.
+ */
+export async function createPromotionPR(
+ opts: CreatePromotionPROptions,
+): Promise {
+ const token = decrypt(opts.encryptedToken);
+ const { owner, repo } = parseGitHubOwnerRepo(opts.repoUrl);
+
+ const octokit = new Octokit({ auth: token });
+
+ // Step 1: Get base branch SHA
+ const { data: refData } = await octokit.rest.git.getRef({
+ owner,
+ repo,
+ ref: `heads/${opts.baseBranch}`,
+ });
+ const baseSha = refData.object.sha;
+
+ // Step 2: Create PR branch with unique name to avoid collision
+ const envSlug = toFilenameSlug(opts.targetEnvironmentName);
+ const pipelineSlug = toFilenameSlug(opts.pipelineName);
+ const prBranch = `vf-promote/${envSlug}-${pipelineSlug}-${opts.requestId.slice(0, 8)}`;
+
+ await octokit.rest.git.createRef({
+ owner,
+ repo,
+ ref: `refs/heads/${prBranch}`,
+ sha: baseSha,
+ });
+
+ // Step 3: Check for existing file (to get SHA for update vs create)
+ const filePath = `${envSlug}/${pipelineSlug}.yaml`;
+ let existingSha: string | undefined;
+ try {
+ const { data: existing } = await octokit.rest.repos.getContent({
+ owner,
+ repo,
+ path: filePath,
+ ref: prBranch,
+ });
+ if (!Array.isArray(existing) && "sha" in existing) {
+ existingSha = existing.sha;
+ }
+ } catch {
+ // File does not exist yet — this is expected for new promotions
+ }
+
+ // Step 4: Commit YAML file to PR branch
+ await octokit.rest.repos.createOrUpdateFileContents({
+ owner,
+ repo,
+ path: filePath,
+ message: `promote: "${opts.pipelineName}" \u2192 ${opts.targetEnvironmentName}`,
+ content: Buffer.from(opts.configYaml).toString("base64"),
+ branch: prBranch,
+ ...(existingSha ? { sha: existingSha } : {}),
+ });
+
+ // Step 5: Create the pull request
+ const { data: pr } = await octokit.rest.pulls.create({
+ owner,
+ repo,
+ title: `Promote "${opts.pipelineName}" to ${opts.targetEnvironmentName}`,
+ body: [
+ ``,
+ ``,
+ `Automatically promoted by **VectorFlow** from **${opts.sourceEnvironmentName}** to **${opts.targetEnvironmentName}**.`,
+ ``,
+ `**Merge this PR to deploy the pipeline to ${opts.targetEnvironmentName}.**`,
+ ].join("\n"),
+ head: prBranch,
+ base: opts.baseBranch,
+ });
+
+ return {
+ prNumber: pr.number,
+ prUrl: pr.html_url,
+ prBranch,
+ };
+}
diff --git a/src/server/services/outbound-webhook.test.ts b/src/server/services/outbound-webhook.test.ts
new file mode 100644
index 00000000..87f215da
--- /dev/null
+++ b/src/server/services/outbound-webhook.test.ts
@@ -0,0 +1,322 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { mockDeep } from "vitest-mock-extended";
+import type { PrismaClient } from "@/generated/prisma";
+import * as cryptoMod from "@/server/services/crypto";
+import * as urlValidation from "@/server/services/url-validation";
+import crypto from "crypto";
+
+// ─── Module mocks ──────────────────────────────────────────────────────────
+
+vi.mock("@/lib/prisma", () => ({
+ prisma: mockDeep(),
+}));
+
+vi.mock("@/server/services/crypto", () => ({
+ decrypt: vi.fn().mockReturnValue("test-secret"),
+ encrypt: vi.fn(),
+}));
+
+vi.mock("@/server/services/url-validation", () => ({
+ validatePublicUrl: vi.fn().mockResolvedValue(undefined),
+}));
+
+// ─── Import after mocks ────────────────────────────────────────────────────
+
+import { prisma } from "@/lib/prisma";
+import {
+ deliverOutboundWebhook,
+ fireOutboundWebhooks,
+ isPermanentFailure,
+} from "@/server/services/outbound-webhook";
+import { AlertMetric } from "@/generated/prisma";
+
+const mockPrisma = prisma as ReturnType>;
+
+// ─── Helpers ───────────────────────────────────────────────────────────────
+
+function makeEndpoint(overrides: Partial<{
+ id: string;
+ url: string;
+ encryptedSecret: string | null;
+ teamId: string;
+ name: string;
+ eventTypes: AlertMetric[];
+ enabled: boolean;
+ createdAt: Date;
+ updatedAt: Date;
+}> = {}) {
+ return {
+ id: "ep-1",
+ url: "https://example.com/webhook",
+ encryptedSecret: "encrypted-secret",
+ teamId: "team-1",
+ name: "Test Endpoint",
+ eventTypes: [AlertMetric.deploy_completed],
+ enabled: true,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ ...overrides,
+ };
+}
+
+const samplePayload = {
+ type: "deploy_completed",
+ timestamp: new Date().toISOString(),
+ data: { pipelineId: "pipe-1" },
+};
+
+// ─── Tests ─────────────────────────────────────────────────────────────────
+
+describe("deliverOutboundWebhook", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(urlValidation.validatePublicUrl).mockResolvedValue(undefined);
+ vi.mocked(cryptoMod.decrypt).mockReturnValue("test-secret");
+ });
+
+ it("signs payload with Standard-Webhooks headers", async () => {
+ const fetchSpy = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ });
+ vi.stubGlobal("fetch", fetchSpy);
+
+ const endpoint = makeEndpoint();
+ const result = await deliverOutboundWebhook(endpoint, samplePayload);
+
+ expect(result.success).toBe(true);
+ expect(fetchSpy).toHaveBeenCalledOnce();
+
+ const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
+ const headers = init.headers as Record;
+
+ // webhook-id must be a UUID
+ expect(headers["webhook-id"]).toMatch(
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
+ );
+
+ // webhook-timestamp must be an integer seconds string
+ const ts = parseInt(headers["webhook-timestamp"], 10);
+ expect(isNaN(ts)).toBe(false);
+ expect(String(ts)).toBe(headers["webhook-timestamp"]);
+ expect(ts).toBeGreaterThan(1_700_000_000); // sanity: after Nov 2023
+
+ // webhook-signature must be v1,{base64}
+ expect(headers["webhook-signature"]).toMatch(/^v1,[A-Za-z0-9+/=]+$/);
+
+ // Independently verify HMAC correctness
+ const msgId = headers["webhook-id"];
+ const timestamp = headers["webhook-timestamp"];
+ const body = init.body as string;
+ const signingString = `${msgId}.${timestamp}.${body}`;
+ const expectedSig = crypto
+ .createHmac("sha256", "test-secret")
+ .update(signingString)
+ .digest("base64");
+ expect(headers["webhook-signature"]).toBe(`v1,${expectedSig}`);
+ });
+
+ it("uses same body string for signing and fetch", async () => {
+ const fetchSpy = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ });
+ vi.stubGlobal("fetch", fetchSpy);
+
+ const endpoint = makeEndpoint();
+ await deliverOutboundWebhook(endpoint, samplePayload);
+
+ const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
+ const headers = init.headers as Record;
+ const body = init.body as string;
+
+ const msgId = headers["webhook-id"];
+ const timestamp = headers["webhook-timestamp"];
+ const sig = headers["webhook-signature"].replace("v1,", "");
+
+ const signingString = `${msgId}.${timestamp}.${body}`;
+ const expectedSig = crypto
+ .createHmac("sha256", "test-secret")
+ .update(signingString)
+ .digest("base64");
+
+ expect(sig).toBe(expectedSig);
+ });
+
+ it("classifies 4xx non-429 as permanent failure", async () => {
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
+ ok: false,
+ status: 400,
+ }));
+
+ const result = await deliverOutboundWebhook(makeEndpoint(), samplePayload);
+ expect(result.success).toBe(false);
+ expect(result.isPermanent).toBe(true);
+ expect(result.statusCode).toBe(400);
+ });
+
+ it("classifies 429 as retryable", async () => {
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
+ ok: false,
+ status: 429,
+ }));
+
+ const result = await deliverOutboundWebhook(makeEndpoint(), samplePayload);
+ expect(result.success).toBe(false);
+ expect(result.isPermanent).toBe(false);
+ expect(result.statusCode).toBe(429);
+ });
+
+ it("classifies 5xx as retryable", async () => {
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
+ ok: false,
+ status: 503,
+ }));
+
+ const result = await deliverOutboundWebhook(makeEndpoint(), samplePayload);
+ expect(result.success).toBe(false);
+ expect(result.isPermanent).toBe(false);
+ expect(result.statusCode).toBe(503);
+ });
+
+ it("classifies DNS failure as permanent", async () => {
+ const dnsError = new Error("getaddrinfo ENOTFOUND example.com");
+ dnsError.name = "Error";
+ vi.stubGlobal("fetch", vi.fn().mockRejectedValue(dnsError));
+
+ const result = await deliverOutboundWebhook(makeEndpoint(), samplePayload);
+ expect(result.success).toBe(false);
+ expect(result.isPermanent).toBe(true);
+ });
+
+ it("classifies timeout as retryable", async () => {
+ const abortError = new Error("The operation was aborted");
+ abortError.name = "AbortError";
+ vi.stubGlobal("fetch", vi.fn().mockRejectedValue(abortError));
+
+ const result = await deliverOutboundWebhook(makeEndpoint(), samplePayload);
+ expect(result.success).toBe(false);
+ expect(result.isPermanent).toBe(false);
+ });
+
+ it("returns isPermanent true for SSRF violation", async () => {
+ const { TRPCError } = await import("@trpc/server");
+ vi.mocked(urlValidation.validatePublicUrl).mockRejectedValue(
+ new TRPCError({ code: "BAD_REQUEST", message: "URL resolves to a private or reserved IP address" }),
+ );
+
+ const result = await deliverOutboundWebhook(makeEndpoint(), samplePayload);
+ expect(result.success).toBe(false);
+ expect(result.isPermanent).toBe(true);
+ expect(result.error).toContain("SSRF");
+ });
+});
+
+describe("isPermanentFailure", () => {
+ it("returns true for 4xx non-429", () => {
+ expect(isPermanentFailure({ success: false, statusCode: 400, isPermanent: true })).toBe(true);
+ expect(isPermanentFailure({ success: false, statusCode: 404, isPermanent: true })).toBe(true);
+ expect(isPermanentFailure({ success: false, statusCode: 403, isPermanent: true })).toBe(true);
+ });
+
+ it("returns false for 429", () => {
+ expect(isPermanentFailure({ success: false, statusCode: 429, isPermanent: false })).toBe(false);
+ });
+
+ it("returns false for 5xx", () => {
+ expect(isPermanentFailure({ success: false, statusCode: 500, isPermanent: false })).toBe(false);
+ expect(isPermanentFailure({ success: false, statusCode: 503, isPermanent: false })).toBe(false);
+ });
+
+ it("returns true for ENOTFOUND error", () => {
+ expect(isPermanentFailure({ success: false, error: "getaddrinfo ENOTFOUND host", isPermanent: true })).toBe(true);
+ });
+
+ it("returns true for ECONNREFUSED error", () => {
+ expect(isPermanentFailure({ success: false, error: "connect ECONNREFUSED 127.0.0.1:80", isPermanent: true })).toBe(true);
+ });
+});
+
+describe("dispatchWithTracking (via fireOutboundWebhooks behavior)", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(urlValidation.validatePublicUrl).mockResolvedValue(undefined);
+ vi.mocked(cryptoMod.decrypt).mockReturnValue("test-secret");
+ });
+
+ it("dispatchWithTracking sets dead_letter for permanent failures", async () => {
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
+ ok: false,
+ status: 400,
+ }));
+
+ const deliveryId = "delivery-1";
+ mockPrisma.webhookDelivery.create.mockResolvedValue({
+ id: deliveryId,
+ webhookEndpointId: "ep-1",
+ eventType: AlertMetric.deploy_completed,
+ msgId: "msg-1",
+ payload: {},
+ status: "pending",
+ statusCode: null,
+ errorMessage: null,
+ attemptNumber: 1,
+ nextRetryAt: null,
+ requestedAt: new Date(),
+ completedAt: null,
+ });
+ mockPrisma.webhookDelivery.update.mockResolvedValue({} as never);
+
+ mockPrisma.webhookEndpoint.findMany.mockResolvedValue([makeEndpoint()]);
+
+ await fireOutboundWebhooks(AlertMetric.deploy_completed, "team-1", samplePayload);
+
+ expect(mockPrisma.webhookDelivery.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: { id: deliveryId },
+ data: expect.objectContaining({
+ status: "dead_letter",
+ nextRetryAt: null,
+ }),
+ }),
+ );
+ });
+
+ it("dispatchWithTracking sets failed with nextRetryAt for retryable failures", async () => {
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
+ ok: false,
+ status: 503,
+ }));
+
+ const deliveryId = "delivery-2";
+ mockPrisma.webhookDelivery.create.mockResolvedValue({
+ id: deliveryId,
+ webhookEndpointId: "ep-1",
+ eventType: AlertMetric.deploy_completed,
+ msgId: "msg-2",
+ payload: {},
+ status: "pending",
+ statusCode: null,
+ errorMessage: null,
+ attemptNumber: 1,
+ nextRetryAt: null,
+ requestedAt: new Date(),
+ completedAt: null,
+ });
+ mockPrisma.webhookDelivery.update.mockResolvedValue({} as never);
+
+ mockPrisma.webhookEndpoint.findMany.mockResolvedValue([makeEndpoint()]);
+
+ await fireOutboundWebhooks(AlertMetric.deploy_completed, "team-1", samplePayload);
+
+ expect(mockPrisma.webhookDelivery.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: { id: deliveryId },
+ data: expect.objectContaining({
+ status: "failed",
+ nextRetryAt: expect.any(Date),
+ }),
+ }),
+ );
+ });
+});
diff --git a/src/server/services/outbound-webhook.ts b/src/server/services/outbound-webhook.ts
new file mode 100644
index 00000000..78c4b6ba
--- /dev/null
+++ b/src/server/services/outbound-webhook.ts
@@ -0,0 +1,210 @@
+import crypto from "crypto";
+import { prisma } from "@/lib/prisma";
+import { decrypt } from "@/server/services/crypto";
+import { validatePublicUrl } from "@/server/services/url-validation";
+import { getNextRetryAt } from "@/server/services/delivery-tracking";
+import type { AlertMetric } from "@/generated/prisma";
+import { debugLog } from "@/lib/logger";
+
+// ─── Types ──────────────────────────────────────────────────────────────────
+
+export interface OutboundPayload {
+ type: string; // AlertMetric value
+ timestamp: string; // ISO-8601
+ data: Record;
+}
+
+export interface OutboundResult {
+ success: boolean;
+ statusCode?: number;
+ error?: string;
+ isPermanent: boolean;
+}
+
+// Minimal endpoint shape needed for delivery (matches WebhookEndpoint Prisma model fields used here)
+interface EndpointLike {
+ id: string;
+ url: string;
+ encryptedSecret: string | null;
+}
+
+// ─── Helpers ────────────────────────────────────────────────────────────────
+
+/**
+ * Returns true if the result represents a permanent (non-retryable) failure.
+ * 4xx non-429 HTTP responses and DNS/connection errors are permanent.
+ */
+export function isPermanentFailure(result: OutboundResult): boolean {
+ if (result.statusCode !== undefined) {
+ return result.statusCode >= 400 && result.statusCode < 500 && result.statusCode !== 429;
+ }
+ if (result.error) {
+ return result.error.includes("ENOTFOUND") || result.error.includes("ECONNREFUSED");
+ }
+ return false;
+}
+
+// ─── Core delivery ──────────────────────────────────────────────────────────
+
+/**
+ * Delivers a POST request to a webhook endpoint using Standard-Webhooks signing.
+ * Signing string: "{msgId}.{timestamp}.{body}"
+ * Headers: webhook-id, webhook-timestamp, webhook-signature (v1,{base64})
+ */
+export async function deliverOutboundWebhook(
+ endpoint: EndpointLike,
+ payload: OutboundPayload,
+ msgId = crypto.randomUUID(),
+): Promise {
+ // SSRF protection
+ try {
+ await validatePublicUrl(endpoint.url);
+ } catch {
+ return { success: false, error: "SSRF: private IP", isPermanent: true };
+ }
+
+ const timestamp = Math.floor(Date.now() / 1000); // integer seconds
+
+ // Serialize body ONCE — same string used for signing AND as request body
+ const body = JSON.stringify(payload);
+
+ const headers: Record = {
+ "Content-Type": "application/json",
+ "webhook-id": msgId,
+ "webhook-timestamp": String(timestamp),
+ };
+
+ // HMAC-SHA256 signing per Standard-Webhooks spec
+ if (endpoint.encryptedSecret) {
+ const secret = decrypt(endpoint.encryptedSecret);
+ const signingString = `${msgId}.${timestamp}.${body}`;
+ const sig = crypto
+ .createHmac("sha256", secret)
+ .update(signingString)
+ .digest("base64");
+ headers["webhook-signature"] = `v1,${sig}`;
+ }
+
+ try {
+ const res = await fetch(endpoint.url, {
+ method: "POST",
+ headers,
+ body,
+ signal: AbortSignal.timeout(15_000),
+ });
+
+ if (res.ok) {
+ return { success: true, statusCode: res.status, isPermanent: false };
+ }
+
+ const permanent = res.status >= 400 && res.status < 500 && res.status !== 429;
+ return {
+ success: false,
+ statusCode: res.status,
+ error: `HTTP ${res.status}`,
+ isPermanent: permanent,
+ };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : "Unknown delivery error";
+ const permanent = message.includes("ENOTFOUND") || message.includes("ECONNREFUSED");
+ return { success: false, error: message, isPermanent: permanent };
+ }
+}
+
+// ─── Dispatch with tracking ──────────────────────────────────────────────────
+
+/**
+ * Creates a WebhookDelivery record, delivers to the endpoint, and updates
+ * the record with the result. Permanent failures are set to "dead_letter"
+ * (no nextRetryAt); retryable failures get a nextRetryAt from the backoff schedule.
+ */
+async function dispatchWithTracking(
+ endpoint: EndpointLike,
+ payload: OutboundPayload,
+ metric: AlertMetric,
+): Promise {
+ const msgId = crypto.randomUUID();
+
+ const delivery = await prisma.webhookDelivery.create({
+ data: {
+ webhookEndpointId: endpoint.id,
+ eventType: metric,
+ msgId,
+ payload: payload as object,
+ status: "pending",
+ attemptNumber: 1,
+ },
+ });
+
+ const result = await deliverOutboundWebhook(endpoint, payload, msgId);
+
+ if (result.success) {
+ await prisma.webhookDelivery.update({
+ where: { id: delivery.id },
+ data: {
+ status: "success",
+ statusCode: result.statusCode ?? null,
+ completedAt: new Date(),
+ },
+ });
+ return;
+ }
+
+ if (isPermanentFailure(result)) {
+ // Permanent failure: dead_letter — retry service will not pick this up
+ await prisma.webhookDelivery.update({
+ where: { id: delivery.id },
+ data: {
+ status: "dead_letter",
+ statusCode: result.statusCode ?? null,
+ errorMessage: result.error ?? null,
+ nextRetryAt: null,
+ completedAt: new Date(),
+ },
+ });
+ } else {
+ // Retryable failure: schedule next attempt
+ await prisma.webhookDelivery.update({
+ where: { id: delivery.id },
+ data: {
+ status: "failed",
+ statusCode: result.statusCode ?? null,
+ errorMessage: result.error ?? null,
+ nextRetryAt: getNextRetryAt(1),
+ completedAt: new Date(),
+ },
+ });
+ }
+}
+
+// ─── Public dispatch hook ────────────────────────────────────────────────────
+
+/**
+ * Queries enabled webhook endpoints subscribed to the given metric for the team,
+ * then dispatches to each. Never throws — errors are logged.
+ *
+ * Call with: void fireOutboundWebhooks(...) — never await in critical path.
+ */
+export async function fireOutboundWebhooks(
+ metric: AlertMetric,
+ teamId: string,
+ payload: OutboundPayload,
+): Promise {
+ const endpoints = await prisma.webhookEndpoint.findMany({
+ where: { teamId, enabled: true, eventTypes: { has: metric } },
+ });
+
+ if (endpoints.length === 0) return;
+
+ for (const endpoint of endpoints) {
+ try {
+ await dispatchWithTracking(endpoint, payload, metric);
+ } catch (err) {
+ debugLog(
+ "outbound-webhook",
+ `Failed to dispatch webhook to endpoint ${endpoint.id}`,
+ err,
+ );
+ }
+ }
+}
diff --git a/src/server/services/promotion-service.ts b/src/server/services/promotion-service.ts
new file mode 100644
index 00000000..8df7ca3d
--- /dev/null
+++ b/src/server/services/promotion-service.ts
@@ -0,0 +1,266 @@
+import { TRPCError } from "@trpc/server";
+import { prisma } from "@/lib/prisma";
+import { collectSecretRefs, convertSecretRefsToEnvVars } from "./secret-resolver";
+import { decryptNodeConfig } from "./config-crypto";
+import { copyPipelineGraph } from "./copy-pipeline-graph";
+import { fireOutboundWebhooks } from "./outbound-webhook";
+import { generateVectorYaml } from "@/lib/config-generator";
+
+// ─── Types ──────────────────────────────────────────────────────────────────
+
+export interface PreflightResult {
+ missing: string[];
+ present: string[];
+ canProceed: boolean;
+}
+
+export interface ExecutePromotionResult {
+ pipelineId: string;
+ pipelineName: string;
+}
+
+export interface DiffPreviewResult {
+ sourceYaml: string;
+ targetYaml: string;
+}
+
+// ─── Service functions ───────────────────────────────────────────────────────
+
+/**
+ * Checks whether all SECRET[name] references used in the source pipeline's
+ * node configs exist as named secrets in the target environment.
+ *
+ * Returns { missing, present, canProceed } without throwing.
+ */
+export async function preflightSecrets(
+ pipelineId: string,
+ targetEnvironmentId: string,
+): Promise {
+ const nodes = await prisma.pipelineNode.findMany({
+ where: { pipelineId },
+ select: { componentType: true, config: true },
+ });
+
+ // Collect all SECRET[name] refs from all node configs
+ const allRefs = new Set();
+ for (const node of nodes) {
+ const config = (node.config ?? {}) as Record;
+ const decrypted = decryptNodeConfig(node.componentType, config);
+ const refs = collectSecretRefs(decrypted);
+ for (const ref of refs) {
+ allRefs.add(ref);
+ }
+ }
+
+ if (allRefs.size === 0) {
+ return { missing: [], present: [], canProceed: true };
+ }
+
+ // Query which secrets exist in target environment
+ const existingSecrets = await prisma.secret.findMany({
+ where: {
+ environmentId: targetEnvironmentId,
+ name: { in: Array.from(allRefs) },
+ },
+ select: { name: true },
+ });
+
+ const presentNames = new Set(existingSecrets.map((s) => s.name));
+ const present: string[] = [];
+ const missing: string[] = [];
+
+ for (const ref of allRefs) {
+ if (presentNames.has(ref)) {
+ present.push(ref);
+ } else {
+ missing.push(ref);
+ }
+ }
+
+ return {
+ missing,
+ present,
+ canProceed: missing.length === 0,
+ };
+}
+
+/**
+ * Executes the promotion by creating the target pipeline via copyPipelineGraph.
+ * SECRET[name] references are preserved intact — they are resolved at deploy time.
+ *
+ * Must be called after a PromotionRequest record exists in DB.
+ * Updates the PromotionRequest with targetPipelineId, status DEPLOYED, deployedAt.
+ * Fires promotion_completed outbound webhook after success (non-blocking).
+ */
+export async function executePromotion(
+ requestId: string,
+ executorId: string,
+): Promise {
+ // Load the request and source pipeline info
+ const request = await prisma.promotionRequest.findUnique({
+ where: { id: requestId },
+ include: {
+ sourcePipeline: {
+ select: {
+ name: true,
+ description: true,
+ environmentId: true,
+ environment: { select: { teamId: true } },
+ },
+ },
+ targetEnvironment: { select: { name: true, teamId: true } },
+ },
+ });
+
+ if (!request) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Promotion request not found" });
+ }
+
+ const targetPipelineName = request.targetPipelineName ?? request.sourcePipeline.name;
+ const teamId = request.sourcePipeline.environment.teamId;
+
+ // Execute in a transaction: create target pipeline + copy graph + update request
+ const { targetPipelineId } = await prisma.$transaction(async (tx) => {
+ // Check for name collision in target environment
+ const existing = await tx.pipeline.findFirst({
+ where: {
+ environmentId: request.targetEnvironmentId,
+ name: targetPipelineName,
+ },
+ });
+ if (existing) {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message: `A pipeline named "${targetPipelineName}" already exists in the target environment`,
+ });
+ }
+
+ // Create the target pipeline
+ const targetPipeline = await tx.pipeline.create({
+ data: {
+ name: targetPipelineName,
+ description: request.sourcePipeline.description ?? undefined,
+ environmentId: request.targetEnvironmentId,
+ globalConfig: request.globalConfigSnapshot ?? undefined,
+ isDraft: true,
+ createdById: executorId,
+ updatedById: executorId,
+ },
+ });
+
+ // Copy nodes and edges from source pipeline WITHOUT stripping SECRET[name] refs.
+ // SECRET resolution happens at deploy time via secret-resolver.ts.
+ await copyPipelineGraph(tx, {
+ sourcePipelineId: request.sourcePipelineId,
+ targetPipelineId: targetPipeline.id,
+ stripSharedComponentLinks: true,
+ // No transformConfig — preserves SECRET[name] refs intact
+ });
+
+ // Mark request as DEPLOYED
+ await tx.promotionRequest.update({
+ where: { id: requestId },
+ data: {
+ targetPipelineId: targetPipeline.id,
+ status: "DEPLOYED",
+ approvedById: executorId,
+ reviewedAt: new Date(),
+ deployedAt: new Date(),
+ },
+ });
+
+ return { targetPipelineId: targetPipeline.id };
+ });
+
+ // Fire outbound webhook after successful promotion (non-blocking)
+ void fireOutboundWebhooks("promotion_completed", teamId ?? "", {
+ type: "promotion_completed",
+ timestamp: new Date().toISOString(),
+ data: {
+ promotionRequestId: requestId,
+ sourcePipelineId: request.sourcePipelineId,
+ targetPipelineId,
+ sourceEnvironmentId: request.sourceEnvironmentId,
+ targetEnvironmentId: request.targetEnvironmentId,
+ promotedBy: request.promotedById,
+ },
+ });
+
+ return { pipelineId: targetPipelineId, pipelineName: targetPipelineName };
+}
+
+/**
+ * Generates a side-by-side YAML diff preview for a pipeline promotion.
+ *
+ * sourceYaml: Generated with SECRET[name] refs visible (as-stored).
+ * targetYaml: Generated with SECRET[name] refs converted to ${VF_SECRET_NAME} env var placeholders.
+ */
+export async function generateDiffPreview(
+ pipelineId: string,
+): Promise {
+ const pipeline = await prisma.pipeline.findUnique({
+ where: { id: pipelineId },
+ include: {
+ nodes: true,
+ edges: true,
+ environment: { select: { name: true } },
+ },
+ });
+
+ if (!pipeline) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Pipeline not found" });
+ }
+
+ const flowEdges = pipeline.edges.map((e) => ({
+ id: e.id,
+ source: e.sourceNodeId,
+ target: e.targetNodeId,
+ ...(e.sourcePort ? { sourceHandle: e.sourcePort } : {}),
+ }));
+
+ // Source YAML: decrypt node configs but keep SECRET[name] refs as-is
+ const sourceFlowNodes = pipeline.nodes.map((n) => ({
+ id: n.id,
+ type: n.kind.toLowerCase(),
+ position: { x: n.positionX, y: n.positionY },
+ data: {
+ componentDef: { type: n.componentType, kind: n.kind.toLowerCase() },
+ componentKey: n.componentKey,
+ config: decryptNodeConfig(n.componentType, (n.config as Record) ?? {}),
+ disabled: n.disabled,
+ },
+ }));
+
+ const sourceYaml = generateVectorYaml(
+ sourceFlowNodes as Parameters[0],
+ flowEdges as Parameters[1],
+ pipeline.globalConfig as Record | null,
+ null,
+ );
+
+ // Target YAML: convert SECRET[name] refs to ${VF_SECRET_NAME} env var placeholders
+ const targetFlowNodes = pipeline.nodes.map((n) => {
+ const decrypted = decryptNodeConfig(n.componentType, (n.config as Record) ?? {});
+ const converted = convertSecretRefsToEnvVars(decrypted);
+ return {
+ id: n.id,
+ type: n.kind.toLowerCase(),
+ position: { x: n.positionX, y: n.positionY },
+ data: {
+ componentDef: { type: n.componentType, kind: n.kind.toLowerCase() },
+ componentKey: n.componentKey,
+ config: converted,
+ disabled: n.disabled,
+ },
+ };
+ });
+
+ const targetYaml = generateVectorYaml(
+ targetFlowNodes as Parameters[0],
+ flowEdges as Parameters[1],
+ pipeline.globalConfig as Record | null,
+ null,
+ );
+
+ return { sourceYaml, targetYaml };
+}
diff --git a/src/server/services/retry-service.ts b/src/server/services/retry-service.ts
index ebb887f9..db4ef86a 100644
--- a/src/server/services/retry-service.ts
+++ b/src/server/services/retry-service.ts
@@ -2,12 +2,14 @@ import { prisma } from "@/lib/prisma";
import {
trackWebhookDelivery,
trackChannelDelivery,
+ getNextRetryAt,
} from "@/server/services/delivery-tracking";
import {
deliverSingleWebhook,
type WebhookPayload,
} from "@/server/services/webhook-delivery";
import { getDriver } from "@/server/services/channels";
+import { deliverOutboundWebhook, isPermanentFailure } from "@/server/services/outbound-webhook";
// ─── Constants ──────────────────────────────────────────────────────────────
@@ -122,6 +124,116 @@ export class RetryService {
);
}
}
+
+ // Also process outbound webhook retries
+ await this.processOutboundRetries();
+ }
+
+ /**
+ * Retry loop for outbound webhook deliveries (WebhookDelivery model).
+ * Separate from alert delivery retries to avoid coupling.
+ * IMPORTANT: Only queries status: "failed" — dead_letter records are NEVER retried.
+ */
+ async processOutboundRetries(): Promise {
+ let dueRetries;
+ try {
+ dueRetries = await prisma.webhookDelivery?.findMany({
+ where: {
+ status: "failed",
+ nextRetryAt: { lte: new Date() },
+ attemptNumber: { lt: MAX_ATTEMPT_NUMBER + 1 },
+ },
+ include: {
+ webhookEndpoint: { select: { url: true, encryptedSecret: true, enabled: true } },
+ },
+ orderBy: { nextRetryAt: "asc" },
+ take: BATCH_SIZE,
+ });
+ } catch (err) {
+ console.error("[retry-service] Error querying outbound webhook retries:", err);
+ return;
+ }
+
+ if (!dueRetries || dueRetries.length === 0) return;
+
+ console.log(
+ `[retry-service] Found ${dueRetries.length} outbound webhook retr${dueRetries.length === 1 ? "y" : "ies"}`,
+ );
+
+ for (const delivery of dueRetries) {
+ try {
+ // Claim: null out nextRetryAt so another poll cycle won't re-pick it
+ await prisma.webhookDelivery.update({
+ where: { id: delivery.id },
+ data: { nextRetryAt: null },
+ });
+
+ // Skip if endpoint was disabled or deleted
+ if (!delivery.webhookEndpoint || !delivery.webhookEndpoint.enabled) {
+ await prisma.webhookDelivery.update({
+ where: { id: delivery.id },
+ data: { status: "dead_letter", completedAt: new Date() },
+ });
+ continue;
+ }
+
+ const nextAttemptNumber = delivery.attemptNumber + 1;
+ const result = await deliverOutboundWebhook(
+ {
+ url: delivery.webhookEndpoint.url,
+ encryptedSecret: delivery.webhookEndpoint.encryptedSecret,
+ id: delivery.webhookEndpointId,
+ },
+ delivery.payload as { type: string; timestamp: string; data: Record },
+ );
+
+ if (result.success) {
+ await prisma.webhookDelivery.update({
+ where: { id: delivery.id },
+ data: {
+ status: "success",
+ statusCode: result.statusCode,
+ attemptNumber: nextAttemptNumber,
+ completedAt: new Date(),
+ },
+ });
+ console.log(
+ `[retry-service] Outbound webhook retry succeeded (delivery=${delivery.id}, attempt=${nextAttemptNumber})`,
+ );
+ } else if (isPermanentFailure(result)) {
+ await prisma.webhookDelivery.update({
+ where: { id: delivery.id },
+ data: {
+ status: "dead_letter",
+ statusCode: result.statusCode,
+ errorMessage: result.error,
+ attemptNumber: nextAttemptNumber,
+ completedAt: new Date(),
+ },
+ });
+ console.log(
+ `[retry-service] Outbound webhook dead-lettered (delivery=${delivery.id}): ${result.error}`,
+ );
+ } else {
+ const nextRetryAt = getNextRetryAt(nextAttemptNumber);
+ await prisma.webhookDelivery.update({
+ where: { id: delivery.id },
+ data: {
+ status: "failed",
+ statusCode: result.statusCode,
+ errorMessage: result.error,
+ attemptNumber: nextAttemptNumber,
+ nextRetryAt,
+ },
+ });
+ console.log(
+ `[retry-service] Outbound webhook retry failed (delivery=${delivery.id}, attempt=${nextAttemptNumber}): ${result.error}`,
+ );
+ }
+ } catch (err) {
+ console.error(`[retry-service] Error retrying outbound delivery ${delivery.id}:`, err);
+ }
+ }
}
/**
diff --git a/src/trpc/init.ts b/src/trpc/init.ts
index 0a4f721f..56408dd4 100644
--- a/src/trpc/init.ts
+++ b/src/trpc/init.ts
@@ -270,6 +270,17 @@ export const withTeamAccess = (minRole: Role) =>
}
}
+ // Resolve requestId → PromotionRequest → sourceEnvironment.teamId
+ if (!teamId && rawInput?.requestId) {
+ const promoReq = await prisma.promotionRequest.findUnique({
+ where: { id: rawInput.requestId as string },
+ select: { sourceEnvironment: { select: { teamId: true } } },
+ });
+ if (promoReq) {
+ teamId = promoReq.sourceEnvironment.teamId ?? undefined;
+ }
+ }
+
// Resolve versionId → PipelineVersion → pipeline → environment.teamId
if (!teamId && rawInput?.versionId) {
const version = await prisma.pipelineVersion.findUnique({
diff --git a/src/trpc/router.ts b/src/trpc/router.ts
index f43f2cfb..35e0d19b 100644
--- a/src/trpc/router.ts
+++ b/src/trpc/router.ts
@@ -22,8 +22,11 @@ import { userPreferenceRouter } from "@/server/routers/user-preference";
import { sharedComponentRouter } from "@/server/routers/shared-component";
import { aiRouter } from "@/server/routers/ai";
import { pipelineGroupRouter } from "@/server/routers/pipeline-group";
+import { nodeGroupRouter } from "@/server/routers/node-group";
import { stagedRolloutRouter } from "@/server/routers/staged-rollout";
import { pipelineDependencyRouter } from "@/server/routers/pipeline-dependency";
+import { webhookEndpointRouter } from "@/server/routers/webhook-endpoint";
+import { promotionRouter } from "@/server/routers/promotion";
export const appRouter = router({
team: teamRouter,
@@ -49,8 +52,11 @@ export const appRouter = router({
sharedComponent: sharedComponentRouter,
ai: aiRouter,
pipelineGroup: pipelineGroupRouter,
+ nodeGroup: nodeGroupRouter,
stagedRollout: stagedRolloutRouter,
pipelineDependency: pipelineDependencyRouter,
+ webhookEndpoint: webhookEndpointRouter,
+ promotion: promotionRouter,
});
export type AppRouter = typeof appRouter;