Skip to content

Commit 9cf3eb7

Browse files
committed
feat(role-manager): implement snapshot export with schema-compliant JSON format
Phase 6 (User Story 4): Download JSON snapshot of access control state. Changes: - Add generateSnapshotFilename utility using truncateMiddle from utils package - Update AccessSnapshot interface to match access-snapshot.schema.json: - Root-level version "1.0" and exportedAt timestamp - Nested contract object with address, label, networkId, networkName - Transform roles to roleId/roleName format - Integrate useExportSnapshot in useDashboardData hook - Connect Dashboard "Download Snapshot" button with loading state - Add comprehensive tests for snapshot utilities - Add missing deduplication utility tests
1 parent 9063a4b commit 9cf3eb7

File tree

9 files changed

+663
-88
lines changed

9 files changed

+663
-88
lines changed

apps/role-manager/src/hooks/__tests__/useAccessControlMutations.test.tsx

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -942,6 +942,7 @@ describe('useExportSnapshot', () => {
942942
() =>
943943
useExportSnapshot(mockAdapter, 'CONTRACT_ADDRESS', {
944944
networkId: 'stellar-testnet',
945+
networkName: 'Stellar Testnet',
945946
}),
946947
{ wrapper: createWrapper() }
947948
);
@@ -956,6 +957,7 @@ describe('useExportSnapshot', () => {
956957
() =>
957958
useExportSnapshot(null, 'CONTRACT_ADDRESS', {
958959
networkId: 'stellar-testnet',
960+
networkName: 'Stellar Testnet',
959961
}),
960962
{ wrapper: createWrapper() }
961963
);
@@ -970,6 +972,7 @@ describe('useExportSnapshot', () => {
970972
() =>
971973
useExportSnapshot(adapterWithoutService, 'CONTRACT_ADDRESS', {
972974
networkId: 'stellar-testnet',
975+
networkName: 'Stellar Testnet',
973976
}),
974977
{ wrapper: createWrapper() }
975978
);
@@ -986,6 +989,7 @@ describe('useExportSnapshot', () => {
986989
() =>
987990
useExportSnapshot(mockAdapter, 'CONTRACT_ADDRESS', {
988991
networkId: 'stellar-testnet',
992+
networkName: 'Stellar Testnet',
989993
onSuccess,
990994
}),
991995
{ wrapper: createWrapper() }
@@ -1004,14 +1008,37 @@ describe('useExportSnapshot', () => {
10041008
expect(onSuccess).toHaveBeenCalledTimes(1);
10051009
const snapshot: AccessSnapshot = onSuccess.mock.calls[0][0];
10061010

1007-
expect(snapshot.contractAddress).toBe('CONTRACT_ADDRESS');
1008-
expect(snapshot.networkId).toBe('stellar-testnet');
1009-
expect(snapshot.capabilities).toEqual(mockCapabilities);
1010-
expect(snapshot.ownership).toEqual(mockOwnership);
1011-
expect(snapshot.roles).toEqual(mockRoles);
1012-
expect(snapshot.metadata.version).toBe('1.0.0');
1013-
expect(snapshot.metadata.generatedBy).toBe('OpenZeppelin Role Manager');
1014-
expect(snapshot.timestamp).toBeDefined();
1011+
// Verify schema-compliant structure
1012+
expect(snapshot.version).toBe('1.0');
1013+
expect(snapshot.exportedAt).toBeDefined();
1014+
expect(snapshot.contract).toEqual({
1015+
address: 'CONTRACT_ADDRESS',
1016+
label: null,
1017+
networkId: 'stellar-testnet',
1018+
networkName: 'Stellar Testnet',
1019+
});
1020+
expect(snapshot.capabilities).toEqual({
1021+
hasAccessControl: mockCapabilities.hasAccessControl,
1022+
hasOwnable: mockCapabilities.hasOwnable,
1023+
hasEnumerableRoles: mockCapabilities.hasEnumerableRoles,
1024+
});
1025+
expect(snapshot.ownership).toEqual({
1026+
owner: mockOwnership.owner,
1027+
pendingOwner: null,
1028+
});
1029+
// Roles should be transformed to roleId/roleName format
1030+
expect(snapshot.roles).toEqual([
1031+
{
1032+
roleId: 'DEFAULT_ADMIN_ROLE',
1033+
roleName: 'DEFAULT_ADMIN_ROLE',
1034+
members: ['0x1111111111111111111111111111111111111111'],
1035+
},
1036+
{
1037+
roleId: 'MINTER_ROLE',
1038+
roleName: 'MINTER_ROLE',
1039+
members: ['0x2222222222222222222222222222222222222222'],
1040+
},
1041+
]);
10151042
});
10161043

10171044
it('should fetch all data in parallel', async () => {
@@ -1021,6 +1048,7 @@ describe('useExportSnapshot', () => {
10211048
() =>
10221049
useExportSnapshot(mockAdapter, 'CONTRACT_ADDRESS', {
10231050
networkId: 'stellar-testnet',
1051+
networkName: 'Stellar Testnet',
10241052
onSuccess,
10251053
}),
10261054
{ wrapper: createWrapper() }
@@ -1051,6 +1079,7 @@ describe('useExportSnapshot', () => {
10511079
() =>
10521080
useExportSnapshot(slowAdapter, 'CONTRACT_ADDRESS', {
10531081
networkId: 'stellar-testnet',
1082+
networkName: 'Stellar Testnet',
10541083
}),
10551084
{ wrapper: createWrapper() }
10561085
);
@@ -1084,6 +1113,7 @@ describe('useExportSnapshot', () => {
10841113
() =>
10851114
useExportSnapshot(adapterWithoutService, 'CONTRACT_ADDRESS', {
10861115
networkId: 'stellar-testnet',
1116+
networkName: 'Stellar Testnet',
10871117
onError,
10881118
}),
10891119
{ wrapper: createWrapper() }
@@ -1105,6 +1135,7 @@ describe('useExportSnapshot', () => {
11051135
() =>
11061136
useExportSnapshot(mockAdapter, '', {
11071137
networkId: 'stellar-testnet',
1138+
networkName: 'Stellar Testnet',
11081139
onError,
11091140
}),
11101141
{ wrapper: createWrapper() }
@@ -1130,6 +1161,7 @@ describe('useExportSnapshot', () => {
11301161
() =>
11311162
useExportSnapshot(errorAdapter, 'CONTRACT_ADDRESS', {
11321163
networkId: 'stellar-testnet',
1164+
networkName: 'Stellar Testnet',
11331165
onError,
11341166
}),
11351167
{ wrapper: createWrapper() }
@@ -1155,6 +1187,7 @@ describe('useExportSnapshot', () => {
11551187
() =>
11561188
useExportSnapshot(errorAdapter, 'CONTRACT_ADDRESS', {
11571189
networkId: 'stellar-testnet',
1190+
networkName: 'Stellar Testnet',
11581191
}),
11591192
{ wrapper: createWrapper() }
11601193
);
@@ -1176,6 +1209,7 @@ describe('useExportSnapshot', () => {
11761209
() =>
11771210
useExportSnapshot(adapterWithoutService, 'CONTRACT_ADDRESS', {
11781211
networkId: 'stellar-testnet',
1212+
networkName: 'Stellar Testnet',
11791213
}),
11801214
{ wrapper: createWrapper() }
11811215
);
@@ -1205,6 +1239,7 @@ describe('useExportSnapshot', () => {
12051239
() =>
12061240
useExportSnapshot(mockAdapter, 'CONTRACT_ADDRESS', {
12071241
networkId: 'stellar-testnet',
1242+
networkName: 'Stellar Testnet',
12081243
onSuccess,
12091244
}),
12101245
{ wrapper: createWrapper() }
@@ -1215,17 +1250,18 @@ describe('useExportSnapshot', () => {
12151250
});
12161251

12171252
const snapshot: AccessSnapshot = onSuccess.mock.calls[0][0];
1218-
const parsedDate = new Date(snapshot.timestamp);
1253+
const parsedDate = new Date(snapshot.exportedAt);
12191254
expect(parsedDate.toString()).not.toBe('Invalid Date');
12201255
});
12211256

1222-
it('should include correct metadata', async () => {
1257+
it('should include correct version', async () => {
12231258
const onSuccess = vi.fn();
12241259

12251260
const { result } = renderHook(
12261261
() =>
12271262
useExportSnapshot(mockAdapter, 'CONTRACT_ADDRESS', {
12281263
networkId: 'stellar-testnet',
1264+
networkName: 'Stellar Testnet',
12291265
onSuccess,
12301266
}),
12311267
{ wrapper: createWrapper() }
@@ -1236,10 +1272,7 @@ describe('useExportSnapshot', () => {
12361272
});
12371273

12381274
const snapshot: AccessSnapshot = onSuccess.mock.calls[0][0];
1239-
expect(snapshot.metadata).toEqual({
1240-
version: '1.0.0',
1241-
generatedBy: 'OpenZeppelin Role Manager',
1242-
});
1275+
expect(snapshot.version).toBe('1.0');
12431276
});
12441277
});
12451278
});

apps/role-manager/src/hooks/__tests__/useDashboardData.test.tsx

Lines changed: 70 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@ describe('useDashboardData', () => {
3232
let mockAdapter: ContractAdapter;
3333
const testAddress = '0x1234567890123456789012345678901234567890';
3434

35+
// Default options for tests
36+
const defaultOptions = {
37+
networkId: 'stellar-testnet',
38+
networkName: 'Stellar Testnet',
39+
label: 'Test Contract',
40+
isContractRegistered: true,
41+
};
42+
3543
const wrapper = ({ children }: { children: ReactNode }) => (
3644
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
3745
);
@@ -77,7 +85,10 @@ describe('useDashboardData', () => {
7785
hasError: false,
7886
});
7987

80-
const { result } = renderHook(() => useDashboardData(mockAdapter, testAddress), { wrapper });
88+
const { result } = renderHook(
89+
() => useDashboardData(mockAdapter, testAddress, defaultOptions),
90+
{ wrapper }
91+
);
8192

8293
expect(result.current.isLoading).toBe(true);
8394
expect(result.current.rolesCount).toBeNull();
@@ -121,26 +132,38 @@ describe('useDashboardData', () => {
121132
});
122133

123134
it('returns correct roles count', () => {
124-
const { result } = renderHook(() => useDashboardData(mockAdapter, testAddress), { wrapper });
135+
const { result } = renderHook(
136+
() => useDashboardData(mockAdapter, testAddress, defaultOptions),
137+
{ wrapper }
138+
);
125139

126140
expect(result.current.rolesCount).toBe(2);
127141
});
128142

129143
it('returns correct unique accounts count (deduplicated)', () => {
130-
const { result } = renderHook(() => useDashboardData(mockAdapter, testAddress), { wrapper });
144+
const { result } = renderHook(
145+
() => useDashboardData(mockAdapter, testAddress, defaultOptions),
146+
{ wrapper }
147+
);
131148

132149
// 0xabc, 0xdef, 0x123 = 3 unique accounts
133150
expect(result.current.uniqueAccountsCount).toBe(3);
134151
});
135152

136153
it('returns isLoading as false when data is loaded', () => {
137-
const { result } = renderHook(() => useDashboardData(mockAdapter, testAddress), { wrapper });
154+
const { result } = renderHook(
155+
() => useDashboardData(mockAdapter, testAddress, defaultOptions),
156+
{ wrapper }
157+
);
138158

139159
expect(result.current.isLoading).toBe(false);
140160
});
141161

142162
it('returns hasError as false when no errors', () => {
143-
const { result } = renderHook(() => useDashboardData(mockAdapter, testAddress), { wrapper });
163+
const { result } = renderHook(
164+
() => useDashboardData(mockAdapter, testAddress, defaultOptions),
165+
{ wrapper }
166+
);
144167

145168
expect(result.current.hasError).toBe(false);
146169
expect(result.current.errorMessage).toBeNull();
@@ -176,7 +199,10 @@ describe('useDashboardData', () => {
176199
hasError: false,
177200
});
178201

179-
const { result } = renderHook(() => useDashboardData(mockAdapter, testAddress), { wrapper });
202+
const { result } = renderHook(
203+
() => useDashboardData(mockAdapter, testAddress, defaultOptions),
204+
{ wrapper }
205+
);
180206

181207
expect(result.current.hasError).toBe(true);
182208
expect(result.current.errorMessage).toBe('Failed to load roles');
@@ -211,7 +237,10 @@ describe('useDashboardData', () => {
211237
hasError: true,
212238
});
213239

214-
const { result } = renderHook(() => useDashboardData(mockAdapter, testAddress), { wrapper });
240+
const { result } = renderHook(
241+
() => useDashboardData(mockAdapter, testAddress, defaultOptions),
242+
{ wrapper }
243+
);
215244

216245
expect(result.current.hasError).toBe(true);
217246
expect(result.current.errorMessage).toBe('Failed to load ownership');
@@ -252,7 +281,10 @@ describe('useDashboardData', () => {
252281
hasError: true,
253282
});
254283

255-
const { result } = renderHook(() => useDashboardData(mockAdapter, testAddress), { wrapper });
284+
const { result } = renderHook(
285+
() => useDashboardData(mockAdapter, testAddress, defaultOptions),
286+
{ wrapper }
287+
);
256288

257289
expect(result.current.hasError).toBe(true);
258290
// Should show first error or combined message
@@ -288,7 +320,10 @@ describe('useDashboardData', () => {
288320
hasError: false,
289321
});
290322

291-
const { result } = renderHook(() => useDashboardData(mockAdapter, testAddress), { wrapper });
323+
const { result } = renderHook(
324+
() => useDashboardData(mockAdapter, testAddress, defaultOptions),
325+
{ wrapper }
326+
);
292327

293328
await result.current.refetch();
294329

@@ -323,7 +358,10 @@ describe('useDashboardData', () => {
323358
hasError: false,
324359
});
325360

326-
const { result } = renderHook(() => useDashboardData(mockAdapter, testAddress), { wrapper });
361+
const { result } = renderHook(
362+
() => useDashboardData(mockAdapter, testAddress, defaultOptions),
363+
{ wrapper }
364+
);
327365

328366
await expect(result.current.refetch()).rejects.toThrow('Network error');
329367
});
@@ -355,7 +393,10 @@ describe('useDashboardData', () => {
355393
hasError: false,
356394
});
357395

358-
const { result } = renderHook(() => useDashboardData(mockAdapter, testAddress), { wrapper });
396+
const { result } = renderHook(
397+
() => useDashboardData(mockAdapter, testAddress, defaultOptions),
398+
{ wrapper }
399+
);
359400

360401
await expect(result.current.refetch()).rejects.toThrow('Failed to load ownership');
361402
});
@@ -387,7 +428,10 @@ describe('useDashboardData', () => {
387428
hasError: false,
388429
});
389430

390-
const { result } = renderHook(() => useDashboardData(mockAdapter, testAddress), { wrapper });
431+
const { result } = renderHook(
432+
() => useDashboardData(mockAdapter, testAddress, defaultOptions),
433+
{ wrapper }
434+
);
391435

392436
await expect(result.current.refetch()).rejects.toThrow('Failed to refresh data');
393437
});
@@ -418,7 +462,10 @@ describe('useDashboardData', () => {
418462
hasError: false,
419463
});
420464

421-
const { result } = renderHook(() => useDashboardData(mockAdapter, testAddress), { wrapper });
465+
const { result } = renderHook(
466+
() => useDashboardData(mockAdapter, testAddress, defaultOptions),
467+
{ wrapper }
468+
);
422469

423470
expect(result.current.hasAccessControl).toBe(true);
424471
});
@@ -447,7 +494,10 @@ describe('useDashboardData', () => {
447494
hasError: false,
448495
});
449496

450-
const { result } = renderHook(() => useDashboardData(mockAdapter, testAddress), { wrapper });
497+
const { result } = renderHook(
498+
() => useDashboardData(mockAdapter, testAddress, defaultOptions),
499+
{ wrapper }
500+
);
451501

452502
expect(result.current.hasOwnable).toBe(true);
453503
});
@@ -478,7 +528,9 @@ describe('useDashboardData', () => {
478528
hasError: false,
479529
});
480530

481-
const { result } = renderHook(() => useDashboardData(null, testAddress), { wrapper });
531+
const { result } = renderHook(() => useDashboardData(null, testAddress, defaultOptions), {
532+
wrapper,
533+
});
482534

483535
expect(result.current.rolesCount).toBeNull();
484536
expect(result.current.uniqueAccountsCount).toBeNull();
@@ -510,7 +562,9 @@ describe('useDashboardData', () => {
510562
hasError: false,
511563
});
512564

513-
const { result } = renderHook(() => useDashboardData(mockAdapter, ''), { wrapper });
565+
const { result } = renderHook(() => useDashboardData(mockAdapter, '', defaultOptions), {
566+
wrapper,
567+
});
514568

515569
expect(result.current.rolesCount).toBeNull();
516570
});

0 commit comments

Comments
 (0)