fix: keep synthetics reachable on an org with no data (#14496)

This commit is contained in:
Omkar Kesarkhane
2026-09-15 17:51:06 +00:00
committed by GitHub
parent 81c2e30d04
commit 9f05c3bb95
13 changed files with 257 additions and 231 deletions
-27
View File
@@ -68,9 +68,7 @@ const router = createRouter({
// Mock the router methods we need to test
const mockRouterPush = vi.fn();
const mockRouterReplace = vi.fn().mockResolvedValue(undefined);
router.push = mockRouterPush;
router.replace = mockRouterReplace;
const createWrapper = (props = {}, options = {}) => {
return mount(SettingsIndex, {
@@ -125,7 +123,6 @@ describe("SettingsIndex", () => {
mockStore.state.selectedOrganization = { identifier: "test-org" };
mockStore.state.zoConfig = { service_streams_enabled: true };
mockRouterPush.mockClear();
mockRouterReplace.mockClear();
// Set up router state
await router.push("/");
@@ -238,24 +235,6 @@ describe("SettingsIndex", () => {
});
describe("Router integration", () => {
it("should redirect to /settings/general on settings root route", () => {
createWrapper();
expect(mockRouterReplace).toHaveBeenCalledWith(
expect.objectContaining({
path: "/settings/general",
}),
);
});
it("should include org_identifier in redirect query", () => {
createWrapper();
expect(mockRouterReplace).toHaveBeenCalledWith(
expect.objectContaining({
query: { org_identifier: "test-org" },
}),
);
});
it("should map syntheticsLocations route to synthetics_locations tab", () => {
// Save the original route reference so we can restore it after the test
// (router.push is mocked and won't reset currentRoute on its own).
@@ -393,12 +372,6 @@ describe("SettingsIndex", () => {
const wrapper = createWrapper();
expect(wrapper.vm.settingsTab).toBe("general");
});
it("should expose isHub computed property", () => {
const wrapper = createWrapper();
// On '/' route named 'settings', isHub is true
expect(typeof wrapper.vm.isHub).toBe("boolean");
});
});
describe("Edge cases", () => {
-25
View File
@@ -109,12 +109,6 @@ export default defineComponent({
);
const { isMetaOrg } = useIsMetaOrg();
// /settings (name "settings") with no child → show the hub.
const isHub = computed(() => route.name === "settings");
const hubRoute = computed(() => ({
name: "settings",
query: { org_identifier: store.state.selectedOrganization?.identifier },
}));
const activeSection = computed(() => routeToSettingsTab[route.name as string] ?? "");
// Form-style sections render in a centered reading column (ConstrainedPage);
@@ -127,25 +121,8 @@ export default defineComponent({
]);
const isConstrainedSection = computed(() => CONSTRAINED_SECTIONS.has(activeSection.value));
// Full-width sections that still want the shell-owned header (their content
// fills the whole width instead of a centered reading column).
// The rail is always shown, so the Settings root has no standalone landing —
// send it to the first section (General). Also guard meta-only sections.
const handleSettingsRouting = () => {
const name = router.currentRoute.value.name;
if (name === "settings") {
// .catch: a rejected navigation (e.g. unit-test router without child
// routes) must not surface as an unhandled error.
Promise.resolve(
router.replace({
path: "/settings/general",
query: {
org_identifier: store.state.selectedOrganization?.identifier,
},
}),
).catch(() => {});
return;
}
const toGeneral = () => {
settingsTab.value = "general";
router.push({
@@ -417,8 +394,6 @@ export default defineComponent({
config,
settingsTab,
isMetaOrg,
isHub,
hubRoute,
activeSection,
isConstrainedSection,
activeSectionItem,
@@ -219,21 +219,6 @@ describe("SettingsIndex.vue", () => {
mockReplace.mockClear();
});
it("should redirect via replace to general when on settings route", () => {
mockRouter.currentRoute.value.name = "settings";
wrapper = createWrapper();
mockReplace.mockClear();
wrapper.vm.handleSettingsRouting();
expect(mockReplace).toHaveBeenCalledWith({
path: "/settings/general",
query: {
org_identifier: "test-org",
},
});
});
it("should not redirect when on general route", () => {
mockRouter.currentRoute.value.name = "general";
wrapper = createWrapper();
@@ -254,9 +239,7 @@ describe("SettingsIndex.vue", () => {
wrapper.vm.handleSettingsRouting();
// nodes route is not "settings" so replace is not called;
// notMeta guard may or may not apply depending on zoConfig.meta_org
// Without meta_org set, notMeta is falsy → no redirect
// Without meta_org set, the notMeta guard is falsy, so nodes is not redirected.
expect(mockReplace).not.toHaveBeenCalled();
});
@@ -280,20 +263,6 @@ describe("SettingsIndex.vue", () => {
expect(mockReplace).not.toHaveBeenCalled();
});
it("should handle missing selectedOrganization gracefully", () => {
wrapper = createWrapper({ selectedOrganization: null });
mockRouter.currentRoute.value.name = "settings";
mockReplace.mockClear();
expect(() => wrapper.vm.handleSettingsRouting()).not.toThrow();
expect(mockReplace).toHaveBeenCalledWith({
path: "/settings/general",
query: {
org_identifier: undefined,
},
});
});
it("should not redirect when on other non-settings routes", () => {
mockRouter.currentRoute.value.name = "alertDestinations";
wrapper = createWrapper();
@@ -328,17 +297,6 @@ describe("SettingsIndex.vue", () => {
});
describe("Lifecycle Hooks", () => {
it("should call handleSettingsRouting on mount (settings route)", () => {
mockRouter.currentRoute.value.name = "settings";
wrapper = createWrapper();
// During mount, handleSettingsRouting is called via onBeforeMount
// It should have triggered a replace call for the "settings" route
expect(mockReplace).toHaveBeenCalledWith(
expect.objectContaining({ path: "/settings/general" }),
);
});
it("should not redirect during mount when on a valid child route", () => {
mockRouter.currentRoute.value.name = "general";
mockReplace.mockClear();
@@ -450,17 +408,6 @@ describe("SettingsIndex.vue", () => {
});
describe("Integration Tests", () => {
it("should handle workflow for non-enterprise org", async () => {
mockRouter.currentRoute.value.name = "settings";
wrapper = createWrapper();
mockReplace.mockClear();
wrapper.vm.handleSettingsRouting();
expect(mockReplace).toHaveBeenCalledWith(
expect.objectContaining({ path: "/settings/general" }),
);
});
it("should have stable activeSection after mount on general", () => {
mockRouter.currentRoute.value.name = "general";
wrapper = createWrapper();
@@ -485,15 +432,6 @@ describe("SettingsIndex.vue", () => {
wrapper = createWrapper();
expect(() => wrapper.vm.handleSettingsRouting()).not.toThrow();
});
it("should handle router replace failures gracefully", () => {
mockRouter.currentRoute.value.name = "settings";
// replace returns a rejected promise — the component wraps it in Promise.resolve().catch()
mockReplace.mockReturnValue(Promise.reject(new Error("nav failed")));
expect(() => {
wrapper = createWrapper();
}).not.toThrow();
});
});
describe("Computed Properties Reactivity", () => {
@@ -280,6 +280,29 @@ describe("useEnterpriseRoutes.ts", () => {
]),
);
});
// Synthetics produces an empty org's first data, so the empty-data gate must not block it.
it.each([
"synthetics",
"synthetics-add",
"synthetics-edit",
"synthetics-status-page-edit",
"synthetic-private-location",
"synthetic-monitor-results",
"synthetics-run-detail",
])("should flag the %s route as allowOnEmptyData", (name) => {
const routes = useEnterpriseRoutes();
const route = routes.find((r: any) => r.name === name);
expect(route).toBeDefined();
expect(route.meta?.allowOnEmptyData).toBe(true);
});
// The flag on the parent is inherited by every IAM child through merged meta.
it("should flag the iam route as allowOnEmptyData", () => {
const routes = useEnterpriseRoutes();
const iamRoute = routes.find((route: any) => route.name === "iam");
expect(iamRoute.meta?.allowOnEmptyData).toBe(true);
});
});
describe("Cloud Configuration", () => {
@@ -386,6 +409,22 @@ describe("useEnterpriseRoutes.ts", () => {
const routes = useEnterpriseRoutes();
expect(routes.length).toBe(18);
});
// On-call is configured before any data flows, so the empty-data gate must not block it.
it.each([
"onCallResponses",
"onCallResponseDetail",
"onCallMine",
"onCallTeams",
"onCallTeamDetail",
"onCallPolicies",
"onCallRouting",
])("should flag the %s route as allowOnEmptyData", (name) => {
const routes = useEnterpriseRoutes();
const route = routes.find((r: any) => r.name === name);
expect(route).toBeDefined();
expect(route.meta?.allowOnEmptyData).toBe(true);
});
});
describe("Both Cloud and Enterprise Configuration", () => {
@@ -112,6 +112,7 @@ const useEnterpriseRoutes = () => {
path: "iam",
name: "iam",
component: IdentityAccessManagement,
meta: { allowOnEmptyData: true },
beforeEnter(to: any, from: any, next: any) {
routeGuard(to, from, next);
},
@@ -215,7 +216,7 @@ const useEnterpriseRoutes = () => {
path: "synthetics",
name: "synthetics",
component: () => import("@/views/SyntheticMonitoring.vue"),
meta: { titleKey: "menu.synthetic" },
meta: { titleKey: "menu.synthetic", allowOnEmptyData: true },
beforeEnter(to: any, from: any, next: any) {
syntheticsRouteGuard(to, from, next);
},
@@ -226,7 +227,7 @@ const useEnterpriseRoutes = () => {
path: "synthetics/add",
name: "synthetics-add",
component: () => import("@/views/synthetics/CreateCheck.vue"),
meta: { titleKey: "routeTitles.addCheck" },
meta: { titleKey: "routeTitles.addCheck", allowOnEmptyData: true },
beforeEnter(to: any, from: any, next: any) {
syntheticsRouteGuard(to, from, next);
},
@@ -235,7 +236,7 @@ const useEnterpriseRoutes = () => {
path: "synthetics/edit/:id",
name: "synthetics-edit",
component: () => import("@/views/synthetics/CreateCheck.vue"),
meta: { titleKey: "synthetics.results.editCheck" },
meta: { titleKey: "synthetics.results.editCheck", allowOnEmptyData: true },
beforeEnter(to: any, from: any, next: any) {
syntheticsRouteGuard(to, from, next);
},
@@ -244,7 +245,7 @@ const useEnterpriseRoutes = () => {
path: "synthetics/status-pages/edit/:id",
name: "synthetics-status-page-edit",
component: () => import("@/views/synthetics/status-pages/StatusPageEditor.vue"),
meta: { titleKey: "statusPages.editTitle" },
meta: { titleKey: "statusPages.editTitle", allowOnEmptyData: true },
beforeEnter(to: any, from: any, next: any) {
syntheticsRouteGuard(to, from, next);
},
@@ -253,7 +254,7 @@ const useEnterpriseRoutes = () => {
path: "synthetic/private-locations/:id",
name: "synthetic-private-location",
component: () => import("@/views/synthetics/PrivateLocationDetail.vue"),
meta: { titleKey: "synthetics.privateLocations.detail.title" },
meta: { titleKey: "synthetics.privateLocations.detail.title", allowOnEmptyData: true },
beforeEnter(to: any, from: any, next: any) {
privateLocationRouteGuard(to, from, next);
},
@@ -262,7 +263,7 @@ const useEnterpriseRoutes = () => {
path: "synthetics/:id/results",
name: "synthetic-monitor-results",
component: () => import("@/views/synthetics/MonitorResults.vue"),
meta: { titleKey: "synthetics.results.title" },
meta: { titleKey: "synthetics.results.title", allowOnEmptyData: true },
beforeEnter(to: any, from: any, next: any) {
syntheticsRouteGuard(to, from, next);
},
@@ -271,7 +272,7 @@ const useEnterpriseRoutes = () => {
path: "synthetics/:id/results/run/:runId/:executionId",
name: "synthetics-run-detail",
component: () => import("@/views/synthetics/RunDetail.vue"),
meta: { titleKey: "synthetics.runDetail.title" },
meta: { titleKey: "synthetics.runDetail.title", allowOnEmptyData: true },
beforeEnter(to: any, from: any, next: any) {
syntheticsRouteGuard(to, from, next);
},
@@ -282,14 +283,13 @@ const useEnterpriseRoutes = () => {
//the above are the routes that we support for oss including both enterprise and cloud
if (config.isCloud == "true" || config.isEnterprise == "true") {
// Seven on-call routes, one guard, one gate expression. `titleKey` rather
// than `title` so the browser tab is translated like everything else.
// On-call is configured before any data flows, so every route here stays open on an empty org.
routes.push(
{
path: "oncall/responses",
name: "onCallResponses",
component: OnCallResponses,
meta: { titleKey: "oncall.responsesTitle" },
meta: { titleKey: "oncall.responsesTitle", allowOnEmptyData: true },
beforeEnter(to: any, from: any, next: any) {
oncallRouteGuard(to, from, next);
},
@@ -298,7 +298,7 @@ const useEnterpriseRoutes = () => {
path: "oncall/responses/:responseId",
name: "onCallResponseDetail",
component: OnCallResponseDetail,
meta: { titleKey: "oncall.responseDetail" },
meta: { titleKey: "oncall.responseDetail", allowOnEmptyData: true },
beforeEnter(to: any, from: any, next: any) {
oncallRouteGuard(to, from, next);
},
@@ -316,7 +316,7 @@ const useEnterpriseRoutes = () => {
path: "oncall/me",
name: "onCallMine",
component: OnCallMine,
meta: { titleKey: "oncall.mineTitle" },
meta: { titleKey: "oncall.mineTitle", allowOnEmptyData: true },
beforeEnter(to: any, from: any, next: any) {
oncallRouteGuard(to, from, next);
},
@@ -325,7 +325,7 @@ const useEnterpriseRoutes = () => {
path: "oncall/teams",
name: "onCallTeams",
component: OnCallTeams,
meta: { titleKey: "oncall.teamsTitle" },
meta: { titleKey: "oncall.teamsTitle", allowOnEmptyData: true },
beforeEnter(to: any, from: any, next: any) {
oncallRouteGuard(to, from, next);
},
@@ -343,7 +343,7 @@ const useEnterpriseRoutes = () => {
path: "oncall/teams/:teamId/:tab(overview|schedule|members|policy|escalation|ownership|routing)?",
name: "onCallTeamDetail",
component: OnCallTeamDetail,
meta: { titleKey: "oncall.teamDetail" },
meta: { titleKey: "oncall.teamDetail", allowOnEmptyData: true },
beforeEnter(to: any, from: any, next: any) {
oncallRouteGuard(to, from, next);
},
@@ -352,7 +352,7 @@ const useEnterpriseRoutes = () => {
path: "oncall/policies",
name: "onCallPolicies",
component: OnCallPolicies,
meta: { titleKey: "oncall.policiesTitle" },
meta: { titleKey: "oncall.policiesTitle", allowOnEmptyData: true },
beforeEnter(to: any, from: any, next: any) {
oncallRouteGuard(to, from, next);
},
@@ -361,7 +361,7 @@ const useEnterpriseRoutes = () => {
path: "oncall/routing",
name: "onCallRouting",
component: OnCallRouting,
meta: { titleKey: "oncall.routingTitle" },
meta: { titleKey: "oncall.routingTitle", allowOnEmptyData: true },
beforeEnter(to: any, from: any, next: any) {
oncallRouteGuard(to, from, next);
},
@@ -143,6 +143,12 @@ describe("useIngestionRoutes", () => {
expect(Array.isArray(mainRoute.children)).toBe(true);
});
// Ingestion is where an empty org gets data, so the empty-data gate must not block it.
it("should flag the ingestion route as allowOnEmptyData", () => {
const routes = useIngestionRoutes();
expect(routes[0].meta?.allowOnEmptyData).toBe(true);
});
it("should have all expected main child routes", () => {
const routes = useIngestionRoutes();
const mainRoute = routes[0];
@@ -145,6 +145,7 @@ const useIngestionRoutes = () => {
component: Ingestion,
meta: {
titleKey: "menu.ingestionText",
allowOnEmptyData: true,
},
beforeEnter(to: any, from: any, next: any) {
routeGuard(to, from, next);
@@ -122,6 +122,15 @@ describe("useManagementRoutes", () => {
const routes = useManagementRoutes();
expect(routes[0].children.length).toBeGreaterThanOrEqual(4);
});
// Bare /settings never lands; the record redirects to general and the query must survive the hop.
it("should redirect settings to general, preserving the query", () => {
const routes = useManagementRoutes();
expect(routes[0].redirect({ query: { org_identifier: "o" } })).toEqual({
name: "general",
query: { org_identifier: "o" },
});
});
});
describe("Base Children Routes", () => {
@@ -137,6 +146,12 @@ describe("useManagementRoutes", () => {
expect(generalRoute.path).toBe("general");
});
// General hosts the Danger Zone; an admin must reach it to delete an empty org.
it("should flag the general route as allowOnEmptyData", () => {
const generalRoute = routes[0].children.find((child: any) => child.name === "general");
expect(generalRoute.meta?.allowOnEmptyData).toBe(true);
});
it("should have organizationSettings route", () => {
const orgRoute = routes[0].children.find(
(child: any) => child.name === "organizationSettings",
@@ -398,9 +413,19 @@ describe("useManagementRoutes", () => {
expect(synthRoute.meta).toEqual({
keepAlive: true,
titleKey: "routeTitles.syntheticsLocations",
allowOnEmptyData: true,
});
});
// Locations are a prerequisite of the first check, so the empty-data gate must not block them.
it("should flag the syntheticsLocations route as allowOnEmptyData", () => {
const routes = useManagementRoutes();
const synthRoute = routes[0].children.find(
(child: any) => child.name === "syntheticsLocations",
);
expect(synthRoute.meta?.allowOnEmptyData).toBe(true);
});
// Synthetics ships in OSS, so the route registers in every build — the
// feature gate is the backend `/config` flag, not the build type.
it("should have syntheticsLocations route in an OSS build", () => {
@@ -13,6 +13,7 @@ const useManagementRoutes = () => {
keepAlive: true,
titleKey: "menu.settings",
},
redirect: (to: any) => ({ name: "general", query: to.query }),
beforeEnter(to: any, from: any, next: any) {
routeGuard(to, from, next);
},
@@ -22,6 +23,7 @@ const useManagementRoutes = () => {
name: "general",
meta: {
titleKey: "settings.groupGeneral",
allowOnEmptyData: true,
},
component: () => import("@/components/settings/General.vue"),
beforeEnter(to: any, from: any, next: any) {
@@ -73,9 +75,11 @@ const useManagementRoutes = () => {
path: "synthetics_locations",
name: "syntheticsLocations",
component: () => import("@/components/settings/SyntheticsLocationsList.vue"),
// Locations are set up before the first check can run, so an empty org must reach this.
meta: {
keepAlive: true,
titleKey: "routeTitles.syntheticsLocations",
allowOnEmptyData: true,
},
beforeEnter(to: any, from: any, next: any) {
routeGuard(to, from, next);
+2 -13
View File
@@ -228,7 +228,7 @@ import {
getImageURL,
invalidateLoginData,
shouldPaywallRoute,
emptyDataAllowedPaths,
isEmptyDataExempt,
} from "../utils/zincutils";
import {
@@ -954,18 +954,7 @@ export default defineComponent({
});
if (response.list.length == 0) {
store.dispatch("setIsDataIngested", false);
// IAM is org-setup, not data consumption — don't bounce out of IAM
// screens just because no streams exist yet. General Settings is exempt
// because it hosts the Danger Zone: switching to an empty org must still
// leave the admin able to delete it. Mirrors the routeGuard exemptions —
// General only, not the rest of the Settings tree.
const currentPath = router.currentRoute.value.path || "";
if (
currentPath.indexOf("/iam") !== -1 ||
emptyDataAllowedPaths.indexOf(currentPath.replace(/\/$/, "")) !== -1
) {
return;
}
if (isEmptyDataExempt(router.currentRoute.value)) return;
toast({
variant: "warning",
message: t("toastMessages.layouts.ingestionNotStarted"),
+22
View File
@@ -0,0 +1,22 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import "vue-router";
declare module "vue-router" {
interface RouteMeta {
allowOnEmptyData?: boolean;
}
}
+135 -53
View File
@@ -53,6 +53,7 @@ import {
trialPeriodAllowedPath,
trialPaywallAllowedPath,
isTrialExpired,
isEmptyDataExempt,
getUserInfo,
invalidateLoginData,
getDecodedAccessToken,
@@ -133,7 +134,6 @@ describe("trialPeriodAllowedPath", () => {
expect(trialPeriodAllowedPath).toHaveLength(4);
});
// The empty-data guard reads this list, so "general" must stay out of it.
it("does not contain general", () => {
expect(trialPeriodAllowedPath).not.toContain("general");
});
@@ -152,12 +152,31 @@ describe("trialPeriodAllowedPath", () => {
trialPeriodAllowedPath.forEach((p) => expect(trialPaywallAllowedPath).toContain(p));
});
// The empty-data guard reads this list, so the shell must stay out of it.
it("does not contain the settings shell in the empty-data list", () => {
it("does not contain the settings shell", () => {
expect(trialPeriodAllowedPath).not.toContain("settings");
});
});
// ---------------------------------------------------------------------------
// isEmptyDataExempt
// ---------------------------------------------------------------------------
describe("isEmptyDataExempt", () => {
it("is true when the route meta carries allowOnEmptyData: true", () => {
expect(isEmptyDataExempt({ meta: { allowOnEmptyData: true } })).toBe(true);
});
// Merged meta is child-over-parent, so a child can re-gate itself with `false`.
it.each([
["a route with no meta", {}],
["a route whose meta lacks the flag", { meta: { titleKey: "menu.logs" } }],
["a child override of false", { meta: { allowOnEmptyData: false } }],
["a truthy non-boolean flag", { meta: { allowOnEmptyData: "true" } }],
])("is false for %s", (_label, route) => {
expect(isEmptyDataExempt(route)).toBe(false);
});
});
// ---------------------------------------------------------------------------
// getUserInfo
// ---------------------------------------------------------------------------
@@ -406,7 +425,6 @@ describe("routeGuard", () => {
expect(mockNext).toHaveBeenCalledWith(expect.objectContaining({ name: "plans" }));
});
// The nav resolves to name "settings", not "general"; paywalling it hides the feature.
it("lets the nav settings shell through", async () => {
(config as any).isCloud = "true";
mockStore = buildExpiredTrialStore();
@@ -520,7 +538,7 @@ describe("routeGuard", () => {
},
});
// emptyDataAllowedPaths is what exempts /settings/general here, not the name list.
// The route's own meta flag is what exempts /settings/general here, not its name.
it("lets /settings/general through on a no-data org", async () => {
(config as any).isCloud = "false";
vi.mocked(organizationService.get_organization_summary).mockResolvedValue({
@@ -529,13 +547,18 @@ describe("routeGuard", () => {
mockStore = buildNoDataStore();
vi.mocked(useStore).mockReturnValue(mockStore as any);
await routeGuard({ name: "general", path: "/settings/general" }, {}, mockNext);
await routeGuard(
{ name: "general", path: "/settings/general", meta: { allowOnEmptyData: true } },
{},
mockNext,
);
expect(organizationService.get_organization_summary).not.toHaveBeenCalled();
expect(mockNext).toHaveBeenCalledWith();
expect(mockNext).not.toHaveBeenCalledWith({ path: "/ingestion" });
});
// Same name, uncovered path: fails if the name list ever does the exempting.
// Same name as the flagged fixture above, no flag: fails if the name ever does the exempting.
it("does not exempt a general-named route at an uncovered path", async () => {
(config as any).isCloud = "false";
vi.mocked(organizationService.get_organization_summary).mockResolvedValue({
@@ -635,7 +658,11 @@ describe("routeGuard", () => {
});
vi.mocked(useStore).mockReturnValue(mockStore as any);
await routeGuard({ name: "ingestion", path: "/ingestion" }, {}, mockNext);
await routeGuard(
{ name: "ingestion", path: "/ingestion", meta: { allowOnEmptyData: true } },
{},
mockNext,
);
expect(organizationService.get_organization_summary).not.toHaveBeenCalled();
expect(mockNext).toHaveBeenCalledTimes(1);
@@ -655,7 +682,11 @@ describe("routeGuard", () => {
});
vi.mocked(useStore).mockReturnValue(mockStore as any);
await routeGuard({ name: "iam", path: "/iam" }, {}, mockNext);
await routeGuard(
{ name: "iam", path: "/iam", meta: { allowOnEmptyData: true } },
{},
mockNext,
);
expect(organizationService.get_organization_summary).not.toHaveBeenCalled();
expect(mockNext).toHaveBeenCalledTimes(1);
@@ -673,32 +704,7 @@ describe("routeGuard", () => {
},
});
// /settings/general hosts the Danger Zone, and an org with nothing ingested is
// the one an admin is most likely to delete — bouncing to /ingestion would
// leave no way to. "/settings" is the nav's landing path before it redirects
// to general, so it has to survive the guard too.
it.each([
["settings landing", "settings", "/settings"],
["general settings", "general", "/settings/general"],
["general settings, trailing slash", "general", "/settings/general/"],
])("calls next() directly for %s", async (_label, name, path) => {
(config as any).isCloud = "false";
vi.mocked(organizationService.get_organization_summary).mockResolvedValue({
data: { streams: { num_streams: 0 } },
});
mockStore = buildEmptyDataStore();
vi.mocked(useStore).mockReturnValue(mockStore as any);
await routeGuard({ name, path }, {}, mockNext);
expect(organizationService.get_organization_summary).not.toHaveBeenCalled();
expect(mockNext).toHaveBeenCalledTimes(1);
expect(mockNext).not.toHaveBeenCalledWith({ path: "/ingestion" });
});
// The exemption is deliberately only the two paths above — the rest of the
// Settings tree shows no ingested data either, but it is not needed to escape
// an empty org, so it stays behind the ingestion redirect.
// Settings children carry no flag, so they stay gated even though they show no data.
it.each([
["organizationSettings", "/settings/organization"],
["license", "/settings/license"],
@@ -716,19 +722,26 @@ describe("routeGuard", () => {
expect(mockNext).toHaveBeenCalledWith({ path: "/ingestion" });
});
// I14: on-call is configured BEFORE any data flows — teams, schedules and
// routing are exactly what a fresh org sets up first. Bouncing those clicks
// to /ingestion made every on-call screen unreachable by navigation (a
// direct URL worked only because a cold load races zoConfig). The whole
// subtree is exempt, including the dynamic-segment routes the exact-match
// list cannot express.
// Synthetics produces an empty org's first data and on-call is set up before any flows.
it.each([
["triage list", "onCallResponses", "/oncall/responses"],
["team list", "onCallTeams", "/oncall/teams"],
["team detail with params", "onCallTeamDetail", "/oncall/teams/team_1/schedule"],
["org routing", "onCallRouting", "/oncall/routing"],
["trailing slash", "onCallTeams", "/oncall/teams/"],
])("calls next() directly for on-call %s", async (_label, name, path) => {
["synthetics", { name: "synthetics", path: "/synthetics", meta: { allowOnEmptyData: true } }],
[
"synthetic-private-location",
{
name: "synthetic-private-location",
path: "/synthetic/private-locations/abc",
meta: { allowOnEmptyData: true },
},
],
[
"onCallTeamDetail",
{
name: "onCallTeamDetail",
path: "/oncall/teams/team_1/schedule",
meta: { allowOnEmptyData: true },
},
],
])("calls next() directly for the flagged %s route", async (_label, route) => {
(config as any).isCloud = "false";
vi.mocked(organizationService.get_organization_summary).mockResolvedValue({
data: { streams: { num_streams: 0 } },
@@ -736,16 +749,84 @@ describe("routeGuard", () => {
mockStore = buildEmptyDataStore();
vi.mocked(useStore).mockReturnValue(mockStore as any);
await routeGuard({ name, path }, {}, mockNext);
await routeGuard(route, {}, mockNext);
expect(organizationService.get_organization_summary).not.toHaveBeenCalled();
expect(mockNext).toHaveBeenCalledTimes(1);
expect(mockNext).not.toHaveBeenCalledWith({ path: "/ingestion" });
expect(mockNext).toHaveBeenCalledWith();
});
// The prefix is a path SEGMENT, not a substring — a route that merely
// starts with the same letters must not inherit the exemption.
it("still redirects a near-miss path that only shares the prefix's letters", async () => {
// The flag exempts a route from the empty-data gate only, never from the paywall.
it("still redirects a flagged route to plans when the cloud trial is expired", async () => {
(config as any).isCloud = "true";
mockStore = buildMockStore({
state: {
organizationData: {
organizationSettings: {
free_trial_expiry: (Date.now() - 30 * 24 * 60 * 60 * 1000) * 1000,
},
isDataIngested: false,
},
selectedOrganization: { identifier: "my-org" },
zoConfig: { restricted_routes_on_empty_data: true },
},
});
vi.mocked(useStore).mockReturnValue(mockStore as any);
await routeGuard(
{ name: "synthetics", path: "/synthetics", meta: { allowOnEmptyData: true } },
{},
mockNext,
);
expect(organizationService.get_organization_summary).not.toHaveBeenCalled();
expect(mockNext).toHaveBeenCalledTimes(1);
expect(mockNext).toHaveBeenCalledWith({
name: "plans",
query: { org_identifier: "my-org" },
});
});
// Paywall runs before the empty-data gate; an empty org must not turn a paywall into a bounce.
it("still redirects an unflagged route to plans when the cloud trial is expired on an empty org", async () => {
(config as any).isCloud = "true";
mockStore = buildMockStore({
state: {
organizationData: {
organizationSettings: {
free_trial_expiry: (Date.now() - 30 * 24 * 60 * 60 * 1000) * 1000,
},
isDataIngested: false,
},
selectedOrganization: { identifier: "my-org" },
zoConfig: { restricted_routes_on_empty_data: true },
},
});
vi.mocked(useStore).mockReturnValue(mockStore as any);
await routeGuard({ name: "logs", path: "/logs" }, {}, mockNext);
expect(organizationService.get_organization_summary).not.toHaveBeenCalled();
expect(mockNext).toHaveBeenCalledTimes(1);
expect(mockNext).toHaveBeenCalledWith({
name: "plans",
query: { org_identifier: "my-org" },
});
});
// Only the meta flag exempts; a matching name or path prefix does not.
it.each([
["an unflagged general route", { name: "general", path: "/settings/general" }],
["an unflagged route on a synthetics path", { name: "x", path: "/synthetics/foo" }],
["an unflagged near-miss of the on-call path", { name: "oncallish", path: "/oncallish" }],
["a route named users under /iam", { name: "users", path: "/iam/users" }],
["an unflagged route named iam", { name: "iam", path: "/access" }],
[
"an unflagged route under /ingestion",
{ name: "curl", path: "/ingestion/custom/logs/curl" },
],
["the unflagged settings shell", { name: "settings", path: "/settings" }],
])("redirects %s to /ingestion", async (_label, route) => {
(config as any).isCloud = "false";
vi.mocked(organizationService.get_organization_summary).mockResolvedValue({
data: { streams: { num_streams: 0 } },
@@ -753,8 +834,9 @@ describe("routeGuard", () => {
mockStore = buildEmptyDataStore();
vi.mocked(useStore).mockReturnValue(mockStore as any);
await routeGuard({ name: "oncallish", path: "/oncallish" }, {}, mockNext);
await routeGuard(route, {}, mockNext);
expect(mockNext).toHaveBeenCalledTimes(1);
expect(mockNext).toHaveBeenCalledWith({ path: "/ingestion" });
});
});
+6 -34
View File
@@ -2,39 +2,15 @@
import config from "../aws-exports";
import { useStore } from "vuex";
import type { RouteMeta } from "vue-router";
import userService from "@/services/users";
import organizationService from "@/services/organizations";
import { b64DecodeUnicode, b64EncodeStandard, b64DecodeStandard } from "@/utils/formatters";
import { useLocalUserInfo } from "@/utils/storage";
import { getUUID, getUUIDv7 } from "@/utils/uuid";
// Exact paths that stay reachable when the org has ingested nothing yet. The
// empty-data redirect exists to push people toward ingestion rather than show
// them empty data views, and that still applies to most of Settings — but an org
// with nothing ingested is precisely the one an admin is most likely to want to
// delete, and the Danger Zone lives on /settings/general. "/settings" is included
// because the nav's Settings entry lands there before redirecting to general.
// Matched exactly, not by prefix, so the rest of the Settings tree stays gated.
export const emptyDataAllowedPaths = ["/settings", "/settings/general"];
// Route SUBTREES that stay reachable with nothing ingested. On-call is
// configured before data flows — teams, schedules, escalation and routing are
// exactly what a fresh org sets up first, and bouncing those clicks to
// /ingestion made every on-call screen unreachable by navigation (I14). A
// prefix list because these routes carry dynamic segments (:teamId, :tab),
// which the exact-match list above cannot express. A prefix admits its whole
// subtree — add a screen's root here only when everything under it is
// configuration rather than a data view.
export const emptyDataAllowedPrefixes = ["/oncall"];
// "/settings/general/" and "/settings/general" are the same page; an exact match
// must not hinge on a trailing slash.
const normalizePath = (path: string) =>
path !== "/" && path.endsWith("/") ? path.slice(0, -1) : path;
export const trialPeriodAllowedPath = ["iam", "users", "organizations", "invitations"];
// Kept out of the empty-data guard; "settings" only redirects on to general.
export const trialPaywallAllowedPath = [...trialPeriodAllowedPath, "settings", "general"];
export const getUserInfo = (loginString: string) => {
@@ -133,6 +109,10 @@ export const shouldPaywallRoute = (expiry: unknown, routeName: unknown): boolean
isTrialExpired(expiry) &&
trialPaywallAllowedPath.indexOf(routeName as string) === -1;
// Org-setup and data-producing surfaces declare their empty-data exemption per route, in meta.
export const isEmptyDataExempt = (route: { meta?: RouteMeta | Record<string, unknown> }): boolean =>
route.meta?.allowOnEmptyData === true;
export const routeGuard = async (to: any, from: any, next: any) => {
const store = useStore();
if (
@@ -151,15 +131,7 @@ export const routeGuard = async (to: any, from: any, next: any) => {
}
if (
to.path.indexOf("/ingestion") === -1 &&
to.path.indexOf("/iam") === -1 &&
to.name !== "iam" &&
emptyDataAllowedPaths.indexOf(normalizePath(to.path)) === -1 &&
!emptyDataAllowedPrefixes.some(
(prefix) =>
normalizePath(to.path) === prefix || normalizePath(to.path).startsWith(prefix + "/"),
) &&
trialPeriodAllowedPath.indexOf(to.name) === -1 &&
!isEmptyDataExempt(to) &&
Object.prototype.hasOwnProperty.call(store.state.zoConfig, "restricted_routes_on_empty_data") &&
store.state.zoConfig.restricted_routes_on_empty_data === true &&
store.state.organizationData.isDataIngested === false