@crewhaus/deployment-controller 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +8 -13
  2. package/src/index.test.ts +123 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/deployment-controller",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "description": "Version pinning + promotion: promote(name, fromEnv, toEnv) / rollback. Records every action to audit-log.",
6
6
  "main": "src/index.ts",
@@ -12,15 +12,15 @@
12
12
  "test": "bun test src"
13
13
  },
14
14
  "dependencies": {
15
- "@crewhaus/audit-log": "0.1.1",
16
- "@crewhaus/errors": "0.1.1",
17
- "@crewhaus/spec-registry": "0.1.1"
15
+ "@crewhaus/audit-log": "0.1.3",
16
+ "@crewhaus/errors": "0.1.3",
17
+ "@crewhaus/spec-registry": "0.1.3"
18
18
  },
19
19
  "license": "Apache-2.0",
20
20
  "author": {
21
21
  "name": "Max Meier",
22
- "email": "max@studiomax.io",
23
- "url": "https://studiomax.io"
22
+ "email": "max@crewhaus.ai",
23
+ "url": "https://crewhaus.ai"
24
24
  },
25
25
  "repository": {
26
26
  "type": "git",
@@ -32,12 +32,7 @@
32
32
  "url": "https://github.com/crewhaus/factory/issues"
33
33
  },
34
34
  "publishConfig": {
35
- "access": "restricted"
35
+ "access": "public"
36
36
  },
37
- "files": [
38
- "src",
39
- "README.md",
40
- "LICENSE",
41
- "NOTICE"
42
- ]
37
+ "files": ["src", "README.md", "LICENSE", "NOTICE"]
43
38
  }
package/src/index.test.ts CHANGED
@@ -1,12 +1,16 @@
1
1
  /**
2
2
  * Section 28 — `deployment-controller` tests:
3
3
  * - T3 promote + rollback round-trip with audit-log assertions
4
+ * - tenant-scoped promote/rollback overlay isolation
5
+ * - audit-after-pin ordering + payload-shape regression guards (mocked)
4
6
  */
5
- import { afterEach, beforeEach, describe, expect, test } from "bun:test";
7
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
6
8
  import { mkdtempSync, rmSync } from "node:fs";
7
9
  import { tmpdir } from "node:os";
8
10
  import { join } from "node:path";
9
- import { type AuditRecord, openAuditLog } from "@crewhaus/audit-log";
11
+ import type { AppendInput, AuditLog, AuditRecord } from "@crewhaus/audit-log";
12
+ import { openAuditLog } from "@crewhaus/audit-log";
13
+ import type { RegistryAdapter } from "@crewhaus/spec-registry";
10
14
  import { createFileBackedRegistry } from "@crewhaus/spec-registry";
11
15
  import { DeploymentError, createDeploymentController } from "./index";
12
16
 
@@ -97,4 +101,121 @@ describe("deployment-controller — T3 promote/rollback", () => {
97
101
  expect(await reg.aliasFor("hello", "prod")).toBe("v1");
98
102
  expect(await reg.aliasForTenant("tenant-a", "hello", "prod")).toBe("v2");
99
103
  });
104
+
105
+ test("tenant-scoped rollback re-pins only the tenant overlay and tags the record", async () => {
106
+ const reg = createFileBackedRegistry({ rootDir: join(tmpRoot, "specs") });
107
+ await reg.put("hello", "v1", "x");
108
+ await reg.put("hello", "v2", "y");
109
+ await reg.pin("hello", "prod", "v2"); // global prod = v2
110
+ await reg.pinForTenant("tenant-a", "hello", "prod", "v2"); // tenant prod = v2
111
+ const ctrl = createDeploymentController({
112
+ registry: reg,
113
+ tenantId: "tenant-a",
114
+ actor: "bob",
115
+ });
116
+ const rec = await ctrl.rollback("hello", "prod", "v1");
117
+ expect(rec.action).toBe("rollback");
118
+ expect(rec.fromVersion).toBe("v2"); // previous tenant alias captured
119
+ expect(rec.toVersion).toBe("v1");
120
+ expect(rec.tenantId).toBe("tenant-a");
121
+ expect(rec.actor).toBe("bob");
122
+ // Global prod is untouched; only the tenant overlay rolled back.
123
+ expect(await reg.aliasFor("hello", "prod")).toBe("v2");
124
+ expect(await reg.aliasForTenant("tenant-a", "hello", "prod")).toBe("v1");
125
+ });
126
+
127
+ test("rollback with no prior pin omits fromVersion", async () => {
128
+ const reg = createFileBackedRegistry({ rootDir: join(tmpRoot, "specs") });
129
+ await reg.put("hello", "v1", "x");
130
+ // No pin for `prod` yet.
131
+ const ctrl = createDeploymentController({ registry: reg });
132
+ const rec = await ctrl.rollback("hello", "prod", "v1");
133
+ expect(rec.fromVersion).toBeUndefined();
134
+ expect(rec.toVersion).toBe("v1");
135
+ expect(await reg.aliasFor("hello", "prod")).toBe("v1");
136
+ });
137
+
138
+ test("promote audit payload carries tenantId + actor and the deployment_action kind", async () => {
139
+ const reg = createFileBackedRegistry({ rootDir: join(tmpRoot, "specs") });
140
+ await reg.put("hello", "v1", "x");
141
+ await reg.pin("hello", "staging", "v1");
142
+ const appended: AppendInput[] = [];
143
+ const auditLog: AuditLog = {
144
+ append: mock(async (input: AppendInput): Promise<AuditRecord> => {
145
+ appended.push(input);
146
+ return {
147
+ ts: 0,
148
+ version: 1,
149
+ kind: input.kind,
150
+ seq: 0,
151
+ payload: input.payload,
152
+ prevHash: "",
153
+ hash: "",
154
+ };
155
+ }),
156
+ read: () => {
157
+ throw new Error("read should not be called");
158
+ },
159
+ };
160
+ const ctrl = createDeploymentController({
161
+ registry: reg,
162
+ auditLog,
163
+ tenantId: "tenant-a",
164
+ actor: "carol",
165
+ });
166
+ const rec = await ctrl.promote("hello", "staging", "prod");
167
+ expect(appended.length).toBe(1);
168
+ expect(appended[0]?.kind).toBe("deployment_action");
169
+ expect(appended[0]?.payload).toBe(rec); // the exact record is audit-logged
170
+ expect(rec.tenantId).toBe("tenant-a");
171
+ expect(rec.actor).toBe("carol");
172
+ });
173
+
174
+ test("audit is appended only AFTER the registry pin succeeds (no misleading history)", async () => {
175
+ const calls: string[] = [];
176
+ const pinError = new Error("pin failed: storage down");
177
+ // Minimal RegistryAdapter mock; only the methods promote() touches are real.
178
+ const reg: RegistryAdapter = {
179
+ aliasFor: mock(async (): Promise<string | undefined> => {
180
+ calls.push("aliasFor");
181
+ return "v2";
182
+ }),
183
+ pin: mock(async (): Promise<void> => {
184
+ calls.push("pin");
185
+ throw pinError;
186
+ }),
187
+ // Unused by this path — present to satisfy the interface.
188
+ put: async () => {},
189
+ get: async () => "",
190
+ list: async () => [],
191
+ listSpecs: async () => [],
192
+ delete: async () => {},
193
+ manifest: async () => ({ versions: [], pins: {} }),
194
+ pinForTenant: async () => {},
195
+ aliasForTenant: async () => undefined,
196
+ };
197
+ const auditAppend = mock(async (input: AppendInput): Promise<AuditRecord> => {
198
+ calls.push("audit");
199
+ return {
200
+ ts: 0,
201
+ version: 1,
202
+ kind: input.kind,
203
+ seq: 0,
204
+ payload: input.payload,
205
+ prevHash: "",
206
+ hash: "",
207
+ };
208
+ });
209
+ const auditLog: AuditLog = {
210
+ append: auditAppend,
211
+ read: () => {
212
+ throw new Error("read should not be called");
213
+ },
214
+ };
215
+ const ctrl = createDeploymentController({ registry: reg, auditLog });
216
+ await expect(ctrl.promote("hello", "staging", "prod")).rejects.toBe(pinError);
217
+ // The pin threw, so NO audit record was written for the failed deploy.
218
+ expect(auditAppend).not.toHaveBeenCalled();
219
+ expect(calls).toEqual(["aliasFor", "aliasFor", "pin"]);
220
+ });
100
221
  });