create-sailor 1.9.6 → 1.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create-sailor.js +28 -0
- package/dist/index.js +229 -210
- package/package.json +8 -2
- package/templates/infra/ops/README.md +16 -0
- package/templates/infra/ops/platform-expected.example.json +54 -0
- package/templates/tests/README.md +18 -0
- package/templates/tests/degradation.test.example.ts +469 -0
package/package.json
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-sailor",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.1",
|
|
4
|
+
"nebutra": {
|
|
5
|
+
"status": "foundation",
|
|
6
|
+
"graph": "core",
|
|
7
|
+
"productionReady": false
|
|
8
|
+
},
|
|
4
9
|
"description": "Governed AI-native SaaS scaffolder for Nebutra Sailor. Bootstrap a production-ready Next.js + Hono + Prisma monorepo with multi-tenant foundations, region-aware defaults, and AI integrations.",
|
|
5
10
|
"type": "module",
|
|
6
11
|
"main": "dist/index.js",
|
|
7
12
|
"bin": {
|
|
8
|
-
"create-sailor": "
|
|
13
|
+
"create-sailor": "./bin/create-sailor.js"
|
|
9
14
|
},
|
|
10
15
|
"files": [
|
|
16
|
+
"bin",
|
|
11
17
|
"dist",
|
|
12
18
|
"templates",
|
|
13
19
|
"README.md",
|
|
@@ -1,3 +1,19 @@
|
|
|
1
1
|
# infra/ops
|
|
2
2
|
|
|
3
3
|
Operational scripts and runbooks — backup, restore, on-call playbooks, incident response.
|
|
4
|
+
|
|
5
|
+
## Declared provider state
|
|
6
|
+
|
|
7
|
+
`platform-expected.example.json` declares the settings that live only in a
|
|
8
|
+
provider dashboard — Vercel build machine and ignore step, Git links, env vars
|
|
9
|
+
that must not be flagged Sensitive, Fly secret names that must exist or must
|
|
10
|
+
not, GitHub repository variables, the status checks a protected branch requires
|
|
11
|
+
before a merge, Cloudflare Worker bindings. Copy it to
|
|
12
|
+
`platform-expected.json`, replace the names, delete the sections you do not
|
|
13
|
+
use, and run the read-only engine from Nebutra-Sailor against it on a schedule:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
node scripts/ops/platform-reconcile.mjs infra/ops/platform-expected.json --strict
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Exit 1 is the alert. Only names are ever declared or printed, never values.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "Declare the provider settings that live only in a dashboard, then run scripts/ops/platform-reconcile.mjs against this file on a schedule. Delete every section you do not use; every check is optional. Never put a secret value here — only names.",
|
|
3
|
+
"version": 1,
|
|
4
|
+
"vercel": {
|
|
5
|
+
"projects": [
|
|
6
|
+
{
|
|
7
|
+
"name": "{PRODUCT_NAME}-landing",
|
|
8
|
+
"buildMachineType": "standard",
|
|
9
|
+
"ignoreBuildStep": "exit 0",
|
|
10
|
+
"gitLinked": false,
|
|
11
|
+
"envNotSensitive": {
|
|
12
|
+
"production": ["NEXT_PUBLIC_SITE_URL", "NEXT_PUBLIC_API_URL"]
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"name": "{PRODUCT_NAME}-web",
|
|
17
|
+
"buildMachineType": "standard",
|
|
18
|
+
"ignoreBuildStep": "exit 0"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
},
|
|
22
|
+
"fly": {
|
|
23
|
+
"apps": [
|
|
24
|
+
{
|
|
25
|
+
"name": "{PRODUCT_NAME}-gateway",
|
|
26
|
+
"secretsPresent": ["QUEUE_PROVIDER", "UPSTASH_REDIS_REST_URL", "UPSTASH_REDIS_REST_TOKEN"],
|
|
27
|
+
"secretsAbsent": ["REDIS_URL", "ALLOW_MEMORY_QUEUE_IN_PRODUCTION"]
|
|
28
|
+
}
|
|
29
|
+
]
|
|
30
|
+
},
|
|
31
|
+
"github": {
|
|
32
|
+
"repo": "your-org/{PRODUCT_NAME}",
|
|
33
|
+
"variables": {
|
|
34
|
+
"DEPLOY_TARGET_GATEWAY": "cloudflare-workers"
|
|
35
|
+
},
|
|
36
|
+
"branchProtection": [
|
|
37
|
+
{
|
|
38
|
+
"branch": "main",
|
|
39
|
+
"requiredStatusChecks": ["Lint & Typecheck", "Test"],
|
|
40
|
+
"strict": false,
|
|
41
|
+
"enforceAdmins": false,
|
|
42
|
+
"requiredApprovingReviewCount": null
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
},
|
|
46
|
+
"cloudflare": {
|
|
47
|
+
"workers": [
|
|
48
|
+
{
|
|
49
|
+
"name": "{PRODUCT_NAME}-gateway-edge",
|
|
50
|
+
"bindings": [{ "name": "IP_LIMITER", "type": "ratelimit" }]
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -6,3 +6,21 @@ Cross-cutting tests that don't belong to a single package.
|
|
|
6
6
|
|--------|------|---------|
|
|
7
7
|
| `architecture/` | vitest | Architecture rules — import boundaries, layering, no-cycle |
|
|
8
8
|
| `load/` | k6 | Load + perf tests against deployed environments |
|
|
9
|
+
|
|
10
|
+
## `degradation.test.example.ts`
|
|
11
|
+
|
|
12
|
+
A template, not a live test — no vitest glob matches `.example.ts`, so it is
|
|
13
|
+
inert until you activate it. It is the gateway degradation suite: one
|
|
14
|
+
`describe` per dependency outage (Redis failing every command, Redis
|
|
15
|
+
credentials missing, Redis healthy, database down), each `it` stating the
|
|
16
|
+
behaviour the gateway must have while the outage lasts — which routes keep
|
|
17
|
+
answering, what the health endpoint reports, which failures are logged rather
|
|
18
|
+
than surfaced.
|
|
19
|
+
|
|
20
|
+
To activate it, move the file to `backends/gateway/src/__tests__/degradation.test.ts`
|
|
21
|
+
(drop `.example`) and follow the numbered steps in its header: point the
|
|
22
|
+
imports at your middleware chain and health routes, mounted in the same order
|
|
23
|
+
as your `index.ts`, and point the `vi.mock()` specifiers at whatever your
|
|
24
|
+
gateway imports for cache, database and logging. The point of the suite is
|
|
25
|
+
that a misconfigured dependency has a tested behaviour before it has an
|
|
26
|
+
incident; add a `describe` for every dependency your gateway gains.
|
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway degradation suite — template.
|
|
3
|
+
*
|
|
4
|
+
* A dependency outage is a code path like any other, and it needs a test that
|
|
5
|
+
* states the intended behaviour: which routes keep answering, what the health
|
|
6
|
+
* endpoint reports, and which failures are logged rather than surfaced. This
|
|
7
|
+
* file is the shape Nebutra-Sailor uses for that in
|
|
8
|
+
* `backends/gateway/src/__tests__/degradation.test.ts`.
|
|
9
|
+
*
|
|
10
|
+
* To activate it in a scaffold:
|
|
11
|
+
*
|
|
12
|
+
* 1. Move it to `backends/gateway/src/__tests__/degradation.test.ts` — drop
|
|
13
|
+
* the `.example` so the gateway's vitest `include` picks it up.
|
|
14
|
+
* 2. Point the imports in `buildGateway()` at your middleware chain and
|
|
15
|
+
* health routes, mounted in the same order and on the same path scopes as
|
|
16
|
+
* your `index.ts`. Do not import `index.ts` itself if its module body
|
|
17
|
+
* boots telemetry or route groups; compose the pieces instead.
|
|
18
|
+
* 3. Point the `vi.mock()` specifiers at whatever your gateway imports for
|
|
19
|
+
* cache, database and logging. The doubles below match the
|
|
20
|
+
* `@nebutra/cache` client surface (get/set/del/eval/ping/incr/incrby/expire).
|
|
21
|
+
* 4. Replace `s2sHeaders` with however your tenant middleware resolves a
|
|
22
|
+
* tenant in tests, so rate-limit and metering keys are real, not
|
|
23
|
+
* "anonymous".
|
|
24
|
+
*
|
|
25
|
+
* Scenarios, one `describe` each:
|
|
26
|
+
*
|
|
27
|
+
* (a) Redis constructs but every command fails (malformed URL, bad token,
|
|
28
|
+
* outage): rate-limited routes answer from the in-memory bucket and
|
|
29
|
+
* carry rate-limit headers; health is 200 degraded with cache down.
|
|
30
|
+
* (b) Redis credentials missing entirely: same outcome as (a).
|
|
31
|
+
* (c) Redis healthy: remaining tokens decrease across requests and the
|
|
32
|
+
* limiter issues exactly one EVAL per request.
|
|
33
|
+
* (d) Database down: health is 200 degraded with database down; routes that
|
|
34
|
+
* do not touch the database keep answering; with Redis also down health
|
|
35
|
+
* is 503 unhealthy while those routes still answer.
|
|
36
|
+
*
|
|
37
|
+
* When you add a readiness route, it belongs here next to the health route.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { Hono } from "hono";
|
|
41
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
42
|
+
import { s2sHeaders, TEST_SERVICE_SECRET } from "./helpers/s2s-token.js";
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Module mocks. Hoisted handles survive `vi.resetModules()`, which re-runs the
|
|
46
|
+
// factories below; each re-run hands back the same vi.fn instances.
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
const { getRedis, queryRaw, logger } = vi.hoisted(() => ({
|
|
50
|
+
getRedis: vi.fn<() => Promise<unknown>>(),
|
|
51
|
+
queryRaw: vi.fn<() => Promise<unknown>>(),
|
|
52
|
+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
vi.mock("@nebutra/cache", () => ({
|
|
56
|
+
getRedis: () => getRedis(),
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
vi.mock("@nebutra/db", () => ({
|
|
60
|
+
getSystemDb: () => ({ $queryRaw: queryRaw }),
|
|
61
|
+
prisma: { $queryRaw: queryRaw },
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
vi.mock("@nebutra/logger", () => ({ logger }));
|
|
65
|
+
|
|
66
|
+
// tenantContext imports the auth provider factory statically. It is only
|
|
67
|
+
// invoked for Bearer tokens, which this suite never sends; mocking it keeps
|
|
68
|
+
// the provider SDKs out of the module graph.
|
|
69
|
+
vi.mock("@nebutra/auth/server", () => ({
|
|
70
|
+
createAuth: vi.fn(),
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Redis doubles
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
type RedisDouble = {
|
|
78
|
+
get: ReturnType<typeof vi.fn>;
|
|
79
|
+
set: ReturnType<typeof vi.fn>;
|
|
80
|
+
del: ReturnType<typeof vi.fn>;
|
|
81
|
+
eval: ReturnType<typeof vi.fn>;
|
|
82
|
+
ping: ReturnType<typeof vi.fn>;
|
|
83
|
+
incr: ReturnType<typeof vi.fn>;
|
|
84
|
+
incrby: ReturnType<typeof vi.fn>;
|
|
85
|
+
expire: ReturnType<typeof vi.fn>;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A client that constructs but fails every command. This is what a malformed
|
|
90
|
+
* URL, a bad token or an outage looks like from the caller: `getRedis()`
|
|
91
|
+
* resolves, the first command rejects.
|
|
92
|
+
*/
|
|
93
|
+
function brokenRedis(): RedisDouble {
|
|
94
|
+
const rejecting = () => vi.fn(async () => Promise.reject(new TypeError("Invalid URL")));
|
|
95
|
+
return {
|
|
96
|
+
get: rejecting(),
|
|
97
|
+
set: rejecting(),
|
|
98
|
+
del: rejecting(),
|
|
99
|
+
eval: rejecting(),
|
|
100
|
+
ping: rejecting(),
|
|
101
|
+
incr: rejecting(),
|
|
102
|
+
incrby: rejecting(),
|
|
103
|
+
expire: rejecting(),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A working client with just enough state to answer the chain: the token
|
|
109
|
+
* bucket EVAL decrements by the request cost and reports what is left, the
|
|
110
|
+
* counters increment, everything else is a key-value map. No refill — the
|
|
111
|
+
* assertions want determinism, not a second bucket implementation.
|
|
112
|
+
*/
|
|
113
|
+
function healthyRedis(): RedisDouble {
|
|
114
|
+
const buckets = new Map<string, number>();
|
|
115
|
+
const kv = new Map<string, unknown>();
|
|
116
|
+
return {
|
|
117
|
+
eval: vi.fn(async (_script: string, keys: string[], args: Array<string | number>) => {
|
|
118
|
+
const key = keys[0] ?? "";
|
|
119
|
+
// ARGV: now (ms), maxTokens, refillRate, refillInterval (ms), cost, ttl (s)
|
|
120
|
+
const now = Number(args[0]);
|
|
121
|
+
const maxTokens = Number(args[1]);
|
|
122
|
+
const cost = Number(args[4]);
|
|
123
|
+
const tokens = (buckets.get(key) ?? maxTokens) - cost;
|
|
124
|
+
buckets.set(key, Math.max(tokens, 0));
|
|
125
|
+
return [tokens >= 0 ? 1 : 0, Math.max(tokens, 0), now];
|
|
126
|
+
}),
|
|
127
|
+
get: vi.fn(async (key: string) => kv.get(key) ?? null),
|
|
128
|
+
set: vi.fn(async (key: string, value: unknown) => {
|
|
129
|
+
kv.set(key, value);
|
|
130
|
+
return "OK";
|
|
131
|
+
}),
|
|
132
|
+
del: vi.fn(async (key: string) => (kv.delete(key) ? 1 : 0)),
|
|
133
|
+
incr: vi.fn(async (key: string) => {
|
|
134
|
+
const next = Number(kv.get(key) ?? 0) + 1;
|
|
135
|
+
kv.set(key, next);
|
|
136
|
+
return next;
|
|
137
|
+
}),
|
|
138
|
+
incrby: vi.fn(async (key: string, by: number) => {
|
|
139
|
+
const next = Number(kv.get(key) ?? 0) + by;
|
|
140
|
+
kv.set(key, next);
|
|
141
|
+
return next;
|
|
142
|
+
}),
|
|
143
|
+
expire: vi.fn(async () => 1),
|
|
144
|
+
ping: vi.fn(async () => "PONG"),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// App under test — adapt the imports and mounts to your gateway.
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
async function buildGateway() {
|
|
153
|
+
vi.resetModules();
|
|
154
|
+
|
|
155
|
+
const [
|
|
156
|
+
{ tenantContextMiddleware },
|
|
157
|
+
{ usageMeteringMiddleware },
|
|
158
|
+
{ idempotencyMiddleware },
|
|
159
|
+
{ rateLimitMiddleware },
|
|
160
|
+
{ shouldSkipGlobalRateLimit },
|
|
161
|
+
{ healthRoutes },
|
|
162
|
+
] = await Promise.all([
|
|
163
|
+
import("../middlewares/tenantContext.js"),
|
|
164
|
+
import("../middlewares/usageMetering.js"),
|
|
165
|
+
import("../middlewares/idempotency.js"),
|
|
166
|
+
import("../middlewares/rateLimit.js"),
|
|
167
|
+
import("../middlewares/rateLimitSkip.js"),
|
|
168
|
+
import("../routes/misc/health.js"),
|
|
169
|
+
]);
|
|
170
|
+
|
|
171
|
+
const handled = vi.fn();
|
|
172
|
+
const app = new Hono();
|
|
173
|
+
|
|
174
|
+
// Same mounts, same order, same path scopes as index.ts.
|
|
175
|
+
app.use("*", tenantContextMiddleware);
|
|
176
|
+
app.use("/api/v1/*", usageMeteringMiddleware);
|
|
177
|
+
app.use("/api/v1/*", idempotencyMiddleware);
|
|
178
|
+
app.use("/api/*", async (c, next) => {
|
|
179
|
+
const path = new URL(c.req.url).pathname;
|
|
180
|
+
if (shouldSkipGlobalRateLimit(path)) return next();
|
|
181
|
+
return rateLimitMiddleware(c, next);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
app.route("/api/misc", healthRoutes);
|
|
185
|
+
app.get("/api/v1/things", (c) => {
|
|
186
|
+
handled();
|
|
187
|
+
return c.json({ items: [] });
|
|
188
|
+
});
|
|
189
|
+
app.post("/api/v1/things", (c) => {
|
|
190
|
+
handled();
|
|
191
|
+
return c.json({ id: "thing_1" }, 201);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
return { app, handled };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
type Gateway = Awaited<ReturnType<typeof buildGateway>>;
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// Helpers
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
const REDIS_ENV = [
|
|
204
|
+
"UPSTASH_REDIS_REST_URL",
|
|
205
|
+
"UPSTASH_REDIS_REST_TOKEN",
|
|
206
|
+
"UPSTASH_REDIS_URL",
|
|
207
|
+
"UPSTASH_REDIS_TOKEN",
|
|
208
|
+
] as const;
|
|
209
|
+
|
|
210
|
+
function redisConfigured(present: boolean) {
|
|
211
|
+
for (const name of REDIS_ENV) vi.stubEnv(name, undefined);
|
|
212
|
+
if (present) {
|
|
213
|
+
vi.stubEnv("UPSTASH_REDIS_REST_URL", "https://x.upstash.io");
|
|
214
|
+
vi.stubEnv("UPSTASH_REDIS_REST_TOKEN", "t");
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** A resolved tenant, so rate-limit and metering keys are real, not "anonymous". */
|
|
219
|
+
function tenantHeaders() {
|
|
220
|
+
return s2sHeaders({
|
|
221
|
+
userId: "user_degradation",
|
|
222
|
+
orgId: "org_degradation",
|
|
223
|
+
role: "org:member",
|
|
224
|
+
plan: "FREE",
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function getThings(app: Gateway["app"]) {
|
|
229
|
+
return app.request("/api/v1/things", { method: "GET", headers: await tenantHeaders() });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function getHealth(app: Gateway["app"]) {
|
|
233
|
+
return app.request("/api/misc/health", { method: "GET" });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Metering writes behind `void (async () => …)()`; let the microtasks drain. */
|
|
237
|
+
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
238
|
+
|
|
239
|
+
const FREE_LIMIT = "100";
|
|
240
|
+
|
|
241
|
+
beforeEach(() => {
|
|
242
|
+
vi.stubEnv("SERVICE_SECRET", TEST_SERVICE_SECRET);
|
|
243
|
+
queryRaw.mockResolvedValue([{ "?column?": 1 }]);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
afterEach(() => {
|
|
247
|
+
vi.unstubAllEnvs();
|
|
248
|
+
vi.clearAllMocks();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// ===========================================================================
|
|
252
|
+
// (a) Redis constructs but every command fails
|
|
253
|
+
// ===========================================================================
|
|
254
|
+
|
|
255
|
+
describe("Redis constructs but every command fails (TypeError: Invalid URL)", () => {
|
|
256
|
+
let redis: RedisDouble;
|
|
257
|
+
let gateway: Gateway;
|
|
258
|
+
|
|
259
|
+
beforeEach(async () => {
|
|
260
|
+
redisConfigured(true);
|
|
261
|
+
redis = brokenRedis();
|
|
262
|
+
getRedis.mockResolvedValue(redis);
|
|
263
|
+
gateway = await buildGateway();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("GET /api/v1/things answers from the route, rate-limited by the in-memory bucket", async () => {
|
|
267
|
+
const res = await getThings(gateway.app);
|
|
268
|
+
|
|
269
|
+
expect(res.status).toBe(200);
|
|
270
|
+
expect(await res.json()).toEqual({ items: [] });
|
|
271
|
+
expect(gateway.handled).toHaveBeenCalledOnce();
|
|
272
|
+
|
|
273
|
+
// The store was tried and failed — this is the fallback, not a skip.
|
|
274
|
+
expect(redis.eval).toHaveBeenCalled();
|
|
275
|
+
expect(res.headers.get("x-ratelimit-limit")).toBe(FREE_LIMIT);
|
|
276
|
+
const remaining = Number(res.headers.get("x-ratelimit-remaining"));
|
|
277
|
+
expect(remaining).toBeGreaterThanOrEqual(0);
|
|
278
|
+
expect(remaining).toBeLessThan(Number(FREE_LIMIT));
|
|
279
|
+
expect(res.headers.get("x-ratelimit-reset")).toMatch(/^\d+$/);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("keeps serving after repeated failures — the fallback is per request, not per process", async () => {
|
|
283
|
+
const first = await getThings(gateway.app);
|
|
284
|
+
const second = await getThings(gateway.app);
|
|
285
|
+
|
|
286
|
+
expect(first.status).toBe(200);
|
|
287
|
+
expect(second.status).toBe(200);
|
|
288
|
+
expect(gateway.handled).toHaveBeenCalledTimes(2);
|
|
289
|
+
expect(Number(second.headers.get("x-ratelimit-remaining"))).toBeLessThan(
|
|
290
|
+
Number(first.headers.get("x-ratelimit-remaining")),
|
|
291
|
+
);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("logs the metering write failure instead of surfacing it", async () => {
|
|
295
|
+
const res = await getThings(gateway.app);
|
|
296
|
+
await flush();
|
|
297
|
+
|
|
298
|
+
expect(res.status).toBe(200);
|
|
299
|
+
expect(redis.incr).toHaveBeenCalledOnce();
|
|
300
|
+
expect(logger.warn).toHaveBeenCalledWith(
|
|
301
|
+
"Usage metering write failed",
|
|
302
|
+
expect.objectContaining({ tenantId: "org_degradation" }),
|
|
303
|
+
);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("GET /api/misc/health reports cache down and stays 200 degraded", async () => {
|
|
307
|
+
const res = await getHealth(gateway.app);
|
|
308
|
+
const body = await res.json();
|
|
309
|
+
|
|
310
|
+
expect(res.status).toBe(200);
|
|
311
|
+
expect(body.status).toBe("degraded");
|
|
312
|
+
expect(body.dependencies.cache.status).toBe("down");
|
|
313
|
+
expect(body.dependencies.database.status).toBe("up");
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it.todo(
|
|
317
|
+
"POST /api/v1/things with an Idempotency-Key needs a decided behaviour while Redis is down — an idempotency store with no fallback surfaces the outage as a 500; fail-closed (503 + Retry-After) or fail-open is a product call, pin it here once made",
|
|
318
|
+
);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// ===========================================================================
|
|
322
|
+
// (b) Redis credentials are missing entirely
|
|
323
|
+
// ===========================================================================
|
|
324
|
+
|
|
325
|
+
describe("Redis credentials are missing entirely", () => {
|
|
326
|
+
let gateway: Gateway;
|
|
327
|
+
|
|
328
|
+
beforeEach(async () => {
|
|
329
|
+
redisConfigured(false);
|
|
330
|
+
// What @nebutra/cache's getRedisConfig() throws when no URL/token is set.
|
|
331
|
+
getRedis.mockRejectedValue(new Error("Redis credentials not configured"));
|
|
332
|
+
gateway = await buildGateway();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("GET /api/v1/things answers from the route, rate-limited by the in-memory bucket", async () => {
|
|
336
|
+
const res = await getThings(gateway.app);
|
|
337
|
+
|
|
338
|
+
expect(res.status).toBe(200);
|
|
339
|
+
expect(await res.json()).toEqual({ items: [] });
|
|
340
|
+
expect(gateway.handled).toHaveBeenCalledOnce();
|
|
341
|
+
expect(res.headers.get("x-ratelimit-limit")).toBe(FREE_LIMIT);
|
|
342
|
+
expect(Number(res.headers.get("x-ratelimit-remaining"))).toBeLessThan(Number(FREE_LIMIT));
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("does not surface the metering no-op as an error", async () => {
|
|
346
|
+
const res = await getThings(gateway.app);
|
|
347
|
+
await flush();
|
|
348
|
+
|
|
349
|
+
expect(res.status).toBe(200);
|
|
350
|
+
// usageMetering resolves the client once and treats "not configured" as
|
|
351
|
+
// a local-dev no-op: nothing to warn about.
|
|
352
|
+
expect(logger.warn).not.toHaveBeenCalledWith("Usage metering write failed", expect.anything());
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("GET /api/misc/health reports cache down and stays 200 degraded", async () => {
|
|
356
|
+
const res = await getHealth(gateway.app);
|
|
357
|
+
const body = await res.json();
|
|
358
|
+
|
|
359
|
+
expect(res.status).toBe(200);
|
|
360
|
+
expect(body.status).toBe("degraded");
|
|
361
|
+
expect(body.dependencies.cache.status).toBe("down");
|
|
362
|
+
expect(body.dependencies.database.status).toBe("up");
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// ===========================================================================
|
|
367
|
+
// (c) Redis is healthy
|
|
368
|
+
// ===========================================================================
|
|
369
|
+
|
|
370
|
+
describe("Redis is healthy", () => {
|
|
371
|
+
let redis: RedisDouble;
|
|
372
|
+
let gateway: Gateway;
|
|
373
|
+
|
|
374
|
+
beforeEach(async () => {
|
|
375
|
+
redisConfigured(true);
|
|
376
|
+
redis = healthyRedis();
|
|
377
|
+
getRedis.mockResolvedValue(redis);
|
|
378
|
+
gateway = await buildGateway();
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("x-ratelimit-remaining decreases across two requests, one EVAL per request", async () => {
|
|
382
|
+
const first = await getThings(gateway.app);
|
|
383
|
+
expect(first.status).toBe(200);
|
|
384
|
+
expect(redis.eval).toHaveBeenCalledTimes(1);
|
|
385
|
+
|
|
386
|
+
const second = await getThings(gateway.app);
|
|
387
|
+
expect(second.status).toBe(200);
|
|
388
|
+
expect(redis.eval).toHaveBeenCalledTimes(2);
|
|
389
|
+
|
|
390
|
+
// Default API weight is 2 tokens; FREE starts at 100.
|
|
391
|
+
expect(first.headers.get("x-ratelimit-limit")).toBe(FREE_LIMIT);
|
|
392
|
+
expect(first.headers.get("x-ratelimit-remaining")).toBe("98");
|
|
393
|
+
expect(second.headers.get("x-ratelimit-remaining")).toBe("96");
|
|
394
|
+
|
|
395
|
+
// The atomic script is the only rate-limit command: no GET+SET fallback ran.
|
|
396
|
+
expect(redis.get).not.toHaveBeenCalled();
|
|
397
|
+
expect(redis.set).not.toHaveBeenCalled();
|
|
398
|
+
expect(gateway.handled).toHaveBeenCalledTimes(2);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("meters each request once under the tenant's billing-period key", async () => {
|
|
402
|
+
await getThings(gateway.app);
|
|
403
|
+
await getThings(gateway.app);
|
|
404
|
+
await flush();
|
|
405
|
+
|
|
406
|
+
expect(redis.incr).toHaveBeenCalledTimes(2);
|
|
407
|
+
expect(redis.incr.mock.calls[0]?.[0]).toMatch(/^usage:org_degradation:\d{4}-\d{2}:api_calls$/);
|
|
408
|
+
expect(logger.warn).not.toHaveBeenCalledWith("Usage metering write failed", expect.anything());
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("GET /api/misc/health is 200 healthy with both dependencies up", async () => {
|
|
412
|
+
const res = await getHealth(gateway.app);
|
|
413
|
+
const body = await res.json();
|
|
414
|
+
|
|
415
|
+
expect(res.status).toBe(200);
|
|
416
|
+
expect(body.status).toBe("healthy");
|
|
417
|
+
expect(body.dependencies.database.status).toBe("up");
|
|
418
|
+
expect(body.dependencies.cache.status).toBe("up");
|
|
419
|
+
expect(redis.ping).toHaveBeenCalledOnce();
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// ===========================================================================
|
|
424
|
+
// (d) The database is down
|
|
425
|
+
// ===========================================================================
|
|
426
|
+
|
|
427
|
+
describe("The database is down", () => {
|
|
428
|
+
let gateway: Gateway;
|
|
429
|
+
|
|
430
|
+
beforeEach(async () => {
|
|
431
|
+
redisConfigured(true);
|
|
432
|
+
getRedis.mockResolvedValue(healthyRedis());
|
|
433
|
+
queryRaw.mockRejectedValue(new Error("connection refused"));
|
|
434
|
+
gateway = await buildGateway();
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("GET /api/misc/health reports database down and stays 200 degraded", async () => {
|
|
438
|
+
const res = await getHealth(gateway.app);
|
|
439
|
+
const body = await res.json();
|
|
440
|
+
|
|
441
|
+
expect(res.status).toBe(200);
|
|
442
|
+
expect(body.status).toBe("degraded");
|
|
443
|
+
expect(body.dependencies.database.status).toBe("down");
|
|
444
|
+
expect(body.dependencies.cache.status).toBe("up");
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("routes that do not touch the database keep answering", async () => {
|
|
448
|
+
const res = await getThings(gateway.app);
|
|
449
|
+
|
|
450
|
+
expect(res.status).toBe(200);
|
|
451
|
+
expect(gateway.handled).toHaveBeenCalledOnce();
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("with Redis also down, health is 503 unhealthy while the route still answers", async () => {
|
|
455
|
+
getRedis.mockResolvedValue(brokenRedis());
|
|
456
|
+
gateway = await buildGateway();
|
|
457
|
+
|
|
458
|
+
const health = await getHealth(gateway.app);
|
|
459
|
+
const body = await health.json();
|
|
460
|
+
expect(health.status).toBe(503);
|
|
461
|
+
expect(body.status).toBe("unhealthy");
|
|
462
|
+
expect(body.dependencies.database.status).toBe("down");
|
|
463
|
+
expect(body.dependencies.cache.status).toBe("down");
|
|
464
|
+
|
|
465
|
+
const things = await getThings(gateway.app);
|
|
466
|
+
expect(things.status).toBe(200);
|
|
467
|
+
expect(things.headers.get("x-ratelimit-limit")).toBe(FREE_LIMIT);
|
|
468
|
+
});
|
|
469
|
+
});
|