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
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,18 @@ jobs:
- name: Run Server unit tests
run: cd Server && npm test

- name: Install Extension dependencies
run: cd Extension && npm ci

- name: Run Extension unit tests
run: cd Extension && npm test

- name: Run full-system scan (E2E)
env:
USE_SCORING_TEST_DATA: "1"
PORT: "3000"
run: |
E2E_START=$(date +%s)
node Server/server.js &
SERVER_PID=$!
sleep 6
Expand All @@ -59,3 +66,6 @@ jobs:
}
console.log('Full-system scan OK, safetyScore:', d.safetyScore);
"
E2E_END=$(date +%s)
E2E_DURATION=$((E2E_END - E2E_START))
echo "E2E test duration: ${E2E_DURATION}s"
75 changes: 75 additions & 0 deletions Docs/bill-of-materials.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Bill of Materials

| Field | Value |
|-------|-------|
| Artifact ID | ART-021 |
| Artifact Title | Project Management |
| Revision | 01 |
| Revision Date | 25 FEB 2026 |
| Prepared by | Tate McCauley |
| Checked by | Cam Gedris |
| Purpose | Preview of future work plans from our Jira instance |

---

## Bill of Materials

### Deliverables

| Item # | Part / Description | Quantity | Notes |
|--------|--------------------|----------|-------|
| 1 | Chrome Extension (netstar-extension) | 1 | React 19, MV3; popup, background, content script |
| 2 | Node.js Server (Express API) | 1 | `/scan` endpoint; validates targets, spawns scoring engine |
| 3 | Python Scoring Engine | 1 | scoring_main.py, score_engine.py, data_fetch.py, scoring_logic.py |

### Runtime / Prerequisites

| Item # | Part / Description | Quantity | Notes |
|--------|--------------------|----------|-------|
| 4 | Node.js | 1 | v18+ required (Extension + Server) |
| 5 | Python | 1 | 3.x required (Scoring Engine) |
| 6 | Chrome / Chromium browser | 1 | Edge, Brave, etc. for Extension |

### Extension — Production Dependencies

| Item # | Part / Description | Quantity | Notes |
|--------|--------------------|----------|-------|
| 7 | react | 19.1.0 | UI framework |
| 8 | react-dom | 19.1.0 | React DOM renderer |
| 9 | @radix-ui/react-progress, react-slot, react-switch | 3 | Accessible UI primitives |
| 10 | lucide-react | ^0.552.0 | Icons |
| 11 | tailwind-merge, tailwind-scrollbar, tailwindcss-animate | 3 | Styling utilities |
| 12 | class-variance-authority, clsx | 2 | Component styling (cva/clsx) |

### Extension — Build / Dev Dependencies

| Item # | Part / Description | Quantity | Notes |
|--------|--------------------|----------|-------|
| 13 | Vite | ^5.4.21 | Build tool |
| 14 | @crxjs/vite-plugin | ^2.2.0 | Chrome extension build |
| 15 | @vitejs/plugin-react | ^4.3.1 | React HMR/build |
| 16 | Tailwind CSS, PostCSS, Autoprefixer | 3 | Styles (Tailwind ^4.1.17) |
| 17 | Jest | ^30.2.0 | Unit tests |
| 18 | JSDoc | ^4.0.5 | Documentation |

### Server — Dependencies

| Item # | Part / Description | Quantity | Notes |
|--------|--------------------|----------|-------|
| 19 | express | ^5.1.0 | HTTP API |
| 20 | jest, supertest | 2 | Testing (dev) |

### Scoring Engine — Dependencies

| Item # | Part / Description | Quantity | Notes |
|--------|--------------------|----------|-------|
| 21 | Python standard library + curl | — | Telemetry (cert, DNS, RDAP, etc.) via curl |
| 22 | pytest, pytest-cov, pytest-mock | 3 | Testing (requirements.txt) |

### Documentation

| Item # | Part / Description | Quantity | Notes |
|--------|--------------------|----------|-------|
| 23 | Docs (deployment, testing, url-sanitization-policy, extension-server-flow) | 4+ | NetSTAR-Shield/Docs |
| 24 | Extension docs (architecture, development, theme, content, caching) | 5+ | Extension/docs |
| 25 | READMEs (project, Extension, Server, Scoring Engine) | 4 | Top-level and per-component |
222 changes: 222 additions & 0 deletions Extension/__tests__/scan.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import { jest, describe, it, expect, beforeEach, afterEach } from "@jest/globals";

// ─── Mocks ─────────────────────────────────────────────────────────────
// We use jest.unstable_mockModule for ESM. Constants are mocked with a
// short timeout so the abort test completes quickly.

const TEST_TIMEOUT_MS = 200;

jest.unstable_mockModule("../src/background/constants.js", () => ({
SCAN_API_BASE: "http://test-server:3000",
CACHE_DURATION_MS: 300_000,
SCAN_FETCH_TIMEOUT_MS: TEST_TIMEOUT_MS,
}));

// The urlNormalize module is real (no mock needed).

// Global chrome mock — getCachedOrScan uses chrome.storage.local
const storageBacking = {};
globalThis.chrome = {
storage: {
local: {
get: jest.fn((key) => {
const k = typeof key === "string" ? key : Object.keys(key)[0];
return Promise.resolve(k in storageBacking ? { [k]: storageBacking[k] } : {});
}),
set: jest.fn((obj) => {
Object.assign(storageBacking, obj);
return Promise.resolve();
}),
remove: jest.fn((key) => {
delete storageBacking[key];
return Promise.resolve();
}),
},
},
};

// Import after mocks are registered
const { performSecurityScan, getCachedOrScan } = await import(
"../src/background/scan.js"
);

// ─── Helpers ───────────────────────────────────────────────────────────

function mockFetchResponse(body, { ok = true, status = 200 } = {}) {
globalThis.fetch = jest.fn(() =>
Promise.resolve({
ok,
status,
json: () => Promise.resolve(body),
})
);
}

function clearStorageBacking() {
for (const key of Object.keys(storageBacking)) delete storageBacking[key];
}

// ─── Tests ─────────────────────────────────────────────────────────────

beforeEach(() => {
jest.clearAllMocks();
clearStorageBacking();
});

// ═══ performSecurityScan ═══════════════════════════════════════════════

describe("performSecurityScan", () => {
it("returns { safetyScore, indicators, timestamp } on success", async () => {
mockFetchResponse({ safetyScore: 85, indicators: [{ id: "cert" }] });

const result = await performSecurityScan("https://www.example.com/path");

expect(result).toEqual(
expect.objectContaining({
safetyScore: 85,
indicators: [{ id: "cert" }],
})
);
expect(typeof result.timestamp).toBe("number");
});

it("uses the normalized domain in the fetch URL", async () => {
mockFetchResponse({ safetyScore: 90, indicators: [] });

await performSecurityScan("https://www.Example.COM/page?q=1");

expect(globalThis.fetch).toHaveBeenCalledTimes(1);
const url = globalThis.fetch.mock.calls[0][0];
expect(url).toContain("domain=example.com");
expect(url).not.toContain("www.");
});

it("falls back to aggregatedScore when safetyScore is missing", async () => {
mockFetchResponse({ aggregatedScore: 70, indicators: [] });

const result = await performSecurityScan("example.com");

expect(result.safetyScore).toBe(70);
});

it("rejects with a timeout error when fetch never resolves", async () => {
globalThis.fetch = jest.fn(
() => new Promise((_, reject) => {
// Listen for the abort signal to reject, like a real fetch would.
const opts = globalThis.fetch.mock.calls[0]?.[1];
if (opts?.signal) {
opts.signal.addEventListener("abort", () =>
reject(Object.assign(new Error("The operation was aborted"), { name: "AbortError" }))
);
}
})
);

await expect(performSecurityScan("example.com")).rejects.toThrow(
/timed out/i
);
}, TEST_TIMEOUT_MS + 5000);

it("rejects with the original error on network failure", async () => {
globalThis.fetch = jest.fn(() => Promise.reject(new Error("Network down")));

await expect(performSecurityScan("example.com")).rejects.toThrow(
"Network down"
);
});

it("passes an AbortSignal to fetch", async () => {
mockFetchResponse({ safetyScore: 50, indicators: [] });

await performSecurityScan("example.com");

const opts = globalThis.fetch.mock.calls[0][1];
expect(opts).toBeDefined();
expect(opts.signal).toBeInstanceOf(AbortSignal);
});
});

// ═══ getCachedOrScan ═══════════════════════════════════════════════════

describe("getCachedOrScan", () => {
it("returns cached data on cache hit (no fetch)", async () => {
const cached = {
safetyScore: 92,
indicators: [],
timestamp: Date.now(),
};
const cacheKey = "scan_example.com";
storageBacking[cacheKey] = cached;

globalThis.fetch = jest.fn();

const result = await getCachedOrScan("https://www.example.com");

expect(result).toEqual(cached);
expect(globalThis.fetch).not.toHaveBeenCalled();
expect(chrome.storage.local.set).not.toHaveBeenCalled();
});

it("fetches and caches on cache miss", async () => {
mockFetchResponse({ safetyScore: 77, indicators: [{ id: "dns" }] });

const result = await getCachedOrScan("https://example.com");

expect(result.safetyScore).toBe(77);
expect(result.indicators).toEqual([{ id: "dns" }]);
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
expect(chrome.storage.local.set).toHaveBeenCalledTimes(1);

const setArg = chrome.storage.local.set.mock.calls[0][0];
expect(Object.keys(setArg)[0]).toBe("scan_example.com");
});

it("removes expired cache and fetches fresh data", async () => {
const expired = {
safetyScore: 50,
indicators: [],
timestamp: Date.now() - 600_000, // 10 min ago, well past 5 min TTL
};
storageBacking["scan_example.com"] = expired;

mockFetchResponse({ safetyScore: 88, indicators: [] });

const result = await getCachedOrScan("https://example.com");

expect(chrome.storage.local.remove).toHaveBeenCalledWith("scan_example.com");
expect(result.safetyScore).toBe(88);
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
});

it("deduplicates concurrent in-flight requests", async () => {
let resolveFetch;
globalThis.fetch = jest.fn(
() =>
new Promise((resolve) => {
resolveFetch = resolve;
})
);

// Launch both before awaiting either — storage.get is async so we need
// to let microtasks drain so both calls reach the in-flight check.
const p1 = getCachedOrScan("https://example.com");
const p2 = getCachedOrScan("https://example.com");

// Wait a tick so the async storage calls resolve and fetch is invoked
await new Promise((r) => setTimeout(r, 50));

expect(globalThis.fetch).toHaveBeenCalledTimes(1);

resolveFetch({
ok: true,
status: 200,
json: () => Promise.resolve({ safetyScore: 60, indicators: [] }),
});

const [r1, r2] = await Promise.all([p1, p2]);

expect(r1.safetyScore).toBe(60);
expect(r2.safetyScore).toBe(60);
expect(r1).toBe(r2);
});
});
3 changes: 3 additions & 0 deletions Extension/src/background/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,6 @@ export const CACHE_DURATION_MS = 1000 * 5 * 60; // 5 minutes
// Score threshold to trigger a warning notification
export const ALERT_THRESHOLD = 60;

// Max time to wait for the scan API before aborting the fetch
export const SCAN_FETCH_TIMEOUT_MS = 10_000; // 10 seconds

Loading