fix: production CSP blocking PostHog/Sentry/Scalar and silent failure hardening

The production CSP had connect-src/script-src/font-src set to 'self' only,
silently blocking all analytics and error reporting in production while
working fine in dev (where CSP is not applied).

CSP fixes:
- Add PostHog ingest + assets origins to connect-src and script-src
- Add Sentry ingest origin to connect-src
- Add Scalar fonts origin to font-src for API docs pages
- Extract CSP construction into testable buildCsp() function

Silent failure hardening:
- Settings/features stores now set loadError flag and allow retry on
  subsequent fetch() calls instead of permanently caching failed state
- Analytics init no longer sets initialized=true before the try block,
  allowing retry on failure
- Settings dialog Tools section disables save button when settings
  failed to load, preventing accidental config wipe
- Branding logo storage moved from process.cwd() to FILES_STORAGE_PATH
  so logos persist across Docker container recreation

Test coverage:
- 16 CSP directive tests covering all external service domains
- Store retry-on-error behavior tests for settings and features stores
- Analytics init retry-after-failure test
This commit is contained in:
SnapOtter
2026-05-05 17:16:19 +08:00
parent fe86c5ac9c
commit e358634f8b
10 changed files with 183 additions and 25 deletions
+49 -6
View File
@@ -1697,6 +1697,7 @@ describe("useSettingsStore", () => {
defaultToolView: "sidebar",
defaultTheme: "light",
loaded: false,
loadError: false,
});
localStorage.removeItem("snapotter-theme-user-set");
mockApiGet.mockReset();
@@ -1765,8 +1766,8 @@ describe("useSettingsStore", () => {
expect(useSettingsStore.getState().defaultTheme).toBe("light");
});
it("fetch skips when already loaded", async () => {
useSettingsStore.setState({ loaded: true });
it("fetch skips when already loaded without error", async () => {
useSettingsStore.setState({ loaded: true, loadError: false });
await useSettingsStore.getState().fetch();
expect(mockApiGet).not.toHaveBeenCalled();
});
@@ -1797,14 +1798,29 @@ describe("useSettingsStore", () => {
expect(useSettingsStore.getState().defaultToolView).toBe("sidebar");
});
it("fetch sets loaded=true on error", async () => {
it("fetch sets loaded=true and loadError=true on error", async () => {
mockApiGet.mockRejectedValueOnce(new Error("Network error"));
await useSettingsStore.getState().fetch();
expect(useSettingsStore.getState().loaded).toBe(true);
expect(useSettingsStore.getState().loadError).toBe(true);
expect(useSettingsStore.getState().disabledTools).toEqual([]);
});
it("fetch retries when loadError is true", async () => {
mockApiGet.mockRejectedValueOnce(new Error("Network error"));
await useSettingsStore.getState().fetch();
expect(useSettingsStore.getState().loadError).toBe(true);
mockApiGet.mockResolvedValueOnce({
settings: { disabledTools: JSON.stringify(["resize"]) },
});
await useSettingsStore.getState().fetch();
expect(useSettingsStore.getState().loadError).toBe(false);
expect(useSettingsStore.getState().disabledTools).toEqual(["resize"]);
});
});
// ==========================================================================
@@ -1835,6 +1851,7 @@ describe("useFeaturesStore", () => {
useFeaturesStore.setState({
bundles: [],
loaded: false,
loadError: false,
installing: {},
errors: {},
queued: [],
@@ -1877,19 +1894,45 @@ describe("useFeaturesStore", () => {
expect(s.loaded).toBe(true);
});
it("fetch skips when already loaded", async () => {
useFeaturesStore.setState({ loaded: true });
it("fetch skips when already loaded without error", async () => {
useFeaturesStore.setState({ loaded: true, loadError: false });
await useFeaturesStore.getState().fetch();
expect(mockApiGet).not.toHaveBeenCalled();
});
it("fetch sets loaded=true on error", async () => {
it("fetch sets loaded=true and loadError=true on error", async () => {
mockApiGet.mockRejectedValueOnce(new Error("Network error"));
await useFeaturesStore.getState().fetch();
expect(useFeaturesStore.getState().loaded).toBe(true);
expect(useFeaturesStore.getState().loadError).toBe(true);
expect(useFeaturesStore.getState().bundles).toEqual([]);
});
it("fetch retries when loadError is true", async () => {
mockApiGet.mockRejectedValueOnce(new Error("Network error"));
await useFeaturesStore.getState().fetch();
expect(useFeaturesStore.getState().loadError).toBe(true);
const bundles = [
{
id: "ai-rembg",
name: "AI Background Remover",
description: "Remove backgrounds",
status: "installed" as const,
installedVersion: "1.0.0",
estimatedSize: "500MB",
enablesTools: ["remove-bg"],
progress: null,
error: null,
},
];
mockApiGet.mockResolvedValueOnce({ bundles });
await useFeaturesStore.getState().fetch();
expect(useFeaturesStore.getState().loadError).toBe(false);
expect(useFeaturesStore.getState().bundles).toEqual(bundles);
});
it("isToolInstalled returns true for tools whose bundle is installed", () => {
useFeaturesStore.setState({
bundles: [