Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions extensions/package-vulnerability-scanner/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,14 @@
},
"packages": {},
"files": {
"dist/assets/index-CGfl-rCQ.css": {
"checksum": "b4dadccacaa63c585d549a30e09ed801"
"dist/assets/index-Bd7WoAqv.css": {
"checksum": "980b7708188eee5f84707dcc6cf3b01d"
},
"dist/assets/index-SEZBmp0A.js": {
"checksum": "5045b070467147359ba4430ff5a5d602"
"dist/assets/index-DDPGfFmA.js": {
"checksum": "b6d254fb7554759a23d54603d83fd836"
},
"dist/index.html": {
"checksum": "35a9caef18b056356d5caff6e12b3c10"
"checksum": "58ca7cb5afb344897d80bf2bb8a0b1ac"
},
"main.py": {
"checksum": "331f2474111a6280734062de89152bb6"
Expand Down
61 changes: 61 additions & 0 deletions extensions/package-vulnerability-scanner/src/App.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// @vitest-environment happy-dom
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { setActivePinia, createPinia } from "pinia";
import { mount, flushPromises } from "@vue/test-utils";

import App from "./App.vue";

// A fetch stub that fails every request with a 424 carrying the given detail,
// as the backend does for the setup cases.
function stub424(detail: string) {
return vi.fn(async () => ({
ok: false,
status: 424,
json: async () => ({ detail }),
}));
}

beforeEach(() => {
setActivePinia(createPinia());
vi.spyOn(console, "error").mockImplementation(() => {});
});

afterEach(() => {
vi.unstubAllGlobals();
});

describe("App setup screen", () => {
it("shows the reason when the viewer's session can't be read", async () => {
vi.stubGlobal(
"fetch",
stub424(
"Couldn't read your Connect session. In the content settings, on the " +
"Access tab, add a Connect Visitor API Key integration under " +
"Integrations, to scan content as you.",
),
);

const wrapper = mount(App);
await flushPromises();

expect(wrapper.text()).toContain("Setup");
expect(wrapper.text()).toContain("Couldn't read your Connect session");
});

it("shows the reason when the integration is missing", async () => {
vi.stubGlobal(
"fetch",
stub424(
"In the content settings, on the Access tab, add a Connect Visitor API " +
"Key integration under Integrations, to scan your content.",
),
);

const wrapper = mount(App);
await flushPromises();

expect(wrapper.text()).toContain("add a Connect Visitor API Key");
// Each cause shows its own reason, not the other one.
expect(wrapper.text()).not.toContain("Couldn't read your Connect session");
});
});
57 changes: 54 additions & 3 deletions extensions/package-vulnerability-scanner/src/App.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { computed } from "vue";
import VulnerabilityChecker from "./components/VulnerabilityChecker.vue";
import ContentList from "./components/ContentList.vue";
import PoweredByFooter from "./components/PoweredByFooter.vue";
import StatusMessage from "./components/ui/StatusMessage.vue";
import { useContentStore } from "./stores/content";
import { useScannerStore } from "./stores/scanner";
import { useUserStore } from "./stores/user";
Expand All @@ -13,25 +15,74 @@ const userStore = useUserStore();

// ContentList fetches vulnerabilities after it gathers the installed packages.
userStore.fetchCurrentUser().catch(() => {});
contentStore.fetchContentList();
contentStore.fetchContentList().catch(() => {});

const loadingMessage = "Fetching your content...";

// The scan needs the viewer's identity, so a missing Visitor API Key integration
// gates the whole UI behind a setup message (see the stores' setupRequired).
const setupRequired = computed(
() => userStore.setupRequired || contentStore.setupRequired,
);
const isLoading = computed(
() => contentStore.isLoading || (!userStore.user && !userStore.error),
);
// The stores only set setupRequired on a path that also records the error, so
// the setup screen can show the reason the backend reported (the session
// couldn't be read, or the integration is missing) rather than a fixed message.
const errorMessage = computed(
() => userStore.error?.message || contentStore.error?.message,
);
</script>

<template>
<div class="flex flex-col min-h-svh">
<div
v-if="setupRequired"
class="grow bg-gray-100 flex items-center justify-center p-4"
>
<div
class="max-w-md w-full bg-white rounded-lg border border-gray-200 shadow-sm p-8"
>
<h1 class="text-lg font-semibold text-gray-900 text-center">Setup</h1>
<p class="mt-4 text-sm leading-relaxed text-gray-600">
{{ errorMessage }}
</p>
<p class="mt-4 text-sm text-gray-500">
For more information, see the
<a
href="https://docs.posit.co/connect/user/oauth-integrations/"
class="text-indigo-600 hover:underline"
target="_blank"
rel="noopener"
>OAuth Integrations documentation</a
>.
</p>
</div>
</div>
<LoadingSpinner
v-if="contentStore.isLoading || !userStore.user"
v-else-if="isLoading"
class="grow bg-gray-100"
:message="loadingMessage"
/>
<div
v-else-if="errorMessage"
class="grow bg-gray-100 flex items-center justify-center p-4"
>
<StatusMessage
type="error"
message="The scanner couldn't load"
:details="errorMessage"
class="max-w-md"
/>
</div>
<main v-else class="flex-1 p-4 md:p-8 bg-gray-100">
<div class="max-w-4xl mx-auto">
<VulnerabilityChecker
v-if="scannerStore.currentContent"
:content="scannerStore.currentContent"
/>
<ContentList :user="userStore.user" v-else />
<ContentList v-else-if="userStore.user" :user="userStore.user" />
</div>
</main>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { setActivePinia, createPinia } from "pinia";

import { useContentStore } from "./content";

function mockFetch(impl: (url: string) => unknown) {
const fn = vi.fn(async (url: string) => impl(url));
vi.stubGlobal("fetch", fn);
return fn;
}

function ok(body: unknown) {
return { ok: true, status: 200, json: async () => body };
}

beforeEach(() => {
setActivePinia(createPinia());
vi.spyOn(console, "error").mockImplementation(() => {});
});

afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

describe("content store fetchContentList", () => {
it("loads the content list and marks it loaded", async () => {
mockFetch(() => ok([{ guid: "g1", title: "A" }]));
const store = useContentStore();

await store.fetchContentList();

expect(store.contentList).toHaveLength(1);
expect(store.isContentLoaded).toBe(true);
expect(store.isLoading).toBe(false);
expect(store.error).toBeNull();
});

it("requests all content when showAllContent is set", async () => {
const fetchFn = mockFetch(() => ok([]));
const store = useContentStore();
store.showAllContent = true;

await store.fetchContentList();

expect(fetchFn).toHaveBeenCalledWith("api/content?show_all=true");
});

it("flags setupRequired and rethrows on a 424", async () => {
mockFetch(() => ({ ok: false, status: 424 }));
const store = useContentStore();

await expect(store.fetchContentList()).rejects.toThrow("424");
expect(store.setupRequired).toBe(true);
expect(store.error).not.toBeNull();
});

it("records the error without flagging setup on a non-424 failure", async () => {
mockFetch(() => ({ ok: false, status: 500 }));
const store = useContentStore();

await expect(store.fetchContentList()).rejects.toThrow("500");
expect(store.setupRequired).toBe(false);
expect(store.error).not.toBeNull();
});

it("skips a refetch once loaded unless forced", async () => {
const fetchFn = mockFetch(() => ok([{ guid: "g1", title: "A" }]));
const store = useContentStore();

await store.fetchContentList();
await store.fetchContentList();
expect(fetchFn).toHaveBeenCalledTimes(1);

await store.fetchContentList(true);
expect(fetchFn).toHaveBeenCalledTimes(2);
});
});
14 changes: 13 additions & 1 deletion extensions/package-vulnerability-scanner/src/stores/content.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { defineStore } from "pinia";
import { ref } from "vue";

import { errorDetail } from "../lib/errorDetail";

export interface ContentListItem {
guid: string;
title: string;
Expand All @@ -20,6 +22,7 @@ export const useContentStore = defineStore("content", () => {
const contentList = ref<ContentListItem[]>([]);
const isLoading = ref(false);
const error = ref<Error | null>(null);
const setupRequired = ref(false);
const showAllContent = ref(false);

// Track if content has been loaded for the current mode
Expand All @@ -33,6 +36,7 @@ export const useContentStore = defineStore("content", () => {

isLoading.value = true;
error.value = null;
setupRequired.value = false;

try {
const url = showAllContent.value
Expand All @@ -41,7 +45,14 @@ export const useContentStore = defineStore("content", () => {
const response = await fetch(url);

if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
// 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 content from Connect",
),
);
}

const data = await response.json();
Expand All @@ -61,6 +72,7 @@ export const useContentStore = defineStore("content", () => {
contentList,
isLoading,
error,
setupRequired,
isContentLoaded,
showAllContent,

Expand Down
Loading