Skip to content

Commit 5444de3

Browse files
ana-davydovashahzad31cursoragent
committed
Legacy Uptime app appears in nav for users with feature visibility but no ES index privileges (#271932)
Closes [#5657](elastic/observability-dev#5657) Fixes the legacy Uptime app showing in the navigation for users who have Kibana feature visibility for "Synthetics and Uptime" but no Elasticsearch index read privileges. **Testing** Non-visible Uptime: 1. Create a space with Classic or Observability solution nav 2. Create a role with Kibana feature visibility for Synthetics and Uptime only — no ES index privileges 3. Create a user with that role and log in 4. Before: Uptime appears in the nav 5. After: Uptime is hidden from the nav Only when feature 'Always show legacy Uptime app': true: 1. Log in as superuser 2. Enable `observability:enableLegacyUptimeApp` in Advanced Settings 3. Expected: Uptime appears in the nav ✓ --------- Co-authored-by: Shahzad <shahzad31comp@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> (cherry picked from commit 50280fb)
1 parent c08f9e6 commit 5444de3

2 files changed

Lines changed: 204 additions & 9 deletions

File tree

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import type { BehaviorSubject } from 'rxjs';
9+
import { coreMock } from '@kbn/core/public/mocks';
10+
import type { App, AppUpdater } from '@kbn/core-application-browser';
11+
import { AppStatus } from '@kbn/core-application-browser';
12+
import { enableLegacyUptimeApp } from '@kbn/observability-plugin/public';
13+
14+
import { UptimePlugin } from './plugin';
15+
import type { ClientPluginsSetup, ClientPluginsStart } from './plugin';
16+
import { UptimeDataHelper } from './legacy_uptime/app/uptime_overview_fetcher';
17+
18+
jest.mock('./legacy_uptime/app/uptime_overview_fetcher');
19+
jest.mock('./legacy_uptime/lib/alert_types', () => ({
20+
legacyAlertTypeInitializers: [],
21+
uptimeAlertTypeInitializers: [],
22+
}));
23+
jest.mock('./legacy_uptime/components/fleet_package', () => ({
24+
LazySyntheticsPolicyCreateExtension: () => null,
25+
LazySyntheticsPolicyEditExtension: () => null,
26+
}));
27+
jest.mock(
28+
'./legacy_uptime/components/fleet_package/lazy_synthetics_custom_assets_extension',
29+
() => ({
30+
LazySyntheticsCustomAssetsExtension: () => null,
31+
})
32+
);
33+
jest.mock('./kibana_services', () => ({ setStartServices: jest.fn() }));
34+
35+
const mockUptimeDataHelper = UptimeDataHelper as jest.MockedFunction<typeof UptimeDataHelper>;
36+
37+
const flushPromises = () => new Promise((resolve) => setImmediate(resolve));
38+
39+
const createInitContext = () =>
40+
({
41+
config: { get: () => ({}) },
42+
env: { packageInfo: { version: '9.0.0' }, mode: { dev: false } },
43+
} as unknown as ConstructorParameters<typeof UptimePlugin>[0]);
44+
45+
const createPluginsSetup = () =>
46+
({
47+
observability: { dashboard: { register: jest.fn() } },
48+
exploratoryView: { register: jest.fn() },
49+
} as unknown as ClientPluginsSetup);
50+
51+
const createPluginsStart = () =>
52+
({
53+
fleet: { registerExtension: jest.fn() },
54+
observability: { observabilityRuleTypeRegistry: { register: jest.fn() } },
55+
triggersActionsUi: {
56+
ruleTypeRegistry: { has: jest.fn().mockReturnValue(false), register: jest.fn() },
57+
},
58+
share: { url: { locators: { create: jest.fn() } } },
59+
observabilityShared: { navigation: { registerSections: jest.fn() } },
60+
} as unknown as ClientPluginsStart);
61+
62+
const getLatestStatus = (updater$: BehaviorSubject<AppUpdater>): AppStatus | undefined => {
63+
let latest: Partial<App> | undefined;
64+
updater$
65+
.subscribe((updaterFn) => {
66+
latest = updaterFn({} as App);
67+
})
68+
.unsubscribe();
69+
return latest?.status;
70+
};
71+
72+
interface Scenario {
73+
legacyEnabled?: boolean;
74+
hasUptimeCapability?: boolean;
75+
indexStatus?: 'data' | 'no-data' | 'error';
76+
}
77+
78+
const setupPlugin = (scenario: Scenario = {}) => {
79+
const { legacyEnabled = false, hasUptimeCapability = true, indexStatus = 'no-data' } = scenario;
80+
81+
const indexStatusMock = jest.fn();
82+
if (indexStatus === 'data') {
83+
indexStatusMock.mockResolvedValue({ indexExists: true, indices: 'heartbeat-*' });
84+
} else if (indexStatus === 'no-data') {
85+
indexStatusMock.mockResolvedValue({ indexExists: false, indices: '' });
86+
} else {
87+
// A user with feature visibility but no ES index read privileges gets a 403
88+
// from the index-status endpoint, which rejects the promise.
89+
indexStatusMock.mockRejectedValue({ body: { statusCode: 403 } });
90+
}
91+
92+
mockUptimeDataHelper.mockReturnValue({
93+
indexStatus: indexStatusMock,
94+
overviewData: jest.fn(),
95+
} as unknown as ReturnType<typeof UptimeDataHelper>);
96+
97+
const coreSetup = coreMock.createSetup();
98+
const coreStart = coreMock.createStart();
99+
100+
(coreStart.uiSettings.get as jest.Mock).mockImplementation((key: string) =>
101+
key === enableLegacyUptimeApp ? legacyEnabled : undefined
102+
);
103+
(coreStart.application as unknown as { capabilities: Record<string, unknown> }).capabilities = {
104+
...coreStart.application.capabilities,
105+
uptime: { show: hasUptimeCapability },
106+
};
107+
108+
const plugin = new UptimePlugin(createInitContext());
109+
plugin.setup(coreSetup, createPluginsSetup());
110+
111+
const registerCall = (coreSetup.application.register as jest.Mock).mock.calls[0][0];
112+
const updater$ = registerCall.updater$ as BehaviorSubject<AppUpdater>;
113+
114+
return { plugin, coreStart, updater$, indexStatusMock };
115+
};
116+
117+
describe('UptimePlugin app status', () => {
118+
afterEach(() => {
119+
jest.clearAllMocks();
120+
});
121+
122+
it('registers the app as inaccessible by default before the data check runs', () => {
123+
const { updater$ } = setupPlugin();
124+
125+
expect(getLatestStatus(updater$)).toBe(AppStatus.inaccessible);
126+
});
127+
128+
it('makes the app accessible when the legacy Uptime app setting is enabled', async () => {
129+
const { plugin, coreStart, updater$ } = setupPlugin({ legacyEnabled: true });
130+
131+
plugin.start(coreStart, createPluginsStart());
132+
await flushPromises();
133+
134+
expect(getLatestStatus(updater$)).toBe(AppStatus.accessible);
135+
});
136+
137+
it('makes the app accessible when legacy heartbeat data exists', async () => {
138+
const { plugin, coreStart, updater$ } = setupPlugin({
139+
hasUptimeCapability: true,
140+
indexStatus: 'data',
141+
});
142+
143+
plugin.start(coreStart, createPluginsStart());
144+
await flushPromises();
145+
146+
expect(getLatestStatus(updater$)).toBe(AppStatus.accessible);
147+
});
148+
149+
it('keeps the app inaccessible when there is no legacy heartbeat data', async () => {
150+
const { plugin, coreStart, updater$ } = setupPlugin({
151+
hasUptimeCapability: true,
152+
indexStatus: 'no-data',
153+
});
154+
155+
plugin.start(coreStart, createPluginsStart());
156+
await flushPromises();
157+
158+
expect(getLatestStatus(updater$)).toBe(AppStatus.inaccessible);
159+
});
160+
161+
it('keeps the app inaccessible when the index-status check fails with a 403 (feature visibility but no ES index privileges)', async () => {
162+
const { plugin, coreStart, updater$, indexStatusMock } = setupPlugin({
163+
hasUptimeCapability: true,
164+
indexStatus: 'error',
165+
});
166+
167+
plugin.start(coreStart, createPluginsStart());
168+
await flushPromises();
169+
170+
expect(indexStatusMock).toHaveBeenCalled();
171+
expect(getLatestStatus(updater$)).toBe(AppStatus.inaccessible);
172+
});
173+
174+
it('keeps the app inaccessible and skips the data check when the user lacks the Uptime capability', async () => {
175+
const { plugin, coreStart, updater$, indexStatusMock } = setupPlugin({
176+
hasUptimeCapability: false,
177+
});
178+
179+
plugin.start(coreStart, createPluginsStart());
180+
await flushPromises();
181+
182+
expect(indexStatusMock).not.toHaveBeenCalled();
183+
expect(getLatestStatus(updater$)).toBe(AppStatus.inaccessible);
184+
});
185+
});

x-pack/solutions/observability/plugins/uptime/public/plugin.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,9 @@ export class UptimePlugin
130130
this.initContext.config.get().experimental || this.experimentalFeatures;
131131
}
132132

133-
private uptimeAppUpdater = new BehaviorSubject<AppUpdater>(() => ({}));
133+
private uptimeAppUpdater = new BehaviorSubject<AppUpdater>(() => ({
134+
status: AppStatus.inaccessible,
135+
}));
134136
private experimentalFeatures: UptimeConfig['experimental'] = {
135137
ruleFormV2Enabled: false,
136138
};
@@ -313,16 +315,24 @@ function setUptimeAppStatus(
313315
const hasUptimePrivileges = coreStart.application.capabilities.uptime?.show;
314316
if (hasUptimePrivileges) {
315317
const indexStatusPromise = UptimeDataHelper(coreStart).indexStatus('now-7d/d', 'now/d');
316-
indexStatusPromise.then((indexStatus) => {
317-
if (indexStatus.indexExists) {
318-
registerUptimeRoutesWithNavigation(coreStart, pluginsStart);
319-
updater.next(() => ({ status: AppStatus.accessible }));
320-
registerAlertRules(coreStart, pluginsStart, stackVersion, false);
321-
} else {
318+
indexStatusPromise
319+
.then((indexStatus) => {
320+
if (indexStatus.indexExists) {
321+
registerUptimeRoutesWithNavigation(coreStart, pluginsStart);
322+
updater.next(() => ({ status: AppStatus.accessible }));
323+
registerAlertRules(coreStart, pluginsStart, stackVersion, false);
324+
} else {
325+
updater.next(() => ({ status: AppStatus.inaccessible }));
326+
registerAlertRules(coreStart, pluginsStart, stackVersion, true);
327+
}
328+
})
329+
.catch(() => {
330+
// The index-status check runs as the current user, so feature visibility
331+
// without ES index read privileges returns a 403 (not a 404). Keep the app
332+
// hidden on any failure instead of leaving the promise rejection unhandled.
322333
updater.next(() => ({ status: AppStatus.inaccessible }));
323334
registerAlertRules(coreStart, pluginsStart, stackVersion, true);
324-
}
325-
});
335+
});
326336
}
327337
}
328338
}

0 commit comments

Comments
 (0)