diff --git a/.github/workflows/package-vulnerability-scanner.yml b/.github/workflows/package-vulnerability-scanner.yml index 6da7b970..ff0836f4 100644 --- a/.github/workflows/package-vulnerability-scanner.yml +++ b/.github/workflows/package-vulnerability-scanner.yml @@ -34,6 +34,11 @@ jobs: cache-dependency-path: extensions/${{ env.EXTENSION_NAME }}/package-lock.json - run: npm ci + + # Run the frontend unit tests before building. + - name: Run frontend tests + run: npm test + - run: npm run build # Run the Python backend tests. diff --git a/extensions/package-vulnerability-scanner/manifest.json b/extensions/package-vulnerability-scanner/manifest.json index 896934ae..80c4ca99 100644 --- a/extensions/package-vulnerability-scanner/manifest.json +++ b/extensions/package-vulnerability-scanner/manifest.json @@ -23,11 +23,11 @@ "dist/assets/index-CGfl-rCQ.css": { "checksum": "b4dadccacaa63c585d549a30e09ed801" }, - "dist/assets/index-B8aNyp2W.js": { - "checksum": "f464a4bc802b89c167439c82979af2d0" + "dist/assets/index-SEZBmp0A.js": { + "checksum": "5045b070467147359ba4430ff5a5d602" }, "dist/index.html": { - "checksum": "07435c1b16a3ab78d62c07501cc2e32d" + "checksum": "35a9caef18b056356d5caff6e12b3c10" }, "main.py": { "checksum": "331f2474111a6280734062de89152bb6" diff --git a/extensions/package-vulnerability-scanner/src/App.vue b/extensions/package-vulnerability-scanner/src/App.vue index aca51c57..0a40095e 100644 --- a/extensions/package-vulnerability-scanner/src/App.vue +++ b/extensions/package-vulnerability-scanner/src/App.vue @@ -12,7 +12,7 @@ const scannerStore = useScannerStore(); const userStore = useUserStore(); // ContentList fetches vulnerabilities after it gathers the installed packages. -userStore.fetchCurrentUser(); +userStore.fetchCurrentUser().catch(() => {}); contentStore.fetchContentList(); const loadingMessage = "Fetching your content..."; diff --git a/extensions/package-vulnerability-scanner/src/lib/errorDetail.test.ts b/extensions/package-vulnerability-scanner/src/lib/errorDetail.test.ts new file mode 100644 index 00000000..16ce0d66 --- /dev/null +++ b/extensions/package-vulnerability-scanner/src/lib/errorDetail.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; + +import { errorDetail } from "./errorDetail"; + +function response(status: number, body?: unknown): Response { + return { + status, + json: async () => { + if (body === undefined) throw new Error("no body"); + return body; + }, + } as unknown as Response; +} + +describe("errorDetail", () => { + it("returns the backend detail when present", async () => { + const msg = await errorDetail( + response(502, { detail: "Connect API error: nope" }), + "Fallback", + ); + expect(msg).toBe("Connect API error: nope"); + }); + + it("falls back to the status when there is no detail field", async () => { + expect(await errorDetail(response(500, {}), "Couldn't load")).toBe( + "Couldn't load (status 500).", + ); + }); + + it("falls back when the body is not JSON", async () => { + expect(await errorDetail(response(503), "Couldn't load")).toBe( + "Couldn't load (status 503).", + ); + }); +}); diff --git a/extensions/package-vulnerability-scanner/src/lib/errorDetail.ts b/extensions/package-vulnerability-scanner/src/lib/errorDetail.ts new file mode 100644 index 00000000..ea8c847d --- /dev/null +++ b/extensions/package-vulnerability-scanner/src/lib/errorDetail.ts @@ -0,0 +1,16 @@ +// Prefer the backend's error `detail` so the user sees the real reason a request +// failed; fall back to a generic message with the HTTP status when there's none. +export async function errorDetail( + response: Response, + fallback: string, +): Promise { + try { + const data = await response.json(); + if (data && typeof data.detail === "string" && data.detail) { + return data.detail; + } + } catch { + // No JSON body; use the fallback below. + } + return `${fallback} (status ${response.status}).`; +} diff --git a/extensions/package-vulnerability-scanner/src/stores/user.test.ts b/extensions/package-vulnerability-scanner/src/stores/user.test.ts new file mode 100644 index 00000000..1566d306 --- /dev/null +++ b/extensions/package-vulnerability-scanner/src/stores/user.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { setActivePinia, createPinia } from "pinia"; + +import { useUserStore } from "./user"; + +function mockFetch(response: unknown) { + vi.stubGlobal( + "fetch", + vi.fn(async () => response), + ); +} + +beforeEach(() => { + setActivePinia(createPinia()); + vi.spyOn(console, "error").mockImplementation(() => {}); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("user store fetchCurrentUser", () => { + it("sets the user and clears error state on success", async () => { + mockFetch({ + ok: true, + status: 200, + json: async () => ({ username: "alice", user_role: "administrator" }), + }); + const store = useUserStore(); + + await store.fetchCurrentUser(); + + expect(store.user?.username).toBe("alice"); + expect(store.error).toBeNull(); + expect(store.setupRequired).toBe(false); + expect(store.isAdmin).toBe(true); + }); + + it("flags setupRequired on a 424 and records the error", async () => { + mockFetch({ ok: false, status: 424 }); + const store = useUserStore(); + + await expect(store.fetchCurrentUser()).rejects.toThrow("424"); + + expect(store.setupRequired).toBe(true); + expect(store.error?.message).toContain("424"); + expect(store.user).toBeUndefined(); + }); + + it("records the error but does not flag setup for a non-424 failure", async () => { + mockFetch({ ok: false, status: 500 }); + const store = useUserStore(); + + await expect(store.fetchCurrentUser()).rejects.toThrow("500"); + + expect(store.setupRequired).toBe(false); + expect(store.error?.message).toContain("500"); + }); + + it("isAdmin is false for a non-administrator", async () => { + mockFetch({ + ok: true, + status: 200, + json: async () => ({ username: "bob", user_role: "publisher" }), + }); + const store = useUserStore(); + + await store.fetchCurrentUser(); + + expect(store.isAdmin).toBe(false); + }); +}); diff --git a/extensions/package-vulnerability-scanner/src/stores/user.ts b/extensions/package-vulnerability-scanner/src/stores/user.ts index 864a0798..7b739445 100644 --- a/extensions/package-vulnerability-scanner/src/stores/user.ts +++ b/extensions/package-vulnerability-scanner/src/stores/user.ts @@ -1,6 +1,8 @@ import { ref, computed } from "vue"; import { defineStore } from "pinia"; +import { errorDetail } from "../lib/errorDetail"; + export interface User { email: string; username: string; @@ -17,18 +19,38 @@ export interface User { export const useUserStore = defineStore("user", () => { const user = ref(); + const error = ref(null); + const setupRequired = ref(false); const isAdmin = computed(() => user.value?.user_role === "administrator"); async function fetchCurrentUser() { - const response = await fetch("api/user"); - const data = await response.json(); - - user.value = data; + error.value = null; + setupRequired.value = false; + try { + const response = await fetch("api/user"); + if (!response.ok) { + // 424: the Connect Visitor API Key integration this app needs is not configured. + if (response.status === 424) setupRequired.value = true; + throw new Error( + await errorDetail( + response, + "Couldn't load your account from Connect", + ), + ); + } + user.value = await response.json(); + } catch (err) { + console.error("Error fetching current user:", err); + error.value = err as Error; + throw err; + } } return { user, + error, + setupRequired, isAdmin, fetchCurrentUser, diff --git a/extensions/package-vulnerability-scanner/tsconfig.json b/extensions/package-vulnerability-scanner/tsconfig.json index 1ffef600..08c8a904 100644 --- a/extensions/package-vulnerability-scanner/tsconfig.json +++ b/extensions/package-vulnerability-scanner/tsconfig.json @@ -2,6 +2,7 @@ "files": [], "references": [ { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.vitest.json" } ] }