@crewhaus/cloud-adapter-render 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 +7 -12
  2. package/src/branches.test.ts +323 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/cloud-adapter-render",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "description": "One-click deploy adapter for Render — generates a render.yaml blueprint + a Render-tuned Dockerfile and wraps the Render Deploy API for daemon target shapes (Section 44)",
6
6
  "main": "src/index.ts",
@@ -12,14 +12,14 @@
12
12
  "test": "bun test src"
13
13
  },
14
14
  "dependencies": {
15
- "@crewhaus/docker-images": "0.1.1",
16
- "@crewhaus/errors": "0.1.1"
15
+ "@crewhaus/docker-images": "0.1.3",
16
+ "@crewhaus/errors": "0.1.3"
17
17
  },
18
18
  "license": "Apache-2.0",
19
19
  "author": {
20
20
  "name": "Max Meier",
21
- "email": "max@studiomax.io",
22
- "url": "https://studiomax.io"
21
+ "email": "max@crewhaus.ai",
22
+ "url": "https://crewhaus.ai"
23
23
  },
24
24
  "repository": {
25
25
  "type": "git",
@@ -31,12 +31,7 @@
31
31
  "url": "https://github.com/crewhaus/factory/issues"
32
32
  },
33
33
  "publishConfig": {
34
- "access": "restricted"
34
+ "access": "public"
35
35
  },
36
- "files": [
37
- "src",
38
- "README.md",
39
- "LICENSE",
40
- "NOTICE"
41
- ]
36
+ "files": ["src", "README.md", "LICENSE", "NOTICE"]
42
37
  }
@@ -0,0 +1,323 @@
1
+ // Supplementary branch/edge coverage for @crewhaus/cloud-adapter-render.
2
+ // The primary behavioural contract lives in ./index.test.ts; this file
3
+ // drives the error, fallback, and combinatorial branches that the happy-path
4
+ // suite does not exercise explicitly (network failure, non-JSON bodies,
5
+ // response-shape fallbacks, and the env-var/group merge matrix), with a
6
+ // strong focus on the T8 credential-leak guarantee on every error surface.
7
+
8
+ import { afterEach, describe, expect, test } from "bun:test";
9
+ import {
10
+ RENDER_DEFAULT_API_BASE,
11
+ RenderAdapterError,
12
+ deployToRender,
13
+ isRenderTargetShape,
14
+ renderBlueprintFor,
15
+ renderDockerfileFor,
16
+ scrubApiKey,
17
+ } from "./index";
18
+
19
+ const API_KEY_ENV = "RENDER_API_KEY";
20
+
21
+ /** Build a fetch test seam returning a fixed body/status. */
22
+ function fakeFetch(body: string, init?: ResponseInit): typeof fetch {
23
+ return (async () => new Response(body, init)) as unknown as typeof fetch;
24
+ }
25
+
26
+ describe("renderBlueprintFor — env var branches", () => {
27
+ test("merges envVars and envVarsFromGroup under a single envVars block", () => {
28
+ const yaml = renderBlueprintFor({
29
+ target: "channel",
30
+ serviceName: "svc",
31
+ envVars: { FOO: "bar" },
32
+ envVarsFromGroup: ["secrets-prod", "secrets-shared"],
33
+ });
34
+ // The `envVars:` header must appear exactly once (not re-emitted for the group).
35
+ const headerCount = yaml.split("\n").filter((l) => l === " envVars:").length;
36
+ expect(headerCount).toBe(1);
37
+ expect(yaml).toContain(" - key: FOO");
38
+ expect(yaml).toContain(' value: "bar"');
39
+ expect(yaml).toContain(" - fromGroup: secrets-prod");
40
+ expect(yaml).toContain(" - fromGroup: secrets-shared");
41
+ });
42
+
43
+ test("empty envVars object emits no envVars block", () => {
44
+ const yaml = renderBlueprintFor({ target: "channel", serviceName: "svc", envVars: {} });
45
+ expect(yaml).not.toContain("envVars:");
46
+ });
47
+
48
+ test("empty envVarsFromGroup array emits no envVars block", () => {
49
+ const yaml = renderBlueprintFor({
50
+ target: "channel",
51
+ serviceName: "svc",
52
+ envVarsFromGroup: [],
53
+ });
54
+ expect(yaml).not.toContain("envVars:");
55
+ });
56
+
57
+ test("skips env entries whose value is undefined", () => {
58
+ // Defends the `if (value === undefined) continue` guard against callers
59
+ // who pass a partial record at runtime despite the string-typed signature.
60
+ const yaml = renderBlueprintFor({
61
+ target: "channel",
62
+ serviceName: "svc",
63
+ envVars: { KEEP: "yes", DROP: undefined } as unknown as Record<string, string>,
64
+ });
65
+ expect(yaml).toContain(" - key: KEEP");
66
+ expect(yaml).not.toContain("DROP");
67
+ });
68
+
69
+ test("env values are JSON-escaped (YAML-injection defense)", () => {
70
+ const yaml = renderBlueprintFor({
71
+ target: "channel",
72
+ serviceName: "svc",
73
+ envVars: { TOKEN: 'a"b\nc: pwned' },
74
+ });
75
+ // JSON.stringify keeps the value on one quoted line with escapes — the
76
+ // newline and quote cannot break out into a new YAML node.
77
+ expect(yaml).toContain(' value: "a\\"b\\nc: pwned"');
78
+ expect(yaml).not.toContain("\nc: pwned");
79
+ });
80
+
81
+ test("explicit undefined imageTag falls through to build-from-Dockerfile", () => {
82
+ const yaml = renderBlueprintFor({
83
+ target: "channel",
84
+ serviceName: "svc",
85
+ imageTag: undefined,
86
+ });
87
+ expect(yaml).not.toContain("\n image:");
88
+ });
89
+
90
+ test("honors a custom dockerfilePath", () => {
91
+ const yaml = renderBlueprintFor({
92
+ target: "batch",
93
+ serviceName: "svc",
94
+ dockerfilePath: "./docker/Dockerfile.batch",
95
+ });
96
+ expect(yaml).toContain("dockerfilePath: ./docker/Dockerfile.batch");
97
+ });
98
+
99
+ test("accepts a 63-char service name but rejects a 64-char one", () => {
100
+ const name63 = "a".repeat(63);
101
+ const name64 = "a".repeat(64);
102
+ expect(() => renderBlueprintFor({ target: "channel", serviceName: name63 })).not.toThrow();
103
+ expect(() => renderBlueprintFor({ target: "channel", serviceName: name64 })).toThrow(
104
+ RenderAdapterError,
105
+ );
106
+ });
107
+ });
108
+
109
+ describe("renderDockerfileFor — every web shape", () => {
110
+ test("all web-typed shapes emit a HEALTHCHECK", () => {
111
+ for (const shape of ["channel", "managed", "voice", "browser"] as const) {
112
+ const df = renderDockerfileFor({ target: shape, baseImage: `crewhaus/${shape}:0.1.0` });
113
+ expect(df).toContain("HEALTHCHECK");
114
+ expect(df).toContain("http://localhost:$PORT/healthz");
115
+ }
116
+ });
117
+
118
+ test("output ends with exactly one trailing newline", () => {
119
+ const df = renderDockerfileFor({ target: "batch", baseImage: "crewhaus/batch:0.1.0" });
120
+ expect(df.endsWith("\n")).toBe(true);
121
+ expect(df.endsWith("\n\n")).toBe(false);
122
+ });
123
+ });
124
+
125
+ describe("scrubApiKey — boundary lengths", () => {
126
+ test("scrubs at exactly 8 chars (inclusive lower bound)", () => {
127
+ expect(scrubApiKey("x 12345678 y", "12345678")).toBe("x [REDACTED:RENDER_API_KEY] y");
128
+ });
129
+
130
+ test("does not scrub at 7 chars (just below bound)", () => {
131
+ expect(scrubApiKey("x 1234567 y", "1234567")).toBe("x 1234567 y");
132
+ });
133
+
134
+ test("empty-string key is treated as too short and left untouched", () => {
135
+ expect(scrubApiKey("nothing to scrub", "")).toBe("nothing to scrub");
136
+ });
137
+ });
138
+
139
+ describe("isRenderTargetShape — extra negatives", () => {
140
+ test("rejects objects and arrays", () => {
141
+ expect(isRenderTargetShape({})).toBe(false);
142
+ expect(isRenderTargetShape([])).toBe(false);
143
+ expect(isRenderTargetShape("")).toBe(false);
144
+ });
145
+ });
146
+
147
+ describe("deployToRender — error & fallback branches", () => {
148
+ const ORIGINAL_KEY = process.env[API_KEY_ENV];
149
+ afterEach(() => {
150
+ if (ORIGINAL_KEY === undefined) delete process.env[API_KEY_ENV];
151
+ else process.env[API_KEY_ENV] = ORIGINAL_KEY;
152
+ });
153
+
154
+ test("wraps a thrown network error and scrubs the key from it", async () => {
155
+ const key = "rnd_network_failure_key_long";
156
+ const fetchImpl = (async () => {
157
+ throw new Error(`connect ECONNREFUSED while using ${key}`);
158
+ }) as unknown as typeof fetch;
159
+ let caught: RenderAdapterError | undefined;
160
+ try {
161
+ await deployToRender({
162
+ apiKey: key,
163
+ blueprintYaml: "services: []\n",
164
+ serviceName: "svc",
165
+ fetchImpl,
166
+ });
167
+ } catch (err) {
168
+ caught = err as RenderAdapterError;
169
+ }
170
+ expect(caught).toBeInstanceOf(RenderAdapterError);
171
+ expect(caught?.message).toContain("Render API request failed");
172
+ expect(caught?.message).not.toContain(key);
173
+ expect(caught?.message).toContain("[REDACTED:RENDER_API_KEY]");
174
+ });
175
+
176
+ test("handles a non-Error thrown value from fetch (String(err) branch)", async () => {
177
+ const fetchImpl = (async () => {
178
+ // eslint-disable-next-line no-throw-literal
179
+ throw "raw string failure";
180
+ }) as unknown as typeof fetch;
181
+ let caught: RenderAdapterError | undefined;
182
+ try {
183
+ await deployToRender({
184
+ apiKey: "rnd_key_nonerror_throw",
185
+ blueprintYaml: "x",
186
+ serviceName: "svc",
187
+ fetchImpl,
188
+ });
189
+ } catch (err) {
190
+ caught = err as RenderAdapterError;
191
+ }
192
+ expect(caught).toBeInstanceOf(RenderAdapterError);
193
+ expect(caught?.message).toContain("raw string failure");
194
+ });
195
+
196
+ test("throws scrubbed error on non-2xx response", async () => {
197
+ const key = "rnd_status500_key_longenough";
198
+ const fetchImpl = fakeFetch(`upstream blew up with ${key}`, {
199
+ status: 500,
200
+ statusText: "Internal Server Error",
201
+ });
202
+ let caught: RenderAdapterError | undefined;
203
+ try {
204
+ await deployToRender({
205
+ apiKey: key,
206
+ blueprintYaml: "x",
207
+ serviceName: "svc",
208
+ fetchImpl,
209
+ });
210
+ } catch (err) {
211
+ caught = err as RenderAdapterError;
212
+ }
213
+ expect(caught?.message).toContain("Render API returned 500 Internal Server Error");
214
+ expect(caught?.message).not.toContain(key);
215
+ expect(caught?.message).toContain("[REDACTED:RENDER_API_KEY]");
216
+ });
217
+
218
+ test("throws scrubbed error when a 2xx body is not JSON", async () => {
219
+ const key = "rnd_nonjson_body_key_longxx";
220
+ const fetchImpl = fakeFetch(`<html>not json ${key}</html>`, { status: 200 });
221
+ let caught: RenderAdapterError | undefined;
222
+ try {
223
+ await deployToRender({
224
+ apiKey: key,
225
+ blueprintYaml: "x",
226
+ serviceName: "svc",
227
+ fetchImpl,
228
+ });
229
+ } catch (err) {
230
+ caught = err as RenderAdapterError;
231
+ }
232
+ expect(caught?.message).toContain("Render API returned non-JSON body");
233
+ expect(caught?.message).not.toContain(key);
234
+ expect(caught?.message).toContain("[REDACTED:RENDER_API_KEY]");
235
+ });
236
+
237
+ test("throws scrubbed error when the JSON body has no service id", async () => {
238
+ const key = "rnd_missing_id_key_longenough";
239
+ const fetchImpl = fakeFetch(JSON.stringify({ deploy: { id: "d-1" }, hint: key }), {
240
+ status: 201,
241
+ });
242
+ let caught: RenderAdapterError | undefined;
243
+ try {
244
+ await deployToRender({
245
+ apiKey: key,
246
+ blueprintYaml: "x",
247
+ serviceName: "svc",
248
+ fetchImpl,
249
+ });
250
+ } catch (err) {
251
+ caught = err as RenderAdapterError;
252
+ }
253
+ expect(caught?.message).toContain("missing service id");
254
+ expect(caught?.message).not.toContain(key);
255
+ expect(caught?.message).toContain("[REDACTED:RENDER_API_KEY]");
256
+ });
257
+
258
+ test("falls back to top-level `id` when `service.id` is absent", async () => {
259
+ const fetchImpl = fakeFetch(
260
+ JSON.stringify({ id: "srv-top-level", deploy: { id: "d-9", status: "live" } }),
261
+ { status: 201 },
262
+ );
263
+ const result = await deployToRender({
264
+ apiKey: "rnd_toplevel_id_key_long",
265
+ blueprintYaml: "x",
266
+ serviceName: "svc",
267
+ fetchImpl,
268
+ });
269
+ expect(result.serviceId).toBe("srv-top-level");
270
+ });
271
+
272
+ test("applies default deployId/status/url when those fields are absent", async () => {
273
+ const fetchImpl = fakeFetch(JSON.stringify({ service: { id: "srv-min" } }), { status: 201 });
274
+ const result = await deployToRender({
275
+ apiKey: "rnd_defaults_key_longenough",
276
+ blueprintYaml: "x",
277
+ serviceName: "svc",
278
+ fetchImpl,
279
+ });
280
+ expect(result.serviceId).toBe("srv-min");
281
+ expect(result.deployId).toBe("");
282
+ expect(result.status).toBe("pending");
283
+ expect(result.url).toBeUndefined();
284
+ });
285
+
286
+ test("surfaces serviceDetails.url when present", async () => {
287
+ const fetchImpl = fakeFetch(
288
+ JSON.stringify({
289
+ service: { id: "srv-u" },
290
+ deploy: { id: "d-u", status: "live" },
291
+ serviceDetails: { url: "https://svc-u.onrender.com" },
292
+ }),
293
+ { status: 201 },
294
+ );
295
+ const result = await deployToRender({
296
+ apiKey: "rnd_url_present_key_long",
297
+ blueprintYaml: "x",
298
+ serviceName: "svc",
299
+ fetchImpl,
300
+ });
301
+ expect(result.url).toBe("https://svc-u.onrender.com");
302
+ });
303
+
304
+ test("honors a custom apiBaseUrl (VCR-style override)", async () => {
305
+ let observedUrl: string | undefined;
306
+ const fetchImpl = (async (url: string | URL) => {
307
+ observedUrl = String(url);
308
+ return new Response(
309
+ JSON.stringify({ service: { id: "srv-vcr" }, deploy: { id: "d", status: "ok" } }),
310
+ { status: 201 },
311
+ );
312
+ }) as unknown as typeof fetch;
313
+ await deployToRender({
314
+ apiKey: "rnd_custom_base_key_long",
315
+ blueprintYaml: "x",
316
+ serviceName: "svc",
317
+ apiBaseUrl: "http://localhost:9999/mock",
318
+ fetchImpl,
319
+ });
320
+ expect(observedUrl).toBe("http://localhost:9999/mock/services");
321
+ expect(observedUrl).not.toBe(`${RENDER_DEFAULT_API_BASE}/services`);
322
+ });
323
+ });