@jango-blockchained/hoox-cli 0.3.4

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 (94) hide show
  1. package/README.md +403 -0
  2. package/bin/hoox.js +12 -0
  3. package/package.json +60 -0
  4. package/src/commands/check/check-command.test.ts +468 -0
  5. package/src/commands/check/check-command.ts +1144 -0
  6. package/src/commands/check/index.ts +10 -0
  7. package/src/commands/check/prerequisites-command.test.ts +19 -0
  8. package/src/commands/check/prerequisites-command.ts +92 -0
  9. package/src/commands/check/types.ts +103 -0
  10. package/src/commands/clone/clone-command.test.ts +442 -0
  11. package/src/commands/clone/clone-command.ts +440 -0
  12. package/src/commands/clone/index.ts +1 -0
  13. package/src/commands/config/config-command.test.ts +583 -0
  14. package/src/commands/config/config-command.ts +901 -0
  15. package/src/commands/config/env-command.test.ts +43 -0
  16. package/src/commands/config/env-command.ts +314 -0
  17. package/src/commands/config/index.ts +3 -0
  18. package/src/commands/config/kv-command.test.ts +14 -0
  19. package/src/commands/config/kv-command.ts +329 -0
  20. package/src/commands/dashboard/dashboard-command.test.ts +47 -0
  21. package/src/commands/dashboard/dashboard-command.ts +127 -0
  22. package/src/commands/dashboard/index.ts +1 -0
  23. package/src/commands/db/db-command.test.ts +21 -0
  24. package/src/commands/db/db-command.ts +314 -0
  25. package/src/commands/db/index.ts +1 -0
  26. package/src/commands/deploy/deploy-command.test.ts +304 -0
  27. package/src/commands/deploy/deploy-command.ts +1053 -0
  28. package/src/commands/deploy/index.ts +2 -0
  29. package/src/commands/deploy/telegram-service.ts +61 -0
  30. package/src/commands/deploy/types.ts +34 -0
  31. package/src/commands/dev/dev-command.test.ts +383 -0
  32. package/src/commands/dev/dev-command.ts +407 -0
  33. package/src/commands/dev/index.ts +1 -0
  34. package/src/commands/infra/index.ts +5 -0
  35. package/src/commands/infra/infra-command.test.ts +719 -0
  36. package/src/commands/infra/infra-command.ts +940 -0
  37. package/src/commands/infra/types.ts +23 -0
  38. package/src/commands/init/index.ts +1 -0
  39. package/src/commands/init/init-command.test.ts +827 -0
  40. package/src/commands/init/init-command.ts +627 -0
  41. package/src/commands/init/types.ts +185 -0
  42. package/src/commands/monitor/index.ts +2 -0
  43. package/src/commands/monitor/monitor-command.test.ts +235 -0
  44. package/src/commands/monitor/monitor-command.ts +245 -0
  45. package/src/commands/monitor/monitor-service.ts +50 -0
  46. package/src/commands/monitor/types.ts +13 -0
  47. package/src/commands/repair/index.ts +2 -0
  48. package/src/commands/repair/repair-command.test.ts +204 -0
  49. package/src/commands/repair/repair-command.ts +199 -0
  50. package/src/commands/repair/repair-service.ts +102 -0
  51. package/src/commands/repair/types.ts +13 -0
  52. package/src/commands/test/index.ts +2 -0
  53. package/src/commands/test/test-command.test.ts +319 -0
  54. package/src/commands/test/test-command.ts +412 -0
  55. package/src/commands/waf/index.ts +2 -0
  56. package/src/commands/waf/types.ts +48 -0
  57. package/src/commands/waf/waf-command.test.ts +506 -0
  58. package/src/commands/waf/waf-command.ts +548 -0
  59. package/src/index.ts +198 -0
  60. package/src/services/cloudflare/cloudflare-service.test.ts +654 -0
  61. package/src/services/cloudflare/cloudflare-service.ts +435 -0
  62. package/src/services/cloudflare/index.ts +2 -0
  63. package/src/services/cloudflare/types.ts +29 -0
  64. package/src/services/config/config-service.test.ts +395 -0
  65. package/src/services/config/config-service.ts +207 -0
  66. package/src/services/config/index.ts +9 -0
  67. package/src/services/config/types.ts +66 -0
  68. package/src/services/db/db-service.test.ts +51 -0
  69. package/src/services/db/db-service.ts +140 -0
  70. package/src/services/db/index.ts +1 -0
  71. package/src/services/docker/docker-service.ts +155 -0
  72. package/src/services/docker/index.ts +1 -0
  73. package/src/services/env/env-service.test.ts +210 -0
  74. package/src/services/env/env-service.ts +156 -0
  75. package/src/services/env/index.ts +1 -0
  76. package/src/services/kv/index.ts +1 -0
  77. package/src/services/kv/kv-sync-service.test.ts +38 -0
  78. package/src/services/kv/kv-sync-service.ts +151 -0
  79. package/src/services/prerequisites/index.ts +1 -0
  80. package/src/services/prerequisites/prerequisites-service.test.ts +89 -0
  81. package/src/services/prerequisites/prerequisites-service.ts +269 -0
  82. package/src/services/prerequisites/types.ts +48 -0
  83. package/src/services/secrets/index.ts +12 -0
  84. package/src/services/secrets/secrets-service.test.ts +486 -0
  85. package/src/services/secrets/secrets-service.ts +293 -0
  86. package/src/services/secrets/types.ts +57 -0
  87. package/src/ui/banner.ts +52 -0
  88. package/src/ui/index.ts +8 -0
  89. package/src/ui/menu.ts +473 -0
  90. package/src/utils/errors.test.ts +69 -0
  91. package/src/utils/errors.ts +23 -0
  92. package/src/utils/formatters.test.ts +180 -0
  93. package/src/utils/formatters.ts +252 -0
  94. package/src/utils/theme.ts +94 -0
@@ -0,0 +1,506 @@
1
+ /**
2
+ * Unit tests for `hoox waf` command.
3
+ *
4
+ * Mocks CloudflareService.zonesList() and global fetch() for Cloudflare API
5
+ * calls. Tests verify command action functions produce correct output and
6
+ * handle errors gracefully.
7
+ */
8
+
9
+ import {
10
+ afterEach,
11
+ beforeEach,
12
+ describe,
13
+ expect,
14
+ it,
15
+ mock,
16
+ spyOn,
17
+ } from "bun:test";
18
+ import { Command } from "commander";
19
+ import { registerWafCommand } from "./waf-command.js";
20
+ import { CloudflareService } from "../../services/cloudflare/cloudflare-service.js";
21
+ import type { WranglerResult } from "../../services/cloudflare/types.js";
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Helpers
25
+ // ---------------------------------------------------------------------------
26
+
27
+ const realFetch = globalThis.fetch;
28
+ const realEnv = { ...process.env };
29
+
30
+ /** Mock CloudflareService.zonesList to return a known zone. */
31
+ function mockZonesList(output: string, ok = true, error?: string): void {
32
+ const result: WranglerResult<string> = ok
33
+ ? { ok: true, data: output }
34
+ : { ok: false, error: error ?? "zone list failed" };
35
+
36
+ spyOn(CloudflareService.prototype, "zonesList").mockImplementation(() =>
37
+ Promise.resolve(result)
38
+ );
39
+ }
40
+
41
+ /**
42
+ * Sets up a mock fetch that returns a CF-style JSON response.
43
+ * Returns the list of calls made to fetch for assertions.
44
+ */
45
+ function mockCfFetch(
46
+ responseMap: Record<string, unknown>
47
+ ): Array<{ url: string; method: string; body?: string }> {
48
+ const calls: Array<{ url: string; method: string; body?: string }> = [];
49
+
50
+ const fetchMock = mock(
51
+ async (url: string, init?: Record<string, unknown>) => {
52
+ void init; // Mark as used
53
+ // Find matching response by path partial match
54
+ let matched: unknown = null;
55
+ for (const [pathPattern, response] of Object.entries(responseMap)) {
56
+ if ((url as string).includes(pathPattern)) {
57
+ matched = response;
58
+ break;
59
+ }
60
+ }
61
+
62
+ calls.push({
63
+ url: url as string,
64
+ method: (init?.method ?? "GET") as string,
65
+ body: init?.body as string | undefined,
66
+ });
67
+
68
+ if (
69
+ matched &&
70
+ typeof matched === "object" &&
71
+ matched !== null &&
72
+ "error" in (matched as Record<string, unknown>)
73
+ ) {
74
+ // Error response
75
+ return {
76
+ ok: false,
77
+ status: 400,
78
+ json: async () => ({
79
+ success: false,
80
+ errors: [{ message: (matched as Record<string, string>).error }],
81
+ }),
82
+ };
83
+ }
84
+
85
+ return {
86
+ ok: true,
87
+ status: 200,
88
+ json: async () => ({
89
+ success: true,
90
+ result: matched,
91
+ }),
92
+ };
93
+ }
94
+ );
95
+
96
+ (globalThis as unknown as Record<string, unknown>).fetch = fetchMock;
97
+ return calls;
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Setup / Teardown
102
+ // ---------------------------------------------------------------------------
103
+
104
+ beforeEach(() => {
105
+ // Set required env vars for CF API calls
106
+ process.env.CLOUDFLARE_API_TOKEN = "test-token";
107
+ process.env.CLOUDFLARE_ACCOUNT_ID = "test-account-id";
108
+ process.env.CLOUDFLARE_ZONE_ID = ""; // force zonesList path
109
+ });
110
+
111
+ afterEach(() => {
112
+ (globalThis as unknown as Record<string, unknown>).fetch = realFetch;
113
+ process.env = { ...realEnv };
114
+ mock.restore();
115
+ });
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Helper: build a Commander program with waf registered
119
+ // ---------------------------------------------------------------------------
120
+
121
+ function makeProgram(): Command {
122
+ const program = new Command()
123
+ .name("hoox")
124
+ .option("--json", "JSON output")
125
+ .option("--quiet", "Minimal output");
126
+
127
+ registerWafCommand(program);
128
+ return program;
129
+ }
130
+
131
+ /** Run a command string (e.g. "waf status --json") and capture stdout. */
132
+ async function runCommand(
133
+ args: string
134
+ ): Promise<{ stdout: string; exitCode: number }> {
135
+ const program = makeProgram();
136
+
137
+ let stdout = "";
138
+ const writeSpy = spyOn(process.stdout, "write").mockImplementation(
139
+ (chunk: unknown) => {
140
+ stdout += typeof chunk === "string" ? chunk : String(chunk ?? "");
141
+ return true;
142
+ }
143
+ );
144
+
145
+ // We don't want process.exit() to kill the test runner
146
+ const exitSpy = spyOn(process, "exit").mockImplementation(
147
+ (() => {}) as never
148
+ );
149
+
150
+ try {
151
+ await program.parseAsync([...args.split(" ")], { from: "user" });
152
+ } catch {
153
+ // Some commands may fail — that's fine, we test output
154
+ }
155
+
156
+ writeSpy.mockRestore();
157
+ exitSpy.mockRestore();
158
+
159
+ return { stdout, exitCode: 0 };
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Tests
164
+ // ---------------------------------------------------------------------------
165
+
166
+ describe("waf command", () => {
167
+ // -- waf status ------------------------------------------------------------
168
+
169
+ describe("status", () => {
170
+ it("shows WAF status when enabled", async () => {
171
+ mockZonesList("example.com (abc123def456)");
172
+ mockCfFetch({
173
+ "/settings/waf": { id: "waf", value: "on" },
174
+ "/firewall/rules": [
175
+ {
176
+ id: "rule-1",
177
+ description: "Block bad IPs",
178
+ action: "block",
179
+ filter: { id: "f1", expression: "(ip.src eq 1.2.3.4)" },
180
+ created_on: "2026-01-01T00:00:00Z",
181
+ modified_on: "2026-01-02T00:00:00Z",
182
+ },
183
+ ],
184
+ "/analytics/dashboard": { totals: { threats: 42 } },
185
+ });
186
+
187
+ const { stdout } = await runCommand("waf status");
188
+
189
+ expect(stdout).toContain("example.com");
190
+ expect(stdout).toContain("ENABLED");
191
+ expect(stdout).toContain("1 active");
192
+ expect(stdout).toContain("42");
193
+ });
194
+
195
+ it("shows WAF status when disabled", async () => {
196
+ mockZonesList("example.com (abc123def456)");
197
+ mockCfFetch({
198
+ "/settings/waf": { id: "waf", value: "off" },
199
+ "/firewall/rules": [],
200
+ "/analytics/dashboard": { totals: { threats: 0 } },
201
+ });
202
+
203
+ const { stdout } = await runCommand("waf status");
204
+
205
+ expect(stdout).toContain("DISABLED");
206
+ expect(stdout).toContain("0 active");
207
+ expect(stdout).toContain("0");
208
+ });
209
+
210
+ it("outputs JSON when --json flag is used", async () => {
211
+ mockZonesList("example.com (abc123def456)");
212
+ mockCfFetch({
213
+ "/settings/waf": { id: "waf", value: "on" },
214
+ "/firewall/rules": [],
215
+ "/analytics/dashboard": { totals: { threats: 0 } },
216
+ });
217
+
218
+ const { stdout } = await runCommand("waf status --json");
219
+
220
+ const parsed = JSON.parse(stdout.trim());
221
+ expect(parsed).toHaveProperty("enabled", true);
222
+ expect(parsed).toHaveProperty("zoneId", "abc123def456");
223
+ expect(parsed).toHaveProperty("zoneName", "example.com");
224
+ });
225
+
226
+ it("throws when zonesList fails", async () => {
227
+ mockZonesList("", false, "auth error");
228
+
229
+ const { stdout } = await runCommand("waf status");
230
+
231
+ expect(stdout).toContain("Failed to list zones");
232
+ });
233
+
234
+ it("uses CLOUDFLARE_ZONE_ID env var when set", async () => {
235
+ process.env.CLOUDFLARE_ZONE_ID = "direct-zone-id";
236
+ mockCfFetch({
237
+ "/settings/waf": { id: "waf", value: "on" },
238
+ "/firewall/rules": [],
239
+ "/analytics/dashboard": { totals: { threats: 0 } },
240
+ });
241
+
242
+ const { stdout } = await runCommand("waf status");
243
+
244
+ expect(stdout).toContain("direct-zone-id");
245
+ });
246
+ });
247
+
248
+ // -- waf rules list --------------------------------------------------------
249
+
250
+ describe("rules list", () => {
251
+ it("lists firewall rules in human-readable format", async () => {
252
+ mockZonesList("example.com (zone-1)");
253
+ mockCfFetch({
254
+ "/firewall/rules": [
255
+ {
256
+ id: "rule-1-is-a-very-long-id",
257
+ description: "IP blocklist rule",
258
+ action: "block",
259
+ filter: {
260
+ id: "f1",
261
+ expression: "(ip.src eq 10.0.0.1)",
262
+ },
263
+ created_on: "2026-01-01T00:00:00Z",
264
+ modified_on: "2026-01-02T00:00:00Z",
265
+ },
266
+ {
267
+ id: "rule-2-short",
268
+ description: "",
269
+ action: "allow",
270
+ filter: {
271
+ id: "f2",
272
+ expression: '(http.request.uri.path contains "/api")',
273
+ },
274
+ created_on: "2026-01-03T00:00:00Z",
275
+ modified_on: "2026-01-04T00:00:00Z",
276
+ },
277
+ ],
278
+ });
279
+
280
+ const { stdout } = await runCommand("waf rules list");
281
+
282
+ expect(stdout).toContain("IP blocklist rule");
283
+ expect(stdout).toContain("block");
284
+ expect(stdout).toContain("allow");
285
+ expect(stdout).toContain("(ip.src eq 10.0.0.1)");
286
+ });
287
+
288
+ it("outputs JSON when --json flag is used", async () => {
289
+ mockZonesList("example.com (zone-1)");
290
+ mockCfFetch({
291
+ "/firewall/rules": [
292
+ {
293
+ id: "rule-1",
294
+ description: "Test rule",
295
+ action: "block",
296
+ filter: { id: "f1", expression: "(ip.src eq 1.1.1.1)" },
297
+ created_on: "2026-01-01T00:00:00Z",
298
+ modified_on: "2026-01-01T00:00:00Z",
299
+ },
300
+ ],
301
+ });
302
+
303
+ const { stdout } = await runCommand("waf rules list --json");
304
+
305
+ const parsed = JSON.parse(stdout.trim());
306
+ expect(Array.isArray(parsed)).toBe(true);
307
+ expect(parsed[0]).toHaveProperty("id", "rule-1");
308
+ });
309
+
310
+ it("shows empty message when no rules exist", async () => {
311
+ mockZonesList("example.com (zone-1)");
312
+ mockCfFetch({ "/firewall/rules": [] });
313
+
314
+ const { stdout } = await runCommand("waf rules list");
315
+
316
+ expect(stdout).toContain("No firewall rules");
317
+ });
318
+
319
+ it("throws when rules fetch fails", async () => {
320
+ mockZonesList("example.com (zone-1)");
321
+ mockCfFetch({
322
+ "/firewall/rules": { error: "API error" },
323
+ });
324
+
325
+ const { stdout } = await runCommand("waf rules list");
326
+
327
+ expect(stdout).toContain("API error");
328
+ });
329
+ });
330
+
331
+ // -- waf rules add ---------------------------------------------------------
332
+
333
+ describe("rules add", () => {
334
+ it("adds an IP allowlist rule", async () => {
335
+ mockZonesList("example.com (zone-1)");
336
+ mockCfFetch({
337
+ "/filters": { id: "filter-1" },
338
+ // NOTE: the firewall/rules endpoint is matched but returns array
339
+ "/firewall/rules": [
340
+ {
341
+ id: "new-rule-1",
342
+ description: "ip-allowlist rule for 192.168.1.1",
343
+ action: "allow",
344
+ filter: { id: "filter-1", expression: "(ip.src eq 192.168.1.1)" },
345
+ created_on: "2026-01-01T00:00:00Z",
346
+ modified_on: "2026-01-01T00:00:00Z",
347
+ },
348
+ ],
349
+ });
350
+
351
+ const { stdout } = await runCommand(
352
+ "waf rules add ip-allowlist 192.168.1.1"
353
+ );
354
+
355
+ expect(stdout).toContain("WAF rule added");
356
+ expect(stdout).toContain("192.168.1.1");
357
+ });
358
+
359
+ it("rejects invalid rule types", async () => {
360
+ mockZonesList("example.com (zone-1)");
361
+
362
+ const { stdout } = await runCommand("waf rules add invalid-type 1.2.3.4");
363
+
364
+ expect(stdout).toContain("Invalid rule type");
365
+ });
366
+
367
+ it("throws when filter creation fails", async () => {
368
+ mockZonesList("example.com (zone-1)");
369
+ mockCfFetch({
370
+ "/filters": { error: "Filter creation failed" },
371
+ });
372
+
373
+ const { stdout } = await runCommand(
374
+ "waf rules add ip-blocklist 10.0.0.1"
375
+ );
376
+
377
+ expect(stdout).toContain("Filter creation failed");
378
+ });
379
+ });
380
+
381
+ // -- waf rules remove ------------------------------------------------------
382
+
383
+ describe("rules remove", () => {
384
+ it("removes a WAF rule by ID", async () => {
385
+ mockZonesList("example.com (zone-1)");
386
+ mockCfFetch({
387
+ "/firewall/rules/rule-to-delete": { id: "rule-to-delete" },
388
+ });
389
+
390
+ const { stdout } = await runCommand("waf rules remove rule-to-delete");
391
+
392
+ expect(stdout).toContain("removed");
393
+ expect(stdout).toContain("rule-to-delete");
394
+ });
395
+
396
+ it("throws when rule deletion fails", async () => {
397
+ mockZonesList("example.com (zone-1)");
398
+ mockCfFetch({
399
+ "/firewall/rules/bad-id": { error: "Rule not found" },
400
+ });
401
+
402
+ const { stdout } = await runCommand("waf rules remove bad-id");
403
+
404
+ expect(stdout).toContain("Rule not found");
405
+ });
406
+ });
407
+
408
+ // -- waf mode --------------------------------------------------------------
409
+
410
+ describe("mode", () => {
411
+ it("enables WAF on the zone", async () => {
412
+ mockZonesList("example.com (zone-1)");
413
+ mockCfFetch({
414
+ "/settings/waf": { id: "waf", value: "on" },
415
+ });
416
+
417
+ const { stdout } = await runCommand("waf mode enable");
418
+
419
+ expect(stdout).toContain("enabled");
420
+ expect(stdout).toContain("example.com");
421
+ });
422
+
423
+ it("disables WAF on the zone", async () => {
424
+ mockZonesList("example.com (zone-1)");
425
+ mockCfFetch({
426
+ "/settings/waf": { id: "waf", value: "off" },
427
+ });
428
+
429
+ const { stdout } = await runCommand("waf mode disable");
430
+
431
+ expect(stdout).toContain("disabled");
432
+ expect(stdout).toContain("example.com");
433
+ });
434
+
435
+ it("throws when mode update fails", async () => {
436
+ mockZonesList("example.com (zone-1)");
437
+ mockCfFetch({
438
+ "/settings/waf": { error: "Permission denied" },
439
+ });
440
+
441
+ const { stdout } = await runCommand("waf mode enable");
442
+
443
+ expect(stdout).toContain("Permission denied");
444
+ });
445
+ });
446
+
447
+ // -- Error handling --------------------------------------------------------
448
+
449
+ describe("error handling", () => {
450
+ it("throws when CLOUDFLARE_API_TOKEN is missing", async () => {
451
+ delete process.env.CLOUDFLARE_API_TOKEN;
452
+ mockZonesList("example.com (zone-1)");
453
+
454
+ const { stdout } = await runCommand("waf status");
455
+
456
+ expect(stdout).toContain("CLOUDFLARE_API_TOKEN");
457
+ });
458
+
459
+ it("throws when CLOUDFLARE_ACCOUNT_ID is missing", async () => {
460
+ delete process.env.CLOUDFLARE_ACCOUNT_ID;
461
+ mockZonesList("example.com (zone-1)");
462
+
463
+ const { stdout } = await runCommand("waf status");
464
+
465
+ expect(stdout).toContain("CLOUDFLARE_ACCOUNT_ID");
466
+ });
467
+ });
468
+
469
+ // -- Subcommand structure --------------------------------------------------
470
+
471
+ describe("subcommand structure", () => {
472
+ it("registers waf as a command", () => {
473
+ const program = makeProgram();
474
+ const wafCmd = program.commands.find((c) => c.name() === "waf");
475
+ expect(wafCmd).toBeDefined();
476
+
477
+ const subNames = (wafCmd as Command).commands.map((c) => c.name());
478
+ expect(subNames).toContain("status");
479
+ expect(subNames).toContain("rules");
480
+ expect(subNames).toContain("mode");
481
+ });
482
+
483
+ it("registers rules subcommands", () => {
484
+ const program = makeProgram();
485
+ const wafCmd = program.commands.find((c) => c.name() === "waf")!;
486
+ const rulesCmd = (wafCmd as Command).commands.find(
487
+ (c) => c.name() === "rules"
488
+ )!;
489
+ const ruleSubNames = (rulesCmd as Command).commands.map((c) => c.name());
490
+ expect(ruleSubNames).toContain("list");
491
+ expect(ruleSubNames).toContain("add");
492
+ expect(ruleSubNames).toContain("remove");
493
+ });
494
+
495
+ it("registers mode subcommands", () => {
496
+ const program = makeProgram();
497
+ const wafCmd = program.commands.find((c) => c.name() === "waf")!;
498
+ const modeCmd = (wafCmd as Command).commands.find(
499
+ (c) => c.name() === "mode"
500
+ )!;
501
+ const modeSubNames = (modeCmd as Command).commands.map((c) => c.name());
502
+ expect(modeSubNames).toContain("enable");
503
+ expect(modeSubNames).toContain("disable");
504
+ });
505
+ });
506
+ });