Skip to content

Commit 12a3bcf

Browse files
authored
fix: access denied for operations (#2485)
1 parent 00d0286 commit 12a3bcf

File tree

5 files changed

+225
-29
lines changed

5 files changed

+225
-29
lines changed

src/containers/Operations/Operations.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,17 +34,17 @@ export function Operations({database, scrollContainerRef}: OperationsProps) {
3434
scrollContainerRef,
3535
});
3636

37-
if (isAccessError(error)) {
38-
return <AccessDenied position="left" />;
39-
}
40-
4137
const settings = React.useMemo(() => {
4238
return {
4339
...DEFAULT_TABLE_SETTINGS,
4440
sortable: false,
4541
};
4642
}, []);
4743

44+
if (isAccessError(error)) {
45+
return <AccessDenied position="left" />;
46+
}
47+
4848
return (
4949
<TableWithControlsLayout>
5050
<TableWithControlsLayout.Controls>

src/store/reducers/operations.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type {
88

99
import {api} from './api';
1010

11-
const DEFAULT_PAGE_SIZE = 10;
11+
const DEFAULT_PAGE_SIZE = 20;
1212

1313
export const operationsApi = api.injectEndpoints({
1414
endpoints: (build) => ({

tests/suites/tenant/diagnostics/tabs/OperationsModel.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export class OperationsTable extends BaseModel {
1313
private emptyState: Locator;
1414
private loadingMore: Locator;
1515
private scrollContainer: Locator;
16+
private accessDeniedState: Locator;
17+
private accessDeniedTitle: Locator;
1618

1719
constructor(page: Page) {
1820
super(page, page.locator('.kv-tenant-diagnostics'));
@@ -22,6 +24,9 @@ export class OperationsTable extends BaseModel {
2224
this.emptyState = page.locator('.operations__table:has-text("No operations data")');
2325
this.loadingMore = page.locator('.operations__loading-more');
2426
this.scrollContainer = page.locator('.kv-tenant-diagnostics__page-wrapper');
27+
// AccessDenied component is rendered at the root level of Operations component
28+
this.accessDeniedState = page.locator('.kv-tenant-diagnostics .empty-state');
29+
this.accessDeniedTitle = this.accessDeniedState.locator('.empty-state__title');
2530
}
2631

2732
async waitForTableVisible() {
@@ -124,4 +129,69 @@ export class OperationsTable extends BaseModel {
124129

125130
return false;
126131
}
132+
133+
async isAccessDeniedVisible(): Promise<boolean> {
134+
try {
135+
await this.accessDeniedState.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT});
136+
return true;
137+
} catch {
138+
return false;
139+
}
140+
}
141+
142+
async getAccessDeniedTitle(): Promise<string> {
143+
return await this.accessDeniedTitle.innerText();
144+
}
145+
146+
async getOperationsCount(): Promise<number> {
147+
// The EntitiesCount component renders a Label with the count
148+
const countLabel = await this.page
149+
.locator('.ydb-entities-count .g-label__content')
150+
.textContent();
151+
if (!countLabel) {
152+
return 0;
153+
}
154+
const match = countLabel.match(/(\d+)/);
155+
return match ? parseInt(match[1], 10) : 0;
156+
}
157+
158+
async waitForOperationsCount(expectedCount: number, timeout = 5000): Promise<void> {
159+
await this.page.waitForFunction(
160+
(expected) => {
161+
const countElement = document.querySelector(
162+
'.ydb-entities-count .g-label__content',
163+
);
164+
if (!countElement) {
165+
return false;
166+
}
167+
const text = countElement.textContent || '';
168+
const match = text.match(/(\d+)/);
169+
const currentCount = match ? parseInt(match[1], 10) : 0;
170+
return currentCount === expected;
171+
},
172+
expectedCount,
173+
{timeout},
174+
);
175+
}
176+
177+
async waitForOperationsCountToChange(previousCount: number, timeout = 5000): Promise<number> {
178+
await this.page.waitForFunction(
179+
(prev) => {
180+
const countElement = document.querySelector(
181+
'.ydb-entities-count .g-label__content',
182+
);
183+
if (!countElement) {
184+
return false;
185+
}
186+
const text = countElement.textContent || '';
187+
const match = text.match(/(\d+)/);
188+
const currentCount = match ? parseInt(match[1], 10) : 0;
189+
return currentCount !== prev;
190+
},
191+
previousCount,
192+
{timeout},
193+
);
194+
// Now get the actual new count
195+
return await this.getOperationsCount();
196+
}
127197
}

tests/suites/tenant/diagnostics/tabs/operations.test.ts

Lines changed: 135 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,16 @@ import {tenantName} from '../../../../utils/constants';
44
import {TenantPage} from '../../TenantPage';
55
import {Diagnostics, DiagnosticsTab} from '../Diagnostics';
66

7-
import {setupEmptyOperationsMock, setupOperationsMock} from './operationsMocks';
7+
import {
8+
setupEmptyOperationsMock,
9+
setupOperation403Mock,
10+
setupOperationsMock,
11+
} from './operationsMocks';
812

913
test.describe('Operations Tab - Infinite Query', () => {
1014
test('loads initial page of operations on tab click', async ({page}) => {
11-
// Setup mocks with 30 operations (3 pages of 10)
12-
await setupOperationsMock(page, {totalOperations: 30});
15+
// Setup mocks with 80 operations (4 pages of 20)
16+
await setupOperationsMock(page, {totalOperations: 80});
1317

1418
const pageQueryParams = {
1519
schema: tenantName,
@@ -27,10 +31,13 @@ test.describe('Operations Tab - Infinite Query', () => {
2731
await diagnostics.operations.waitForTableVisible();
2832
await diagnostics.operations.waitForDataLoad();
2933

30-
// Verify initial page loaded (should have some rows)
31-
const rowCount = await diagnostics.operations.getRowCount();
32-
expect(rowCount).toBeGreaterThan(0);
33-
expect(rowCount).toBeLessThanOrEqual(20); // Reasonable page size
34+
// Wait a bit for the counter to stabilize after initial load
35+
await page.waitForTimeout(1000);
36+
37+
// Verify initial page loaded (should show count in badge)
38+
const operationsCount = await diagnostics.operations.getOperationsCount();
39+
expect(operationsCount).toBeGreaterThan(0);
40+
expect(operationsCount).toBeLessThanOrEqual(20); // Should have up to DEFAULT_PAGE_SIZE operations loaded initially
3441

3542
// Verify first row data structure
3643
const firstRowData = await diagnostics.operations.getRowData(0);
@@ -49,8 +56,8 @@ test.describe('Operations Tab - Infinite Query', () => {
4956
});
5057

5158
test('loads more operations on scroll', async ({page}) => {
52-
// Setup mocks with 30 operations (3 pages of 10)
53-
await setupOperationsMock(page, {totalOperations: 30});
59+
// Setup mocks with 80 operations (4 pages of 20)
60+
await setupOperationsMock(page, {totalOperations: 80});
5461

5562
const pageQueryParams = {
5663
schema: tenantName,
@@ -68,26 +75,32 @@ test.describe('Operations Tab - Infinite Query', () => {
6875
await diagnostics.operations.waitForTableVisible();
6976
await diagnostics.operations.waitForDataLoad();
7077

71-
// Get initial row count
72-
const initialRowCount = await diagnostics.operations.getRowCount();
73-
expect(initialRowCount).toBeGreaterThan(0);
78+
// Get initial operations count
79+
const initialOperationsCount = await diagnostics.operations.getOperationsCount();
80+
expect(initialOperationsCount).toBeGreaterThan(0);
7481

7582
// Scroll to bottom
7683
await diagnostics.operations.scrollToBottom();
7784

78-
// Wait a bit for potential loading
79-
await page.waitForTimeout(2000);
80-
81-
// Get final row count
82-
const finalRowCount = await diagnostics.operations.getRowCount();
85+
// Wait for operations count to potentially change
86+
let finalOperationsCount: number;
87+
try {
88+
finalOperationsCount = await diagnostics.operations.waitForOperationsCountToChange(
89+
initialOperationsCount,
90+
3000,
91+
);
92+
} catch (_e) {
93+
// If timeout, the count didn't change
94+
finalOperationsCount = await diagnostics.operations.getOperationsCount();
95+
}
8396

84-
// Check if more rows were loaded
85-
if (finalRowCount > initialRowCount) {
86-
// Infinite scroll worked - more rows were loaded
87-
expect(finalRowCount).toBeGreaterThan(initialRowCount);
97+
// Check if more operations were loaded
98+
if (finalOperationsCount > initialOperationsCount) {
99+
// Infinite scroll worked - more operations were loaded
100+
expect(finalOperationsCount).toBeGreaterThan(initialOperationsCount);
88101
} else {
89-
// No more data to load - row count should stay the same
90-
expect(finalRowCount).toBe(initialRowCount);
102+
// No more data to load - operations count should stay the same
103+
expect(finalOperationsCount).toBe(initialOperationsCount);
91104
}
92105
});
93106

@@ -119,4 +132,103 @@ test.describe('Operations Tab - Infinite Query', () => {
119132
const rowCount = await diagnostics.operations.getRowCount();
120133
expect(rowCount).toBeLessThanOrEqual(1);
121134
});
135+
136+
test('shows access denied when operations request returns 403', async ({page}) => {
137+
// Setup 403 error mock
138+
await setupOperation403Mock(page);
139+
140+
const pageQueryParams = {
141+
schema: tenantName,
142+
database: tenantName,
143+
tenantPage: 'diagnostics',
144+
};
145+
146+
const tenantPageInstance = new TenantPage(page);
147+
await tenantPageInstance.goto(pageQueryParams);
148+
149+
const diagnostics = new Diagnostics(page);
150+
await diagnostics.clickTab(DiagnosticsTab.Operations);
151+
// Wait a bit for potential loading
152+
await page.waitForTimeout(2000);
153+
154+
// Wait for access denied state to be visible
155+
const isAccessDeniedVisible = await diagnostics.operations.isAccessDeniedVisible();
156+
expect(isAccessDeniedVisible).toBe(true);
157+
158+
// Verify the access denied message
159+
const accessDeniedTitle = await diagnostics.operations.getAccessDeniedTitle();
160+
expect(accessDeniedTitle).toBe('Access denied');
161+
});
162+
163+
test('loads all operations when scrolling to the bottom multiple times', async ({page}) => {
164+
// Setup mocks with 80 operations (4 pages of 20)
165+
await setupOperationsMock(page, {totalOperations: 80});
166+
167+
const pageQueryParams = {
168+
schema: tenantName,
169+
database: tenantName,
170+
tenantPage: 'diagnostics',
171+
};
172+
173+
const tenantPageInstance = new TenantPage(page);
174+
await tenantPageInstance.goto(pageQueryParams);
175+
176+
const diagnostics = new Diagnostics(page);
177+
await diagnostics.clickTab(DiagnosticsTab.Operations);
178+
179+
// Wait for initial data
180+
await diagnostics.operations.waitForTableVisible();
181+
await diagnostics.operations.waitForDataLoad();
182+
183+
// Wait a bit for the counter to stabilize after initial load
184+
await page.waitForTimeout(2000);
185+
186+
// Get initial operations count (should be around 20)
187+
const initialOperationsCount = await diagnostics.operations.getOperationsCount();
188+
expect(initialOperationsCount).toBeGreaterThan(0);
189+
expect(initialOperationsCount).toBeLessThanOrEqual(20);
190+
191+
// Keep scrolling until all operations are loaded
192+
let previousOperationsCount = initialOperationsCount;
193+
let currentOperationsCount = initialOperationsCount;
194+
const maxScrollAttempts = 10; // Safety limit to prevent infinite loop
195+
let scrollAttempts = 0;
196+
197+
while (currentOperationsCount < 80 && scrollAttempts < maxScrollAttempts) {
198+
// Scroll to bottom
199+
await diagnostics.operations.scrollToBottom();
200+
201+
// Wait for potential loading
202+
await page.waitForTimeout(1000);
203+
204+
// Check if loading more is visible and wait for it to complete
205+
const isLoadingVisible = await diagnostics.operations.isLoadingMoreVisible();
206+
if (isLoadingVisible) {
207+
await diagnostics.operations.waitForLoadingMoreToDisappear();
208+
}
209+
210+
// Wait for operations count to change or timeout
211+
try {
212+
currentOperationsCount =
213+
await diagnostics.operations.waitForOperationsCountToChange(
214+
previousOperationsCount,
215+
3000,
216+
);
217+
} catch (_e) {
218+
// If timeout, the count didn't change - we might have reached the end
219+
currentOperationsCount = await diagnostics.operations.getOperationsCount();
220+
}
221+
222+
previousOperationsCount = currentOperationsCount;
223+
scrollAttempts++;
224+
}
225+
226+
// Verify all 80 operations were loaded
227+
expect(currentOperationsCount).toBe(80);
228+
229+
const rowCount = await diagnostics.operations.getRowCount();
230+
// Verify the last operation has the expected ID pattern
231+
const lastRowData = await diagnostics.operations.getRowData(rowCount - 1);
232+
expect(lastRowData['Operation ID']).toContain('ydb://');
233+
});
122234
});

tests/suites/tenant/diagnostics/tabs/operationsMocks.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ export const setupOperationsMock = async (page: Page, options?: OperationMockOpt
123123
const url = new URL(route.request().url());
124124
const params = Object.fromEntries(url.searchParams);
125125

126-
const requestedPageSize = parseInt(params.page_size || '10', 10);
126+
const requestedPageSize = parseInt(params.page_size || '20', 10);
127127
const pageToken = params.page_token;
128128
const kind = params.kind || 'buildindex';
129129

@@ -226,6 +226,20 @@ export const setupOperationErrorMock = async (page: Page) => {
226226
});
227227
};
228228

229+
export const setupOperation403Mock = async (page: Page) => {
230+
await page.route(`${backend}/operation/list*`, async (route) => {
231+
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY));
232+
233+
await route.fulfill({
234+
status: 403,
235+
contentType: 'application/json',
236+
body: JSON.stringify({
237+
error: 'Forbidden',
238+
}),
239+
});
240+
});
241+
};
242+
229243
// Helper to setup all required mocks for operations
230244
export const setupAllOperationMocks = async (page: Page, options?: {totalOperations?: number}) => {
231245
await setupOperationsMock(page, options);

0 commit comments

Comments
 (0)