Skip to content

Commit 261788c

Browse files
authored
feat(admin): Admin settings should only apply if adminControlsApplicable = true and fetch errors should be fatal (#19453)
1 parent 012392a commit 261788c

5 files changed

Lines changed: 79 additions & 72 deletions

File tree

packages/cli/src/ui/AppContainer.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -699,6 +699,7 @@ export const AppContainer = (props: AppContainerProps) => {
699699
settings.setValue(scope, 'security.auth.selectedType', authType);
700700

701701
try {
702+
config.setRemoteAdminSettings(undefined);
702703
await config.refreshAuth(authType);
703704
setAuthState(AuthState.Authenticated);
704705
} catch (e) {

packages/core/src/code_assist/admin/admin_controls.test.ts

Lines changed: 54 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,7 @@ describe('Admin Controls', () => {
345345
// Should still start polling
346346
(mockServer.fetchAdminControls as Mock).mockResolvedValue({
347347
strictModeDisabled: true,
348+
adminControlsApplicable: true,
348349
});
349350
await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
350351

@@ -363,7 +364,10 @@ describe('Admin Controls', () => {
363364
});
364365

365366
it('should fetch from server if no cachedSettings provided', async () => {
366-
const serverResponse = { strictModeDisabled: false };
367+
const serverResponse = {
368+
strictModeDisabled: false,
369+
adminControlsApplicable: true,
370+
};
367371
(mockServer.fetchAdminControls as Mock).mockResolvedValue(serverResponse);
368372

369373
const result = await fetchAdminControls(
@@ -386,31 +390,24 @@ describe('Admin Controls', () => {
386390
expect(mockServer.fetchAdminControls).toHaveBeenCalledTimes(1);
387391
});
388392

389-
it('should return empty object on fetch error and still start polling', async () => {
390-
(mockServer.fetchAdminControls as Mock).mockRejectedValue(
391-
new Error('Network error'),
392-
);
393-
const result = await fetchAdminControls(
394-
mockServer,
395-
undefined,
396-
true,
397-
mockOnSettingsChanged,
398-
);
393+
it('should throw error on fetch error and NOT start polling', async () => {
394+
const error = new Error('Network error');
395+
(mockServer.fetchAdminControls as Mock).mockRejectedValue(error);
399396

400-
expect(result).toEqual({});
397+
await expect(
398+
fetchAdminControls(mockServer, undefined, true, mockOnSettingsChanged),
399+
).rejects.toThrow(error);
401400

402-
// Polling should have been started and should retry
403-
(mockServer.fetchAdminControls as Mock).mockResolvedValue({
404-
strictModeDisabled: false,
405-
});
401+
// Polling should NOT have been started
402+
// Advance timers just to be absolutely sure
406403
await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
407-
expect(mockServer.fetchAdminControls).toHaveBeenCalledTimes(2); // Initial + poll
404+
expect(mockServer.fetchAdminControls).toHaveBeenCalledTimes(1); // Only initial fetch
408405
});
409406

410-
it('should return empty object on 403 fetch error and STOP polling', async () => {
411-
const error403 = new Error('Forbidden');
412-
Object.assign(error403, { status: 403 });
413-
(mockServer.fetchAdminControls as Mock).mockRejectedValue(error403);
407+
it('should return empty object on adminControlsApplicable false and STOP polling', async () => {
408+
(mockServer.fetchAdminControls as Mock).mockResolvedValue({
409+
adminControlsApplicable: false,
410+
});
414411

415412
const result = await fetchAdminControls(
416413
mockServer,
@@ -421,7 +418,7 @@ describe('Admin Controls', () => {
421418

422419
expect(result).toEqual({});
423420

424-
// Advance time - should NOT poll because of 403
421+
// Advance time - should NOT poll because of adminControlsApplicable: false
425422
await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
426423
expect(mockServer.fetchAdminControls).toHaveBeenCalledTimes(1); // Only the initial call
427424
});
@@ -430,6 +427,7 @@ describe('Admin Controls', () => {
430427
(mockServer.fetchAdminControls as Mock).mockResolvedValue({
431428
strictModeDisabled: false,
432429
unknownField: 'bad',
430+
adminControlsApplicable: true,
433431
});
434432

435433
const result = await fetchAdminControls(
@@ -455,7 +453,9 @@ describe('Admin Controls', () => {
455453
});
456454

457455
it('should reset polling interval if called again', async () => {
458-
(mockServer.fetchAdminControls as Mock).mockResolvedValue({});
456+
(mockServer.fetchAdminControls as Mock).mockResolvedValue({
457+
adminControlsApplicable: true,
458+
});
459459

460460
// First call
461461
await fetchAdminControls(
@@ -514,6 +514,7 @@ describe('Admin Controls', () => {
514514
const serverResponse = {
515515
strictModeDisabled: true,
516516
unknownField: 'should be removed',
517+
adminControlsApplicable: true,
517518
};
518519
(mockServer.fetchAdminControls as Mock).mockResolvedValue(serverResponse);
519520

@@ -532,30 +533,32 @@ describe('Admin Controls', () => {
532533
expect(mockServer.fetchAdminControls).toHaveBeenCalledTimes(1);
533534
});
534535

535-
it('should return empty object on 403 fetch error', async () => {
536-
const error403 = new Error('Forbidden');
537-
Object.assign(error403, { status: 403 });
538-
(mockServer.fetchAdminControls as Mock).mockRejectedValue(error403);
536+
it('should return empty object on adminControlsApplicable false', async () => {
537+
(mockServer.fetchAdminControls as Mock).mockResolvedValue({
538+
adminControlsApplicable: false,
539+
});
539540

540541
const result = await fetchAdminControlsOnce(mockServer, true);
541542
expect(result).toEqual({});
542543
expect(mockServer.fetchAdminControls).toHaveBeenCalledTimes(1);
543544
});
544545

545-
it('should return empty object on any other fetch error', async () => {
546-
(mockServer.fetchAdminControls as Mock).mockRejectedValue(
547-
new Error('Network error'),
546+
it('should throw error on any other fetch error', async () => {
547+
const error = new Error('Network error');
548+
(mockServer.fetchAdminControls as Mock).mockRejectedValue(error);
549+
await expect(fetchAdminControlsOnce(mockServer, true)).rejects.toThrow(
550+
error,
548551
);
549-
const result = await fetchAdminControlsOnce(mockServer, true);
550-
expect(result).toEqual({});
551552
expect(mockServer.fetchAdminControls).toHaveBeenCalledTimes(1);
552553
});
553554

554555
it('should not start or stop any polling timers', async () => {
555556
const setIntervalSpy = vi.spyOn(global, 'setInterval');
556557
const clearIntervalSpy = vi.spyOn(global, 'clearInterval');
557558

558-
(mockServer.fetchAdminControls as Mock).mockResolvedValue({});
559+
(mockServer.fetchAdminControls as Mock).mockResolvedValue({
560+
adminControlsApplicable: true,
561+
});
559562
await fetchAdminControlsOnce(mockServer, true);
560563

561564
expect(setIntervalSpy).not.toHaveBeenCalled();
@@ -568,6 +571,7 @@ describe('Admin Controls', () => {
568571
// Initial fetch
569572
(mockServer.fetchAdminControls as Mock).mockResolvedValue({
570573
strictModeDisabled: true,
574+
adminControlsApplicable: true,
571575
});
572576
await fetchAdminControls(
573577
mockServer,
@@ -579,6 +583,7 @@ describe('Admin Controls', () => {
579583
// Update for next poll
580584
(mockServer.fetchAdminControls as Mock).mockResolvedValue({
581585
strictModeDisabled: false,
586+
adminControlsApplicable: true,
582587
});
583588

584589
// Fast forward
@@ -598,7 +603,10 @@ describe('Admin Controls', () => {
598603
});
599604

600605
it('should NOT emit if settings are deeply equal but not the same instance', async () => {
601-
const settings = { strictModeDisabled: false };
606+
const settings = {
607+
strictModeDisabled: false,
608+
adminControlsApplicable: true,
609+
};
602610
(mockServer.fetchAdminControls as Mock).mockResolvedValue(settings);
603611

604612
await fetchAdminControls(
@@ -613,6 +621,7 @@ describe('Admin Controls', () => {
613621
// Next poll returns a different object with the same values
614622
(mockServer.fetchAdminControls as Mock).mockResolvedValue({
615623
strictModeDisabled: false,
624+
adminControlsApplicable: true,
616625
});
617626
await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
618627

@@ -623,6 +632,7 @@ describe('Admin Controls', () => {
623632
// Initial fetch is successful
624633
(mockServer.fetchAdminControls as Mock).mockResolvedValue({
625634
strictModeDisabled: true,
635+
adminControlsApplicable: true,
626636
});
627637
await fetchAdminControls(
628638
mockServer,
@@ -643,6 +653,7 @@ describe('Admin Controls', () => {
643653
// Subsequent poll succeeds with new data
644654
(mockServer.fetchAdminControls as Mock).mockResolvedValue({
645655
strictModeDisabled: false,
656+
adminControlsApplicable: true,
646657
});
647658
await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
648659
expect(mockServer.fetchAdminControls).toHaveBeenCalledTimes(3);
@@ -659,10 +670,11 @@ describe('Admin Controls', () => {
659670
});
660671
});
661672

662-
it('should STOP polling if server returns 403', async () => {
673+
it('should STOP polling if server returns adminControlsApplicable false', async () => {
663674
// Initial fetch is successful
664675
(mockServer.fetchAdminControls as Mock).mockResolvedValue({
665676
strictModeDisabled: true,
677+
adminControlsApplicable: true,
666678
});
667679
await fetchAdminControls(
668680
mockServer,
@@ -672,10 +684,10 @@ describe('Admin Controls', () => {
672684
);
673685
expect(mockServer.fetchAdminControls).toHaveBeenCalledTimes(1);
674686

675-
// Next poll returns 403
676-
const error403 = new Error('Forbidden');
677-
Object.assign(error403, { status: 403 });
678-
(mockServer.fetchAdminControls as Mock).mockRejectedValue(error403);
687+
// Next poll returns adminControlsApplicable: false
688+
(mockServer.fetchAdminControls as Mock).mockResolvedValue({
689+
adminControlsApplicable: false,
690+
});
679691

680692
await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
681693
expect(mockServer.fetchAdminControls).toHaveBeenCalledTimes(2);
@@ -688,7 +700,9 @@ describe('Admin Controls', () => {
688700

689701
describe('stopAdminControlsPolling', () => {
690702
it('should stop polling after it has started', async () => {
691-
(mockServer.fetchAdminControls as Mock).mockResolvedValue({});
703+
(mockServer.fetchAdminControls as Mock).mockResolvedValue({
704+
adminControlsApplicable: true,
705+
});
692706

693707
// Start polling
694708
await fetchAdminControls(

packages/core/src/code_assist/admin/admin_controls.ts

Lines changed: 22 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -80,15 +80,6 @@ export function sanitizeAdminSettings(
8080
};
8181
}
8282

83-
function isGaxiosError(error: unknown): error is { status: number } {
84-
return (
85-
typeof error === 'object' &&
86-
error !== null &&
87-
'status' in error &&
88-
typeof (error as { status: unknown }).status === 'number'
89-
);
90-
}
91-
9283
/**
9384
* Fetches the admin controls from the server if enabled by experiment flag.
9485
* Safely handles polling start/stop based on the flag and server availability.
@@ -113,7 +104,7 @@ export async function fetchAdminControls(
113104

114105
// If we already have settings (e.g. from IPC during relaunch), use them
115106
// to avoid blocking startup with another fetch. We'll still start polling.
116-
if (cachedSettings) {
107+
if (cachedSettings && Object.keys(cachedSettings).length !== 0) {
117108
currentSettings = cachedSettings;
118109
startAdminControlsPolling(server, server.projectId, onSettingsChanged);
119110
return cachedSettings;
@@ -123,22 +114,20 @@ export async function fetchAdminControls(
123114
const rawSettings = await server.fetchAdminControls({
124115
project: server.projectId,
125116
});
117+
118+
if (rawSettings.adminControlsApplicable !== true) {
119+
stopAdminControlsPolling();
120+
currentSettings = undefined;
121+
return {};
122+
}
123+
126124
const sanitizedSettings = sanitizeAdminSettings(rawSettings);
127125
currentSettings = sanitizedSettings;
128126
startAdminControlsPolling(server, server.projectId, onSettingsChanged);
129127
return sanitizedSettings;
130128
} catch (e) {
131-
// Non-enterprise users don't have access to fetch settings.
132-
if (isGaxiosError(e) && e.status === 403) {
133-
stopAdminControlsPolling();
134-
currentSettings = undefined;
135-
return {};
136-
}
137129
debugLogger.error('Failed to fetch admin controls: ', e);
138-
// If initial fetch fails, start polling to retry.
139-
currentSettings = {};
140-
startAdminControlsPolling(server, server.projectId, onSettingsChanged);
141-
return {};
130+
throw e;
142131
}
143132
}
144133

@@ -162,17 +151,18 @@ export async function fetchAdminControlsOnce(
162151
const rawSettings = await server.fetchAdminControls({
163152
project: server.projectId,
164153
});
165-
return sanitizeAdminSettings(rawSettings);
166-
} catch (e) {
167-
// Non-enterprise users don't have access to fetch settings.
168-
if (isGaxiosError(e) && e.status === 403) {
154+
155+
if (rawSettings.adminControlsApplicable !== true) {
169156
return {};
170157
}
158+
159+
return sanitizeAdminSettings(rawSettings);
160+
} catch (e) {
171161
debugLogger.error(
172162
'Failed to fetch admin controls: ',
173163
e instanceof Error ? e.message : e,
174164
);
175-
return {};
165+
throw e;
176166
}
177167
}
178168

@@ -192,19 +182,20 @@ function startAdminControlsPolling(
192182
const rawSettings = await server.fetchAdminControls({
193183
project,
194184
});
185+
186+
if (rawSettings.adminControlsApplicable !== true) {
187+
stopAdminControlsPolling();
188+
currentSettings = undefined;
189+
return;
190+
}
191+
195192
const newSettings = sanitizeAdminSettings(rawSettings);
196193

197194
if (!isDeepStrictEqual(newSettings, currentSettings)) {
198195
currentSettings = newSettings;
199196
onSettingsChanged(newSettings);
200197
}
201198
} catch (e) {
202-
// Non-enterprise users don't have access to fetch settings.
203-
if (isGaxiosError(e) && e.status === 403) {
204-
stopAdminControlsPolling();
205-
currentSettings = undefined;
206-
return;
207-
}
208199
debugLogger.error('Failed to poll admin controls: ', e);
209200
}
210201
},

packages/core/src/code_assist/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,4 +355,5 @@ export const FetchAdminControlsResponseSchema = z.object({
355355
strictModeDisabled: z.boolean().optional(),
356356
mcpSetting: McpSettingSchema.optional(),
357357
cliFeatureSetting: CliFeatureSettingSchema.optional(),
358+
adminControlsApplicable: z.boolean().optional(),
358359
});

packages/core/src/config/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1171,7 +1171,7 @@ export class Config {
11711171
return this.remoteAdminSettings;
11721172
}
11731173

1174-
setRemoteAdminSettings(settings: AdminControlsSettings): void {
1174+
setRemoteAdminSettings(settings: AdminControlsSettings | undefined): void {
11751175
this.remoteAdminSettings = settings;
11761176
}
11771177

0 commit comments

Comments
 (0)