Skip to content

Commit 1fc16d0

Browse files
committed
test(e2e): add Phase 1 REST-to-Connect coverage (UX-1208)
8 Playwright specs plus a Connect RPC mock helper, exercising the paths that were silently changed by the REST→Connect migration. All 7 tests pass against testcontainers locally; the view-only authorization spec is gated behind VIEW_ONLY_FIXTURE_READY pending UX-1209. Shared infrastructure - shared/connect-mock.ts: mockConnectError, mockConnectNetworkFailure, rpcUrl, captureConnectRequests — fulfills matching requests with a Connect-JSON error envelope so specs can deterministically exercise the error-handling code paths added when ConsoleSvc REST handlers were replaced by Connect Query mutations. - utils/acl-page.ts: extended with submitFormExpectingError and validateRuleNotExists helpers used by the new error specs. Specs - acls/user-error-handling: CreateUser network failure + ALREADY_EXISTS toast copy via formatToastErrorMessageGRPC. - acls/user-delete-error: DeleteUser FAILED_PRECONDITION leaves the user in place and surfaces the toast on the details page. - acls/users-deeplink: cold-cache deep-link to /security/users/.../details and /security/acls/<principal>/details — the ACL case seeds a real ACL via AclPage and loads the URL in a fresh browser context to defeat the Connect Query cache. - acls/acl-create-error, acl-delete-multi-match, acl-principal-special-chars: Connect-side error and edge-case coverage for ACL create/delete flows. - quotas/quota-error: ListQuotas INTERNAL renders the Chakra Alert with the rawMessage (timeout absorbs the query-client's exponential-backoff retries on Code.Internal); PERMISSION_DENIED renders the 403 ResultHttpError with no leak of the raw gRPC code. - console-enterprise/users-authorization: view-only role spec — gated behind VIEW_ONLY_FIXTURE_READY pending UX-1209.
1 parent 1d3a65b commit 1fc16d0

10 files changed

Lines changed: 742 additions & 0 deletions

File tree

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
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+
/**
83+
* DEBUG flag for diagnosing mock interception issues. Flip to true while iterating,
84+
* false before commit. Gated at module level so route handlers log only when we
85+
* ask them to (no performance hit in normal runs, no process.env dependency).
86+
*/
87+
const DEBUG_CONNECT_MOCK = false;
88+
89+
export async function mockConnectError(args: MockConnectErrorArgs): Promise<void> {
90+
const { page, urlGlob, code, message = `mocked ${code}`, times } = args;
91+
if (DEBUG_CONNECT_MOCK) {
92+
// biome-ignore lint/suspicious/noConsole: diagnostic gated behind DEBUG_CONNECT_MOCK
93+
console.log(`[connect-mock] registering route glob=${urlGlob} code=${code}`);
94+
}
95+
await page.route(
96+
urlGlob,
97+
(route) => {
98+
if (DEBUG_CONNECT_MOCK) {
99+
const req = route.request();
100+
// biome-ignore lint/suspicious/noConsole: diagnostic gated behind DEBUG_CONNECT_MOCK
101+
console.log(
102+
`[connect-mock] route FIRED url=${req.url()} method=${req.method()} body=${(req.postData() ?? '').slice(0, 200)}`
103+
);
104+
}
105+
return route.fulfill({
106+
status: connectCodeToHttpStatus(code),
107+
contentType: 'application/json',
108+
body: JSON.stringify({ code, message }),
109+
});
110+
},
111+
times === undefined ? undefined : { times }
112+
);
113+
}
114+
115+
/**
116+
* Aborts all matching requests with a network-level failure. Use for simulating offline/timeout
117+
* behavior where no response envelope is sent at all.
118+
*/
119+
export async function mockConnectNetworkFailure(args: MockConnectNetworkFailureArgs): Promise<void> {
120+
const { page, urlGlob, reason = 'failed' } = args;
121+
await page.route(urlGlob, (route) => route.abort(reason));
122+
}
123+
124+
/**
125+
* Returns the URL glob for a Connect RPC. Use instead of hand-writing paths so tests stay
126+
* readable and the fully-qualified service name is centralized.
127+
*
128+
* @example rpcUrl('redpanda.api.dataplane.v1.UserService', 'CreateUser')
129+
*/
130+
export function rpcUrl(fullyQualifiedService: string, method: string): string {
131+
return `**/${fullyQualifiedService}/${method}`;
132+
}
133+
134+
/**
135+
* Convenience wrapper around page.route that records request bodies so assertions can verify
136+
* that the expected RPC was invoked with the expected input.
137+
*/
138+
export async function captureConnectRequests(
139+
page: Page,
140+
urlGlob: string
141+
): Promise<{ requests: Array<{ url: string; postData: string | null }>; stop: () => Promise<void> }> {
142+
const requests: Array<{ url: string; postData: string | null }> = [];
143+
const handler = async (route: Route) => {
144+
requests.push({ url: route.request().url(), postData: route.request().postData() });
145+
await route.continue();
146+
};
147+
await page.route(urlGlob, handler);
148+
return {
149+
requests,
150+
stop: () => page.unroute(urlGlob, handler),
151+
};
152+
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
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) — see UX-1209.
10+
*/
11+
12+
import { expect, test } from '@playwright/test';
13+
14+
// TODO(UX-1209): flip to true once the view-only fixture lands. All assertions below are
15+
// ready; only the seeded role and storageState are missing.
16+
const VIEW_ONLY_STORAGE_STATE = 'playwright/.auth/view-only.json';
17+
const VIEW_ONLY_FIXTURE_READY = false;
18+
19+
test.describe('Authorization - Users page (view-only role)', () => {
20+
test.skip(!VIEW_ONLY_FIXTURE_READY, 'view-only auth fixture pending — see UX-1209');
21+
test.use({ storageState: VIEW_ONLY_STORAGE_STATE });
22+
23+
test('users list renders for view-only role', async ({ page }) => {
24+
await page.goto('/security/users', { waitUntil: 'domcontentloaded' });
25+
26+
// Structural: the page loads (not a 403 / permission-denied boundary).
27+
await expect(page.getByRole('table')).toBeVisible({ timeout: 10_000 });
28+
29+
// At least one row (the seeded view-only user itself should be present).
30+
const rows = page.getByRole('row');
31+
await expect(rows).not.toHaveCount(0);
32+
});
33+
34+
test('create user button is hidden or disabled for view-only role', async ({ page }) => {
35+
await page.goto('/security/users', { waitUntil: 'domcontentloaded' });
36+
37+
// Structural: the Create button is either absent or disabled — both acceptable.
38+
const createButton = page.getByTestId('create-user-button');
39+
const isVisible = await createButton.isVisible().catch(() => false);
40+
if (isVisible) {
41+
await expect(createButton).toBeDisabled();
42+
} else {
43+
await expect(createButton).not.toBeVisible();
44+
}
45+
46+
// Direct navigation: /security/users/create should NOT show a working create form.
47+
// The form may either be hidden entirely (route redirect) or rendered with the submit
48+
// disabled (inline permission gate) — both are acceptable view-only behaviors.
49+
await page.goto('/security/users/create', { waitUntil: 'domcontentloaded' });
50+
const submitButton = page.getByTestId('create-user-submit');
51+
const submitVisible = await submitButton.isVisible().catch(() => false);
52+
if (submitVisible) {
53+
await expect(submitButton).toBeDisabled();
54+
}
55+
});
56+
57+
test('delete user button is not available on details page for view-only role', async ({ page }) => {
58+
// The seeded view-only user should be visible in the list.
59+
await page.goto('/security/users', { waitUntil: 'domcontentloaded' });
60+
await expect(page.getByRole('table')).toBeVisible({ timeout: 10_000 });
61+
62+
// Click the first user link (whichever exists — we don't care which one).
63+
const firstUserLink = page.locator("a[href^='/security/users/'][href$='/details']").first();
64+
await expect(firstUserLink).toBeVisible();
65+
await firstUserLink.click();
66+
67+
// Structural: Delete button is hidden or disabled.
68+
const deleteBtn = page.getByRole('button', { name: 'Delete user' });
69+
const deleteVisible = await deleteBtn.isVisible().catch(() => false);
70+
if (deleteVisible) {
71+
await expect(deleteBtn).toBeDisabled();
72+
} else {
73+
await expect(deleteBtn).not.toBeVisible();
74+
}
75+
});
76+
});
77+
78+
test.describe('Authorization - ACLs page (view-only role)', () => {
79+
test.skip(!VIEW_ONLY_FIXTURE_READY, 'view-only auth fixture pending — see UX-1209');
80+
test.use({ storageState: VIEW_ONLY_STORAGE_STATE });
81+
82+
test('ACLs list is visible but Create ACL is blocked', async ({ page }) => {
83+
await page.goto('/security/acls', { waitUntil: 'domcontentloaded' });
84+
expect(page.url()).toContain('/security/acls');
85+
86+
// Structural: Create ACL button hidden or disabled.
87+
const createAcl = page.getByTestId('create-acls');
88+
const createVisible = await createAcl.isVisible().catch(() => false);
89+
if (createVisible) {
90+
await expect(createAcl).toBeDisabled();
91+
} else {
92+
await expect(createAcl).not.toBeVisible();
93+
}
94+
95+
// Direct-nav: /security/acls/create should not present a working form.
96+
await page.goto('/security/acls/create', { waitUntil: 'domcontentloaded' });
97+
const principalInput = page.getByTestId('shared-principal-input');
98+
const principalVisible = await principalInput.isVisible().catch(() => false);
99+
if (principalVisible) {
100+
await expect(principalInput).toBeDisabled();
101+
}
102+
});
103+
104+
test('DeleteACLs dropdown items are hidden in permissions list for view-only', async ({ page }) => {
105+
await page.goto('/security/permissions-list', { waitUntil: 'domcontentloaded' });
106+
107+
// Find any row and open its dropdown.
108+
const firstRow = page.getByRole('row').nth(1); // nth(0) is the header
109+
const rowExists = await firstRow.isVisible().catch(() => false);
110+
test.skip(!rowExists, 'no permission-list rows to exercise; seed required');
111+
112+
await firstRow.getByRole('button').click();
113+
114+
// Structural: all three delete options are absent or disabled for view-only role.
115+
for (const testId of ['delete-user-and-acls', 'delete-user-only', 'delete-acls-only']) {
116+
const item = page.getByTestId(testId);
117+
const isVisible = await item.isVisible().catch(() => false);
118+
if (isVisible) {
119+
await expect(item).toBeDisabled();
120+
} else {
121+
await expect(item).not.toBeVisible();
122+
}
123+
}
124+
});
125+
});
126+
127+
test.describe('Authorization - Quotas page (view-only role)', () => {
128+
test.skip(!VIEW_ONLY_FIXTURE_READY, 'view-only auth fixture pending — see UX-1209');
129+
test.use({ storageState: VIEW_ONLY_STORAGE_STATE });
130+
131+
test('quotas table loads for view-only role', async ({ page }) => {
132+
await page.goto('/quotas', { waitUntil: 'domcontentloaded' });
133+
134+
// Structural: heading renders.
135+
await expect(page.getByRole('heading', { name: 'Quotas' })).toBeVisible({ timeout: 10_000 });
136+
137+
// Column headers are visible (the table is rendered even if zero quotas).
138+
await expect(page.getByRole('columnheader', { name: 'Type' })).toBeVisible();
139+
await expect(page.getByRole('columnheader', { name: 'Name' })).toBeVisible();
140+
});
141+
});
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/** biome-ignore-all lint/performance/useTopLevelRegex: e2e test */
2+
/**
3+
* spec: UX-1208 — Phase 1 e2e test coverage (CRITICAL + HIGH)
4+
* parent epic: UX-1198 — REST-to-Connect RPC migration
5+
*
6+
* Previously REST /api/acls returned REST error bodies. Now ACLService.CreateACL uses
7+
* Connect with InvalidArgument + structured details. Guards field-level error surfacing
8+
* through formatToastErrorMessageGRPC.
9+
*
10+
* Also guards the UI fix in UX-1218: the create page must not navigate to the detail
11+
* page when the create RPC fails.
12+
*/
13+
14+
import { expect, test } from '@playwright/test';
15+
16+
import {
17+
ModeCustom,
18+
OperationTypeAllow,
19+
ResourcePatternTypeLiteral,
20+
ResourceTypeCluster,
21+
type Rule,
22+
} from '../../../src/components/pages/security/shared/acl-model';
23+
import { mockConnectError, mockConnectNetworkFailure, rpcUrl } from '../../shared/connect-mock';
24+
import { AclPage } from '../utils/acl-page';
25+
26+
const ACL_SERVICE = 'redpanda.api.dataplane.v1.ACLService';
27+
28+
const MINIMAL_RULE: Rule = {
29+
id: 0,
30+
resourceType: ResourceTypeCluster,
31+
mode: ModeCustom,
32+
selectorType: ResourcePatternTypeLiteral,
33+
selectorValue: 'kafka-cluster',
34+
operations: {
35+
DESCRIBE: OperationTypeAllow,
36+
},
37+
};
38+
39+
test.describe('ACL creation - Connect RPC error handling', () => {
40+
test('CreateACL INVALID_ARGUMENT surfaces a field-level error', async ({ page }) => {
41+
await mockConnectError({
42+
page,
43+
urlGlob: rpcUrl(ACL_SERVICE, 'CreateACL'),
44+
code: 'invalid_argument',
45+
message: 'principal cannot be empty',
46+
});
47+
48+
const aclPage = new AclPage(page);
49+
await aclPage.goto();
50+
await aclPage.setPrincipal(`err-${Date.now()}`);
51+
await aclPage.setHost('*');
52+
await aclPage.configureRules([MINIMAL_RULE]);
53+
await aclPage.submitFormExpectingError('http-error');
54+
55+
// Structural: mock fails the create, so page must stay on /create and NOT reach detail.
56+
await expect(page).toHaveURL(/\/security\/acls\/create/);
57+
});
58+
59+
test('CreateACL network timeout keeps user on the form', async ({ page }) => {
60+
await mockConnectNetworkFailure({
61+
page,
62+
urlGlob: rpcUrl(ACL_SERVICE, 'CreateACL'),
63+
reason: 'timedout',
64+
});
65+
66+
const aclPage = new AclPage(page);
67+
await aclPage.goto();
68+
await aclPage.setPrincipal(`timeout-${Date.now()}`);
69+
await aclPage.setHost('*');
70+
await aclPage.configureRules([MINIMAL_RULE]);
71+
await aclPage.submitFormExpectingError('network-abort');
72+
73+
// Structural: URL still /create.
74+
await expect(page).toHaveURL(/\/security\/acls\/create/);
75+
});
76+
});

0 commit comments

Comments
 (0)