Skip to content

Commit 6c6514b

Browse files
Remove unintentionally committed backend files and fix signer Playwright tests
1 parent bb5afb4 commit 6c6514b

6 files changed

Lines changed: 433 additions & 42 deletions

File tree

web/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"test": "vitest run --passWithNoTests",
1818
"test:watch": "vitest",
1919
"test:e2e": "playwright test",
20-
"test:e2e:signer": "playwright test tests/signer-certificates.e2e.ts --project=chromium --workers=1",
20+
"test:e2e:signer": "playwright test --config=playwright.signer.config.ts --project=chromium --workers=1",
2121
"clean": "rm -rf .next dist coverage",
2222
"gen:api": "tsx scripts/gen-api.mts",
2323
"gen:api:check": "pnpm gen:api && git diff --quiet src/generated || (echo 'OpenAPI codegen drift — re-run pnpm gen:api' && exit 1)",

web/playwright.signer.config.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
import { defineConfig, devices } from "@playwright/test";
19+
20+
const port = Number(process.env.PORT ?? 3217);
21+
const baseURL = `http://localhost:${port}`;
22+
const sharedSecret = "test-secret-for-playwright-cookie-fixture-only-32chars";
23+
24+
export default defineConfig({
25+
testDir: "./tests",
26+
testMatch: "signer-certificates.e2e.ts",
27+
fullyParallel: false,
28+
forbidOnly: !!process.env.CI,
29+
retries: process.env.CI ? 2 : 0,
30+
workers: 1,
31+
reporter: "line",
32+
use: {
33+
baseURL,
34+
trace: "retain-on-failure",
35+
// The signer suite installs deterministic Playwright routes. Blocking
36+
// service workers ensures browser MSW cannot consume or bypass a request.
37+
serviceWorkers: "block",
38+
},
39+
projects: [
40+
{
41+
name: "chromium",
42+
use: { ...devices["Desktop Chrome"] },
43+
},
44+
],
45+
webServer: {
46+
command: `corepack pnpm dev --port ${port}`,
47+
url: baseURL,
48+
reuseExistingServer: !process.env.CI,
49+
timeout: 120_000,
50+
env: {
51+
PORT: String(port),
52+
NEXT_PUBLIC_PORTAL_USE_MSW: "false",
53+
CUSTOS_E2E_FAIL_ON_SIGNER_PROXY_REQUEST: "true",
54+
NEXTAUTH_SECRET: sharedSecret,
55+
NEXTAUTH_URL: baseURL,
56+
OIDC_ISSUER_URL: "http://localhost:8081/realms/custos",
57+
OIDC_CLIENT_ID: "playwright-cookie-fixture",
58+
OIDC_CLIENT_SECRET: "playwright-cookie-fixture",
59+
},
60+
},
61+
});
62+
63+
process.env.NEXTAUTH_SECRET = sharedSecret;

web/src/app/api/v1/[...path]/__tests__/route.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { NextRequest } from "next/server";
1919
import { afterEach, describe, expect, it, vi } from "vitest";
2020

2121
import { responseBodyForStatus } from "../proxy-response";
22+
import { pickBackendBearer } from "@/shared/auth/session";
2223

2324
vi.mock("@/lib/env", () => ({
2425
serverEnv: {
@@ -41,6 +42,9 @@ const ctx = { params: Promise.resolve({ path: ["roles", "role-1", "privileges"]
4142

4243
afterEach(() => {
4344
fetchMock.mockReset();
45+
vi.mocked(pickBackendBearer).mockReturnValue("access-token-abc");
46+
vi.unstubAllEnvs();
47+
vi.restoreAllMocks();
4448
});
4549

4650
describe("responseBodyForStatus", () => {
@@ -117,4 +121,65 @@ describe("api v1 proxy route", () => {
117121
"https://core.example.org/roles/role-1/privileges",
118122
);
119123
});
124+
125+
it("rejects unauthenticated requests before contacting an upstream", async () => {
126+
vi.mocked(pickBackendBearer).mockReturnValueOnce(null);
127+
128+
const response = await GET(
129+
new NextRequest("http://localhost:3000/api/v1/signer/admin/certificates"),
130+
{ params: Promise.resolve({ path: ["signer", "admin", "certificates"] }) },
131+
);
132+
133+
expect(response.status).toBe(401);
134+
expect(await response.json()).toEqual({
135+
code: "missing_bearer",
136+
message: "Not authenticated",
137+
});
138+
expect(fetchMock).not.toHaveBeenCalled();
139+
});
140+
141+
it("returns a sanitized 503 when the selected upstream is unavailable", async () => {
142+
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
143+
fetchMock.mockRejectedValueOnce(new TypeError("fetch failed for secret upstream URL"));
144+
145+
const response = await GET(
146+
new NextRequest("http://localhost:3000/api/v1/signer/admin/certificates?limit=20"),
147+
{ params: Promise.resolve({ path: ["signer", "admin", "certificates"] }) },
148+
);
149+
150+
expect(response.status).toBe(503);
151+
expect(await response.json()).toEqual({
152+
code: "upstream_unavailable",
153+
message: "Backend service is unavailable",
154+
});
155+
expect(consoleError).toHaveBeenCalledWith("API proxy upstream unavailable", {
156+
service: "signer",
157+
method: "GET",
158+
path: "/api/v1/admin/certificates",
159+
});
160+
expect(JSON.stringify(consoleError.mock.calls)).not.toContain("access-token-abc");
161+
expect(JSON.stringify(consoleError.mock.calls)).not.toContain("signer.example.org");
162+
expect(JSON.stringify(consoleError.mock.calls)).not.toContain("secret upstream URL");
163+
});
164+
165+
it("fails explicitly if a hermetic signer E2E request reaches the proxy", async () => {
166+
vi.stubEnv("CUSTOS_E2E_FAIL_ON_SIGNER_PROXY_REQUEST", "true");
167+
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
168+
169+
const response = await GET(
170+
new NextRequest("http://localhost:3000/api/v1/signer/admin/certificates"),
171+
{ params: Promise.resolve({ path: ["signer", "admin", "certificates"] }) },
172+
);
173+
174+
expect(response.status).toBe(500);
175+
expect(await response.json()).toEqual({
176+
code: "unexpected_signer_proxy_request",
177+
message: "Signer E2E request escaped its Playwright route",
178+
});
179+
expect(fetchMock).not.toHaveBeenCalled();
180+
expect(consoleError).toHaveBeenCalledWith(
181+
"Unexpected signer proxy request during hermetic E2E",
182+
{ method: "GET", path: "/api/v1/signer/admin/certificates" },
183+
);
184+
});
120185
});

web/src/app/api/v1/[...path]/route.ts

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,19 @@ type Context = { params: Promise<{ path: string[] }> };
2727
async function proxy(request: NextRequest, ctx: Context) {
2828
const { path } = await ctx.params;
2929
const isSigner = path[0] === "signer";
30+
if (isSigner && process.env.CUSTOS_E2E_FAIL_ON_SIGNER_PROXY_REQUEST === "true") {
31+
console.error("Unexpected signer proxy request during hermetic E2E", {
32+
method: request.method,
33+
path: request.nextUrl.pathname,
34+
});
35+
return NextResponse.json(
36+
{
37+
code: "unexpected_signer_proxy_request",
38+
message: "Signer E2E request escaped its Playwright route",
39+
},
40+
{ status: 500 },
41+
);
42+
}
3043
const upstreamBase = isSigner
3144
? serverEnv.CUSTOS_SIGNER_API_BASE_URL
3245
: serverEnv.CUSTOS_CORE_API_BASE_URL;
@@ -53,12 +66,25 @@ async function proxy(request: NextRequest, ctx: Context) {
5366
const method = request.method;
5467
const body = method === "GET" || method === "HEAD" ? undefined : await request.text();
5568

56-
const upstream = await fetch(upstreamUrl, {
57-
method,
58-
headers,
59-
body,
60-
cache: "no-store",
61-
});
69+
let upstream: Response;
70+
try {
71+
upstream = await fetch(upstreamUrl, {
72+
method,
73+
headers,
74+
body,
75+
cache: "no-store",
76+
});
77+
} catch {
78+
console.error("API proxy upstream unavailable", {
79+
service: isSigner ? "signer" : "core",
80+
method,
81+
path: upstreamPath,
82+
});
83+
return NextResponse.json(
84+
{ code: "upstream_unavailable", message: "Backend service is unavailable" },
85+
{ status: 503 },
86+
);
87+
}
6288

6389
const responseHeaders = new Headers();
6490
const upstreamType = upstream.headers.get("content-type");

0 commit comments

Comments
 (0)