diff --git a/surfsense_web/app/api/updates/[file]/route.ts b/surfsense_web/app/api/updates/[file]/route.ts new file mode 100644 index 0000000000..a88923a169 --- /dev/null +++ b/surfsense_web/app/api/updates/[file]/route.ts @@ -0,0 +1,12 @@ +import { recordAfterResponse } from "@/lib/desktop-telemetry/record-after-response"; +import { serveUpdateManifest } from "@/lib/desktop-telemetry/update-feed/serve-update-manifest"; + +// Each check must reach the handler to be counted. The GitHub fetch inside +// keeps its own 15-minute cache. +export const dynamic = "force-dynamic"; + +/** The desktop update feed at updates.surfsense.com (docs/contracts/05-desktop-telemetry.md). */ +export async function GET(request: Request, { params }: { params: Promise<{ file: string }> }) { + const { file } = await params; + return serveUpdateManifest(request, file, { fetchManifest: fetch, record: recordAfterResponse }); +} diff --git a/surfsense_web/app/api/usage/route.ts b/surfsense_web/app/api/usage/route.ts new file mode 100644 index 0000000000..9df46a79cc --- /dev/null +++ b/surfsense_web/app/api/usage/route.ts @@ -0,0 +1,9 @@ +import { recordAfterResponse } from "@/lib/desktop-telemetry/record-after-response"; +import { receiveUsageReport } from "@/lib/desktop-telemetry/usage-report/receive-usage-report"; + +export const dynamic = "force-dynamic"; + +/** Opt-in usage reports at usage.surfsense.com (docs/contracts/05-desktop-telemetry.md). */ +export async function POST(request: Request) { + return receiveUsageReport(request, { record: recordAfterResponse }); +} diff --git a/surfsense_web/lib/desktop-telemetry/desktop-dimensions.ts b/surfsense_web/lib/desktop-telemetry/desktop-dimensions.ts new file mode 100644 index 0000000000..45ec8e2914 --- /dev/null +++ b/surfsense_web/lib/desktop-telemetry/desktop-dimensions.ts @@ -0,0 +1,51 @@ +/** + * App version, platform and OS as the desktop app reports them + * (docs/contracts/05-desktop-telemetry.md). Both routes accept only these + * spellings, so nothing free-form reaches an event. + */ + +export const DESKTOP_PLATFORMS = [ + "win32-x64", + "win32-arm64", + "darwin-arm64", + "darwin-x64", + "linux-x64", + "linux-arm64", +] as const; + +const FIXED_OS_NAMES = new Set(["windows-10", "windows-11", "linux"]); +const MACOS_MAJOR = /^macos-[1-9][0-9]$/; + +// Plain x.y.z only: a pre-release tag is free text and could carry a name, so +// a pre-release build's checks count as "invalid" and its usage reports get 400. +export const APP_VERSION = /^(0|[1-9][0-9]{0,3})\.(0|[1-9][0-9]{0,3})\.(0|[1-9][0-9]{0,3})$/; + +export function isDesktopPlatform(value: unknown): value is (typeof DESKTOP_PLATFORMS)[number] { + return typeof value === "string" && (DESKTOP_PLATFORMS as readonly string[]).includes(value); +} + +export function isDesktopOs(value: unknown): value is string { + return typeof value === "string" && (FIXED_OS_NAMES.has(value) || MACOS_MAJOR.test(value)); +} + +export function isAppVersion(value: unknown): value is string { + return typeof value === "string" && APP_VERSION.test(value); +} + +export type UpdateCheckDimensions = { + app_version: string; + platform: string; + os: string; +}; + +/** Reads the three X-SurfSense-* headers, folding anything unexpected into one bucket. */ +export function readUpdateCheckDimensions(headers: Headers): UpdateCheckDimensions { + const version = headers.get("x-surfsense-version"); + const platform = headers.get("x-surfsense-platform"); + const os = headers.get("x-surfsense-os"); + return { + app_version: isAppVersion(version) ? version : "invalid", + platform: isDesktopPlatform(platform) ? platform : "other", + os: isDesktopOs(os) ? os : "other", + }; +} diff --git a/surfsense_web/lib/desktop-telemetry/personless-capture.ts b/surfsense_web/lib/desktop-telemetry/personless-capture.ts new file mode 100644 index 0000000000..3d8e98270a --- /dev/null +++ b/surfsense_web/lib/desktop-telemetry/personless-capture.ts @@ -0,0 +1,61 @@ +import { randomUUID } from "node:crypto"; +import { PostHog, type PostHogOptions } from "posthog-node"; + +/** An event with no person behind it; every value is an enum, flag or fixed format. */ +export type PersonlessEvent = { + event: string; + properties: Record; +}; + +export type CapturePersonless = (event: PersonlessEvent) => Promise; + +type CaptureConfig = { + apiKey: string | undefined; + host: string | undefined; + fetch?: PostHogOptions["fetch"]; +}; + +/** + * Sends one event straight away, with GeoIP off and no person profile, so + * PostHog keeps neither a location from our server's address nor a person + * (docs/contracts/05-desktop-telemetry.md). Never throws: posthog-node logs + * and drops a failed send. + */ +export function createPersonlessCapture({ apiKey, host, fetch }: CaptureConfig): CapturePersonless { + if (!apiKey) return async () => {}; + + const client = new PostHog(apiKey, { + host: host || undefined, + disableGeoip: true, + flushAt: 1, + flushInterval: 0, + requestTimeout: 5000, + fetchRetryCount: 1, + fetch, + }); + + return async ({ event, properties }) => { + await client.captureImmediate({ + // A fresh id per event: there is no one to count twice and nothing to join on. + distinctId: randomUUID(), + event, + properties: { ...properties, $process_person_profile: false }, + disableGeoip: true, + }); + }; +} + +let captureFromEnv: CapturePersonless | undefined; + +/** + * The routes' capture. Does nothing until DESKTOP_TELEMETRY_POSTHOG_KEY is set. + * The key's project must live on the instance NEXT_PUBLIC_POSTHOG_HOST names + * (fixed at build time), or PostHog drops every event without an error. + */ +export function capturePersonlessEvent(event: PersonlessEvent): Promise { + captureFromEnv ??= createPersonlessCapture({ + apiKey: process.env.DESKTOP_TELEMETRY_POSTHOG_KEY, + host: process.env.NEXT_PUBLIC_POSTHOG_HOST, + }); + return captureFromEnv(event); +} diff --git a/surfsense_web/lib/desktop-telemetry/record-after-response.ts b/surfsense_web/lib/desktop-telemetry/record-after-response.ts new file mode 100644 index 0000000000..d74b692d8a --- /dev/null +++ b/surfsense_web/lib/desktop-telemetry/record-after-response.ts @@ -0,0 +1,11 @@ +import { after } from "next/server"; +import { capturePersonlessEvent, type PersonlessEvent } from "./personless-capture"; + +/** + * Captures once the response has gone, so PostHog being slow or down never + * delays an update check or a usage report. On Vercel, after() keeps the + * function alive until the send settles. + */ +export function recordAfterResponse(event: PersonlessEvent): void { + after(() => capturePersonlessEvent(event)); +} diff --git a/surfsense_web/lib/desktop-telemetry/request-country.ts b/surfsense_web/lib/desktop-telemetry/request-country.ts new file mode 100644 index 0000000000..0d1795d67e --- /dev/null +++ b/surfsense_web/lib/desktop-telemetry/request-country.ts @@ -0,0 +1,10 @@ +const COUNTRY_CODE = /^[A-Z]{2}$/; + +/** + * Country from the header Vercel derives at its edge, so the address itself + * is never read here (docs/contracts/05-desktop-telemetry.md). + */ +export function requestCountry(headers: Headers): string { + const country = headers.get("x-vercel-ip-country"); + return country && COUNTRY_CODE.test(country) ? country : "unknown"; +} diff --git a/surfsense_web/lib/desktop-telemetry/update-feed/channel-files.ts b/surfsense_web/lib/desktop-telemetry/update-feed/channel-files.ts new file mode 100644 index 0000000000..6ce964730a --- /dev/null +++ b/surfsense_web/lib/desktop-telemetry/update-feed/channel-files.ts @@ -0,0 +1,7 @@ +// The four 2.x channel files. latest*.yml belongs to the legacy 0.0.x bridge +// on GitHub and must never be served from here. +const CHANNEL_FILE = /^stable(-mac|-linux(-arm64)?)?\.yml$/; + +export function isChannelFile(file: string): boolean { + return CHANNEL_FILE.test(file); +} diff --git a/surfsense_web/lib/desktop-telemetry/update-feed/latest-github-manifest.ts b/surfsense_web/lib/desktop-telemetry/update-feed/latest-github-manifest.ts new file mode 100644 index 0000000000..148ee3334f --- /dev/null +++ b/surfsense_web/lib/desktop-telemetry/update-feed/latest-github-manifest.ts @@ -0,0 +1,30 @@ +const LATEST_DOWNLOAD = "https://github.com/MODSetter/SurfSense/releases/latest/download"; +// Real manifests are about 500 bytes. +const MAX_MANIFEST_CHARS = 64 * 1024; + +export type FetchManifest = ( + url: string, + init: RequestInit & { next?: { revalidate?: number } } +) => Promise; + +/** + * The channel file of the latest published release. "latest" only moves on + * publish, so a draft merged to main early is never served. Each revalidation + * is still a download, so 15 minutes of caching adds about 96 a day per file + * (more if Vercel caches per region) to GitHub's stable*.yml counts. + */ +export async function fetchLatestGithubManifest( + file: string, + fetchManifest: FetchManifest +): Promise { + const response = await fetchManifest(`${LATEST_DOWNLOAD}/${file}`, { + redirect: "follow", + next: { revalidate: 900 }, + // Well inside the app's 10 s, so it still has time to fall back to GitHub. + signal: AbortSignal.timeout(5000), + }); + if (response.status !== 200) throw new Error(`GitHub answered ${response.status}`); + const text = await response.text(); + if (text.length > MAX_MANIFEST_CHARS) throw new Error("manifest is too large"); + return text; +} diff --git a/surfsense_web/lib/desktop-telemetry/update-feed/pin-manifest-to-release.ts b/surfsense_web/lib/desktop-telemetry/update-feed/pin-manifest-to-release.ts new file mode 100644 index 0000000000..3b04f8c2d4 --- /dev/null +++ b/surfsense_web/lib/desktop-telemetry/update-feed/pin-manifest-to-release.ts @@ -0,0 +1,114 @@ +import { APP_VERSION } from "../desktop-dimensions"; + +const RELEASE_DOWNLOAD = "https://github.com/MODSetter/SurfSense/releases/download"; +const RELEASE_FILE = /^SurfSense[-A-Za-z0-9._]*\.(exe|zip|dmg|AppImage|deb)$/; + +const SHA512 = /^[A-Za-z0-9+/]{86}==$/; +const BYTE_COUNT = /^[0-9]{1,13}$/; +const ISO_DATE = /^(['"]?)[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,3})?Z\1$/; +const PERCENTAGE = /^(100|[1-9]?[0-9])(\.[0-9]+)?$/; + +// Every line electron-builder writes for our releases, value shapes included. +// With no YAML parser here, a line outside this list could hide a url (a flow +// mapping, a quoted key, a folded scalar), so it fails the whole manifest and +// the app falls back to GitHub. +const TOP_LEVEL_LINE = /^([A-Za-z][A-Za-z0-9]*):(?: (.*))?$/; +const FILE_START_LINE = /^ {2}- url: (.*)$/; +const FILE_FIELD_LINE = /^ {4}([A-Za-z][A-Za-z0-9]*): (.*)$/; + +const TOP_LEVEL_VALUES = new Map([ + ["version", APP_VERSION], + ["files", /^$/], + ["path", /^\S+$/], + ["sha512", SHA512], + ["releaseDate", ISO_DATE], + ["stagingPercentage", PERCENTAGE], +]); + +const FILE_FIELD_VALUES = new Map([ + ["sha512", SHA512], + ["size", BYTE_COUNT], + ["blockMapSize", BYTE_COUNT], + ["isAdminRightsRequired", /^(true|false)$/], +]); + +type Line = + | { kind: "top"; key: string; value: string } + | { kind: "file-start"; url: string } + | { kind: "file-field"; key: string; value: string }; + +function parseLine(line: string, number: number): Line { + const fileStart = FILE_START_LINE.exec(line); + if (fileStart) return { kind: "file-start", url: fileStart[1] }; + + const fileField = FILE_FIELD_LINE.exec(line); + if (fileField && FILE_FIELD_VALUES.get(fileField[1])?.test(fileField[2])) { + return { kind: "file-field", key: fileField[1], value: fileField[2] }; + } + + const topLevel = TOP_LEVEL_LINE.exec(line); + const value = topLevel?.[2] ?? ""; + if (topLevel && TOP_LEVEL_VALUES.get(topLevel[1])?.test(value)) { + return { kind: "top", key: topLevel[1], value }; + } + + throw new Error(`line ${number} is not part of an update manifest`); +} + +/** Checks the nesting and that no key repeats, which the app's YAML parser would reject. */ +function checkStructure(lines: Line[]): string { + const topKeys = new Set(); + let inFiles = false; + let fileKeys: Set | null = null; + let fileCount = 0; + + for (const line of lines) { + if (line.kind === "top") { + if (topKeys.has(line.key)) throw new Error(`${line.key} appears twice`); + topKeys.add(line.key); + inFiles = line.key === "files"; + fileKeys = null; + } else if (line.kind === "file-start") { + if (!inFiles) throw new Error("a file entry sits outside files:"); + fileKeys = new Set(["url"]); + fileCount += 1; + } else { + if (!fileKeys) throw new Error(`${line.key} sits outside a file entry`); + if (fileKeys.has(line.key)) throw new Error(`${line.key} appears twice in one file entry`); + fileKeys.add(line.key); + } + } + + const version = lines.find((line) => line.kind === "top" && line.key === "version"); + if (!version || version.kind !== "top") throw new Error("no version"); + if (fileCount === 0) throw new Error("no files"); + return version.value; +} + +/** Accepts a bare release file name, or the exact versioned URL for it. */ +function releaseFileUrl(value: string, version: string): string { + const prefix = `${RELEASE_DOWNLOAD}/v${version}/`; + const name = value.startsWith(prefix) ? value.slice(prefix.length) : value; + if (!RELEASE_FILE.test(name)) throw new Error("a file is not a SurfSense release file"); + return `${prefix}${name}`; +} + +/** + * Points every url: and path: at the GitHub release for the manifest's own + * version, leaving hashes, sizes and dates as they are. On Linux the sha512 + * here is the only check on an update, so anything unexpected throws and the + * route answers 502 rather than serve it (docs/contracts/05-desktop-telemetry.md). + */ +export function pinManifestToRelease(manifest: string): string { + const body = manifest.endsWith("\n") ? manifest.slice(0, -1) : manifest; + const lines = body.split("\n").map((line, index) => parseLine(line, index + 1)); + const version = checkStructure(lines); + + const pinned = lines.map((line) => { + if (line.kind === "file-start") return ` - url: ${releaseFileUrl(line.url, version)}`; + if (line.kind === "file-field") return ` ${line.key}: ${line.value}`; + if (line.key === "path") return `path: ${releaseFileUrl(line.value, version)}`; + return line.value === "" ? `${line.key}:` : `${line.key}: ${line.value}`; + }); + return `${pinned.join("\n")}\n`; +} diff --git a/surfsense_web/lib/desktop-telemetry/update-feed/serve-update-manifest.ts b/surfsense_web/lib/desktop-telemetry/update-feed/serve-update-manifest.ts new file mode 100644 index 0000000000..97c3a5df58 --- /dev/null +++ b/surfsense_web/lib/desktop-telemetry/update-feed/serve-update-manifest.ts @@ -0,0 +1,55 @@ +import { readUpdateCheckDimensions } from "../desktop-dimensions"; +import type { PersonlessEvent } from "../personless-capture"; +import { requestCountry } from "../request-country"; +import { isChannelFile } from "./channel-files"; +import { type FetchManifest, fetchLatestGithubManifest } from "./latest-github-manifest"; +import { pinManifestToRelease } from "./pin-manifest-to-release"; + +export type UpdateFeedDeps = { + fetchManifest: FetchManifest; + record: (event: PersonlessEvent) => void; +}; + +const NO_STORE = { "Cache-Control": "no-store" }; + +/** + * GET /api/updates/ (docs/contracts/05-desktop-telemetry.md, part A). + * Every failure is a 502, which sends the app to GitHub directly, so this + * route can be down or wrong without blocking an update. Reads only the + * X-SurfSense-* headers and the country header: never the address or the + * User-Agent. + */ +export async function serveUpdateManifest( + request: Request, + file: string, + deps: UpdateFeedDeps +): Promise { + if (!isChannelFile(file)) return new Response("Not found\n", { status: 404, headers: NO_STORE }); + + // Counted whatever GitHub answers: the app checked either way. Next answers + // HEAD with this handler too, but the app only GETs, so a HEAD is a monitor + // or a scanner and is not an update check. + if (request.method !== "HEAD") { + deps.record({ + event: "desktop_update_check", + properties: { + file, + ...readUpdateCheckDimensions(request.headers), + country: requestCountry(request.headers), + }, + }); + } + + try { + const manifest = pinManifestToRelease( + await fetchLatestGithubManifest(file, deps.fetchManifest) + ); + return new Response(manifest, { + status: 200, + headers: { "Content-Type": "text/yaml", ...NO_STORE }, + }); + } catch (error) { + console.warn(`[updates] ${file} not served: ${error instanceof Error ? error.message : error}`); + return new Response("Bad gateway\n", { status: 502, headers: NO_STORE }); + } +} diff --git a/surfsense_web/lib/desktop-telemetry/usage-report/read-text-within.ts b/surfsense_web/lib/desktop-telemetry/usage-report/read-text-within.ts new file mode 100644 index 0000000000..d30cdd494f --- /dev/null +++ b/surfsense_web/lib/desktop-telemetry/usage-report/read-text-within.ts @@ -0,0 +1,25 @@ +/** + * The body as UTF-8 text, or null as soon as it passes `limitBytes`. Stops + * reading there, so an oversized or endless body costs no more than the limit. + * Throws on invalid UTF-8. + */ +export async function readTextWithin(request: Request, limitBytes: number): Promise { + const declared = Number(request.headers.get("content-length")); + if (Number.isFinite(declared) && declared > limitBytes) return null; + if (!request.body) return ""; + + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > limitBytes) { + await reader.cancel(); + return null; + } + chunks.push(value); + } + return new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)); +} diff --git a/surfsense_web/lib/desktop-telemetry/usage-report/receive-usage-report.ts b/surfsense_web/lib/desktop-telemetry/usage-report/receive-usage-report.ts new file mode 100644 index 0000000000..0469807fcb --- /dev/null +++ b/surfsense_web/lib/desktop-telemetry/usage-report/receive-usage-report.ts @@ -0,0 +1,53 @@ +import type { PersonlessEvent } from "../personless-capture"; +import { requestCountry } from "../request-country"; +import { readTextWithin } from "./read-text-within"; +import { usageEventProperties } from "./usage-event-properties"; +import { usageReportSchema } from "./usage-report-schema"; + +export type UsageReportDeps = { + record: (event: PersonlessEvent) => void; +}; + +// The contract's cap; a real report is well under 1 KB. +const MAX_REPORT_BYTES = 4096; + +function reply(status: number, error?: string): Response { + if (!error) return new Response(null, { status, headers: { "Cache-Control": "no-store" } }); + return Response.json({ error }, { status, headers: { "Cache-Control": "no-store" } }); +} + +function isJsonContentType(contentType: string | null): boolean { + return contentType?.split(";")[0].trim().toLowerCase() === "application/json"; +} + +/** + * POST /api/usage (docs/contracts/05-desktop-telemetry.md, part B). Records + * only a report that passes the schema exactly. Reads only the body and the + * country header: never the address, the User-Agent or a cookie. + */ +export async function receiveUsageReport( + request: Request, + deps: UsageReportDeps +): Promise { + if (!isJsonContentType(request.headers.get("content-type"))) { + return reply(415, "content_type_must_be_json"); + } + + let body: unknown; + try { + const text = await readTextWithin(request, MAX_REPORT_BYTES); + if (text === null) return reply(413, "report_too_large"); + body = JSON.parse(text); + } catch { + return reply(400, "invalid_json"); + } + + const report = usageReportSchema.safeParse(body); + if (!report.success) return reply(400, "invalid_report"); + + deps.record({ + event: "desktop_usage_report", + properties: usageEventProperties(report.data, requestCountry(request.headers)), + }); + return reply(204); +} diff --git a/surfsense_web/lib/desktop-telemetry/usage-report/usage-event-properties.ts b/surfsense_web/lib/desktop-telemetry/usage-report/usage-event-properties.ts new file mode 100644 index 0000000000..3c550838bd --- /dev/null +++ b/surfsense_web/lib/desktop-telemetry/usage-report/usage-event-properties.ts @@ -0,0 +1,25 @@ +import type { UsageReport } from "./usage-report-schema"; + +/** + * desktop_usage_report's properties: the report flattened one level + * (active_first, hardware_gpu, week_chat_turns, ...) plus the country. + * A section the report leaves null adds no properties. + */ +export function usageEventProperties( + report: UsageReport, + country: string +): Record { + const { active, hardware, week, ...top } = report; + const properties: Record = { ...top }; + for (const [section, values] of [ + ["active", active], + ["hardware", hardware], + ["week", week], + ] as const) { + for (const [key, value] of Object.entries(values ?? {})) { + properties[`${section}_${key}`] = value; + } + } + properties.country = country; + return properties; +} diff --git a/surfsense_web/lib/desktop-telemetry/usage-report/usage-report-schema.ts b/surfsense_web/lib/desktop-telemetry/usage-report/usage-report-schema.ts new file mode 100644 index 0000000000..d43437993d --- /dev/null +++ b/surfsense_web/lib/desktop-telemetry/usage-report/usage-report-schema.ts @@ -0,0 +1,56 @@ +import { z } from "zod"; +import { APP_VERSION, DESKTOP_PLATFORMS, isDesktopOs } from "../desktop-dimensions"; + +const ISO_WEEK = /^20[0-9]{2}-W(0[1-9]|[1-4][0-9]|5[0-3])$/; +const TURNS = z.enum(["0", "1-9", "10-49", "50+", "unknown"]); + +/** + * Usage report schema 1 (docs/contracts/05-desktop-telemetry.md, part B). + * Strict all the way down: every value is an enum, flag or fixed format, so + * an unknown key or value is refused rather than stored. + */ +export const usageReportSchema = z + .strictObject({ + schema: z.literal(1), + app_version: z.string().regex(APP_VERSION), + platform: z.enum(DESKTOP_PLATFORMS), + os: z.string().refine(isDesktopOs), + ui_language: z.enum(["en", "ja", "de", "other"]), + install_week: z.string().regex(ISO_WEEK), + active: z.strictObject({ + first: z.boolean(), + week: z.boolean(), + month: z.boolean(), + }), + hardware: z + .strictObject({ + ram_gb: z.enum(["<8", "8-15", "16-31", "32-63", "64+"]), + gpu: z.enum(["nvidia", "amd", "intel", "apple", "none", "unknown"]), + vram_gb: z.enum(["0", "<4", "4-7", "8-11", "12-23", "24+", "unknown"]), + }) + .nullable(), + week: z + .strictObject({ + chat_turns: TURNS, + agent_turns: TURNS, + model_source: z.enum(["none", "local", "remote", "both", "unknown"]), + sources: z.enum(["0", "1-9", "10-99", "100-999", "1000+", "unknown"]), + studio_outputs: z.enum(["0", "1-4", "5+", "unknown"]), + plugin_calls: z.enum(["0", "1-9", "10+", "unknown"]), + onboarding: z.enum([ + "not_started", + "model_chosen", + "model_ready", + "first_source", + "first_answer", + "unknown", + ]), + }) + .nullable(), + }) + // Hardware rides only on an install's first report and the week only on the + // first report of a week, so neither can become a daily trail. + .refine((report) => report.hardware === null || report.active.first, { path: ["hardware"] }) + .refine((report) => report.week === null || report.active.week, { path: ["week"] }); + +export type UsageReport = z.infer; diff --git a/surfsense_web/tests/unit/desktop-telemetry/fixtures/stable-linux.yml b/surfsense_web/tests/unit/desktop-telemetry/fixtures/stable-linux.yml new file mode 100644 index 0000000000..da10152c8d --- /dev/null +++ b/surfsense_web/tests/unit/desktop-telemetry/fixtures/stable-linux.yml @@ -0,0 +1,12 @@ +version: 2.2.3 +files: + - url: SurfSense.AppImage + sha512: 2AdhuIu3+eyrEhjwTIvu/6aO/CG9Zs/PEmeP/RP18oD8iwgYBFeGJJMuCw68AEl1j6/tIINDHWf2bwK/BzIvwA== + size: 1462521628 + blockMapSize: 1519408 + - url: SurfSense.deb + sha512: SI2pnGuRFgukWx2XsgvnvUxS0eKJyjUFOPDtVBXd1v3Z2QzCICp6XA1fuHZzd6ZzoJQt+OY8DGyO87wTzMag0Q== + size: 1345595320 +path: SurfSense.AppImage +sha512: 2AdhuIu3+eyrEhjwTIvu/6aO/CG9Zs/PEmeP/RP18oD8iwgYBFeGJJMuCw68AEl1j6/tIINDHWf2bwK/BzIvwA== +releaseDate: '2026-10-21T10:05:26.481Z' diff --git a/surfsense_web/tests/unit/desktop-telemetry/fixtures/stable-mac.yml b/surfsense_web/tests/unit/desktop-telemetry/fixtures/stable-mac.yml new file mode 100644 index 0000000000..f85baf0f7c --- /dev/null +++ b/surfsense_web/tests/unit/desktop-telemetry/fixtures/stable-mac.yml @@ -0,0 +1,11 @@ +version: 2.2.3 +files: + - url: SurfSense-arm64.zip + sha512: GV/g0Q1MRkWRmgvD7RRNrwEOAiiMIRp1cGmgRc7lWpGg8xWnKKVLc1CZD58EDB8p2Pt/LgmsAMghezrM8skupg== + size: 1338360154 + - url: SurfSense-arm64.dmg + sha512: gbtr+hbisU89CNAT9kI6xKIt51ra6DiOH5Im4ybC4ivuL8dFYXGzLFEUVAK3B5Du55eHq/WXSI2ugIu/WnSexg== + size: 1357516813 +path: SurfSense-arm64.zip +sha512: GV/g0Q1MRkWRmgvD7RRNrwEOAiiMIRp1cGmgRc7lWpGg8xWnKKVLc1CZD58EDB8p2Pt/LgmsAMghezrM8skupg== +releaseDate: '2026-10-21T10:15:37.088Z' diff --git a/surfsense_web/tests/unit/desktop-telemetry/fixtures/stable.yml b/surfsense_web/tests/unit/desktop-telemetry/fixtures/stable.yml new file mode 100644 index 0000000000..fd632dd122 --- /dev/null +++ b/surfsense_web/tests/unit/desktop-telemetry/fixtures/stable.yml @@ -0,0 +1,8 @@ +version: 2.2.3 +files: + - url: SurfSense-Setup.exe + sha512: W4tisJ3ctecrvGbkA6ibMQqWgAhjFNexPOOPtBa602DSzi7eO6zU4sbeF/hYaXVPJksOuUmczyUaa4trrVbvHQ== + size: 1216797720 +path: SurfSense-Setup.exe +sha512: W4tisJ3ctecrvGbkA6ibMQqWgAhjFNexPOOPtBa602DSzi7eO6zU4sbeF/hYaXVPJksOuUmczyUaa4trrVbvHQ== +releaseDate: '2026-10-21T10:03:17.403Z' diff --git a/surfsense_web/tests/unit/desktop-telemetry/personless-capture.test.ts b/surfsense_web/tests/unit/desktop-telemetry/personless-capture.test.ts new file mode 100644 index 0000000000..7786d11046 --- /dev/null +++ b/surfsense_web/tests/unit/desktop-telemetry/personless-capture.test.ts @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { gunzipSync } from "node:zlib"; + +// Run with: pnpm exec tsx --test tests/unit/desktop-telemetry/personless-capture.test.ts + +import { createPersonlessCapture } from "../../../lib/desktop-telemetry/personless-capture"; + +type Sent = { url: string; body: { batch?: Array> } }; + +function postHogDouble() { + const sent: Sent[] = []; + const fetch = async ( + url: string, + options: { body?: string | Blob; headers?: Record } + ) => { + let raw: Buffer; + if (typeof options.body === "string") raw = Buffer.from(options.body); + else raw = Buffer.from(await (options.body as Blob).arrayBuffer()); + const gzipped = options.headers?.["Content-Encoding"] === "gzip"; + sent.push({ url, body: JSON.parse((gzipped ? gunzipSync(raw) : raw).toString("utf8")) }); + return { + status: 200, + text: async () => "{}", + json: async () => ({}), + headers: { get: () => null }, + }; + }; + return { + sent, + fetch: fetch as unknown as Parameters[0]["fetch"], + }; +} + +const EVENT = { + event: "desktop_update_check", + properties: { + file: "stable.yml", + app_version: "2.2.4", + platform: "linux-x64", + os: "linux", + country: "FR", + }, +}; + +test("with no key nothing is sent", async () => { + const { sent, fetch } = postHogDouble(); + await createPersonlessCapture({ apiKey: undefined, host: undefined, fetch })(EVENT); + await createPersonlessCapture({ apiKey: "", host: "https://ph.example", fetch })(EVENT); + assert.equal(sent.length, 0); +}); + +test("an event is sent before the capture resolves, personless and without GeoIP", async () => { + const { sent, fetch } = postHogDouble(); + await createPersonlessCapture({ apiKey: "phc_test", host: "https://ph.example", fetch })(EVENT); + + assert.equal(sent.length, 1); + assert.ok(sent[0].url.startsWith("https://ph.example/"), sent[0].url); + const [event] = sent[0].body.batch ?? []; + const properties = event.properties as Record; + assert.equal(event.event, "desktop_update_check"); + for (const [key, value] of Object.entries(EVENT.properties)) { + assert.equal(properties[key], value, key); + } + assert.equal(properties.$process_person_profile, false); + assert.equal(properties.$geoip_disable, true); + assert.equal("$ip" in properties, false); + assert.equal("ip" in event, false); +}); + +test("no two events share a distinct id, so nothing can be joined across them", async () => { + const { sent, fetch } = postHogDouble(); + const capture = createPersonlessCapture({ + apiKey: "phc_test", + host: "https://ph.example", + fetch, + }); + await capture(EVENT); + await capture(EVENT); + const ids = sent.map((request) => request.body.batch?.[0].distinct_id); + assert.equal(ids.length, 2); + assert.notEqual(ids[0], ids[1]); +}); diff --git a/surfsense_web/tests/unit/desktop-telemetry/sunset-passthrough.test.ts b/surfsense_web/tests/unit/desktop-telemetry/sunset-passthrough.test.ts new file mode 100644 index 0000000000..17a093953b --- /dev/null +++ b/surfsense_web/tests/unit/desktop-telemetry/sunset-passthrough.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +// Run with: pnpm exec tsx --test tests/unit/desktop-telemetry/sunset-passthrough.test.ts + +import { config } from "../../../proxy"; + +// The sunset proxy sends every non-public path to /sunset on the cloud +// deployment. The desktop routes are not public pages, so they rely on the +// matcher never running the proxy for them: a redirect would cost every +// update check its count and every usage report its record. +const runsProxy = (pathname: string) => + config.matcher.some((matcher) => new RegExp(`^${matcher}$`).test(pathname)); + +test("the proxy still runs on app pages, so this check means something", () => { + assert.equal(runsProxy("/dashboard/12"), true); +}); + +test("the proxy never runs for the desktop update feed or usage reports", () => { + for (const pathname of [ + "/api/updates/stable.yml", + "/api/updates/stable-mac.yml", + "/api/updates/stable-linux-arm64.yml", + "/api/updates/latest.yml", + "/api/usage", + ]) { + assert.equal(runsProxy(pathname), false, pathname); + } +}); diff --git a/surfsense_web/tests/unit/desktop-telemetry/update-feed.test.ts b/surfsense_web/tests/unit/desktop-telemetry/update-feed.test.ts new file mode 100644 index 0000000000..7793dcbb31 --- /dev/null +++ b/surfsense_web/tests/unit/desktop-telemetry/update-feed.test.ts @@ -0,0 +1,304 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { mock, test } from "node:test"; + +// Run with: pnpm exec tsx --test tests/unit/desktop-telemetry/update-feed.test.ts + +import type { PersonlessEvent } from "../../../lib/desktop-telemetry/personless-capture"; +import type { FetchManifest } from "../../../lib/desktop-telemetry/update-feed/latest-github-manifest"; +import { serveUpdateManifest } from "../../../lib/desktop-telemetry/update-feed/serve-update-manifest"; + +// Fetched once from releases/latest/download on 21 Oct 2026 (v2.2.3). +const fixture = (name: string) => readFileSync(path.join(__dirname, "fixtures", name), "utf8"); +const PINNED = "https://github.com/MODSetter/SurfSense/releases/download/v2.2.3/"; + +const CLIENT_HEADERS = { + "x-surfsense-version": "2.2.4", + "x-surfsense-platform": "win32-x64", + "x-surfsense-os": "windows-11", + "x-vercel-ip-country": "DE", + "x-forwarded-for": "203.0.113.7", + "x-real-ip": "203.0.113.7", + "x-vercel-forwarded-for": "203.0.113.7", + "user-agent": "electron-builder secret-agent", + cookie: "session=abc", +}; + +type Call = { url: string; init: Parameters[1] }; + +function upstream(body: string | (() => Promise), status = 200) { + const calls: Call[] = []; + const fetchManifest: FetchManifest = async (url, init) => { + calls.push({ url, init }); + return typeof body === "function" ? body() : new Response(body, { status }); + }; + return { calls, fetchManifest }; +} + +async function check( + file: string, + manifest: string | (() => Promise), + { + status = 200, + headers = CLIENT_HEADERS, + method = "GET", + }: { status?: number; headers?: Record; method?: string } = {} +) { + const events: PersonlessEvent[] = []; + const { calls, fetchManifest } = upstream(manifest, status); + const request = new Request(`https://updates.surfsense.com/api/updates/${file}?noCache=1`, { + method, + headers, + }); + const response = await serveUpdateManifest(request, file, { + fetchManifest, + record: (event) => events.push(event), + }); + return { response, body: await response.text(), events, calls }; +} + +mock.method(console, "warn", () => {}); + +for (const file of ["stable.yml", "stable-mac.yml", "stable-linux.yml"]) { + test(`${file} from GitHub is served with every file pinned to its release`, async () => { + const original = fixture(file); + const { response, body } = await check(file, original); + + assert.equal(response.status, 200); + assert.equal(response.headers.get("content-type"), "text/yaml"); + assert.equal(response.headers.get("cache-control"), "no-store"); + + const before = original.trimEnd().split("\n"); + const after = body.trimEnd().split("\n"); + assert.equal(after.length, before.length); + for (const [index, line] of after.entries()) { + const match = /^(\s*- url|path): (.*)$/.exec(line); + if (match) { + const name = before[index].split(": ")[1]; + assert.equal(match[2], `${PINNED}${name}`, line); + } else { + // sha512, size, blockMapSize, releaseDate and version pass through untouched. + assert.equal(line, before[index]); + } + } + }); +} + +test("stable-linux-arm64.yml is a channel file too", async () => { + const { response } = await check("stable-linux-arm64.yml", fixture("stable-linux.yml")); + assert.equal(response.status, 200); +}); + +test("a manifest already pinned to its own release is served as it is", async () => { + const pinned = (await check("stable.yml", fixture("stable.yml"))).body; + const { response, body } = await check("stable.yml", pinned); + assert.equal(response.status, 200); + assert.equal(body, pinned); +}); + +test("stagingPercentage passes through untouched", async () => { + const staged = `${fixture("stable.yml")}stagingPercentage: 25\n`; + const { response, body } = await check("stable.yml", staged); + assert.equal(response.status, 200); + assert.match(body, /\nstagingPercentage: 25\n$/); +}); + +test("GitHub's latest release is fetched with nothing from the app's request", async () => { + const { calls } = await check("stable-mac.yml", fixture("stable-mac.yml")); + assert.equal(calls.length, 1); + assert.equal( + calls[0].url, + "https://github.com/MODSetter/SurfSense/releases/latest/download/stable-mac.yml" + ); + assert.equal(calls[0].init.redirect, "follow"); + assert.equal(calls[0].init.next?.revalidate, 900); + assert.equal(calls[0].init.headers, undefined); +}); + +for (const file of [ + "latest.yml", + "latest-mac.yml", + "latest-linux.yml", + "stable.yaml", + "stable-windows.yml", + "stable-linux-arm.yml", + "STABLE.yml", + "beta.yml", + "stable.yml.exe", + "../stable.yml", + "stable.yml/..", + "", +]) { + test(`${JSON.stringify(file)} is not a channel file: 404, no fetch, nothing recorded`, async () => { + const { response, events, calls } = await check(file, fixture("stable.yml")); + assert.equal(response.status, 404); + assert.equal(response.headers.get("cache-control"), "no-store"); + assert.equal(calls.length, 0); + assert.equal(events.length, 0); + }); +} + +const stable = fixture("stable.yml"); +const HOSTILE: Record = { + "an absolute url off GitHub": stable.replace( + "url: SurfSense-Setup.exe", + "url: https://evil.example/SurfSense-Setup.exe" + ), + "a GitHub url for another release": stable.replace( + "url: SurfSense-Setup.exe", + "url: https://github.com/MODSetter/SurfSense/releases/download/v2.2.2/SurfSense-Setup.exe" + ), + "a GitHub url for another repository": stable.replace( + "path: SurfSense-Setup.exe", + "path: https://github.com/someone/SurfSense/releases/download/v2.2.3/SurfSense-Setup.exe" + ), + "path traversal in a url": stable.replace( + "url: SurfSense-Setup.exe", + "url: ../../../evil/SurfSense-Setup.exe" + ), + "path traversal after the name": stable.replace( + "path: SurfSense-Setup.exe", + "path: SurfSense/../../x/SurfSense-Setup.exe" + ), + "a file that is not an installer": stable.replace( + "url: SurfSense-Setup.exe", + "url: SurfSense-Setup.sh" + ), + "a file not named SurfSense": stable.replace("path: SurfSense-Setup.exe", "path: Setup.exe"), + "a quoted url": stable.replace( + "url: SurfSense-Setup.exe", + "url: 'https://evil.example/SurfSense-Setup.exe'" + ), + "a missing version": stable.replace("version: 2.2.3\n", ""), + "a version that is not x.y.z": stable.replace("version: 2.2.3", "version: 2.2.3-evil"), + "two versions": `version: 9.9.9\n${stable}`, + "two paths": `${stable}path: SurfSense-Setup.exe\n`, + "a flow mapping file entry": stable.replace( + " - url: SurfSense-Setup.exe", + " - {url: 'https://evil.example/x.exe', sha512: x}" + ), + "a folded url": stable.replace( + " - url: SurfSense-Setup.exe", + " - url: SurfSense-Setup.exe\n https://evil.example/x.exe" + ), + "an unknown key": `${stable}releaseNotes: hello\n`, + "a comment": `# hi\n${stable}`, + "a document marker": `---\n${stable}`, + "windows line endings": stable.replace(/\n/g, "\r\n"), + "a file field outside files:": stable.replace("releaseDate:", " size: 1\nreleaseDate:"), + "no files at all": "version: 2.2.3\npath: SurfSense-Setup.exe\n", + "an empty body": "", + "an HTML error page": "Not Found", +}; + +for (const [name, manifest] of Object.entries(HOSTILE)) { + test(`a manifest with ${name} is a 502`, async () => { + const { response, body } = await check("stable.yml", manifest); + assert.equal(response.status, 502); + assert.equal(response.headers.get("cache-control"), "no-store"); + assert.doesNotMatch(body, /evil/); + }); +} + +test("GitHub not answering 200 is a 502", async () => { + for (const status of [404, 403, 500, 503]) { + const { response } = await check("stable.yml", "Not Found", { status }); + assert.equal(response.status, 502, String(status)); + } +}); + +test("GitHub failing outright is a 502", async () => { + const { response } = await check("stable.yml", () => Promise.reject(new Error("timeout"))); + assert.equal(response.status, 502); +}); + +test("a manifest over 64 KB is a 502, even when every line is valid", async () => { + // Repeats the fixture's own file entry, so only the size cap can refuse it. + const cap = 64 * 1024; + const entry = `${stable.split("\n").slice(2, 5).join("\n")}\n`; + const withEntries = (count: number) => + stable.replace("files:\n", `files:\n${entry.repeat(count)}`); + const under = withEntries(Math.floor((cap - stable.length) / entry.length)); + const over = withEntries(Math.floor((cap - stable.length) / entry.length) + 1); + assert.ok(under.length <= cap && over.length > cap); + + assert.equal((await check("stable.yml", under)).response.status, 200); + assert.equal((await check("stable.yml", over)).response.status, 502); +}); + +test("each check records one desktop_update_check with exactly the contract's fields", async () => { + const { events } = await check("stable.yml", stable); + assert.deepEqual(events, [ + { + event: "desktop_update_check", + properties: { + file: "stable.yml", + app_version: "2.2.4", + platform: "win32-x64", + os: "windows-11", + country: "DE", + }, + }, + ]); +}); + +test("a HEAD request is answered but not recorded, since the app only GETs", async () => { + const { response, events } = await check("stable.yml", stable, { method: "HEAD" }); + assert.equal(response.status, 200); + assert.equal(events.length, 0); +}); + +test("a check is recorded even when GitHub fails, since the app still checked", async () => { + const { events } = await check("stable-mac.yml", "nope", { status: 500 }); + assert.equal(events.length, 1); + assert.equal(events[0].properties.file, "stable-mac.yml"); +}); + +test("headers outside the contract fold into invalid, other and unknown", async () => { + const { events } = await check("stable-linux.yml", fixture("stable-linux.yml"), { + headers: { + "x-surfsense-version": "2.2.4-beta+alice", + "x-surfsense-platform": "freebsd-x64", + "x-surfsense-os": "windows-7", + "x-vercel-ip-country": "not a country", + }, + }); + assert.deepEqual(events[0].properties, { + file: "stable-linux.yml", + app_version: "invalid", + platform: "other", + os: "other", + country: "unknown", + }); +}); + +test("a check with no SurfSense headers still counts, as invalid, other and unknown", async () => { + const { events } = await check("stable.yml", stable, { headers: {} }); + assert.deepEqual(events[0].properties, { + file: "stable.yml", + app_version: "invalid", + platform: "other", + os: "other", + country: "unknown", + }); +}); + +test("every macOS major is accepted as its own OS", async () => { + for (const os of ["macos-11", "macos-15", "macos-26"]) { + const { events } = await check("stable-mac.yml", fixture("stable-mac.yml"), { + headers: { "x-surfsense-os": os, "x-surfsense-platform": "darwin-arm64" }, + }); + assert.equal(events[0].properties.os, os); + assert.equal(events[0].properties.platform, "darwin-arm64"); + } +}); + +test("the recorded event carries no IP address, User-Agent or cookie", async () => { + const { events } = await check("stable.yml", stable); + const recorded = JSON.stringify(events); + assert.doesNotMatch(recorded, /203\.0\.113\.7/); + assert.doesNotMatch(recorded, /secret-agent|electron-builder/); + assert.doesNotMatch(recorded, /session=abc/); + assert.doesNotMatch(recorded, /"\$?ip"/); +}); diff --git a/surfsense_web/tests/unit/desktop-telemetry/usage-report.test.ts b/surfsense_web/tests/unit/desktop-telemetry/usage-report.test.ts new file mode 100644 index 0000000000..1e6b40d8a1 --- /dev/null +++ b/surfsense_web/tests/unit/desktop-telemetry/usage-report.test.ts @@ -0,0 +1,263 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +// Run with: pnpm exec tsx --test tests/unit/desktop-telemetry/usage-report.test.ts + +import type { PersonlessEvent } from "../../../lib/desktop-telemetry/personless-capture"; +import { receiveUsageReport } from "../../../lib/desktop-telemetry/usage-report/receive-usage-report"; + +// The contract's own example: a first report of the week, not the install's first. +const REPORT = { + schema: 1, + app_version: "2.2.4", + platform: "win32-x64", + os: "windows-11", + ui_language: "en", + install_week: "2026-W43", + active: { first: false, week: true, month: false }, + hardware: null, + week: { + chat_turns: "10-49", + agent_turns: "0", + model_source: "local", + sources: "10-99", + studio_outputs: "1-4", + plugin_calls: "0", + onboarding: "first_answer", + }, +}; + +const FIRST_REPORT = { + ...REPORT, + active: { first: true, week: true, month: true }, + hardware: { ram_gb: "16-31", gpu: "nvidia", vram_gb: "8-11" }, +}; + +const PRIVATE_HEADERS = { + "x-forwarded-for": "203.0.113.7", + "x-real-ip": "203.0.113.7", + "x-vercel-forwarded-for": "203.0.113.7", + "user-agent": "SurfSense secret-agent", + cookie: "session=abc", +}; + +async function post( + body: unknown, + { + contentType = "application/json", + headers = {}, + }: { contentType?: string | null; headers?: Record } = {} +) { + const events: PersonlessEvent[] = []; + const request = new Request("https://usage.surfsense.com/api/usage", { + method: "POST", + headers: { + ...(contentType ? { "content-type": contentType } : {}), + "x-vercel-ip-country": "JP", + ...PRIVATE_HEADERS, + ...headers, + }, + body: typeof body === "string" ? body : JSON.stringify(body), + }); + const response = await receiveUsageReport(request, { record: (event) => events.push(event) }); + return { response, events }; +} + +/** A copy of REPORT with one value replaced at a dotted path. */ +function withValue(pathName: string, value: unknown) { + const copy = structuredClone(REPORT) as Record; + const keys = pathName.split("."); + let target = copy; + for (const key of keys.slice(0, -1)) target = target[key] as Record; + target[keys[keys.length - 1]] = value; + return copy; +} + +test("a valid report is a 204 with no body and nothing cached", async () => { + const { response } = await post(REPORT); + assert.equal(response.status, 204); + assert.equal(await response.text(), ""); + assert.equal(response.headers.get("cache-control"), "no-store"); +}); + +test("a valid report records one desktop_usage_report, flattened, with the country", async () => { + const { events } = await post(REPORT); + assert.deepEqual(events, [ + { + event: "desktop_usage_report", + properties: { + schema: 1, + app_version: "2.2.4", + platform: "win32-x64", + os: "windows-11", + ui_language: "en", + install_week: "2026-W43", + active_first: false, + active_week: true, + active_month: false, + week_chat_turns: "10-49", + week_agent_turns: "0", + week_model_source: "local", + week_sources: "10-99", + week_studio_outputs: "1-4", + week_plugin_calls: "0", + week_onboarding: "first_answer", + country: "JP", + }, + }, + ]); +}); + +test("an install's first report carries its hardware", async () => { + const { response, events } = await post(FIRST_REPORT); + assert.equal(response.status, 204); + assert.equal(events[0].properties.hardware_ram_gb, "16-31"); + assert.equal(events[0].properties.hardware_gpu, "nvidia"); + assert.equal(events[0].properties.hardware_vram_gb, "8-11"); +}); + +test("a mid-week report with neither section records only the flags", async () => { + const { response, events } = await post({ + ...REPORT, + active: { first: false, week: false, month: false }, + week: null, + }); + assert.equal(response.status, 204); + const keys = Object.keys(events[0].properties); + assert.equal( + keys.some((key) => key.startsWith("week_") || key.startsWith("hardware_")), + false + ); +}); + +test("without the country header the country is unknown", async () => { + const request = new Request("https://usage.surfsense.com/api/usage", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(REPORT), + }); + const events: PersonlessEvent[] = []; + await receiveUsageReport(request, { record: (event) => events.push(event) }); + assert.equal(events[0].properties.country, "unknown"); +}); + +test("the recorded event carries no IP address, User-Agent or cookie", async () => { + const { events } = await post(FIRST_REPORT); + const recorded = JSON.stringify(events); + assert.doesNotMatch(recorded, /203\.0\.113\.7/); + assert.doesNotMatch(recorded, /secret-agent/); + assert.doesNotMatch(recorded, /session=abc/); + assert.doesNotMatch(recorded, /"\$?ip"/); +}); + +const UNKNOWN_KEYS: Record = { + "at the top": { ...REPORT, install_id: "abc" }, + "in active": withValue("active.day", true), + "in week": withValue("week.prompt", "hello"), + "in hardware": { ...FIRST_REPORT, hardware: { ...FIRST_REPORT.hardware, cpu: "i9" } }, + "named __proto__": `{"__proto__": {"x": 1}, ${JSON.stringify(REPORT).slice(1)}`, +}; + +for (const [where, body] of Object.entries(UNKNOWN_KEYS)) { + test(`an unknown key ${where} is a 400 and is not recorded`, async () => { + const { response, events } = await post(body); + assert.equal(response.status, 400); + assert.equal(events.length, 0); + }); +} + +const BAD_VALUES: Array<[string, unknown]> = [ + ["schema", 2], + ["app_version", "2.2.4-beta"], + ["app_version", "latest"], + ["platform", "win32-ia32"], + ["os", "windows-7"], + ["os", "macos-"], + ["ui_language", "fr"], + ["install_week", "2026-43"], + ["install_week", "2026-W54"], + ["active.first", "yes"], + ["week.chat_turns", "5"], + ["week.chat_turns", 5], + ["week.model_source", "openai"], + ["week.sources", "10-49"], + ["week.studio_outputs", "1-9"], + ["week.plugin_calls", "10-49"], + ["week.onboarding", "done"], + ["week", "busy"], + ["hardware", { ram_gb: "16-31", gpu: "nvidia", vram_gb: "8-11" }], +]; + +for (const [pathName, value] of BAD_VALUES) { + test(`${pathName} = ${JSON.stringify(value)} is a 400`, async () => { + const { response, events } = await post(withValue(pathName, value)); + assert.equal(response.status, 400); + assert.equal(events.length, 0); + }); +} + +test("a missing key is a 400", async () => { + const { hardware: _hardware, ...withoutHardware } = REPORT; + const { response } = await post(withoutHardware); + assert.equal(response.status, 400); +}); + +test("week sent when it is not the week's first report is a 400", async () => { + const { response } = await post({ + ...REPORT, + active: { first: false, week: false, month: true }, + }); + assert.equal(response.status, 400); +}); + +test("a body that is not JSON is a 400", async () => { + for (const body of ["", "{", "null", "[]", "1"]) { + const { response } = await post(body); + assert.equal(response.status, 400, JSON.stringify(body)); + } +}); + +test("a body over 4 KB is a 413", async () => { + const padded = `${JSON.stringify(REPORT)}${" ".repeat(4097)}`; + const { response, events } = await post(padded); + assert.equal(response.status, 413); + assert.equal(events.length, 0); +}); + +test("a body over 4 KB with no Content-Length is a 413 and is not read to the end", async () => { + let pulled = 0; + const endless = new ReadableStream({ + pull(controller) { + pulled += 1; + controller.enqueue(new Uint8Array(1024).fill(32)); + }, + }); + const request = new Request("https://usage.surfsense.com/api/usage", { + method: "POST", + headers: { "content-type": "application/json" }, + body: endless, + duplex: "half", + } as RequestInit); + const response = await receiveUsageReport(request, { record: () => {} }); + assert.equal(response.status, 413); + assert.ok(pulled < 10, `pulled ${pulled} chunks`); +}); + +test("a body of exactly 4 KB is accepted", async () => { + const json = JSON.stringify(REPORT); + const { response } = await post(`${json}${" ".repeat(4096 - json.length)}`); + assert.equal(response.status, 204); +}); + +test("a body that is not JSON content is a 415", async () => { + for (const contentType of [null, "text/plain", "application/x-www-form-urlencoded"]) { + const { response, events } = await post(REPORT, { contentType }); + assert.equal(response.status, 415, String(contentType)); + assert.equal(events.length, 0); + } +}); + +test("JSON with a charset parameter is accepted", async () => { + const { response } = await post(REPORT, { contentType: "application/json; charset=utf-8" }); + assert.equal(response.status, 204); +});