Skip to content

Commit 50bb9bb

Browse files
committed
test(e2e): add Phase 1 REST-to-Connect coverage skeletons (UX-1208, UX-1198)
Adds infrastructure for the 8 CRITICAL+HIGH e2e gaps identified in the Phase 1 REST-to-Connect RPC migration QA coverage review. All specs are scaffolds with TODO bodies — assertions filled in once adversarial review of PR #2382 closes. Helper: - tests/shared/connect-mock.ts — mockConnectError / mockConnectNetworkFailure / captureConnectRequests / rpcUrl. Works against Connect-JSON transport (no binary protobuf), which src/config.ts + src/federation/console-app.tsx already use by default. Specs (CRITICAL): - users-authorization.spec.ts (enterprise) — view-only role can list users/ACLs/ quotas but cannot create/delete. Gated behind VIEW_ONLY_FIXTURE_READY until the view-only auth setup lands. - user-error-handling.spec.ts — CreateUser network failure + ALREADY_EXISTS. - user-delete-error.spec.ts — DeleteUser FAILED_PRECONDITION. - acl-create-error.spec.ts — CreateACL INVALID_ARGUMENT + timeout. Specs (HIGH): - acl-delete-multi-match.spec.ts — multi-host delete confirm count. - quota-error.spec.ts — ListQuotas INTERNAL/PERMISSION_DENIED vs silent empty. - users-deeplink.spec.ts — cold-cache details route rendering. - acl-principal-special-chars.spec.ts — principal URL encoding round-trip. No assertions yet; bun run type:check and lint:check pass.
1 parent d892de3 commit 50bb9bb

9 files changed

Lines changed: 431 additions & 0 deletions

File tree

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import type { Page, Route } from '@playwright/test';
2+
3+
/**
4+
* Connect protocol error codes per https://connectrpc.com/docs/protocol#error-codes.
5+
* These map 1:1 to gRPC status codes and are what @connectrpc/connect-web emits over JSON.
6+
*/
7+
export type ConnectErrorCode =
8+
| 'canceled'
9+
| 'unknown'
10+
| 'invalid_argument'
11+
| 'deadline_exceeded'
12+
| 'not_found'
13+
| 'already_exists'
14+
| 'permission_denied'
15+
| 'resource_exhausted'
16+
| 'failed_precondition'
17+
| 'aborted'
18+
| 'out_of_range'
19+
| 'unimplemented'
20+
| 'internal'
21+
| 'unavailable'
22+
| 'data_loss'
23+
| 'unauthenticated';
24+
25+
export type MockConnectErrorArgs = {
26+
page: Page;
27+
urlGlob: string;
28+
code: ConnectErrorCode;
29+
message?: string;
30+
/** Forwarded to page.route — use `times: 1` to only mock the first call. */
31+
times?: number;
32+
};
33+
34+
export type MockConnectNetworkFailureArgs = {
35+
page: Page;
36+
urlGlob: string;
37+
reason?: 'failed' | 'timedout' | 'connectionrefused';
38+
};
39+
40+
/**
41+
* Maps a Connect error code to the HTTP status Connect transports use for JSON responses.
42+
*/
43+
function connectCodeToHttpStatus(code: ConnectErrorCode): number {
44+
switch (code) {
45+
case 'invalid_argument':
46+
case 'out_of_range':
47+
return 400;
48+
case 'unauthenticated':
49+
return 401;
50+
case 'permission_denied':
51+
return 403;
52+
case 'not_found':
53+
return 404;
54+
case 'already_exists':
55+
case 'aborted':
56+
return 409;
57+
case 'failed_precondition':
58+
return 412;
59+
case 'resource_exhausted':
60+
return 429;
61+
case 'canceled':
62+
return 499;
63+
case 'deadline_exceeded':
64+
return 504;
65+
case 'unavailable':
66+
return 503;
67+
case 'unimplemented':
68+
return 501;
69+
default:
70+
return 500;
71+
}
72+
}
73+
74+
/**
75+
* Fulfills all matching requests with a Connect-JSON error envelope. Since createConnectTransport
76+
* in src/config.ts and src/federation/console-app.tsx does not opt into useBinaryFormat, requests
77+
* use `application/json` and responses just need `{ code, message }` plus the correct HTTP status.
78+
*
79+
* Note on react-query retries: page.route persists for all matching calls, so retries see the same
80+
* error. If a test needs to simulate recover-on-retry, use `times: 1` or swap the route mid-test.
81+
*/
82+
export async function mockConnectError(args: MockConnectErrorArgs): Promise<void> {
83+
const { page, urlGlob, code, message = `mocked ${code}`, times } = args;
84+
await page.route(
85+
urlGlob,
86+
(route) =>
87+
route.fulfill({
88+
status: connectCodeToHttpStatus(code),
89+
contentType: 'application/json',
90+
body: JSON.stringify({ code, message }),
91+
}),
92+
times === undefined ? undefined : { times }
93+
);
94+
}
95+
96+
/**
97+
* Aborts all matching requests with a network-level failure. Use for simulating offline/timeout
98+
* behavior where no response envelope is sent at all.
99+
*/
100+
export async function mockConnectNetworkFailure(args: MockConnectNetworkFailureArgs): Promise<void> {
101+
const { page, urlGlob, reason = 'failed' } = args;
102+
await page.route(urlGlob, (route) => route.abort(reason));
103+
}
104+
105+
/**
106+
* Returns the URL glob for a Connect RPC. Use instead of hand-writing paths so tests stay
107+
* readable and the fully-qualified service name is centralized.
108+
*
109+
* @example rpcUrl('redpanda.api.dataplane.v1.UserService', 'CreateUser')
110+
*/
111+
export function rpcUrl(fullyQualifiedService: string, method: string): string {
112+
return `**/${fullyQualifiedService}/${method}`;
113+
}
114+
115+
/**
116+
* Convenience wrapper around page.route that records request bodies so assertions can verify
117+
* that the expected RPC was invoked with the expected input.
118+
*/
119+
export async function captureConnectRequests(
120+
page: Page,
121+
urlGlob: string
122+
): Promise<{ requests: Array<{ url: string; postData: string | null }>; stop: () => Promise<void> }> {
123+
const requests: Array<{ url: string; postData: string | null }> = [];
124+
const handler = async (route: Route) => {
125+
requests.push({ url: route.request().url(), postData: route.request().postData() });
126+
await route.continue();
127+
};
128+
await page.route(urlGlob, handler);
129+
return {
130+
requests,
131+
stop: () => page.unroute(urlGlob, handler),
132+
};
133+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* spec: UX-1208 — Phase 1 e2e test coverage (CRITICAL + HIGH)
3+
* parent epic: UX-1198 — REST-to-Connect RPC migration
4+
*
5+
* Guards the Casbin→PERMISSION_VIEW swap for users/ACLs/quotas: view-only roles MUST be able
6+
* to list (all three are PERMISSION_VIEW in the dataplane proto) but MUST NOT be able to
7+
* create/update/delete (those are PERMISSION_ADMIN).
8+
*
9+
* Blocked on: new view-only auth fixture (playwright/.auth/view-only.json).
10+
* See global-setup.mjs + a new auth-view-only.setup.ts to seed the role + login.
11+
*/
12+
13+
import { expect, test } from '@playwright/test';
14+
15+
// TODO(UX-1208): once auth fixture lands, replace the storageState below with the
16+
// real path and remove this describe-level `test.skip`.
17+
const VIEW_ONLY_STORAGE_STATE = 'playwright/.auth/view-only.json';
18+
const VIEW_ONLY_FIXTURE_READY = false;
19+
20+
test.describe('Authorization - Users page (view-only role)', () => {
21+
test.skip(!VIEW_ONLY_FIXTURE_READY, 'view-only auth fixture pending — see UX-1208');
22+
test.use({ storageState: VIEW_ONLY_STORAGE_STATE });
23+
24+
test.beforeEach(async ({ page }) => {
25+
await page.goto('/security/users', { waitUntil: 'domcontentloaded' });
26+
});
27+
28+
test('users list renders for view-only role', async () => {
29+
// TODO: assert /security/users loads without a 403 page (page is reachable)
30+
// TODO: assert users table is visible and contains at least the seed user
31+
});
32+
33+
test('create user button is hidden or disabled for view-only role', async () => {
34+
// TODO: assert getByTestId('create-user-button') is not visible OR is disabled
35+
// TODO: direct navigation to /security/users/create should redirect or show permission error
36+
});
37+
38+
test('delete user button is not available on details page for view-only role', async () => {
39+
// TODO: navigate to a user details page
40+
// TODO: assert the 'Delete user' button is hidden or disabled
41+
});
42+
});
43+
44+
test.describe('Authorization - ACLs page (view-only role)', () => {
45+
test.skip(!VIEW_ONLY_FIXTURE_READY, 'view-only auth fixture pending — see UX-1208');
46+
test.use({ storageState: VIEW_ONLY_STORAGE_STATE });
47+
48+
test('ACLs list is visible but Create ACL is blocked', async ({ page }) => {
49+
await page.goto('/security/acls', { waitUntil: 'domcontentloaded' });
50+
// TODO: assert list renders
51+
// TODO: assert create button is disabled or hidden
52+
// TODO: direct navigation to /security/acls/create should show permission error or redirect
53+
expect(page.url()).toContain('/security/acls');
54+
});
55+
56+
test('DeleteACLs dropdown items are hidden in permissions list for view-only', async () => {
57+
// TODO: goto /security/permissions-list
58+
// TODO: open the row dropdown and assert delete menuitems are absent
59+
});
60+
});
61+
62+
test.describe('Authorization - Quotas page (view-only role)', () => {
63+
test.skip(!VIEW_ONLY_FIXTURE_READY, 'view-only auth fixture pending — see UX-1208');
64+
test.use({ storageState: VIEW_ONLY_STORAGE_STATE });
65+
66+
test('quotas table loads for view-only role', async () => {
67+
// TODO: goto /security/quotas, assert column headers visible
68+
// TODO: assert any existing quota rows render without permission errors
69+
});
70+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* spec: UX-1208 — Phase 1 e2e test coverage (CRITICAL + HIGH)
3+
* parent epic: UX-1198 — REST-to-Connect RPC migration
4+
*
5+
* Previously REST /api/acls returned REST error bodies. Now ACLService.CreateACL uses
6+
* Connect with InvalidArgument + structured details. Guards field-level error surfacing
7+
* through formatToastErrorMessageGRPC.
8+
*/
9+
10+
import { test } from '@playwright/test';
11+
12+
import { mockConnectError, mockConnectNetworkFailure, rpcUrl } from '../../shared/connect-mock';
13+
14+
const ACL_SERVICE = 'redpanda.api.dataplane.v1.ACLService';
15+
16+
test.describe('ACL creation - Connect RPC error handling', () => {
17+
test('CreateACL INVALID_ARGUMENT surfaces a field-level error', async ({ page }) => {
18+
await mockConnectError({
19+
page,
20+
urlGlob: rpcUrl(ACL_SERVICE, 'CreateACL'),
21+
code: 'invalid_argument',
22+
message: 'principal cannot be empty',
23+
});
24+
// TODO: use AclPage to build a minimal valid rule (Cluster + DESCRIBE allow), submit
25+
// TODO: assert page stays on /security/acls/create (no navigation to detail)
26+
// TODO: assert error toast visible with mocked message OR inline field error
27+
});
28+
29+
test('CreateACL network timeout keeps user on the form', async ({ page }) => {
30+
await mockConnectNetworkFailure({
31+
page,
32+
urlGlob: rpcUrl(ACL_SERVICE, 'CreateACL'),
33+
reason: 'timedout',
34+
});
35+
// TODO: fill + submit form
36+
// TODO: assert URL still contains /security/acls/create
37+
// TODO: assert toast shown
38+
});
39+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/**
2+
* spec: UX-1208 — Phase 1 e2e test coverage (CRITICAL + HIGH)
3+
* parent epic: UX-1198 — REST-to-Connect RPC migration
4+
*
5+
* The permissions-list "Delete (ACLs only)" / "Delete (User and ACLs)" flows call
6+
* ACLService.DeleteACLs with a filter that may match multiple rows (e.g. a principal
7+
* with ACLs across multiple hosts). Guards the matched-count UX and ensures all
8+
* matching rows are actually removed.
9+
*/
10+
11+
import { test } from '@playwright/test';
12+
13+
test.describe
14+
.serial('ACL multi-match delete', () => {
15+
test('Delete (ACLs only) with multiple hosts deletes all matching rows', async () => {
16+
// TODO: create two ACLs for the same principal with different hosts (reuse multi-host
17+
// pattern from acl.spec.ts — AclPage.setHost('*') then repeat with '1.1.1.1')
18+
// TODO: navigate to /security/permissions-list, filter by principal
19+
// TODO: open row dropdown → 'Delete (ACLs only)'
20+
// TODO: assert confirmation dialog shows the matched count (expected: 2)
21+
// TODO: confirm deletion; reload list and assert both ACLs are gone
22+
});
23+
24+
test('Delete ACLs for a principal with zero ACLs shows a zero-match UX', async () => {
25+
// TODO: create a SCRAM user with no ACLs
26+
// TODO: open row dropdown → 'Delete (ACLs only)'
27+
// TODO: assert appropriate empty/disabled state (exact behavior TBD by product)
28+
});
29+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* spec: UX-1208 — Phase 1 e2e test coverage (CRITICAL + HIGH)
3+
* parent epic: UX-1198 — REST-to-Connect RPC migration
4+
*
5+
* Connect protobuf string fields accept a wider range of characters than the old REST
6+
* path. Principal names like "User:foo" must round-trip correctly through URL encoding
7+
* (the detail page uses the principal as a URL segment).
8+
*/
9+
10+
import { test } from '@playwright/test';
11+
12+
test.describe('ACL principal URL encoding', () => {
13+
test('principal containing ":" round-trips through create → list → detail', async () => {
14+
// TODO: create ACL with principal 'User:test-colon-<ts>'
15+
// TODO: submit; assert detail page URL contains %3A (encoded ':') correctly
16+
// TODO: navigate back to list; assert row is visible
17+
// TODO: click the row; assert detail renders with the full principal string
18+
});
19+
20+
// biome-ignore lint/suspicious/noSkippedTests: skipped pending backend validation-policy decision (see UX-1208)
21+
test.skip('principal containing a space is rejected with a clear validation error', async () => {
22+
// TODO: attempt create with 'user with space'
23+
// TODO: assert an inline validation error surfaces OR a Connect InvalidArgument is shown to the user
24+
});
25+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* spec: UX-1208 — Phase 1 e2e test coverage (CRITICAL + HIGH)
3+
* parent epic: UX-1198 — REST-to-Connect RPC migration
4+
*
5+
* Exercises the error path in UserService.DeleteUser. The REST endpoint had a slightly
6+
* different error envelope; this guards the migrated toast + UI state.
7+
*/
8+
9+
import { test } from '@playwright/test';
10+
11+
import { mockConnectError, rpcUrl } from '../../shared/connect-mock';
12+
import { SecurityPage } from '../utils/security-page';
13+
14+
const USER_SERVICE = 'redpanda.api.dataplane.v1.UserService';
15+
16+
test.describe('User deletion - error paths', () => {
17+
test('DeleteUser FAILED_PRECONDITION keeps the user and shows error toast', async ({ page }) => {
18+
const securityPage = new SecurityPage(page);
19+
const username = `test-delete-error-${Date.now()}`;
20+
21+
// Seed a real user first so we have a row to attempt deleting.
22+
await securityPage.createUser(username);
23+
24+
await mockConnectError({
25+
page,
26+
urlGlob: rpcUrl(USER_SERVICE, 'DeleteUser'),
27+
code: 'failed_precondition',
28+
message: 'user has dependent ACLs',
29+
});
30+
31+
// TODO: navigate to user details and click 'Delete user'
32+
// TODO: fill confirmation and submit; wait for mocked 412
33+
// TODO: assert toast visible with error content
34+
// TODO: reload users list and assert the user still appears (deletion did NOT happen client-side)
35+
});
36+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* spec: UX-1208 — Phase 1 e2e test coverage (CRITICAL + HIGH)
3+
* parent epic: UX-1198 — REST-to-Connect RPC migration
4+
*
5+
* Exercises the Connect error-mapping path added when UserService.CreateUser replaced
6+
* REST /api/users. Without these, a regression in formatToastErrorMessageGRPC or the
7+
* ConnectError detail extraction would go unnoticed.
8+
*/
9+
10+
import { test } from '@playwright/test';
11+
12+
import { mockConnectError, mockConnectNetworkFailure, rpcUrl } from '../../shared/connect-mock';
13+
14+
const USER_SERVICE = 'redpanda.api.dataplane.v1.UserService';
15+
16+
test.describe('User creation - Connect RPC error handling', () => {
17+
test('network failure on CreateUser shows error toast and preserves form state', async ({ page }) => {
18+
await mockConnectNetworkFailure({ page, urlGlob: rpcUrl(USER_SERVICE, 'CreateUser') });
19+
// TODO: navigate to /security/users/create and fill the form (reuse existing testids)
20+
// TODO: submit and wait for the failed network call
21+
// TODO: assert an error toast is visible
22+
// TODO: assert the username input still contains the entered value (form not reset)
23+
});
24+
25+
test('CreateUser ALREADY_EXISTS surfaces a user-friendly error', async ({ page }) => {
26+
await mockConnectError({
27+
page,
28+
urlGlob: rpcUrl(USER_SERVICE, 'CreateUser'),
29+
code: 'already_exists',
30+
message: 'user "existing-user" already exists',
31+
});
32+
// TODO: fill form with any username, submit
33+
// TODO: assert the page stays on /security/users/create
34+
// TODO: assert toast text (exact copy depends on formatToastErrorMessageGRPC output — verify post-adversarial-review)
35+
});
36+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* spec: UX-1208 — Phase 1 e2e test coverage (CRITICAL + HIGH)
3+
* parent epic: UX-1198 — REST-to-Connect RPC migration
4+
*
5+
* After the REST→Connect swap, the /users/:name/details route fetches via Connect
6+
* Query with a different cache key. This spec catches a deep-link regression where
7+
* the details page breaks when the list cache is cold (e.g. bookmarks, share links).
8+
*/
9+
10+
import { test } from '@playwright/test';
11+
12+
test.describe('Users page deep-link (cold cache)', () => {
13+
test('direct-load /security/users/e2euser/details renders without prior list navigation', async () => {
14+
// TODO: goto the details URL directly WITHOUT visiting /security/users first
15+
// TODO: assert heading "User: e2euser" visible
16+
// TODO: assert User information, Roles, and ACLs sections are visible
17+
// TODO: assert no console errors about missing user / undefined data
18+
});
19+
20+
test('direct-load /security/acls/<principal>/details for an existing principal renders', async () => {
21+
// TODO: seed an ACL via AclPage (happy path), then navigate directly in a fresh context
22+
// TODO: assert detail rules render
23+
});
24+
});

0 commit comments

Comments
 (0)