Skip to content

Commit b30c451

Browse files
authored
Merge pull request #2392 from redpanda-data/fix/ux-1217-acl-rule-id-collision
fix(acls): prevent React key collision wiping ACLs on edit (UX-1217, UX-1219)
2 parents e954c5d + c5b82b6 commit b30c451

4 files changed

Lines changed: 147 additions & 1 deletion

File tree

frontend/src/components/pages/security/acls/create-acl.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { useSupportedFeaturesStore } from 'state/supported-features';
2828
import {
2929
type AclRulesProps,
3030
getIdFromRule,
31+
getInitialRuleIdCounter,
3132
getOperationsForResourceType,
3233
getRuleDataTestId,
3334
type HostType,
@@ -774,7 +775,7 @@ export default function CreateACL({
774775
}
775776
}, [propSharedConfig?.principal, propSharedConfig?.host]);
776777

777-
const ruleIdCounter = useRef(2);
778+
const ruleIdCounter = useRef(getInitialRuleIdCounter(propRules));
778779
const [rules, setRules] = useState<Rule[]>(
779780
propRules ?? [
780781
{

frontend/src/components/pages/security/shared/acl-model.test.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,27 @@ import { describe, expect, test } from 'vitest';
2323

2424
import {
2525
getAclFromAclListResponse,
26+
getInitialRuleIdCounter,
2627
ModeAllowAll,
2728
ModeCustom,
2829
ModeDenyAll,
2930
OperationTypeAllow,
3031
OperationTypeDeny,
3132
ResourcePatternTypeLiteral,
3233
ResourcePatternTypePrefix,
34+
ResourceTypeTopic,
35+
type Rule,
3336
} from './acl-model';
3437

38+
const buildRule = (id: number): Rule => ({
39+
id,
40+
resourceType: ResourceTypeTopic,
41+
mode: ModeCustom,
42+
selectorType: ResourcePatternTypeLiteral,
43+
selectorValue: `topic-${id}`,
44+
operations: {},
45+
});
46+
3547
describe('getAclFromAclListResponse', () => {
3648
describe('Single host scenarios', () => {
3749
test('should return single AclDetail for single host', () => {
@@ -531,3 +543,25 @@ describe('getAclFromAclListResponse', () => {
531543
});
532544
});
533545
});
546+
547+
describe('getInitialRuleIdCounter', () => {
548+
test('returns 2 for a fresh create (undefined rules)', () => {
549+
expect(getInitialRuleIdCounter()).toBe(2);
550+
});
551+
552+
test('returns 2 for an empty rule array', () => {
553+
expect(getInitialRuleIdCounter([])).toBe(2);
554+
});
555+
556+
test('returns max(id) + 1 for sequential edit-mode rules', () => {
557+
expect(getInitialRuleIdCounter([buildRule(0), buildRule(1), buildRule(2)])).toBe(3);
558+
});
559+
560+
test('returns max(id) + 1 for non-sequential ids', () => {
561+
expect(getInitialRuleIdCounter([buildRule(0), buildRule(7), buildRule(3)])).toBe(8);
562+
});
563+
564+
test('handles a single rule with id greater than 2', () => {
565+
expect(getInitialRuleIdCounter([buildRule(9)])).toBe(10);
566+
});
567+
});

frontend/src/components/pages/security/shared/acl-model.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,9 @@ export const getAclFromAclListResponse = (aclList: ListACLsResponse): AclDetail[
446446
export const getOperationsForResourceType = (resourceType: ResourceType): Record<string, OperationType> =>
447447
operationSets[resourceType] || {};
448448

449+
export const getInitialRuleIdCounter = (rules?: Rule[]): number =>
450+
rules && rules.length > 0 ? Math.max(...rules.map((r) => r.id)) + 1 : 2;
451+
449452
// Helper function to get resource name
450453
export const getResourceName = (resourceType: string): string => {
451454
const resourceNames: Record<string, string> = {
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/**
2+
* Regression test for UX-1217 / UX-1219 — adding a rule on edit must not wipe existing ACLs.
3+
*
4+
* Pre-fix bug: `create-acl.tsx` used `useRef(2)` for the new-rule id counter. On edit-load,
5+
* `processResourceAcls` assigns rule ids 0, 1, 2, ... When the user clicked "Add rule",
6+
* the new rule got id=2 and collided with an already-loaded rule's React key, causing
7+
* list-reconciliation state bleed that dropped ACLs on save.
8+
*
9+
* Reproducing the collision requires at least 3 distinct rule groups on edit-load
10+
* (so that id=2 is already taken). The form groups ACLs by (resourceType, pattern, name),
11+
* so three separate rule groups = three different (resourceType/name) combinations.
12+
*/
13+
import { test } from '@playwright/test';
14+
15+
import {
16+
ModeCustom,
17+
OperationTypeAllow,
18+
ResourcePatternTypeLiteral,
19+
ResourceTypeCluster,
20+
ResourceTypeConsumerGroup,
21+
ResourceTypeTopic,
22+
ResourceTypeTransactionalId,
23+
type Rule,
24+
} from '../../../src/components/pages/security/shared/acl-model';
25+
import { AclPage } from '../utils/acl-page';
26+
27+
const initialRules: Rule[] = [
28+
{
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+
id: 1,
40+
resourceType: ResourceTypeTopic,
41+
mode: ModeCustom,
42+
selectorType: ResourcePatternTypeLiteral,
43+
selectorValue: 'topic-acl2',
44+
operations: {
45+
DESCRIBE: OperationTypeAllow,
46+
READ: OperationTypeAllow,
47+
WRITE: OperationTypeAllow,
48+
},
49+
},
50+
{
51+
id: 2,
52+
resourceType: ResourceTypeConsumerGroup,
53+
mode: ModeCustom,
54+
selectorType: ResourcePatternTypeLiteral,
55+
selectorValue: 'cg-a',
56+
operations: {
57+
READ: OperationTypeAllow,
58+
},
59+
},
60+
];
61+
62+
const addedRule: Rule = {
63+
id: 3,
64+
resourceType: ResourceTypeTransactionalId,
65+
mode: ModeCustom,
66+
selectorType: ResourcePatternTypeLiteral,
67+
selectorValue: 'tx-1',
68+
operations: {
69+
DESCRIBE: OperationTypeAllow,
70+
},
71+
};
72+
73+
test.describe('ACL edit preserves existing rules (UX-1217)', () => {
74+
test('adding a rule on edit does not wipe existing ACLs', async ({ page }) => {
75+
test.setTimeout(180_000);
76+
77+
const principal = `edit-preserve-${Date.now()}`;
78+
const host = '*';
79+
const aclPage = new AclPage(page);
80+
81+
await test.step('Create initial ACL with 3 distinct rule groups', async () => {
82+
await aclPage.goto();
83+
await aclPage.setPrincipal(principal);
84+
await aclPage.setHost(host);
85+
await aclPage.configureRules(initialRules);
86+
await aclPage.submitForm();
87+
await aclPage.waitForDetailPage();
88+
await aclPage.validateRulesCount(initialRules.length);
89+
});
90+
91+
await test.step('Navigate to edit page', async () => {
92+
await aclPage.clickUpdateButtonFromDetailPage();
93+
await aclPage.waitForUpdatePage();
94+
});
95+
96+
await test.step('Add a new rule — pre-fix this collides with the loaded id=2 rule', async () => {
97+
await aclPage.updateRules([addedRule]);
98+
await aclPage.submitForm();
99+
await aclPage.waitForDetailPage();
100+
});
101+
102+
await test.step('Verify every original rule plus the added rule is preserved', async () => {
103+
const finalRules = [...initialRules, addedRule];
104+
await aclPage.validateRulesCount(finalRules.length);
105+
await aclPage.validateAllDetailRules(finalRules, principal, host);
106+
});
107+
});
108+
});

0 commit comments

Comments
 (0)