Skip to content

Commit c37d7c9

Browse files
Harden signer frontend test coverage
1 parent cc9c3da commit c37d7c9

11 files changed

Lines changed: 600 additions & 108 deletions

File tree

.github/workflows/web-verify.yml

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,46 @@ jobs:
7575

7676
- name: Production build
7777
run: pnpm build
78+
79+
signer-e2e:
80+
name: Signer Playwright
81+
runs-on: ubuntu-latest
82+
timeout-minutes: 15
83+
defaults:
84+
run:
85+
working-directory: web
86+
steps:
87+
- name: Checkout
88+
uses: actions/checkout@v4
89+
90+
- name: Set up pnpm
91+
uses: pnpm/action-setup@v4
92+
with:
93+
package_json_file: web/package.json
94+
95+
- name: Set up Node
96+
uses: actions/setup-node@v4
97+
with:
98+
node-version: 22
99+
cache: pnpm
100+
cache-dependency-path: web/pnpm-lock.yaml
101+
102+
- name: Install
103+
run: pnpm install --frozen-lockfile
104+
105+
- name: Install Chromium
106+
run: pnpm exec playwright install --with-deps chromium
107+
108+
- name: Run signer end-to-end tests
109+
run: pnpm test:e2e:signer
110+
111+
- name: Upload Playwright artifacts
112+
if: failure()
113+
uses: actions/upload-artifact@v4
114+
with:
115+
name: signer-playwright-artifacts
116+
path: |
117+
web/playwright-report/
118+
web/test-results/
119+
if-no-files-found: ignore
120+
retention-days: 7

web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +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",
2021
"clean": "rm -rf .next dist coverage",
2122
"gen:api": "tsx scripts/gen-api.mts",
2223
"gen:api:check": "pnpm gen:api && git diff --quiet src/generated || (echo 'OpenAPI codegen drift — re-run pnpm gen:api' && exit 1)",

web/playwright.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export default defineConfig({
4040
},
4141
],
4242
webServer: {
43-
command: `pnpm dev --port ${port}`,
43+
command: `corepack pnpm dev --port ${port}`,
4444
url: baseURL,
4545
reuseExistingServer: false,
4646
timeout: 120_000,

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

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ describe("api v1 proxy route", () => {
5858
fetchMock.mockResolvedValueOnce(
5959
new Response(JSON.stringify({ certificates: [] }), {
6060
status: 200,
61-
headers: { "content-type": "application/json" },
61+
headers: { "content-type": "application/json", "x-trace-id": "trace-signer-1" },
6262
}),
6363
);
6464

@@ -71,6 +71,32 @@ describe("api v1 proxy route", () => {
7171
const [url, init] = fetchMock.mock.calls[0] ?? [];
7272
expect(String(url)).toBe("https://signer.example.org/api/v1/admin/certificates?limit=20");
7373
expect(new Headers(init?.headers).get("authorization")).toBe("Bearer access-token-abc");
74+
expect(response.headers.get("x-trace-id")).toBe("trace-signer-1");
75+
});
76+
77+
it("forwards signer request bodies and query parameters", async () => {
78+
fetchMock.mockResolvedValueOnce(
79+
new Response(JSON.stringify({ success: true }), {
80+
status: 200,
81+
headers: { "content-type": "application/json" },
82+
}),
83+
);
84+
85+
await POST(
86+
new NextRequest("http://localhost:3000/api/v1/signer/admin/certificates/42/revoke?audit=true", {
87+
method: "POST",
88+
body: JSON.stringify({ reason: "compromised" }),
89+
headers: { "content-type": "application/json", accept: "application/json" },
90+
}),
91+
{ params: Promise.resolve({ path: ["signer", "admin", "certificates", "42", "revoke"] }) },
92+
);
93+
94+
const [url, init] = fetchMock.mock.calls[0] ?? [];
95+
expect(String(url)).toBe(
96+
"https://signer.example.org/api/v1/admin/certificates/42/revoke?audit=true",
97+
);
98+
expect(init?.body).toBe(JSON.stringify({ reason: "compromised" }));
99+
expect(new Headers(init?.headers).get("content-type")).toBe("application/json");
74100
});
75101

76102
it("proxies no-content backend responses without constructing a response body", async () => {
@@ -87,5 +113,8 @@ describe("api v1 proxy route", () => {
87113

88114
expect(response.status).toBe(204);
89115
expect(await response.text()).toBe("");
116+
expect(String(fetchMock.mock.calls[0]?.[0])).toBe(
117+
"https://core.example.org/roles/role-1/privileges",
118+
);
90119
});
91120
});
Lines changed: 140 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,42 @@
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 { render, screen } from "@testing-library/react";
1+
// Licensed to the Apache Software Foundation (ASF) under one or more
2+
// contributor license agreements. See the NOTICE file distributed with
3+
// this work for additional information regarding copyright ownership.
4+
// The ASF licenses this file to You under the Apache License, Version 2.0.
5+
6+
import { fireEvent, render, screen } from "@testing-library/react";
197
import { beforeEach, describe, expect, it, vi } from "vitest";
8+
import { ApiError } from "@/shared/api/client";
209
import type { Certificate } from "../schemas";
2110

22-
// Presentation gating only — backend enforces the real authorization.
11+
const baseCertificate: Certificate = {
12+
tenant_id: "tenant-1",
13+
client_id: "signer-client",
14+
serial_number: 42,
15+
key_id: "key-42",
16+
principal: "someone-else",
17+
user_email: "admin@example.org",
18+
public_key_fingerprint: "SHA256:pk",
19+
ca_fingerprint: "SHA256:ca",
20+
valid_after: 1_700_000_000,
21+
valid_before: 4_102_444_800,
22+
issued_at: 1_700_000_000,
23+
source_ip: "192.0.2.42",
24+
granted_extensions: ["permit-pty", "permit-user-rc"],
25+
force_command: "/usr/bin/id",
26+
revoked: false,
27+
};
28+
2329
const state = vi.hoisted(() => ({
2430
canManage: true,
25-
cert: {
26-
tenant_id: "tenant-1",
27-
client_id: "signer-client",
28-
serial_number: 42,
29-
key_id: "k",
30-
principal: "someone-else",
31-
user_email: "admin@example.org",
32-
public_key_fingerprint: "SHA256:pk",
33-
ca_fingerprint: "SHA256:ca",
34-
valid_after: 1_700_000_000,
35-
valid_before: 4_102_444_800,
36-
issued_at: 1_700_000_000,
37-
revoked: false,
38-
} as Certificate,
31+
query: {} as {
32+
data?: Certificate;
33+
isLoading: boolean;
34+
error: Error | null;
35+
refetch: ReturnType<typeof vi.fn>;
36+
},
37+
mutate: vi.fn(),
38+
isPending: false,
39+
toastSuccess: vi.fn(),
3940
}));
4041

4142
vi.mock("@/shared/casl/AbilityProvider", () => ({
@@ -45,52 +46,139 @@ vi.mock("@/shared/casl/AbilityProvider", () => ({
4546
}),
4647
}));
4748

49+
vi.mock("sonner", () => ({ toast: { success: state.toastSuccess } }));
50+
4851
vi.mock("../queries", () => ({
49-
useCertificate: () => ({ data: state.cert, isLoading: false, error: null, refetch: vi.fn() }),
50-
useRevokeCertificate: () => ({ mutate: vi.fn(), isPending: false }),
52+
useCertificate: () => state.query,
53+
useRevokeCertificate: () => ({ mutate: state.mutate, isPending: state.isPending }),
5154
}));
5255

5356
import { CertificateDetail } from "../components/CertificateDetail";
5457

5558
beforeEach(() => {
5659
state.canManage = true;
57-
state.cert = { ...state.cert, revoked: false };
60+
state.query = {
61+
data: { ...baseCertificate },
62+
isLoading: false,
63+
error: null,
64+
refetch: vi.fn(),
65+
};
66+
state.mutate.mockReset();
67+
state.toastSuccess.mockReset();
68+
state.isPending = false;
5869
});
5970

60-
describe("<CertificateDetail /> revoke gating", () => {
61-
it("shows the Revoke button for an admin with the signer write privilege", () => {
62-
state.canManage = true;
71+
function submitRevoke(reason = "compromised") {
72+
fireEvent.click(screen.getByRole("button", { name: /^Revoke$/ }));
73+
fireEvent.change(screen.getByLabelText(/reason/i), { target: { value: reason } });
74+
fireEvent.click(screen.getByRole("button", { name: /^Confirm revoke$/ }));
75+
}
76+
77+
describe("<CertificateDetail />", () => {
78+
it("renders complete issuance metadata", () => {
79+
render(<CertificateDetail serial="42" />);
80+
for (const value of [
81+
"tenant-1",
82+
"signer-client",
83+
"admin@example.org",
84+
"key-42",
85+
"SHA256:pk",
86+
"SHA256:ca",
87+
"192.0.2.42",
88+
"permit-pty, permit-user-rc",
89+
"/usr/bin/id",
90+
]) {
91+
expect(screen.getByText(value)).toBeInTheDocument();
92+
}
93+
});
94+
95+
it("renders a loading skeleton", () => {
96+
state.query = { ...state.query, data: undefined, isLoading: true };
97+
const { container } = render(<CertificateDetail serial="42" />);
98+
expect(container.querySelector(".animate-pulse")).toBeInTheDocument();
99+
});
100+
101+
it("renders not-found handling for a 404", () => {
102+
state.query = {
103+
...state.query,
104+
data: undefined,
105+
error: new ApiError(404, "/certificate/99", { error: "not_found" }),
106+
};
107+
render(<CertificateDetail serial="99" />);
108+
expect(screen.getByRole("heading", { name: /certificate not found/i })).toBeInTheDocument();
109+
});
110+
111+
it("renders a retryable detail failure", () => {
112+
state.query = { ...state.query, data: undefined, error: new Error("signer unavailable") };
113+
render(<CertificateDetail serial="42" />);
114+
fireEvent.click(screen.getByRole("button", { name: /try again/i }));
115+
expect(state.query.refetch).toHaveBeenCalledOnce();
116+
});
117+
118+
it("shows the Revoke button only for an active writer", () => {
63119
render(<CertificateDetail serial="42" />);
64120
expect(screen.getByRole("button", { name: /^Revoke$/ })).toBeInTheDocument();
65121
});
66122

67-
it("hides the Revoke button for a user without the privilege (ownership is irrelevant)", () => {
123+
it("hides Revoke without write privilege", () => {
68124
state.canManage = false;
69125
render(<CertificateDetail serial="42" />);
70126
expect(screen.queryByRole("button", { name: /^Revoke$/ })).not.toBeInTheDocument();
71127
});
72128

73-
it("hides the Revoke button when the certificate is already revoked", () => {
74-
state.canManage = true;
75-
state.cert = {
76-
...state.cert,
129+
it.each([
130+
["revoked", { revoked: true, revoked_at: 1_700_500_000, revocation_reason: "old" }],
131+
["expired", { valid_before: 1 }],
132+
["not yet valid", { valid_after: 4_102_444_800 }],
133+
])("hides Revoke when the certificate is %s", (_label, overrides) => {
134+
state.query.data = { ...baseCertificate, ...overrides };
135+
render(<CertificateDetail serial="42" />);
136+
expect(screen.queryByRole("button", { name: /^Revoke$/ })).not.toBeInTheDocument();
137+
});
138+
139+
it("renders authoritative revocation metadata", () => {
140+
state.query.data = {
141+
...baseCertificate,
77142
revoked: true,
78143
revoked_at: 1_700_500_000,
79-
revocation_reason: "old",
144+
revocation_reason: "original reason",
145+
revoked_by: "admin-id",
80146
};
81147
render(<CertificateDetail serial="42" />);
82-
expect(screen.queryByRole("button", { name: /^Revoke$/ })).not.toBeInTheDocument();
148+
expect(screen.getByText("original reason")).toBeInTheDocument();
149+
expect(screen.getByText("admin-id")).toBeInTheDocument();
83150
});
84151

85-
it("hides the Revoke button when the certificate is expired", () => {
86-
state.cert = { ...state.cert, valid_before: 1 };
152+
it.each([
153+
[403, "You no longer have permission to revoke certificates."],
154+
[409, "This certificate is no longer active and cannot be revoked."],
155+
])("keeps the dialog open for API %s", (status, expected) => {
156+
state.mutate.mockImplementation((_variables, options) => {
157+
options.onError(new ApiError(status, "/revoke", { error: "failure" }));
158+
});
87159
render(<CertificateDetail serial="42" />);
88-
expect(screen.queryByRole("button", { name: /^Revoke$/ })).not.toBeInTheDocument();
160+
submitRevoke();
161+
expect(screen.getByRole("dialog")).toBeInTheDocument();
162+
expect(screen.getByText(expected)).toBeInTheDocument();
89163
});
90164

91-
it("hides the Revoke button when the certificate is not yet valid", () => {
92-
state.cert = { ...state.cert, valid_after: 4_102_444_800 };
165+
it("keeps the dialog open after a retryable network failure", () => {
166+
state.mutate.mockImplementation((_variables, options) => {
167+
options.onError(new Error("Network request failed"));
168+
});
93169
render(<CertificateDetail serial="42" />);
94-
expect(screen.queryByRole("button", { name: /^Revoke$/ })).not.toBeInTheDocument();
170+
submitRevoke();
171+
expect(screen.getByRole("dialog")).toBeInTheDocument();
172+
expect(screen.getByText("Network request failed")).toBeInTheDocument();
173+
});
174+
175+
it("treats an already-revoked response as informational success", () => {
176+
state.mutate.mockImplementation((_variables, options) => {
177+
options.onSuccess({ already_revoked: true });
178+
});
179+
render(<CertificateDetail serial="42" />);
180+
submitRevoke();
181+
expect(state.toastSuccess).toHaveBeenCalledWith("Certificate was already revoked");
182+
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
95183
});
96184
});

0 commit comments

Comments
 (0)