Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/chilly-parts-cross.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@nodesecure/server": minor
---

Add `CLEAR` WebSocket command to clear the payload cache
5 changes: 5 additions & 0 deletions .changeset/famous-plants-lay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@nodesecure/cache": minor
---

Add support for `NODESECURE_PAYLOADS_PATH` env
5 changes: 5 additions & 0 deletions .changeset/many-clowns-deny.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@nodesecure/cache": patch
---

Reset `currentSpec` to `null` when manifest load fails
1 change: 1 addition & 0 deletions i18n/english.js
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ const ui = {
action_reset_view: "Reset view",
action_copy_packages: "Copy packages",
action_export_payload: "Export payload",
action_clear_cache: "Clear all cached packages",
section_presets: "Quick filters",
preset_has_vulnerabilities: "Has vulnerabilities",
preset_has_scripts: "Has install scripts",
Expand Down
1 change: 1 addition & 0 deletions i18n/french.js
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ const ui = {
action_reset_view: "Réinitialiser la vue",
action_copy_packages: "Copier les packages",
action_export_payload: "Exporter le payload",
action_clear_cache: "Vider tous les packages en cache",
section_presets: "Filtres rapides",
preset_has_vulnerabilities: "Contient des vulnérabilités",
preset_has_scripts: "Scripts d'installation",
Expand Down
13 changes: 12 additions & 1 deletion playwright.config.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
// Import Node.js Dependencies
import os from "node:os";
import path from "node:path";

// Import Third-party Dependencies
import { defineConfig } from "@playwright/test";

// CONSTANTS
const kE2ECachePath = path.join(os.tmpdir(), "nsecure-e2e-payloads");

export default defineConfig({
testDir: "./test/e2e",
globalTeardown: "./test/e2e/global-teardown.js",
use: {
baseURL: "http://localhost:3000"
},
webServer: {
command: "node . open ./test/e2e/fixtures/nsecure-result.json --port 3000 --ws-port 1339",
env: { NODESECURE_NO_OPEN: true },
env: {
NODESECURE_NO_OPEN: true,
NODESECURE_PAYLOADS_PATH: kE2ECachePath
},
port: 3000,
timeout: 15_000
}
Expand Down
9 changes: 8 additions & 1 deletion public/components/command-palette/command-palette.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ const kActions = [
{ id: "toggle_theme", shortcut: "t" },
{ id: "reset_view", shortcut: "r" },
{ id: "copy_packages", shortcut: "c" },
{ id: "export_payload", shortcut: "e" }
{ id: "export_payload", shortcut: "e" },
{ id: "clear_cache", shortcut: "x" }
];
const kWarningItems = Object.keys(warnings)
.map((id) => {
Expand Down Expand Up @@ -432,6 +433,9 @@ class CommandPalette extends LitElement {
}
break;
}
case "clear_cache":
window.socket.commands.clear();
break;
}

this.#close();
Expand Down Expand Up @@ -577,6 +581,9 @@ class CommandPalette extends LitElement {
case "export_payload":
label = i18n.action_export_payload;
break;
case "clear_cache":
label = i18n.action_clear_cache;
break;
default:
label = action.id;
}
Expand Down
3 changes: 2 additions & 1 deletion public/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -224,9 +224,10 @@ async function onSocketInitOrReload(event) {
searchview.cachedSpecs = cache;
searchview.reset();

if (data.status === "RELOAD" && cache.length === 0) {
if (cache.length === 0) {
window.navigation.hideMenu("network--view");
window.navigation.hideMenu("home--view");
window.navigation.hideMenu("tree--view");
window.navigation.hideMenu("warnings--view");
window.navigation.setNavByName("search--view");
}
Expand Down
3 changes: 2 additions & 1 deletion public/websocket.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ export class WebSocketClient extends EventTarget {
*/
this.commands = {
search: (spec) => this.send({ commandName: "SEARCH", spec }),
remove: (spec) => this.send({ commandName: "REMOVE", spec })
remove: (spec) => this.send({ commandName: "REMOVE", spec }),
clear: () => this.send({ commandName: "CLEAR" })
};

window.socket = this;
Expand Down
107 changes: 102 additions & 5 deletions test/e2e/command-palette.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ test.describe("[command-palette] presets and actions", () => {

test.beforeEach(async({ page }) => {
await page.goto("/");
await page.waitForSelector(`[data-menu="network--view"].active`);
await page.waitForFunction(() => window.cachedSpecs?.length > 0);

i18n = await page.evaluate(() => {
const lang = document.getElementById("lang").dataset.lang;
Expand All @@ -40,9 +40,9 @@ test.describe("[command-palette] presets and actions", () => {
await expect(presetsSection.locator(".range-preset")).toHaveCount(5);
});

test("renders all four action buttons", async({ page }) => {
test("renders all five action buttons", async({ page }) => {
const actionsSection = page.locator(".section").filter({ hasText: i18n.section_actions });
await expect(actionsSection.locator(".range-preset")).toHaveCount(4);
await expect(actionsSection.locator(".range-preset")).toHaveCount(5);
});

test("clicking a preset adds a chip and hides the presets section", async({ page }) => {
Expand Down Expand Up @@ -186,7 +186,7 @@ test.describe("[command-palette] dep filter", () => {

test.beforeEach(async({ page }) => {
await page.goto("/");
await page.waitForSelector(`[data-menu="network--view"].active`);
await page.waitForFunction(() => window.cachedSpecs?.length > 0);

i18n = await page.evaluate(() => {
const lang = document.getElementById("lang").dataset.lang;
Expand Down Expand Up @@ -268,7 +268,7 @@ test.describe("[command-palette] ignore flags and warnings", () => {

test.beforeEach(async({ page }) => {
await page.goto("/");
await page.waitForSelector(`[data-menu="network--view"].active`);
await page.waitForFunction(() => window.cachedSpecs?.length > 0);

i18n = await page.evaluate(() => {
const lang = document.getElementById("lang").dataset.lang;
Expand Down Expand Up @@ -369,3 +369,100 @@ test.describe("[command-palette] ignore flags and warnings", () => {
await expect(page.locator(".section").filter({ hasText: i18n.section_ignore_warnings })).not.toBeVisible();
});
});

test.describe("[command-palette] clear cache action", () => {
async function loadI18n(page) {
return page.evaluate(() => {
const lang = document.getElementById("lang").dataset.lang;
const activeLang = lang in window.i18n ? lang : "english";

return window.i18n[activeLang].search_command;
});
}

async function openPalette(page) {
await page.goto("/");
await page.waitForFunction(() => window.cachedSpecs?.length > 0);
await page.locator(`[data-menu="network--view"].active`).click();
await page.keyboard.press("Control+k");
await expect(page.locator(".backdrop")).toBeVisible();
}

test("renders the clear cache action button", async({ page }) => {
await openPalette(page);
const i18n = await loadI18n(page);

const actionsSection = page.locator(".section").filter({ hasText: i18n.section_actions });
await expect(actionsSection.locator(".range-preset").filter({ hasText: i18n.action_clear_cache })).toBeVisible();
});

// Alt+X is tested via WebSocket interception so the server cache is not actually
// cleared, keeping network--view available for the next test.
test("Alt+X sends the CLEAR command and closes the palette", async({ page }) => {
let clearSent = false;

await page.routeWebSocket("ws://localhost:1339", (ws) => {
const server = ws.connectToServer();

ws.onMessage((msg) => {
const data = JSON.parse(msg);
if (data.commandName === "CLEAR") {
clearSent = true;
ws.send(JSON.stringify({ status: "RELOAD", cache: [] }));
}
else {
server.send(msg);
}
});

server.onMessage((msg) => {
ws.send(msg);
});
});

await openPalette(page);

await page.keyboard.press("Alt+x");

await expect(page.locator(".backdrop")).not.toBeVisible();
await page.waitForSelector(`[data-menu="search--view"].active`);
expect(clearSent).toBe(true);
});

test("clicking clear cache closes the palette and hides data views", async({ page }) => {
let clearSent = false;

await page.routeWebSocket("ws://localhost:1339", (ws) => {
const server = ws.connectToServer();

ws.onMessage((msg) => {
const data = JSON.parse(msg);
if (data.commandName === "CLEAR") {
clearSent = true;
ws.send(JSON.stringify({ status: "RELOAD", cache: [] }));
}
else {
server.send(msg);
}
});

server.onMessage((msg) => {
ws.send(msg);
});
});

await openPalette(page);
const i18n = await loadI18n(page);

const actionsSection = page.locator(".section").filter({ hasText: i18n.section_actions });
await actionsSection.locator(".range-preset").filter({ hasText: i18n.action_clear_cache }).click();

await expect(page.locator(".backdrop")).not.toBeVisible();
await page.waitForSelector(`[data-menu="search--view"].active`);
expect(clearSent).toBe(true);

for (const menu of ["network--view", "home--view", "tree--view", "warnings--view"]) {
await expect(page.locator(`[data-menu="${menu}"]`)).toContainClass("hidden");
}
});
});
11 changes: 11 additions & 0 deletions test/e2e/global-teardown.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Import Node.js Dependencies
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";

export default async function globalTeardown() {
await fs.rm(
path.join(os.tmpdir(), "nsecure-e2e-payloads"),
{ recursive: true, force: true }
);
}
2 changes: 1 addition & 1 deletion workspaces/cache/docs/PayloadCache.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export interface PayloadCacheOptions {

The base directory where payloads are stored.

**Default**: `~/.nsecure/payloads`
**Default**: `NODESECURE_PAYLOADS_PATH` environment variable or `~/.nsecure/payloads` if not set.

### `static getPathBySpec(spec: string): string`

Expand Down
3 changes: 2 additions & 1 deletion workspaces/cache/src/PayloadCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export interface PayloadCacheOptions {
}

export class PayloadCache {
static PATH = path.join(os.homedir(), ".nsecure", "payloads");
static PATH = process.env.NODESECURE_PAYLOADS_PATH ?? path.join(os.homedir(), ".nsecure", "payloads");

static getPathBySpec(
spec: string
Expand Down Expand Up @@ -308,6 +308,7 @@ export class PayloadManifestCache {

async load() {
const storage = new Map<string, PayloadMetadata>();
this.currentSpec = null;

try {
const manifestContent = await this.#fsProvider.readFile(
Expand Down
17 changes: 17 additions & 0 deletions workspaces/cache/test/PayloadCache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -773,5 +773,22 @@ describe("PayloadManifestCache", () => {

assert.equal(storage.size, 0);
});

it("should reset currentSpec when manifest read fails", async() => {
const mockFs = createMockFs();

mockFs.readFile.mock.mockImplementation(
() => Promise.reject(new Error("ENOENT"))
);

const manifest = new PayloadManifestCache({
fsProvider: mockFs as unknown as typeof fs
});
manifest.currentSpec = "express@4.18.2";

await manifest.load();

assert.equal(manifest.currentSpec, null);
});
});
});
15 changes: 15 additions & 0 deletions workspaces/server/src/websocket/commands/clear.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Import Internal Dependencies
import { context } from "../websocket.als.ts";
import type { WebSocketResponse } from "../websocket.types.ts";

export async function* clear(): AsyncGenerator<WebSocketResponse, void, unknown> {
const { cache } = context.getStore()!;

await cache.clear();
await cache.load();

yield {
status: "RELOAD",
cache: []
};
}
5 changes: 4 additions & 1 deletion workspaces/server/src/websocket/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { PayloadCache } from "@nodesecure/cache";
// Import Internal Dependencies
import { search } from "./commands/search.ts";
import { remove } from "./commands/remove.ts";
import { clear } from "./commands/clear.ts";
import { context } from "./websocket.als.ts";
import type {
WebSocketResponse,
Expand Down Expand Up @@ -56,13 +57,15 @@ export class WebSocketServerInstanciator {
};

const commandName = message.commandName;
this.#logger.info(`[ws|command.${commandName.toLowerCase()}] ${message.spec}`);
const specLog = "spec" in message ? ` ${message.spec}` : "";
this.#logger.info(`[ws|command.${commandName.toLowerCase()}]${specLog}`);

context.run(ctx, async() => {
try {
const socketMessages = match(message)
.with({ commandName: "SEARCH" }, (command) => search(command.spec))
.with({ commandName: "REMOVE" }, (command) => remove(command.spec))
.with({ commandName: "CLEAR" }, () => clear())
.exhaustive();

for await (const message of socketMessages) {
Expand Down
8 changes: 4 additions & 4 deletions workspaces/server/src/websocket/websocket.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@ export type WebSocketResponse =
| ScanResponse
| ErrorResponse;

export type WebSocketMessage = {
commandName: "SEARCH" | "REMOVE";
spec: string;
};
export type WebSocketMessage =
| { commandName: "SEARCH"; spec: string; }
| { commandName: "REMOVE"; spec: string; }
| { commandName: "CLEAR"; };

export interface WebSocketContext {
socket: WebSocket;
Expand Down
Loading