mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -10,6 +10,7 @@ import { db, schema } from "./db/index.js";
|
|||||||
import { runMigrations } from "./db/migrate.js";
|
import { runMigrations } from "./db/migrate.js";
|
||||||
import { captureException, initAnalytics, shutdownAnalytics } from "./lib/analytics.js";
|
import { captureException, initAnalytics, shutdownAnalytics } from "./lib/analytics.js";
|
||||||
import { startCleanupCron } from "./lib/cleanup.js";
|
import { startCleanupCron } from "./lib/cleanup.js";
|
||||||
|
import { buildCsp } from "./lib/csp.js";
|
||||||
import { ensureAiDirs, recoverInterruptedInstalls } from "./lib/feature-status.js";
|
import { ensureAiDirs, recoverInterruptedInstalls } from "./lib/feature-status.js";
|
||||||
import { shutdownWorkerPool } from "./lib/worker-pool.js";
|
import { shutdownWorkerPool } from "./lib/worker-pool.js";
|
||||||
import { requirePermission } from "./permissions.js";
|
import { requirePermission } from "./permissions.js";
|
||||||
@@ -127,10 +128,7 @@ app.addHook("onSend", async (_request, reply) => {
|
|||||||
reply.header("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
reply.header("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
||||||
if (process.env.NODE_ENV === "production") {
|
if (process.env.NODE_ENV === "production") {
|
||||||
reply.header("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
reply.header("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
||||||
const csp = _request.url.startsWith("/api/docs")
|
reply.header("Content-Security-Policy", buildCsp(_request.url.startsWith("/api/docs")));
|
||||||
? "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; connect-src 'self'; font-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self'"
|
|
||||||
: "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data: https://tile.openstreetmap.org; connect-src 'self'; font-src 'self' data:; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'";
|
|
||||||
reply.header("Content-Security-Policy", csp);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
const POSTHOG_ORIGINS = ["https://us.i.posthog.com", "https://us-assets.i.posthog.com"];
|
||||||
|
const SENTRY_ORIGINS = ["https://*.ingest.us.sentry.io"];
|
||||||
|
const SCALAR_FONT_ORIGIN = "https://fonts.scalar.com";
|
||||||
|
|
||||||
|
export function buildCsp(isDocs: boolean): string {
|
||||||
|
const connectSrc = ["'self'", ...POSTHOG_ORIGINS, ...SENTRY_ORIGINS].join(" ");
|
||||||
|
const fontSrc = isDocs ? `'self' data: ${SCALAR_FONT_ORIGIN}` : "'self' data:";
|
||||||
|
const scriptSrc = isDocs
|
||||||
|
? "'self' 'unsafe-inline' https://us-assets.i.posthog.com"
|
||||||
|
: "'self' https://us-assets.i.posthog.com";
|
||||||
|
|
||||||
|
if (isDocs) {
|
||||||
|
return `default-src 'self'; script-src ${scriptSrc}; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; connect-src ${connectSrc}; font-src ${fontSrc}; object-src 'none'; base-uri 'self'; form-action 'self'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `default-src 'self'; script-src ${scriptSrc}; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data: https://tile.openstreetmap.org; connect-src ${connectSrc}; font-src ${fontSrc}; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'`;
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ import { db, schema } from "../db/index.js";
|
|||||||
import { ensureSharpCompat } from "../lib/heic-converter.js";
|
import { ensureSharpCompat } from "../lib/heic-converter.js";
|
||||||
import { requirePermission } from "../permissions.js";
|
import { requirePermission } from "../permissions.js";
|
||||||
|
|
||||||
const BRANDING_DIR = join(process.cwd(), "data", "branding");
|
const BRANDING_DIR = join(env.FILES_STORAGE_PATH, "branding");
|
||||||
const LOGO_PATH = join(BRANDING_DIR, "logo.png");
|
const LOGO_PATH = join(BRANDING_DIR, "logo.png");
|
||||||
const maxLogoSize = env.MAX_LOGO_SIZE_KB * 1024;
|
const maxLogoSize = env.MAX_LOGO_SIZE_KB * 1024;
|
||||||
|
|
||||||
|
|||||||
@@ -2292,6 +2292,7 @@ function AuditLogSection() {
|
|||||||
function ToolsSection() {
|
function ToolsSection() {
|
||||||
const [disabledTools, setDisabledTools] = useState<string[]>([]);
|
const [disabledTools, setDisabledTools] = useState<string[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadFailed, setLoadFailed] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [showRestartBanner, setShowRestartBanner] = useState(false);
|
const [showRestartBanner, setShowRestartBanner] = useState(false);
|
||||||
@@ -2302,8 +2303,9 @@ function ToolsSection() {
|
|||||||
setDisabledTools(
|
setDisabledTools(
|
||||||
data.settings.disabledTools ? JSON.parse(data.settings.disabledTools) : [],
|
data.settings.disabledTools ? JSON.parse(data.settings.disabledTools) : [],
|
||||||
);
|
);
|
||||||
|
setLoadFailed(false);
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => setLoadFailed(true))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -2424,11 +2426,17 @@ function ToolsSection() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{loadFailed && (
|
||||||
|
<div className="px-4 py-3 rounded-lg border border-red-500/30 bg-red-500/10 text-sm text-red-700 dark:text-red-400">
|
||||||
|
Failed to load tool settings. Saving is disabled to prevent data loss.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pt-2">
|
<div className="flex items-center gap-3 pt-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving}
|
disabled={saving || loadFailed}
|
||||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
|
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||||
|
|||||||
@@ -16,12 +16,10 @@ function scrubString(str: string): string {
|
|||||||
|
|
||||||
export async function initAnalytics(config: AnalyticsConfig): Promise<void> {
|
export async function initAnalytics(config: AnalyticsConfig): Promise<void> {
|
||||||
if (initialized || !config.enabled) return;
|
if (initialized || !config.enabled) return;
|
||||||
initialized = true;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const posthogJs = (await import("posthog-js")).default;
|
const posthogJs = (await import("posthog-js")).default;
|
||||||
if (!consentGranted) {
|
if (!consentGranted) {
|
||||||
initialized = false;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
posthog =
|
posthog =
|
||||||
@@ -39,15 +37,15 @@ export async function initAnalytics(config: AnalyticsConfig): Promise<void> {
|
|||||||
ip: false,
|
ip: false,
|
||||||
persistence: "localStorage",
|
persistence: "localStorage",
|
||||||
}) ?? null;
|
}) ?? null;
|
||||||
} catch {
|
initialized = true;
|
||||||
// SDK blocked or unavailable
|
} catch (err) {
|
||||||
|
console.warn("[analytics] PostHog init failed:", err);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (config.sentryDsn) {
|
if (config.sentryDsn) {
|
||||||
const Sentry = await import("@sentry/react");
|
const Sentry = await import("@sentry/react");
|
||||||
if (!consentGranted) {
|
if (!consentGranted) {
|
||||||
initialized = false;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
@@ -86,8 +84,8 @@ export async function initAnalytics(config: AnalyticsConfig): Promise<void> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Sentry blocked or unavailable
|
console.warn("[analytics] Sentry init failed:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface BundleProgress {
|
|||||||
interface FeaturesState {
|
interface FeaturesState {
|
||||||
bundles: FeatureBundleState[];
|
bundles: FeatureBundleState[];
|
||||||
loaded: boolean;
|
loaded: boolean;
|
||||||
|
loadError: boolean;
|
||||||
installing: Record<string, BundleProgress>;
|
installing: Record<string, BundleProgress>;
|
||||||
errors: Record<string, string>;
|
errors: Record<string, string>;
|
||||||
queued: string[];
|
queued: string[];
|
||||||
@@ -149,6 +150,7 @@ export const useFeaturesStore = create<FeaturesState>((set, get) => {
|
|||||||
return {
|
return {
|
||||||
bundles: [],
|
bundles: [],
|
||||||
loaded: false,
|
loaded: false,
|
||||||
|
loadError: false,
|
||||||
installing: {},
|
installing: {},
|
||||||
errors: {},
|
errors: {},
|
||||||
queued: [],
|
queued: [],
|
||||||
@@ -156,13 +158,13 @@ export const useFeaturesStore = create<FeaturesState>((set, get) => {
|
|||||||
startTimes: {},
|
startTimes: {},
|
||||||
|
|
||||||
fetch: async () => {
|
fetch: async () => {
|
||||||
if (get().loaded) return;
|
if (get().loaded && !get().loadError) return;
|
||||||
try {
|
try {
|
||||||
const data = await apiGet<{ bundles: FeatureBundleState[] }>("/v1/features");
|
const data = await apiGet<{ bundles: FeatureBundleState[] }>("/v1/features");
|
||||||
set({ bundles: data.bundles, loaded: true });
|
set({ bundles: data.bundles, loaded: true, loadError: false });
|
||||||
recoverActiveInstalls();
|
recoverActiveInstalls();
|
||||||
} catch {
|
} catch {
|
||||||
set({ loaded: true });
|
set({ loaded: true, loadError: true });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface SettingsState {
|
|||||||
defaultToolView: "sidebar" | "fullscreen";
|
defaultToolView: "sidebar" | "fullscreen";
|
||||||
defaultTheme: Theme;
|
defaultTheme: Theme;
|
||||||
loaded: boolean;
|
loaded: boolean;
|
||||||
|
loadError: boolean;
|
||||||
fetch: () => Promise<void>;
|
fetch: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,9 +22,10 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
|||||||
defaultToolView: "sidebar",
|
defaultToolView: "sidebar",
|
||||||
defaultTheme: "light",
|
defaultTheme: "light",
|
||||||
loaded: false,
|
loaded: false,
|
||||||
|
loadError: false,
|
||||||
|
|
||||||
fetch: async () => {
|
fetch: async () => {
|
||||||
if (get().loaded) return;
|
if (get().loaded && !get().loadError) return;
|
||||||
try {
|
try {
|
||||||
const data = await apiGet<{
|
const data = await apiGet<{
|
||||||
settings: Record<string, string>;
|
settings: Record<string, string>;
|
||||||
@@ -39,11 +41,12 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
|||||||
defaultToolView: data.settings.defaultToolView === "fullscreen" ? "fullscreen" : "sidebar",
|
defaultToolView: data.settings.defaultToolView === "fullscreen" ? "fullscreen" : "sidebar",
|
||||||
defaultTheme,
|
defaultTheme,
|
||||||
loaded: true,
|
loaded: true,
|
||||||
|
loadError: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
useThemeStore.getState().applyServerDefault(defaultTheme);
|
useThemeStore.getState().applyServerDefault(defaultTheme);
|
||||||
} catch {
|
} catch {
|
||||||
set({ loaded: true });
|
set({ loaded: true, loadError: true });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildCsp } from "../../../apps/api/src/lib/csp.js";
|
||||||
|
|
||||||
|
function parseDirective(csp: string, directive: string): string[] {
|
||||||
|
const match = csp.match(new RegExp(`${directive}\\s+([^;]+)`));
|
||||||
|
return match ? match[1].trim().split(/\s+/) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("buildCsp", () => {
|
||||||
|
describe("connect-src allows analytics domains", () => {
|
||||||
|
it.each([true, false])("includes PostHog ingest (isDocs=%s)", (isDocs) => {
|
||||||
|
const sources = parseDirective(buildCsp(isDocs), "connect-src");
|
||||||
|
expect(sources).toContain("https://us.i.posthog.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([true, false])("includes PostHog assets (isDocs=%s)", (isDocs) => {
|
||||||
|
const sources = parseDirective(buildCsp(isDocs), "connect-src");
|
||||||
|
expect(sources).toContain("https://us-assets.i.posthog.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([true, false])("includes Sentry ingest (isDocs=%s)", (isDocs) => {
|
||||||
|
const sources = parseDirective(buildCsp(isDocs), "connect-src");
|
||||||
|
expect(sources).toContain("https://*.ingest.us.sentry.io");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([true, false])("keeps self (isDocs=%s)", (isDocs) => {
|
||||||
|
expect(parseDirective(buildCsp(isDocs), "connect-src")).toContain("'self'");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("script-src allows PostHog config loader", () => {
|
||||||
|
it.each([true, false])("includes PostHog assets origin (isDocs=%s)", (isDocs) => {
|
||||||
|
const sources = parseDirective(buildCsp(isDocs), "script-src");
|
||||||
|
expect(sources).toContain("https://us-assets.i.posthog.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("docs pages allow unsafe-inline for Scalar", () => {
|
||||||
|
expect(parseDirective(buildCsp(true), "script-src")).toContain("'unsafe-inline'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("app pages do not allow unsafe-inline", () => {
|
||||||
|
expect(parseDirective(buildCsp(false), "script-src")).not.toContain("'unsafe-inline'");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("font-src allows Scalar docs fonts", () => {
|
||||||
|
it("docs pages include Scalar fonts origin", () => {
|
||||||
|
const sources = parseDirective(buildCsp(true), "font-src");
|
||||||
|
expect(sources).toContain("https://fonts.scalar.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("app pages do not include Scalar fonts origin", () => {
|
||||||
|
const sources = parseDirective(buildCsp(false), "font-src");
|
||||||
|
expect(sources).not.toContain("https://fonts.scalar.com");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes frame-ancestors none for app pages but not docs", () => {
|
||||||
|
expect(buildCsp(false)).toContain("frame-ancestors 'none'");
|
||||||
|
expect(buildCsp(true)).not.toContain("frame-ancestors");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows OpenStreetMap tiles in img-src for app pages", () => {
|
||||||
|
const sources = parseDirective(buildCsp(false), "img-src");
|
||||||
|
expect(sources).toContain("https://tile.openstreetmap.org");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -132,6 +132,28 @@ describe("analytics lib", () => {
|
|||||||
expect(mockInit).toHaveBeenCalledOnce();
|
expect(mockInit).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("retries initialization if first attempt throws", async () => {
|
||||||
|
setAnalyticsConsent(true);
|
||||||
|
mockInit.mockImplementationOnce(() => {
|
||||||
|
throw new Error("init failed");
|
||||||
|
});
|
||||||
|
await initAnalytics(enabledConfig);
|
||||||
|
expect(mockInit).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
mockInit.mockClear();
|
||||||
|
mockInit.mockReturnValueOnce({
|
||||||
|
capture: mockCapture,
|
||||||
|
identify: mockIdentify,
|
||||||
|
startSessionRecording: mockStartSessionRecording,
|
||||||
|
opt_in_capturing: mockOptIn,
|
||||||
|
opt_out_capturing: mockOptOut,
|
||||||
|
reset: mockReset,
|
||||||
|
persistence: { disabled: false },
|
||||||
|
});
|
||||||
|
await initAnalytics(enabledConfig);
|
||||||
|
expect(mockInit).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
it("bails out if consent is revoked during async import", async () => {
|
it("bails out if consent is revoked during async import", async () => {
|
||||||
setAnalyticsConsent(true);
|
setAnalyticsConsent(true);
|
||||||
const initPromise = initAnalytics(enabledConfig);
|
const initPromise = initAnalytics(enabledConfig);
|
||||||
|
|||||||
@@ -1697,6 +1697,7 @@ describe("useSettingsStore", () => {
|
|||||||
defaultToolView: "sidebar",
|
defaultToolView: "sidebar",
|
||||||
defaultTheme: "light",
|
defaultTheme: "light",
|
||||||
loaded: false,
|
loaded: false,
|
||||||
|
loadError: false,
|
||||||
});
|
});
|
||||||
localStorage.removeItem("snapotter-theme-user-set");
|
localStorage.removeItem("snapotter-theme-user-set");
|
||||||
mockApiGet.mockReset();
|
mockApiGet.mockReset();
|
||||||
@@ -1765,8 +1766,8 @@ describe("useSettingsStore", () => {
|
|||||||
expect(useSettingsStore.getState().defaultTheme).toBe("light");
|
expect(useSettingsStore.getState().defaultTheme).toBe("light");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fetch skips when already loaded", async () => {
|
it("fetch skips when already loaded without error", async () => {
|
||||||
useSettingsStore.setState({ loaded: true });
|
useSettingsStore.setState({ loaded: true, loadError: false });
|
||||||
await useSettingsStore.getState().fetch();
|
await useSettingsStore.getState().fetch();
|
||||||
expect(mockApiGet).not.toHaveBeenCalled();
|
expect(mockApiGet).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -1797,14 +1798,29 @@ describe("useSettingsStore", () => {
|
|||||||
expect(useSettingsStore.getState().defaultToolView).toBe("sidebar");
|
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"));
|
mockApiGet.mockRejectedValueOnce(new Error("Network error"));
|
||||||
|
|
||||||
await useSettingsStore.getState().fetch();
|
await useSettingsStore.getState().fetch();
|
||||||
|
|
||||||
expect(useSettingsStore.getState().loaded).toBe(true);
|
expect(useSettingsStore.getState().loaded).toBe(true);
|
||||||
|
expect(useSettingsStore.getState().loadError).toBe(true);
|
||||||
expect(useSettingsStore.getState().disabledTools).toEqual([]);
|
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({
|
useFeaturesStore.setState({
|
||||||
bundles: [],
|
bundles: [],
|
||||||
loaded: false,
|
loaded: false,
|
||||||
|
loadError: false,
|
||||||
installing: {},
|
installing: {},
|
||||||
errors: {},
|
errors: {},
|
||||||
queued: [],
|
queued: [],
|
||||||
@@ -1877,19 +1894,45 @@ describe("useFeaturesStore", () => {
|
|||||||
expect(s.loaded).toBe(true);
|
expect(s.loaded).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fetch skips when already loaded", async () => {
|
it("fetch skips when already loaded without error", async () => {
|
||||||
useFeaturesStore.setState({ loaded: true });
|
useFeaturesStore.setState({ loaded: true, loadError: false });
|
||||||
await useFeaturesStore.getState().fetch();
|
await useFeaturesStore.getState().fetch();
|
||||||
expect(mockApiGet).not.toHaveBeenCalled();
|
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"));
|
mockApiGet.mockRejectedValueOnce(new Error("Network error"));
|
||||||
await useFeaturesStore.getState().fetch();
|
await useFeaturesStore.getState().fetch();
|
||||||
expect(useFeaturesStore.getState().loaded).toBe(true);
|
expect(useFeaturesStore.getState().loaded).toBe(true);
|
||||||
|
expect(useFeaturesStore.getState().loadError).toBe(true);
|
||||||
expect(useFeaturesStore.getState().bundles).toEqual([]);
|
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", () => {
|
it("isToolInstalled returns true for tools whose bundle is installed", () => {
|
||||||
useFeaturesStore.setState({
|
useFeaturesStore.setState({
|
||||||
bundles: [
|
bundles: [
|
||||||
|
|||||||
Reference in New Issue
Block a user