Skip to content

Commit 78b9587

Browse files
test(web): cover the demo safety rails and gate them in CI (#1003)
* test(web): cover the demo safety rails and gate them in CI apps/web had no tests. Six API routes, both rate limiters, the fail-closed Upstash guard, the mainnet signing assertion and the placeholder-contract check were all unverified — and `apps/web` had no `test` script, so the root `pnpm -r --if-present test` skipped it silently rather than reporting a gap. Every finding in this branch lived in that hole. Adds the cases that matter, chosen for what they protect rather than for coverage percentage: - `acquireStream` / `checkWebhookCooldown` — limits hold, `release()` is idempotent (both the abort listener and the session timer call close() in the SSE routes), IPs stay independent, cooldown expires on schedule. - `checkFireEventRateLimit` — fails CLOSED with 503 when Upstash is unconfigured, including when only one of the two variables is set. This is the guard between an anonymous button and a funded testnet key; an in-memory fallback would be per-instance on serverless and bound nothing. - `assertRestrictedSecretNetwork` — refuses to sign on a mainnet passphrase. The fire-event button is unauthenticated, so a stranger's click must not be able to move real value. - `isDemoEmitterConfigured` — placeholder contract IDs count as unconfigured, so the UI cannot offer a button that can't work. - `contracts/deployed.testnet.json` — asserts the manifest is either fully placeholder or fully populated. A half-wired manifest is what produces a demo that looks live and isn't. - the rate-limit bucket is keyed on the trusted identity, so a rotating forged XFF cannot mint new buckets (companion to the clientIp fix). `server-only` is aliased to a stub for tests: it throws on import outside a React Server Component graph, which would otherwise make every server-side lib untestable. `next build` still enforces the real boundary. Adds a `test-web` job to ci.yml beside typecheck-web and build-web, gated on the same `changes.outputs.web` filter. Verified by running the job's exact command sequence locally. 40 tests across 5 files. * ci: pin the new test-web job to pnpm/action-setup@v6.0.9 Matches every other job after #990. --------- Co-authored-by: Salmatcre8 <118213044+Salmatcre8@users.noreply.github.com>
1 parent e48b76e commit 78b9587

6 files changed

Lines changed: 288 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,29 @@ jobs:
181181
- name: Typecheck apps/web
182182
run: pnpm tsc --noEmit -p apps/web/tsconfig.json
183183

184+
test-web:
185+
needs: changes
186+
if: ${{ needs.changes.outputs.web == 'true' }}
187+
runs-on: ubuntu-latest
188+
permissions:
189+
contents: read
190+
steps:
191+
- uses: actions/checkout@v7
192+
- uses: pnpm/action-setup@v6.0.9
193+
- uses: actions/setup-node@v7
194+
with:
195+
node-version: 20
196+
cache: pnpm
197+
- run: pnpm install --frozen-lockfile
198+
# apps/web imports pulse-core and pulse-webhooks from their built output.
199+
- name: Build workspace dependencies
200+
run: |
201+
pnpm tsc -p packages/abi-registry/tsconfig.json
202+
pnpm tsc -p packages/pulse-core/tsconfig.json
203+
pnpm tsc -p packages/pulse-webhooks/tsconfig.json
204+
- name: Test apps/web
205+
run: pnpm --filter orbital/web run test
206+
184207
build-web:
185208
needs: changes
186209
if: ${{ needs.changes.outputs.web == 'true' }}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
2+
import { readFileSync } from "node:fs";
3+
import { resolve } from "node:path";
4+
import { assertRestrictedSecretNetwork } from "@orbital-stellar/pulse-core";
5+
import { isDemoEmitterConfigured } from "@/lib/fireDemoEvent";
6+
7+
const VARS = ["DEMO_EMITTER_CONTRACT_ID", "DEMO_EMITTER_SECRET"] as const;
8+
const saved: Record<string, string | undefined> = {};
9+
10+
beforeEach(() => {
11+
for (const key of VARS) {
12+
saved[key] = process.env[key];
13+
delete process.env[key];
14+
}
15+
});
16+
17+
afterEach(() => {
18+
for (const key of VARS) {
19+
if (saved[key] === undefined) delete process.env[key];
20+
else process.env[key] = saved[key];
21+
}
22+
});
23+
24+
describe("isDemoEmitterConfigured", () => {
25+
it("reports NOT configured when the contract has not been deployed", () => {
26+
// contracts/deployed.testnet.json ships with placeholder IDs until
27+
// contracts/deploy/deploy_testnet.sh is run. Reporting `configured: true`
28+
// here would leave the UI offering a button that cannot work.
29+
expect(isDemoEmitterConfigured()).toBe(false);
30+
});
31+
32+
it("stays NOT configured when a contract ID is set but no secret is", () => {
33+
process.env.DEMO_EMITTER_CONTRACT_ID =
34+
"CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75";
35+
expect(isDemoEmitterConfigured()).toBe(false);
36+
});
37+
38+
it("rejects the deploy script's placeholder as if it were unset", () => {
39+
process.env.DEMO_EMITTER_CONTRACT_ID = "<POPULATED BY deploy_testnet.sh>";
40+
process.env.DEMO_EMITTER_SECRET = "SDEMO";
41+
expect(isDemoEmitterConfigured()).toBe(false);
42+
});
43+
44+
it("reports configured only with a real contract ID and a secret", () => {
45+
process.env.DEMO_EMITTER_CONTRACT_ID =
46+
"CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75";
47+
process.env.DEMO_EMITTER_SECRET = "SDEMOSECRET";
48+
expect(isDemoEmitterConfigured()).toBe(true);
49+
});
50+
});
51+
52+
describe("contracts/deployed.testnet.json", () => {
53+
it("is either fully placeholder or fully populated, never half-wired", () => {
54+
// A manifest with a real registry ID but a placeholder demoEmitter (or the
55+
// reverse) is the state that produces a half-live demo.
56+
const manifest = JSON.parse(
57+
readFileSync(
58+
resolve(process.cwd(), "..", "..", "contracts", "deployed.testnet.json"),
59+
"utf-8",
60+
),
61+
) as { contracts: Record<string, { contractId: string }> };
62+
63+
const ids = Object.values(manifest.contracts).map((c) => c.contractId);
64+
const placeholders = ids.filter((id) => id.startsWith("<") || id.includes("POPULATED BY"));
65+
expect(placeholders.length === 0 || placeholders.length === ids.length).toBe(true);
66+
});
67+
});
68+
69+
describe("assertRestrictedSecretNetwork", () => {
70+
it("permits the testnet passphrase", () => {
71+
expect(() =>
72+
assertRestrictedSecretNetwork({
73+
secretName: "DEMO_EMITTER_SECRET",
74+
networkPassphrase: "Test SDF Network ; September 2015",
75+
context: "demo",
76+
}),
77+
).not.toThrow();
78+
});
79+
80+
it("refuses to sign when the demo path is pointed at mainnet", () => {
81+
// The fire-event button is anonymous and unauthenticated. If this
82+
// deployment is ever configured for mainnet, refusing is the only
83+
// acceptable behaviour - a stranger's click must not move real value.
84+
expect(() =>
85+
assertRestrictedSecretNetwork({
86+
secretName: "DEMO_EMITTER_SECRET",
87+
networkPassphrase: "Public Global Stellar Network ; September 2015",
88+
context: "demo",
89+
}),
90+
).toThrow();
91+
});
92+
});
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
2+
import {
3+
checkFireEventRateLimit,
4+
__resetFireEventRateLimitForTests,
5+
} from "@/lib/fireEventRateLimit";
6+
7+
const UPSTASH_VARS = ["UPSTASH_REDIS_REST_URL", "UPSTASH_REDIS_REST_TOKEN"] as const;
8+
9+
describe("checkFireEventRateLimit", () => {
10+
const saved: Record<string, string | undefined> = {};
11+
12+
beforeEach(() => {
13+
for (const key of UPSTASH_VARS) {
14+
saved[key] = process.env[key];
15+
delete process.env[key];
16+
}
17+
__resetFireEventRateLimitForTests();
18+
});
19+
20+
afterEach(() => {
21+
for (const key of UPSTASH_VARS) {
22+
if (saved[key] === undefined) delete process.env[key];
23+
else process.env[key] = saved[key];
24+
}
25+
__resetFireEventRateLimitForTests();
26+
});
27+
28+
it("fails CLOSED with 503 when Upstash is not configured", async () => {
29+
// POST /api/demo/fire-event signs a real testnet transaction with a funded
30+
// key. A misconfigured deploy must refuse to fire rather than run without
31+
// a shared limiter - an in-memory fallback would be per-instance on
32+
// serverless and would not bound anything.
33+
const result = await checkFireEventRateLimit("203.0.113.9");
34+
35+
expect(result.ok).toBe(false);
36+
if (result.ok) return;
37+
expect(result.status).toBe(503);
38+
expect(result.body.error).toBe("rate_limiter_not_configured");
39+
});
40+
41+
it("fails closed when only one of the two variables is set", async () => {
42+
process.env.UPSTASH_REDIS_REST_URL = "https://example.upstash.io";
43+
__resetFireEventRateLimitForTests();
44+
45+
const result = await checkFireEventRateLimit("203.0.113.9");
46+
expect(result.ok).toBe(false);
47+
if (!result.ok) expect(result.status).toBe(503);
48+
});
49+
50+
it("stays closed for every caller, not just the first", async () => {
51+
for (const ip of ["203.0.113.1", "203.0.113.2", "203.0.113.3"]) {
52+
const result = await checkFireEventRateLimit(ip);
53+
expect(result.ok).toBe(false);
54+
}
55+
});
56+
});

apps/web/test/rateLimits.test.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2+
import { DEMO_LIMITS, acquireStream, checkWebhookCooldown, clientIp } from "@/lib/demo-limits";
3+
4+
function ipHeaders(ip: string): Request {
5+
return new Request("https://orbital.example/api/events/G", {
6+
headers: { "x-vercel-forwarded-for": ip },
7+
});
8+
}
9+
10+
/** Each test gets a distinct IP - the limiter state is module-level. */
11+
let counter = 0;
12+
const freshIp = () => `198.51.100.${++counter % 250}${Math.floor(counter / 250)}`;
13+
14+
describe("acquireStream", () => {
15+
it("permits the configured number of concurrent streams per IP", () => {
16+
const ip = freshIp();
17+
const first = acquireStream(ip);
18+
expect(first.ok).toBe(true);
19+
20+
const second = acquireStream(ip);
21+
expect(second.ok).toBe(false);
22+
if (!second.ok) {
23+
expect(second.body.reason).toBe("per_ip_stream_limit");
24+
expect(second.body.upgradeUrl).toBe(DEMO_LIMITS.upgradeUrl);
25+
}
26+
});
27+
28+
it("frees the slot on release", () => {
29+
const ip = freshIp();
30+
const slot = acquireStream(ip);
31+
expect(slot.ok).toBe(true);
32+
if (!slot.ok) return;
33+
34+
slot.release();
35+
expect(acquireStream(ip).ok).toBe(true);
36+
});
37+
38+
it("is idempotent on repeated release, so a double teardown cannot mint slots", () => {
39+
const ip = freshIp();
40+
const slot = acquireStream(ip);
41+
if (!slot.ok) throw new Error("expected slot");
42+
43+
// Both the abort listener and the session timer call close() in the SSE
44+
// routes; the guard there is belt, this is braces.
45+
slot.release();
46+
slot.release();
47+
slot.release();
48+
49+
const a = acquireStream(ip);
50+
expect(a.ok).toBe(true);
51+
expect(acquireStream(ip).ok).toBe(false);
52+
});
53+
54+
it("tracks IPs independently", () => {
55+
const a = freshIp();
56+
const b = freshIp();
57+
expect(acquireStream(a).ok).toBe(true);
58+
expect(acquireStream(b).ok).toBe(true);
59+
});
60+
});
61+
62+
describe("checkWebhookCooldown", () => {
63+
afterEach(() => vi.useRealTimers());
64+
65+
it("allows the first call and rejects an immediate second", () => {
66+
const ip = freshIp();
67+
expect(checkWebhookCooldown(ip).ok).toBe(true);
68+
69+
const second = checkWebhookCooldown(ip);
70+
expect(second.ok).toBe(false);
71+
if (!second.ok) {
72+
expect(second.body.reason).toBe("rate_limit");
73+
expect(second.body.retryAfterMs).toBeGreaterThan(0);
74+
expect(second.body.retryAfterMs).toBeLessThanOrEqual(DEMO_LIMITS.webhookCooldownMs);
75+
}
76+
});
77+
78+
it("allows again once the cooldown has elapsed", () => {
79+
vi.useFakeTimers();
80+
const ip = freshIp();
81+
expect(checkWebhookCooldown(ip).ok).toBe(true);
82+
expect(checkWebhookCooldown(ip).ok).toBe(false);
83+
84+
vi.advanceTimersByTime(DEMO_LIMITS.webhookCooldownMs + 1);
85+
expect(checkWebhookCooldown(ip).ok).toBe(true);
86+
});
87+
});
88+
89+
describe("limits are keyed on the trusted identity", () => {
90+
it("a rotating forged XFF cannot escape the webhook cooldown", () => {
91+
const real = freshIp();
92+
// Every request comes from the same real peer; only the forged prefix moves.
93+
const requests = ["1.1.1.1", "2.2.2.2", "3.3.3.3"].map(
94+
(forged) =>
95+
new Request("https://orbital.example/api/webhook-sample", {
96+
headers: { "x-vercel-forwarded-for": real, "x-forwarded-for": forged },
97+
}),
98+
);
99+
100+
const results = requests.map((req) => checkWebhookCooldown(clientIp(req)).ok);
101+
expect(results).toEqual([true, false, false]);
102+
});
103+
104+
it("resolves the same bucket for the same peer regardless of forged headers", () => {
105+
const real = freshIp();
106+
expect(clientIp(ipHeaders(real))).toBe(real);
107+
});
108+
});

apps/web/test/stubs/server-only.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// The real `server-only` package throws on import outside a React Server
2+
// Component graph, which would make every server-side module untestable.
3+
// Vitest aliases the package to this no-op (see vitest.config.ts). The guard
4+
// it provides is a build-time one that `next build` still enforces.
5+
export {};

apps/web/vitest.config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ export default defineConfig({
77
resolve: {
88
alias: {
99
"@": fileURLToPath(new URL("./", import.meta.url)),
10+
// `server-only` throws on import outside a React Server Component graph,
11+
// which would make every server-side lib untestable. `next build` still
12+
// enforces the real boundary.
13+
"server-only": fileURLToPath(new URL("./test/stubs/server-only.ts", import.meta.url)),
1014
},
1115
},
1216
test: {

0 commit comments

Comments
 (0)