diff --git a/README.md b/README.md index 30e0618..9d17ec2 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Full Circuit is a web application for exploring PC components and planning a cus - [What is set up on the VM](#what-is-set-up-on-the-vm) - [Working with it](#working-with-it) - [Run the backend locally](#run-the-backend-locally) + - [Abuse protection and request limits](#abuse-protection-and-request-limits) - [Current prototype](#current-prototype) - [PC builder](#pc-builder) - [Navigation and shared build state](#navigation-and-shared-build-state) @@ -146,6 +147,21 @@ npm ci npm run dev ``` +### Abuse protection and request limits + +Publishing stays anonymous, so the backend limits how much a single client can do. The limits are set in `backend/server/src/config.ts`. + +| Limit | Value | When exceeded | +| --- | --- | --- | +| Publishing builds (`publishBuild`) | 5 attempts per client IP in 10 minutes. | HTTP `429` with a `Retry-After` header and error code `RATE_LIMITED`. Nothing is written to the database. | +| Query depth | At most 8 levels of nested fields. | HTTP `400`, error code `GRAPHQL_VALIDATION_FAILED`. | +| Query size | At most 100 selected fields per operation (fragments and aliases are counted). | HTTP `400`, error code `GRAPHQL_VALIDATION_FAILED`. | +| Request body | At most 100 KB. | HTTP `413`, error code `PAYLOAD_TOO_LARGE`. | + +Every publishing attempt counts, including invalid ones, and each alias of `publishBuild` in one request counts separately. Reading data is not rate limited, and introspection queries are not counted towards the depth and size limits. The counters are kept in the server's memory, so they reset when the backend restarts, and they are per backend process. + +The limit is per IP address, so the backend must see the visitor's real address. On the VM, Apache forwards requests to the backend, so `TRUST_PROXY=1` must be set in `backend/server/.env`. Without it, all visitors share the proxy's address and one limit. + ## Current prototype The frontend is built with React, TypeScript, Vite, and Tailwind CSS. The catalog supports search, filtering, sorting, and switching between list and grid layouts. It loads 40 components at a time through infinite scrolling or the **Load more** button, and handles loading, error, and empty states. Recent catalogue responses are cached for one minute. Previous cards remain visible while new results load, and unchanged cards can skip rendering through memoization. @@ -218,6 +234,12 @@ npm run test See [frontend/src/test/README.md](frontend/src/test/README.md) for the test setup and covered scenarios. +The backend has automated tests using Vitest, covering the GraphQL resolvers and build publishing validation. They mock the database, so no running PostgreSQL is needed. Run them from `backend/server/`: + +```bash +npm test +``` + ## Run the prototype locally You need Node.js and npm installed. From the repository root, run: diff --git a/backend/server/.env.example b/backend/server/.env.example index c2a0aa4..2e7f10d 100644 --- a/backend/server/.env.example +++ b/backend/server/.env.example @@ -8,9 +8,13 @@ PORT=3001 # Comma-separated list of browser origins allowed to read API responses, or * for any. # On the VM this is the address the frontend is served from. CORS_ORIGIN=http://it2810-15.idi.ntnu.no +# Number of reverse proxies in front of the backend. On the VM, Apache forwards requests to the +# backend, so this must be 1 for the rate limit to count each visitor separately by IP address. +TRUST_PROXY=1 # --- Running backend and frontend locally, with the database on the VM --- # Needs NTNU network or VPN, and the VM's Postgres must accept outside connections. -# Use these two lines in your own .env instead of the ones above: +# Use these three lines in your own .env instead of the ones above (there is no proxy locally): # DATABASE_URL=postgresql://buildapc:@it2810-15.idi.ntnu.no:5432/buildapc # CORS_ORIGIN=http://localhost:5173 +# TRUST_PROXY=0 diff --git a/backend/server/package-lock.json b/backend/server/package-lock.json index 947b44b..a6728ae 100644 --- a/backend/server/package-lock.json +++ b/backend/server/package-lock.json @@ -12,10 +12,12 @@ "@apollo/server": "^5.5.1", "@as-integrations/express5": "^1.1.2", "cors": "^2.8.6", + "dataloader": "^2.2.3", "dotenv": "^18.0.7", "express": "^5.3.0", "graphql": "^16.14.2", - "pg": "^8.23.1" + "pg": "^8.23.1", + "rate-limiter-flexible": "^11.4.1" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -2279,6 +2281,12 @@ "node": ">= 8" } }, + "node_modules/dataloader": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/dataloader/-/dataloader-2.2.3.tgz", + "integrity": "sha512-y2krtASINtPFS1rSDjacrFgn1dcUuoREVabwlOGOe4SdxenREqwjwjElAdwvbGM7kgZz9a3KVicWR7vcz8rnzA==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -4139,6 +4147,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/rate-limiter-flexible": { + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-11.4.1.tgz", + "integrity": "sha512-rT3HoDh6M+xhmmFKoqwMfIVO+Qw5GpoMZbvt9tgVLAj2H2NsHiEAtEIdZ72rKMXutQ/TntxrYc/BkzjizdrcGw==", + "license": "ISC" + }, "node_modules/raw-body": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", diff --git a/backend/server/package.json b/backend/server/package.json index f628e57..7de0f71 100644 --- a/backend/server/package.json +++ b/backend/server/package.json @@ -33,10 +33,12 @@ "@apollo/server": "^5.5.1", "@as-integrations/express5": "^1.1.2", "cors": "^2.8.6", + "dataloader": "^2.2.3", "dotenv": "^18.0.7", "express": "^5.3.0", "graphql": "^16.14.2", - "pg": "^8.23.1" + "pg": "^8.23.1", + "rate-limiter-flexible": "^11.4.1" }, "overrides": { "minimatch": "^11.0.1" diff --git a/backend/server/src/config.ts b/backend/server/src/config.ts index 3527484..2d7d46d 100644 --- a/backend/server/src/config.ts +++ b/backend/server/src/config.ts @@ -18,4 +18,15 @@ export const config = { corsOrigins: (process.env["CORS_ORIGIN"] ?? "*") .split(",") .map((origin) => origin.trim()), + /** Number of reverse proxies (for example Apache) in front of the server. 0 means none. */ + trustProxy: Number(process.env["TRUST_PROXY"] ?? 0), + limits: { + /** Build publishing: at most this many attempts per client IP in each time window. */ + publishMax: 5, + publishWindowSeconds: 600, + /** GraphQL request limits. */ + maxBodySize: "100kb", + maxQueryDepth: 8, + maxQueryFields: 100, + }, }; diff --git a/backend/server/src/index.ts b/backend/server/src/index.ts index 52eee5f..c0107bc 100644 --- a/backend/server/src/index.ts +++ b/backend/server/src/index.ts @@ -5,11 +5,27 @@ import express from "express"; import path from "node:path"; import { config } from "./config.js"; import { pool } from "./db.js"; -import { resolvers } from "./resolvers.js"; +import { limitOperationSize } from "./limits.js"; +import { + createBuildPartsLoader, + resolvers, + type Context, +} from "./resolvers.js"; import { typeDefs } from "./schema.js"; const app = express(); -const apollo = new ApolloServer({ typeDefs, resolvers }); +// Behind a reverse proxy, trust its forwarded address so each visitor is rate limited by their own IP. +app.set("trust proxy", config.trustProxy); +const apollo = new ApolloServer({ + typeDefs, + resolvers, + validationRules: [ + limitOperationSize({ + maxDepth: config.limits.maxQueryDepth, + maxFields: config.limits.maxQueryFields, + }), + ], +}); await apollo.start(); app.use( @@ -32,7 +48,36 @@ app.get("/health", async (_req, res) => { } }); -app.use("/graphql", express.json(), expressMiddleware(apollo)); +app.use( + "/graphql", + express.json({ limit: config.limits.maxBodySize }), + expressMiddleware(apollo, { + context: async ({ req }) => ({ + ip: req.ip ?? "unknown", + buildParts: createBuildPartsLoader(), + }), + }), +); + +// Oversized request bodies are rejected by express.json before they reach GraphQL. +app.use( + ( + error: { type?: string }, + _req: express.Request, + res: express.Response, + next: express.NextFunction, + ) => { + if (error.type !== "entity.too.large") return next(error); + res.status(413).json({ + errors: [ + { + message: "Request body too large", + extensions: { code: "PAYLOAD_TOO_LARGE" }, + }, + ], + }); + }, +); app.listen(config.port, () => { console.log( diff --git a/backend/server/src/limits.ts b/backend/server/src/limits.ts new file mode 100644 index 0000000..afcf913 --- /dev/null +++ b/backend/server/src/limits.ts @@ -0,0 +1,66 @@ +import { + GraphQLError, + Kind, + type SelectionSetNode, + type ValidationRule, +} from "graphql"; + +interface Limits { + maxDepth: number; + maxFields: number; +} + +/** + * Validation rule that rejects operations that are nested too deeply or select too many + * fields. Fragments are expanded, so they cannot be used to hide the real size, and + * introspection fields (__schema, __type) are not counted. + */ +export function limitOperationSize({ + maxDepth, + maxFields, +}: Limits): ValidationRule { + return (context) => ({ + OperationDefinition(operation) { + let fields = 0; + const spreading = new Set(); + + // Returns an error message as soon as a limit is passed, so huge operations are cheap to reject. + function check( + selectionSet: SelectionSetNode, + depth: number, + ): string | null { + for (const selection of selectionSet.selections) { + if (selection.kind === Kind.FIELD) { + if (selection.name.value.startsWith("__")) continue; + if (depth > maxDepth) + return `Query is nested too deeply (maximum depth is ${maxDepth}).`; + if (++fields > maxFields) + return `Query selects too many fields (maximum is ${maxFields}).`; + if (selection.selectionSet) { + const error = check(selection.selectionSet, depth + 1); + if (error) return error; + } + } else if (selection.kind === Kind.INLINE_FRAGMENT) { + const error = check(selection.selectionSet, depth); + if (error) return error; + } else { + const name = selection.name.value; + const fragment = context.getFragment(name); + // Fragment cycles are reported by another rule; just do not loop here. + if (!fragment || spreading.has(name)) continue; + spreading.add(name); + const error = check(fragment.selectionSet, depth); + spreading.delete(name); + if (error) return error; + } + } + return null; + } + + const message = check(operation.selectionSet, 1); + if (message) { + context.reportError(new GraphQLError(message, { nodes: [operation] })); + } + }, + }); +} diff --git a/backend/server/src/rateLimit.ts b/backend/server/src/rateLimit.ts new file mode 100644 index 0000000..f7ff2b4 --- /dev/null +++ b/backend/server/src/rateLimit.ts @@ -0,0 +1,38 @@ +import { GraphQLError } from "graphql"; +import { RateLimiterMemory } from "rate-limiter-flexible"; +import { config } from "./config.js"; + +const publishLimiter = new RateLimiterMemory({ + points: config.limits.publishMax, + duration: config.limits.publishWindowSeconds, +}); + +/** + * Counts one build publishing attempt for the client and throws a rate limit error + * once the client has used up its attempts. Must run before anything is written. + */ +export async function consumePublishAttempt(ip: string): Promise { + try { + await publishLimiter.consume(ip); + } catch (rejection) { + // The limiter rejects with its result object when the limit is reached. + const retryAfter = Math.max( + 1, + Math.ceil( + ((rejection as { msBeforeNext?: number }).msBeforeNext ?? 0) / 1000, + ), + ); + throw new GraphQLError( + `Too many builds published. Try again in ${retryAfter} seconds.`, + { + extensions: { + code: "RATE_LIMITED", + http: { + status: 429, + headers: new Map([["Retry-After", String(retryAfter)]]), + }, + }, + }, + ); + } +} diff --git a/backend/server/src/resolvers.ts b/backend/server/src/resolvers.ts index 75e7404..e551ba9 100644 --- a/backend/server/src/resolvers.ts +++ b/backend/server/src/resolvers.ts @@ -1,6 +1,14 @@ +import DataLoader from "dataloader"; import { GraphQLError, GraphQLScalarType } from "graphql"; import path from "node:path"; import { pool } from "./db.js"; +import { consumePublishAttempt } from "./rateLimit.js"; + +/** Per-request values made available to every resolver. */ +export interface Context { + ip: string; + buildParts: ReturnType; +} const MAX_PAGE_SIZE = 100; @@ -386,7 +394,10 @@ async function publishBuild( }: { input: { tittel: string; comment?: string | null; partIds: string[] }; }, + { ip }: Context, ) { + // Counted first, so blocked or invalid attempts never reach the database. + await consumePublishAttempt(ip); const tittel = input.tittel.trim(); const comment = input.comment?.trim() || null; const invalid = (message: string) => @@ -447,6 +458,42 @@ async function build(_: unknown, { id }: { id: string }) { return rows[0] ?? null; } +const PART_ID_COLUMNS = [ + "cpu_id", + "gpu_id", + "ram_id", + "storage_id", + "motherboard_id", + "psu_id", + "case_id", + "cooler_id", +] as const; + +/** + * Creates a loader for the parts of builds. All builds requested in the same tick are + * fetched with one query, instead of one query per build. Create one per request. + */ +export function createBuildPartsLoader() { + return new DataLoader, PartRow[], unknown>( + async (builds) => { + const idsOf = (build: Record) => + PART_ID_COLUMNS.map((column) => build[column]).filter(Boolean); + const allIds = [...new Set(builds.flatMap(idsOf))]; + if (!allIds.length) return builds.map(() => []); + + const { rows } = await pool.query( + `SELECT ${PART_COLUMNS} FROM parts WHERE id = ANY($1::uuid[]) ORDER BY category`, + [allIds], + ); + return builds.map((build) => { + const ids = new Set(idsOf(build)); + return rows.filter((part) => ids.has(part.id)); + }); + }, + { cacheKeyFn: (build) => build["id"] }, + ); +} + /** Passes arbitrary JSON (the specs column) through unchanged. */ const JSONScalar = new GraphQLScalarType({ name: "JSON", @@ -489,23 +536,11 @@ export const resolvers = { }, Build: { createdAt: (row: { created_at: Date }) => row.created_at.toISOString(), - parts: async (row: Record) => { - const ids = [ - row.cpu_id, - row.gpu_id, - row.ram_id, - row.storage_id, - row.motherboard_id, - row.psu_id, - row.case_id, - row.cooler_id, - ].filter(Boolean); - const { rows } = await pool.query( - `SELECT ${PART_COLUMNS} FROM parts WHERE id = ANY($1::uuid[]) ORDER BY category`, - [ids], - ); - return rows; - }, + parts: ( + row: Record, + _args: unknown, + { buildParts }: Context, + ) => buildParts.load(row), }, Part: { priceEur: (row: PartRow) => row.price_eur, diff --git a/backend/server/src/test/buildPartsBatching.test.ts b/backend/server/src/test/buildPartsBatching.test.ts new file mode 100644 index 0000000..c0d479a --- /dev/null +++ b/backend/server/src/test/buildPartsBatching.test.ts @@ -0,0 +1,151 @@ +import { ApolloServer } from "@apollo/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const query = vi.hoisted(() => vi.fn()); +vi.mock("../db.js", () => ({ pool: { query } })); +vi.mock("../config.js", () => ({ + config: { limits: { publishMax: 5, publishWindowSeconds: 600 } }, +})); + +import { createBuildPartsLoader, resolvers } from "../resolvers.js"; +import { typeDefs } from "../schema.js"; + +type Row = Record; + +// Every build gets its own CPU and GPU, plus (optionally) one RAM kit that all builds share. +function setUpDatabase(buildCount: number, { sharedRam = false } = {}) { + const parts: Row[] = []; + const builds: Row[] = Array.from({ length: buildCount }, (_, index) => { + const number = index + 1; + parts.push( + { id: `gpu-${number}`, category: "GPU", name: `GPU ${number}` }, + { id: `cpu-${number}`, category: "CPU", name: `CPU ${number}` }, + ); + return { + id: String(number), + tittel: `Build ${number}`, + created_at: new Date("2026-01-01T00:00:00Z"), + cpu_id: `cpu-${number}`, + gpu_id: `gpu-${number}`, + ram_id: sharedRam ? "ram-shared" : null, + }; + }); + if (sharedRam) { + parts.push({ id: "ram-shared", category: "RAM", name: "Shared RAM" }); + } + + query.mockImplementation(async (sql: string, params: unknown[]) => { + if (sql.startsWith("SELECT * FROM builds")) return { rows: builds }; + const ids = params[0] as string[]; + // Like the real query, the parts come back ordered by category. + return { + rows: parts + .filter((part) => ids.includes(part.id as string)) + .sort((a, b) => String(a.category).localeCompare(String(b.category))), + }; + }); +} + +async function listBuilds() { + const server = new ApolloServer({ typeDefs, resolvers }); + const response = await server.executeOperation( + { + query: `{ builds(limit: 100) { id parts { id name category } } }`, + }, + { + contextValue: { ip: "10.0.0.1", buildParts: createBuildPartsLoader() }, + }, + ); + if (response.body.kind !== "single") throw new Error("Unexpected response"); + const { data, errors } = response.body.singleResult; + expect(errors).toBeUndefined(); + return ( + data as { + builds: { id: string; parts: { id: string; category: string }[] }[]; + } + ).builds; +} + +const partsQueries = () => + query.mock.calls.filter(([sql]) => String(sql).includes("FROM parts")); + +beforeEach(() => { + query.mockReset(); +}); + +describe("loading the parts of published builds", () => { + it("returns every build with its own parts", async () => { + setUpDatabase(20); + + const builds = await listBuilds(); + + expect(builds).toHaveLength(20); + for (const build of builds) { + expect(build.parts.map((part) => part.id)).toEqual([ + `cpu-${build.id}`, + `gpu-${build.id}`, + ]); + } + }); + + it("uses one query for the builds and one for all of their parts", async () => { + setUpDatabase(20); + + await listBuilds(); + + expect(query).toHaveBeenCalledTimes(2); + expect(partsQueries()).toHaveLength(1); + }); + + it.each([1, 5, 20, 100])( + "keeps the number of queries at two for %i builds", + async (count) => { + setUpDatabase(count); + + const builds = await listBuilds(); + + expect(builds).toHaveLength(count); + expect(query).toHaveBeenCalledTimes(2); + }, + ); + + it("asks for each part only once and sends the IDs as a parameter", async () => { + setUpDatabase(3, { sharedRam: true }); + + const builds = await listBuilds(); + + const [sql, params] = partsQueries()[0]!; + expect(sql).toContain("id = ANY($1::uuid[])"); + expect(params[0]).toHaveLength(7); // 3 CPUs + 3 GPUs + the shared RAM, once + for (const build of builds) { + expect(build.parts.map((part) => part.category)).toEqual([ + "CPU", + "GPU", + "RAM", + ]); + } + }); + + it("skips the parts query when no build has any parts", async () => { + query.mockImplementation(async () => ({ + rows: [ + { id: "1", tittel: "Empty", created_at: new Date() }, + { id: "2", tittel: "Also empty", created_at: new Date() }, + ], + })); + + const builds = await listBuilds(); + + expect(builds.map((build) => build.parts)).toEqual([[], []]); + expect(query).toHaveBeenCalledTimes(1); + }); + + it("uses a separate loader, and so a fresh query, for each request", async () => { + setUpDatabase(2); + + await listBuilds(); + await listBuilds(); + + expect(partsQueries()).toHaveLength(2); + }); +}); diff --git a/backend/server/src/test/catalogResolvers.test.ts b/backend/server/src/test/catalogResolvers.test.ts new file mode 100644 index 0000000..0299bb8 --- /dev/null +++ b/backend/server/src/test/catalogResolvers.test.ts @@ -0,0 +1,289 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const query = vi.hoisted(() => vi.fn()); +vi.mock("../db.js", () => ({ pool: { query } })); +vi.mock("../config.js", () => ({ + config: { limits: { publishMax: 5, publishWindowSeconds: 600 } }, +})); + +import { resolvers } from "../resolvers.js"; + +const { parts, part, manufacturers, builds } = resolvers.Query; + +const partId = "11111111-1111-4111-8111-111111111111"; +const defaults = { + sortBy: "NAME", + sortDirection: "ASC", + first: 20, +} as const; +const idA = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; +const idB = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; +const row = (id: string, name: string) => ({ id, name, price_eur: 10 }); + +beforeEach(() => { + query.mockReset(); +}); + +describe("Query.parts", () => { + beforeEach(() => { + query.mockImplementation(async (sql: string) => + sql.startsWith("SELECT count") + ? { rows: [{ count: "2" }] } + : { rows: [row(idA, "Alpha"), row(idB, "Beta")] }, + ); + }); + + const lastPageQuery = () => query.mock.calls.at(-1)!; + + it("returns the page with the total count and page info", async () => { + const result = await parts(null, { ...defaults }); + + expect(result.totalCount).toBe(2); + expect(result.edges.map((edge) => edge.node.name)).toEqual([ + "Alpha", + "Beta", + ]); + expect(result.pageInfo).toEqual({ + hasNextPage: false, + endCursor: result.edges[1]!.cursor, + }); + }); + + it("reports another page when there are more rows than requested", async () => { + const result = await parts(null, { ...defaults, first: 1 }); + + expect(result.edges).toHaveLength(1); + expect(result.pageInfo.hasNextPage).toBe(true); + expect(result.pageInfo.endCursor).toBe(result.edges[0]!.cursor); + }); + + it("continues after a cursor from the previous page", async () => { + const first = await parts(null, { ...defaults, first: 1 }); + query.mockClear(); + + await parts(null, { + ...defaults, + first: 1, + after: first.pageInfo.endCursor, + }); + + // Only the page query is sent, and the cursor's sort value and ID are parameters. + expect(lastPageQuery()[1]).toEqual(expect.arrayContaining(["Alpha", idA])); + }); + + it.each([0, -1, 101])( + "rejects a page size of %i without querying", + async (size) => { + await expect( + parts(null, { ...defaults, first: size }), + ).rejects.toMatchObject({ extensions: { code: "BAD_USER_INPUT" } }); + expect(query).not.toHaveBeenCalled(); + }, + ); + + it.each(["not a cursor", Buffer.from("{}").toString("base64url")])( + "rejects the invalid cursor %j", + async (after) => { + await expect(parts(null, { ...defaults, after })).rejects.toMatchObject({ + message: "Invalid cursor", + extensions: { code: "BAD_USER_INPUT" }, + }); + }, + ); + + it("filters by category with a parameter", async () => { + await parts(null, { ...defaults, category: "GPU" }); + + const [sql, params] = lastPageQuery(); + expect(sql).toContain("category = $1"); + expect(params[0]).toBe("GPU"); + }); + + it("requires every search word to match, with wildcards escaped", async () => { + await parts(null, { ...defaults, search: "rtx 100%_" }); + + const [sql, params] = lastPageQuery(); + expect(sql.match(/name ILIKE/g)).toHaveLength(2); + expect(params).toEqual(expect.arrayContaining(["%rtx%", "%100\\%\\_%"])); + }); + + it("never puts user input into the SQL text", async () => { + await parts(null, { + ...defaults, + search: "'; DROP TABLE parts; --", + manufacturers: ["Evil'; --"], + }); + + for (const [sql] of query.mock.calls) { + expect(sql).not.toContain("DROP TABLE"); + expect(sql).not.toContain("Evil"); + } + }); + + it("matches manufacturers case-insensitively", async () => { + await parts(null, { ...defaults, manufacturers: ["NVIDIA", "AMD"] }); + + expect(lastPageQuery()[1]).toContainEqual(["nvidia", "amd"]); + }); + + it("sorts by the requested column and direction", async () => { + await parts(null, { ...defaults, sortBy: "PRICE", sortDirection: "DESC" }); + + expect(lastPageQuery()[0]).toContain("ORDER BY price_eur DESC NULLS LAST"); + }); + + describe("attribute filters", () => { + it("require a category", async () => { + await expect( + parts(null, { ...defaults, attributeFilters: { socket: "AM5" } }), + ).rejects.toMatchObject({ extensions: { code: "BAD_USER_INPUT" } }); + }); + + it("apply to the category's specifications with parameters", async () => { + await parts(null, { + ...defaults, + category: "CPU", + attributeFilters: { socket: "AM5" }, + }); + + const [sql, params] = lastPageQuery(); + expect(sql).toContain("specs->>'socket' = $2"); + expect(params).toContain("AM5"); + }); + + it("are ignored when none are set", async () => { + await expect( + parts(null, { ...defaults, attributeFilters: { socket: null } }), + ).resolves.toMatchObject({ totalCount: 2 }); + }); + + it("reject a minimum capacity above the maximum", async () => { + await expect( + parts(null, { + ...defaults, + category: "RAM", + attributeFilters: { minCapacityGb: 64, maxCapacityGb: 16 }, + }), + ).rejects.toMatchObject({ extensions: { code: "BAD_USER_INPUT" } }); + }); + + it("reject a category that does not support them", async () => { + await expect( + parts(null, { + ...defaults, + category: "Keyboard", + attributeFilters: { socket: "AM5" }, + }), + ).rejects.toMatchObject({ extensions: { code: "BAD_USER_INPUT" } }); + }); + }); +}); + +describe("Query.part", () => { + it("returns the part with a parameterized query", async () => { + const found = row(partId, "RTX"); + query.mockResolvedValue({ rows: [found] }); + + await expect(part(null, { id: partId })).resolves.toBe(found); + expect(query).toHaveBeenCalledWith( + expect.stringContaining("WHERE id = $1"), + [partId], + ); + }); + + it("returns null when the part does not exist", async () => { + query.mockResolvedValue({ rows: [] }); + + await expect(part(null, { id: partId })).resolves.toBeNull(); + }); + + it.each(["abc", "", "1", `${partId}'; DROP TABLE parts;--`])( + "returns null for the invalid ID %j without querying", + async (id) => { + await expect(part(null, { id })).resolves.toBeNull(); + expect(query).not.toHaveBeenCalled(); + }, + ); +}); + +describe("Query.manufacturers", () => { + it("returns the manufacturer names", async () => { + query.mockResolvedValue({ + rows: [{ manufacturer: "AMD" }, { manufacturer: "Intel" }], + }); + + await expect(manufacturers(null, { category: "CPU" })).resolves.toEqual([ + "AMD", + "Intel", + ]); + expect(query).toHaveBeenCalledWith(expect.any(String), ["CPU"]); + }); + + it("passes null to include every category", async () => { + query.mockResolvedValue({ rows: [] }); + + await manufacturers(null, {}); + + expect(query).toHaveBeenCalledWith(expect.any(String), [null]); + }); +}); + +describe("Query.builds", () => { + it("returns builds using the default page size", async () => { + const rows = [{ id: "2" }, { id: "1" }]; + query.mockResolvedValue({ rows }); + + await expect(builds(null, {})).resolves.toBe(rows); + expect(query).toHaveBeenCalledWith( + expect.stringContaining("LIMIT $1 OFFSET $2"), + [20, 0], + ); + }); + + it("passes a custom limit and offset", async () => { + query.mockResolvedValue({ rows: [] }); + + await builds(null, { limit: 5, offset: 10 }); + + expect(query).toHaveBeenCalledWith(expect.any(String), [5, 10]); + }); + + it.each([ + [0, 0], + [101, 0], + [20, -1], + [null, 0], + [20, null], + ])( + "rejects limit %s with offset %s without querying", + async (limit, offset) => { + await expect(builds(null, { limit, offset })).rejects.toMatchObject({ + extensions: { code: "BAD_USER_INPUT" }, + }); + expect(query).not.toHaveBeenCalled(); + }, + ); +}); + +describe("field resolvers", () => { + it("exposes the price as priceEur", () => { + expect(resolvers.Part.priceEur({ price_eur: 99.5 } as never)).toBe(99.5); + }); + + it("builds the image URL from the file name only", () => { + const imageUrl = (image_path: string | null) => + resolvers.Part.imageUrl({ image_path } as never); + + expect(imageUrl("part_images/abc.webp")).toBe("/images/abc.webp"); + expect(imageUrl("../../secret/abc.webp")).toBe("/images/abc.webp"); + expect(imageUrl(null)).toBeNull(); + }); + + it("formats the build creation time as an ISO string", () => { + expect( + resolvers.Build.createdAt({ + created_at: new Date("2026-01-02T03:04:05.000Z"), + }), + ).toBe("2026-01-02T03:04:05.000Z"); + }); +}); diff --git a/backend/server/src/test/limits.test.ts b/backend/server/src/test/limits.test.ts new file mode 100644 index 0000000..e97d905 --- /dev/null +++ b/backend/server/src/test/limits.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { buildSchema, parse, specifiedRules, validate } from "graphql"; +import { limitOperationSize } from "../limits.js"; +import { typeDefs } from "../schema.js"; + +const schema = buildSchema(typeDefs); + +function errors(query: string, maxDepth = 8, maxFields = 100) { + return validate(schema, parse(query), [ + ...specifiedRules, + limitOperationSize({ maxDepth, maxFields }), + ]).map((error) => error.message); +} + +describe("limitOperationSize", () => { + it("accepts normal catalog and build queries", () => { + expect( + errors(`{ parts(first: 40) { edges { node { id name priceEur } cursor } + totalCount pageInfo { hasNextPage endCursor } } }`), + ).toEqual([]); + expect(errors(`{ builds { id tittel parts { id name } } }`)).toEqual([]); + }); + + it("rejects queries nested deeper than the maximum", () => { + // parts > edges > node > id is 4 levels deep. + const query = `{ parts { edges { node { id } } } }`; + expect(errors(query, 4)).toEqual([]); + expect(errors(query, 3)).toEqual([ + "Query is nested too deeply (maximum depth is 3).", + ]); + }); + + it("rejects queries that select too many fields", () => { + const aliases = Array.from( + { length: 11 }, + (_, index) => `a${index}: manufacturers`, + ).join(" "); + expect(errors(`{ ${aliases} }`, 8, 10)).toEqual([ + "Query selects too many fields (maximum is 10).", + ]); + }); + + it("counts fields and depth inside fragments", () => { + const query = ` + query { builds { ...BuildFields } } + fragment BuildFields on Build { id parts { ...PartFields } } + fragment PartFields on Part { id name }`; + expect(errors(query, 3)).toEqual([]); + expect(errors(query, 2)).toEqual([ + "Query is nested too deeply (maximum depth is 2).", + ]); + expect(errors(query, 8, 4)).toEqual([ + "Query selects too many fields (maximum is 4).", + ]); + }); + + it("does not loop on fragment cycles", () => { + const query = ` + { builds { ...A } } + fragment A on Build { id ...B } + fragment B on Build { id ...A }`; + expect(errors(query)).not.toContain(expect.stringContaining("too")); + }); + + it("does not limit introspection queries", () => { + expect( + errors(`{ __schema { types { fields { type { ofType { ofType { ofType { + ofType { ofType { name } } } } } } } } } }`), + ).toEqual([]); + }); +}); diff --git a/backend/server/src/test/publishBuild.test.ts b/backend/server/src/test/publishBuild.test.ts new file mode 100644 index 0000000..8ef4c84 --- /dev/null +++ b/backend/server/src/test/publishBuild.test.ts @@ -0,0 +1,212 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const query = vi.hoisted(() => vi.fn()); +vi.mock("../db.js", () => ({ pool: { query } })); +vi.mock("../config.js", () => ({ + config: { limits: { publishMax: 1000, publishWindowSeconds: 600 } }, +})); + +import { resolvers } from "../resolvers.js"; + +type Input = { tittel: string; comment?: string | null; partIds: string[] }; + +const publishBuild = (input: Input) => + resolvers.Mutation.publishBuild(null, { input }, { ip: "10.0.0.1" }); + +// Fixed order used for the INSERT: CPU, GPU, RAM, Storage, Motherboard, PSU, PCCase, CPUCooler. +const parts = [ + ["11111111-1111-4111-8111-111111111111", "CPU"], + ["22222222-2222-4222-8222-222222222222", "GPU"], + ["33333333-3333-4333-8333-333333333333", "RAM"], + ["44444444-4444-4444-8444-444444444444", "Storage"], + ["55555555-5555-4555-8555-555555555555", "Motherboard"], + ["66666666-6666-4666-8666-666666666666", "PSU"], + ["77777777-7777-4777-8777-777777777777", "PCCase"], + ["88888888-8888-4888-8888-888888888888", "CPUCooler"], +] as const; +const [cpu, gpu, , , motherboard] = parts.map(([id]) => id); + +const inserts = () => + query.mock.calls.filter(([sql]) => String(sql).startsWith("INSERT")); + +beforeEach(() => { + query.mockReset(); + // Looking up parts returns the requested parts; inserting returns the new build. + query.mockImplementation(async (sql: string, params: unknown[]) => + sql.startsWith("INSERT") + ? { rows: [{ id: "1", tittel: params[0], comment: params[1] }] } + : { + rows: parts + .filter(([id]) => (params[0] as string[]).includes(id)) + .map(([id, category]) => ({ id, category })), + }, + ); +}); + +describe("publishing a valid build", () => { + it("saves the build and returns it", async () => { + await expect( + publishBuild({ + tittel: "Gaming PC", + comment: "Fast", + partIds: [cpu, gpu], + }), + ).resolves.toMatchObject({ id: "1", tittel: "Gaming PC", comment: "Fast" }); + expect(inserts()).toHaveLength(1); + }); + + it("puts each part in its own column, whatever order the IDs are sent in", async () => { + await publishBuild({ tittel: "Build", partIds: [motherboard, cpu, gpu] }); + + const [, params] = inserts()[0]!; + // title, comment, then CPU, GPU, RAM, Storage, Motherboard, PSU, case, cooler + expect(params).toEqual([ + "Build", + null, + cpu, + gpu, + null, + null, + motherboard, + null, + null, + null, + ]); + }); + + it("accepts all eight categories", async () => { + await expect( + publishBuild({ tittel: "Full", partIds: parts.map(([id]) => id) }), + ).resolves.toMatchObject({ id: "1" }); + }); + + it("trims the title and comment, and stores an empty comment as null", async () => { + await publishBuild({ tittel: " Build ", comment: " ", partIds: [cpu] }); + + expect(inserts()[0]![1]).toEqual(expect.arrayContaining(["Build", null])); + expect(inserts()[0]![1].slice(0, 2)).toEqual(["Build", null]); + }); + + it("accepts a title of 50 characters and a comment of 200", async () => { + await expect( + publishBuild({ + tittel: "t".repeat(50), + comment: "c".repeat(200), + partIds: [cpu], + }), + ).resolves.toMatchObject({ id: "1" }); + }); + + it("counts characters, not UTF-16 units, when checking the title length", async () => { + // 50 emoji are 100 UTF-16 units but 50 characters. + await expect( + publishBuild({ tittel: "😀".repeat(50), partIds: [cpu] }), + ).resolves.toMatchObject({ id: "1" }); + }); + + it("sends user input as query parameters, never inside the SQL text", async () => { + const title = "x'); DROP TABLE builds; --"; + await publishBuild({ tittel: title, partIds: [cpu] }); + + for (const [sql] of query.mock.calls) { + expect(sql).not.toContain("DROP TABLE"); + expect(sql).not.toContain(cpu); + } + expect(inserts()[0]![0]).toContain("$10"); + expect(inserts()[0]![1][0]).toBe(title); + }); +}); + +describe("rejecting invalid build requests", () => { + const invalidRequests: [string, Input][] = [ + ["an empty title", { tittel: "", partIds: [cpu] }], + ["a title with only spaces", { tittel: " ", partIds: [cpu] }], + ["a title over 50 characters", { tittel: "t".repeat(51), partIds: [cpu] }], + [ + "a comment over 200 characters", + { tittel: "Build", comment: "c".repeat(201), partIds: [cpu] }, + ], + ["no parts", { tittel: "Build", partIds: [] }], + [ + "more than 8 parts", + { + tittel: "Build", + partIds: [...parts.map(([id]) => id), cpu], + }, + ], + ["a part ID that is not a UUID", { tittel: "Build", partIds: ["abc"] }], + [ + "an injection attempt as a part ID", + { tittel: "Build", partIds: [`${cpu}'; DROP TABLE parts;--`] }, + ], + ["the same part twice", { tittel: "Build", partIds: [cpu, cpu] }], + [ + "a part that does not exist", + { tittel: "Build", partIds: ["99999999-9999-4999-8999-999999999999"] }, + ], + ]; + + it.each(invalidRequests)( + "rejects %s without saving anything", + async (_, input) => { + await expect(publishBuild(input)).rejects.toMatchObject({ + extensions: { code: "BAD_USER_INPUT" }, + }); + expect(inserts()).toHaveLength(0); + }, + ); + + it("rejects two parts from the same category", async () => { + const otherCpu = "99999999-9999-4999-8999-999999999999"; + query.mockImplementation(async (sql: string) => + sql.startsWith("INSERT") + ? { rows: [{ id: "1" }] } + : { + rows: [ + { id: cpu, category: "CPU" }, + { id: otherCpu, category: "CPU" }, + ], + }, + ); + + await expect( + publishBuild({ tittel: "Build", partIds: [cpu, otherCpu] }), + ).rejects.toMatchObject({ extensions: { code: "BAD_USER_INPUT" } }); + expect(inserts()).toHaveLength(0); + }); + + it("rejects a part from a category that cannot be built", async () => { + query.mockImplementation(async (sql: string) => + sql.startsWith("INSERT") + ? { rows: [{ id: "1" }] } + : { rows: [{ id: cpu, category: "Keyboard" }] }, + ); + + await expect( + publishBuild({ tittel: "Build", partIds: [cpu] }), + ).rejects.toMatchObject({ extensions: { code: "BAD_USER_INPUT" } }); + expect(inserts()).toHaveLength(0); + }); + + it("does not query the database for requests that are invalid on their own", async () => { + await expect( + publishBuild({ tittel: "", partIds: [cpu] }), + ).rejects.toThrow(); + await expect( + publishBuild({ tittel: "Build", partIds: ["abc"] }), + ).rejects.toThrow(); + await expect( + publishBuild({ tittel: "Build", partIds: [] }), + ).rejects.toThrow(); + // Nine different, valid IDs: one more than the eight categories allow. + const nine = Array.from( + { length: 9 }, + (_, index) => `${index}0000000-0000-4000-8000-000000000000`, + ); + await expect( + publishBuild({ tittel: "Build", partIds: nine }), + ).rejects.toThrow(); + + expect(query).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/server/src/test/publishRateLimit.test.ts b/backend/server/src/test/publishRateLimit.test.ts new file mode 100644 index 0000000..b9a5e70 --- /dev/null +++ b/backend/server/src/test/publishRateLimit.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../config.js", () => ({ + config: { limits: { publishMax: 2, publishWindowSeconds: 600 } }, +})); + +const query = vi.hoisted(() => vi.fn()); +vi.mock("../db.js", () => ({ pool: { query } })); + +import { resolvers } from "../resolvers.js"; + +const partId = "11111111-1111-4111-8111-111111111111"; +const input = { tittel: "My build", partIds: [partId] }; +const publish = (ip: string) => + resolvers.Mutation.publishBuild(null, { input }, { ip }); +const inserts = () => + query.mock.calls.filter(([sql]) => String(sql).startsWith("INSERT")); + +beforeEach(() => { + query.mockReset(); + query.mockImplementation(async (sql: string) => + sql.startsWith("INSERT") + ? { rows: [{ id: "1" }] } + : { rows: [{ id: partId, category: "CPU" }] }, + ); +}); + +describe("publishBuild rate limit", () => { + it("allows normal publishing", async () => { + await expect(publish("10.0.0.1")).resolves.toEqual({ id: "1" }); + expect(inserts()).toHaveLength(1); + }); + + it("blocks excessive publishing without creating database entries", async () => { + await publish("10.0.0.2"); + await publish("10.0.0.2"); + expect(inserts()).toHaveLength(2); + query.mockClear(); + + await expect(publish("10.0.0.2")).rejects.toMatchObject({ + extensions: { + code: "RATE_LIMITED", + http: { status: 429, headers: expect.any(Map) }, + }, + }); + expect(query).not.toHaveBeenCalled(); + }); + + it("limits each IP address separately", async () => { + await publish("10.0.0.3"); + await publish("10.0.0.3"); + await expect(publish("10.0.0.3")).rejects.toMatchObject({ + extensions: { code: "RATE_LIMITED" }, + }); + + await expect(publish("10.0.0.4")).resolves.toEqual({ id: "1" }); + }); + + it("counts invalid attempts too", async () => { + const invalid = (ip: string) => + resolvers.Mutation.publishBuild( + null, + { input: { tittel: "", partIds: [partId] } }, + { ip }, + ); + await expect(invalid("10.0.0.5")).rejects.toMatchObject({ + extensions: { code: "BAD_USER_INPUT" }, + }); + await expect(invalid("10.0.0.5")).rejects.toMatchObject({ + extensions: { code: "BAD_USER_INPUT" }, + }); + await expect(invalid("10.0.0.5")).rejects.toMatchObject({ + extensions: { code: "RATE_LIMITED" }, + }); + }); +}); diff --git a/backend/server/src/test/resolvers.test.ts b/backend/server/src/test/resolvers.test.ts index d154e7a..fff68c9 100644 --- a/backend/server/src/test/resolvers.test.ts +++ b/backend/server/src/test/resolvers.test.ts @@ -2,8 +2,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const query = vi.hoisted(() => vi.fn()); vi.mock("../db.js", () => ({ pool: { query } })); +vi.mock("../config.js", () => ({ + config: { limits: { publishMax: 5, publishWindowSeconds: 600 } }, +})); -import { resolvers } from "../resolvers.js"; +import { createBuildPartsLoader, resolvers } from "../resolvers.js"; const build = resolvers.Query.build; @@ -51,13 +54,13 @@ describe("Build.parts", () => { const parts = [{ id: "p1" }, { id: "p2" }]; query.mockResolvedValue({ rows: parts }); - const result = await resolvers.Build.parts({ - cpu_id: "p1", - gpu_id: null, - ram_id: "p2", - }); + const result = await resolvers.Build.parts( + { id: "1", cpu_id: "p1", gpu_id: null, ram_id: "p2" }, + {}, + { buildParts: createBuildPartsLoader() } as never, + ); - expect(result).toBe(parts); + expect(result).toEqual(parts); expect(query).toHaveBeenCalledWith(expect.stringContaining("$1"), [ ["p1", "p2"], ]); diff --git a/frontend/src/context/BuildProvider.tsx b/frontend/src/context/BuildProvider.tsx index f6b6298..538ff9f 100644 --- a/frontend/src/context/BuildProvider.tsx +++ b/frontend/src/context/BuildProvider.tsx @@ -12,7 +12,13 @@ export default function BuildProvider({ children }: { children: ReactNode }) { const [parts, setParts] = useState(() => { try { const saved = localStorage.getItem(STORAGE_KEY); - return saved ? (JSON.parse(saved) ?? {}) : {}; + const parsed = saved ? JSON.parse(saved) : null; + // Valid JSON that is not an object (a string, number or array) is also corrupted data. + return parsed !== null && + typeof parsed === "object" && + !Array.isArray(parsed) + ? parsed + : {}; } catch { return {}; } diff --git a/frontend/src/test/BuildProvider.test.tsx b/frontend/src/test/BuildProvider.test.tsx new file mode 100644 index 0000000..4074ef0 --- /dev/null +++ b/frontend/src/test/BuildProvider.test.tsx @@ -0,0 +1,158 @@ +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import BuildProvider from "@/context/BuildProvider"; +import { useBuild } from "@/context/BuildContext"; +import type { PcPart } from "@/types/types"; + +const STORAGE_KEY = "pc-builder"; + +// The removal notification uses browser popover APIs that jsdom does not have. +vi.mock("@/components/SuccessNotification", () => ({ default: () => null })); + +const cpu = { id: "cpu-1", category: "CPU", name: "Test CPU" } as PcPart; +const otherCpu = { id: "cpu-2", category: "CPU", name: "Other CPU" } as PcPart; +const gpu = { id: "gpu-1", category: "GPU", name: "Test GPU" } as PcPart; + +function renderBuild() { + return renderHook(() => useBuild(), { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }); +} + +const saved = () => JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "null"); + +beforeEach(() => localStorage.clear()); +afterEach(() => vi.restoreAllMocks()); + +describe("loading a saved build", () => { + it("starts with an empty build when nothing is saved", () => { + const { result } = renderBuild(); + + expect(result.current.parts).toEqual({}); + }); + + it("restores a saved build", () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ CPU: cpu, GPU: gpu })); + + const { result } = renderBuild(); + + expect(result.current.parts).toEqual({ CPU: cpu, GPU: gpu }); + }); + + it.each([ + ["invalid JSON", "{not json"], + ["an empty string", ""], + ["a truncated value", '{"CPU":{"id":"cpu-1"'], + ["null", "null"], + ])("starts empty when the saved value is %s", (_, value) => { + localStorage.setItem(STORAGE_KEY, value); + + const { result } = renderBuild(); + + expect(result.current.parts).toEqual({}); + }); + + it.each([ + ["a string", '"CPU"'], + ["a number", "42"], + ["a boolean", "true"], + ["an array", "[1, 2]"], + ])("starts empty when the saved value is %s", (_, value) => { + localStorage.setItem(STORAGE_KEY, value); + + const { result } = renderBuild(); + + expect(result.current.parts).toEqual({}); + }); + + it("starts empty when the browser refuses to read storage", () => { + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("Storage is blocked"); + }); + + const { result } = renderBuild(); + + expect(result.current.parts).toEqual({}); + }); + + it("overwrites corrupted data with the new build", () => { + localStorage.setItem(STORAGE_KEY, "{not json"); + + const { result } = renderBuild(); + act(() => result.current.addPart(cpu)); + + expect(saved()).toEqual({ CPU: cpu }); + }); +}); + +describe("changing the build", () => { + it("saves added parts and keeps one part per category", () => { + const { result } = renderBuild(); + + act(() => result.current.addPart(cpu)); + act(() => result.current.addPart(gpu)); + act(() => result.current.addPart(otherCpu)); + + expect(result.current.parts).toEqual({ CPU: otherCpu, GPU: gpu }); + expect(saved()).toEqual({ CPU: otherCpu, GPU: gpu }); + }); + + it("removes a part and saves the change", () => { + const { result } = renderBuild(); + act(() => result.current.addPart(cpu)); + act(() => result.current.addPart(gpu)); + + act(() => result.current.removePart("CPU")); + + expect(result.current.parts).toEqual({ GPU: gpu }); + expect(saved()).toEqual({ GPU: gpu }); + }); + + it("ignores removing a category that is not in the build", () => { + const { result } = renderBuild(); + act(() => result.current.addPart(cpu)); + + act(() => result.current.removePart("RAM")); + + expect(result.current.parts).toEqual({ CPU: cpu }); + }); + + it("clears the whole build and the saved copy", () => { + const { result } = renderBuild(); + act(() => result.current.addPart(cpu)); + act(() => result.current.addPart(gpu)); + + act(() => result.current.resetBuild()); + + expect(result.current.parts).toEqual({}); + expect(saved()).toEqual({}); + }); + + it("keeps working when the browser refuses to save", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("Quota exceeded"); + }); + + const { result } = renderBuild(); + act(() => result.current.addPart(cpu)); + + expect(result.current.parts).toEqual({ CPU: cpu }); + expect(warn).toHaveBeenCalledWith( + "Could not save the build in this browser.", + ); + }); +}); + +describe("useBuild", () => { + it("throws when used outside BuildProvider", () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + + expect(() => renderHook(() => useBuild())).toThrow( + "useBuild must be used inside BuildProvider", + ); + }); +}); diff --git a/frontend/src/test/checkCompatability.test.ts b/frontend/src/test/checkCompatability.test.ts new file mode 100644 index 0000000..9a60f4c --- /dev/null +++ b/frontend/src/test/checkCompatability.test.ts @@ -0,0 +1,447 @@ +import { describe, expect, it } from "vitest"; +import { checkCompatability } from "@/hooks/compatibility/checkCompatability"; +import { supportedChipsets } from "@/hooks/compatibility/data/supportedChipsets"; +import type { PcPart } from "@/types/types"; + +type Details = Record; + +// Tests only provide the specifications that a check reads, so parts are built loosely. +function part( + category: PcPart["category"], + details: Details | undefined, + name = `Test ${category}`, +) { + return { + id: `${category}-id`, + category, + name, + manufacturer: "Test", + image_url: "", + price_eur: 100, + details, + } as unknown as PcPart; +} + +function result(parts: PcPart[], check: string) { + return checkCompatability({ parts }).find((line) => + line.includes(`] ${check}:`), + ); +} + +// A CPU name that the chipset table knows, with one of its listed chipsets. +const [knownCpu, knownChipsets] = Object.entries(supportedChipsets)[0]; + +const cpu = (details: Details = {}) => + part( + "CPU", + { + socket: "AM5", + specifications: { + memory: { types: ["DDR5"], maxSupport: 128 }, + ppt: 100, + tdp: 65, + }, + ...details, + }, + knownCpu, + ); +const motherboard = (details: Details = {}) => + part("Motherboard", { + socket: "AM5", + chipset: knownChipsets[0], + form_factor: "ATX", + memory: { ram_type: "DDR5", max: 128, slots: 4 }, + ...details, + }); +const ram = (details: Details = {}) => + part("RAM", { + ram_type: "DDR5", + capacity: 32, + modules: { quantity: 2 }, + ...details, + }); +const pcCase = (details: Details = {}) => + part("PCCase", { + supported_motherboard_form_factors: ["ATX", "Micro ATX"], + supported_power_supply_form_factors: ["ATX"], + max_video_card_length: 300, + max_psu_length: 180, + max_cpu_cooler_height: 160, + ...details, + }); + +describe("CPU and motherboard", () => { + it("accepts matching sockets and a listed chipset", () => { + const parts = [cpu(), motherboard()]; + + expect(result(parts, "CPU / motherboard socket")).toMatch(/^\[COMPATIBLE\]/); + expect(result(parts, "CPU / motherboard chipset")).toMatch( + /^\[COMPATIBLE\]/, + ); + }); + + it("rejects different sockets", () => { + const parts = [cpu(), motherboard({ socket: "LGA1700" })]; + + expect(result(parts, "CPU / motherboard socket")).toMatch( + /^\[INCOMPATIBLE\]/, + ); + expect(result(parts, "CPU / motherboard chipset")).toMatch( + /^\[INCOMPATIBLE\]/, + ); + }); + + it("accepts an FM2 CPU on an FM2+ board", () => { + const parts = [cpu({ socket: "FM2" }), motherboard({ socket: "FM2+" })]; + + expect(result(parts, "CPU / motherboard socket")).toMatch(/^\[COMPATIBLE\]/); + }); + + it("does not call an unlisted chipset incompatible", () => { + const parts = [cpu(), motherboard({ chipset: "Not A Real Chipset" })]; + + expect(result(parts, "CPU / motherboard chipset")).toMatch(/^\[UNKNOWN\]/); + }); + + it("checks that the CPU and motherboard use the same memory technology", () => { + expect( + result([cpu(), motherboard()], "CPU / motherboard memory technology"), + ).toMatch(/^\[COMPATIBLE\]/); + expect( + result( + [cpu(), motherboard({ memory: { ram_type: "DDR4" } })], + "CPU / motherboard memory technology", + ), + ).toMatch(/^\[INCOMPATIBLE\]/); + }); +}); + +describe("RAM", () => { + it("accepts RAM that matches the motherboard", () => { + const parts = [motherboard(), ram()]; + + expect(result(parts, "RAM / motherboard generation")).toMatch( + /^\[COMPATIBLE\]/, + ); + expect(result(parts, "RAM / motherboard capacity")).toMatch( + /^\[COMPATIBLE\]/, + ); + expect(result(parts, "RAM / motherboard slot count")).toMatch( + /^\[COMPATIBLE\]/, + ); + }); + + it("rejects DDR4 RAM on a DDR5 board", () => { + expect( + result( + [motherboard(), ram({ ram_type: "DDR4" })], + "RAM / motherboard generation", + ), + ).toMatch(/^\[INCOMPATIBLE\]/); + }); + + it("rejects more RAM capacity or modules than the board supports", () => { + const parts = [ + motherboard({ memory: { ram_type: "DDR5", max: 64, slots: 2 } }), + ram({ capacity: 128, modules: { quantity: 4 } }), + ]; + + expect(result(parts, "RAM / motherboard capacity")).toMatch( + /^\[INCOMPATIBLE\]/, + ); + expect(result(parts, "RAM / motherboard slot count")).toMatch( + /^\[INCOMPATIBLE\]/, + ); + }); + + it("rejects laptop SO-DIMM RAM on a desktop board", () => { + expect( + result( + [motherboard(), ram({ form_factor: "260-pin SODIMM" })], + "RAM / motherboard module format", + ), + ).toMatch(/^\[INCOMPATIBLE\]/); + }); + + it("rejects RAM capacity above what the CPU supports", () => { + expect( + result( + [ + part("CPU", { + socket: "AM5", + specifications: { memory: { types: ["DDR5"], maxSupport: 64 } }, + }), + ram({ capacity: 128 }), + ], + "RAM / CPU capacity", + ), + ).toMatch(/^\[INCOMPATIBLE\]/); + }); +}); + +describe("case fit", () => { + it("accepts a motherboard form factor the case supports", () => { + expect( + result([motherboard(), pcCase()], "Motherboard / case form factor"), + ).toMatch(/^\[COMPATIBLE\]/); + }); + + it("treats mATX and Micro ATX as the same form factor", () => { + expect( + result( + [motherboard({ form_factor: "mATX" }), pcCase()], + "Motherboard / case form factor", + ), + ).toMatch(/^\[COMPATIBLE\]/); + }); + + it("rejects an ATX board in a mini-ITX only case", () => { + expect( + result( + [ + motherboard(), + pcCase({ supported_motherboard_form_factors: ["Mini ITX"] }), + ], + "Motherboard / case form factor", + ), + ).toMatch(/^\[INCOMPATIBLE\]/); + }); + + it("accepts a GPU that fits the case and rejects one that is too long", () => { + const fits = [part("GPU", { length: 280, total_slot_width: 2 }), pcCase()]; + const tooLong = [ + part("GPU", { length: 320, total_slot_width: 2 }), + pcCase(), + ]; + + expect(result(fits, "GPU / case length")).toMatch(/^\[COMPATIBLE\]/); + expect(result(tooLong, "GPU / case length")).toMatch(/^\[INCOMPATIBLE\]/); + }); + + it("rejects a GPU that needs more slots than the case has", () => { + expect( + result( + [ + part("GPU", { length: 250, total_slot_width: 3.5 }), + pcCase({ expansion_slots: 3 }), + ], + "GPU / case slot space", + ), + ).toMatch(/^\[INCOMPATIBLE\]/); + }); + + it("rejects a power supply that is too long for the case", () => { + const psu = (length: number) => + part("PSU", { form_factor: "ATX", length, wattage: 750 }); + + expect(result([psu(160), pcCase()], "PSU / case length")).toMatch( + /^\[COMPATIBLE\]/, + ); + expect(result([psu(220), pcCase()], "PSU / case length")).toMatch( + /^\[INCOMPATIBLE\]/, + ); + }); +}); + +describe("CPU cooler", () => { + const cooler = (details: Details = {}) => + part("CPUCooler", { + cpu_sockets: ["AM5", "AM4"], + height: 150, + water_cooled: false, + fanless: false, + ...details, + }); + + it("accepts a cooler that lists the CPU socket", () => { + expect(result([cpu(), cooler()], "CPU / cooler socket mount")).toMatch( + /^\[COMPATIBLE\]/, + ); + }); + + it("rejects a cooler that does not list the CPU socket", () => { + expect( + result( + [cpu(), cooler({ cpu_sockets: ["LGA1700"] })], + "CPU / cooler socket mount", + ), + ).toMatch(/^\[INCOMPATIBLE\]/); + }); + + it("rejects an air cooler that is taller than the case allows", () => { + expect( + result([cooler(), pcCase()], "CPU cooler / case height"), + ).toMatch(/^\[COMPATIBLE\]/); + expect( + result( + [cooler({ height: 175 }), pcCase()], + "CPU cooler / case height", + ), + ).toMatch(/^\[INCOMPATIBLE\]/); + }); + + it("leaves liquid cooler radiator fit as unknown", () => { + expect( + result( + [cooler({ water_cooled: true, radiator_size: 240 }), pcCase()], + "CPU cooler / case radiator fit", + ), + ).toMatch(/^\[UNKNOWN\]/); + }); +}); + +describe("storage", () => { + const motherboardWithM2 = (slots: Details[]) => + motherboard({ m2_slots: slots }); + const nvmeSlot = { size: "2280", key: "M", interface: "PCIe 4.0 x4" }; + + it("accepts an NVMe drive in a matching M.2 slot", () => { + expect( + result( + [ + motherboardWithM2([nvmeSlot]), + part("Storage", { + form_factor: "M.2-2280", + interface: "PCIe 4.0 x4", + }), + ], + "M.2 drive / motherboard slot", + ), + ).toMatch(/^\[COMPATIBLE\]/); + }); + + it("rejects an M.2 SATA drive when the board only has NVMe slots", () => { + expect( + result( + [ + motherboardWithM2([nvmeSlot]), + part("Storage", { form_factor: "M.2-2280", interface: "SATA 6.0 Gb/s" }), + ], + "M.2 drive / motherboard slot", + ), + ).toMatch(/^\[INCOMPATIBLE\]/); + }); + + it("rejects an M.2 drive that is longer than any slot supports", () => { + expect( + result( + [ + motherboardWithM2([nvmeSlot]), + part("Storage", { + form_factor: "M.2-22110", + interface: "PCIe 4.0 x4", + }), + ], + "M.2 drive / motherboard slot", + ), + ).toMatch(/^\[INCOMPATIBLE\]/); + }); + + it("checks that the case has a drive bay for a 2.5 inch drive", () => { + const drive = part("Storage", { + form_factor: "2.5", + interface: "SATA 6.0 Gb/s", + }); + + expect( + result([drive, pcCase({ internal_2_5_bays: 2 })], "Storage / case drive bay"), + ).toMatch(/^\[COMPATIBLE\]/); + expect( + result([drive, pcCase({ internal_2_5_bays: 0 })], "Storage / case drive bay"), + ).toMatch(/^\[INCOMPATIBLE\]/); + }); +}); + +describe("power", () => { + const psu = (wattage: number) => + part("PSU", { wattage, form_factor: "ATX", connectors: {} }); + + // Planning target is (CPU 100 + GPU 200 + 100 W) x 1.25 = 500 W. + const withGpu = (wattage: number) => [ + cpu(), + part("GPU", { tdp: 200, length: 250 }), + psu(wattage), + ]; + + it("accepts a power supply above the estimated target", () => { + expect(result(withGpu(650), "PSU estimated wattage")).toMatch( + /^\[COMPATIBLE\].*500 W/, + ); + }); + + it("warns when the power supply is below the estimated target", () => { + expect(result(withGpu(450), "PSU estimated wattage")).toMatch( + /^\[WARNING\].*500 W/, + ); + }); + + it("accepts a power supply with a 24-pin main connector for the board", () => { + expect( + result( + [ + motherboard({ power_connectors: { main_power: "24-pin" } }), + part("PSU", { wattage: 650, connectors: { atx_24_pin: 1 } }), + ], + "Motherboard / PSU main power", + ), + ).toMatch(/^\[COMPATIBLE\]/); + }); +}); + +describe("incomplete or invalid data", () => { + it("reports parts without details as unknown", () => { + const results = checkCompatability({ + parts: [part("CPU", undefined, "Mystery CPU")], + }); + + expect(results).toContainEqual( + expect.stringMatching(/^\[UNKNOWN\] CPU specifications:.*Mystery CPU/), + ); + }); + + it("never turns missing, negative or non-numeric measurements into a pass", () => { + const parts = [ + motherboard({ memory: { ram_type: "DDR5", max: "lots", slots: -1 } }), + ram({ capacity: Number.NaN, modules: { quantity: null } }), + part("GPU", { length: "long", total_slot_width: 0 }), + pcCase({ max_video_card_length: -5 }), + ]; + + expect(result(parts, "RAM / motherboard capacity")).toMatch(/^\[UNKNOWN\]/); + expect(result(parts, "RAM / motherboard slot count")).toMatch( + /^\[UNKNOWN\]/, + ); + expect(result(parts, "GPU / case length")).toMatch(/^\[UNKNOWN\]/); + }); + + it("does not crash on details that are not objects", () => { + const parts = [ + part("CPU", "broken" as unknown as Details), + part("Motherboard", [] as unknown as Details), + part("RAM", null as unknown as Details), + ]; + + expect(() => checkCompatability({ parts })).not.toThrow(); + expect(checkCompatability({ parts }).join("\n")).not.toContain( + "[COMPATIBLE]", + ); + }); + + it("reports every category as not selected for an empty build", () => { + const results = checkCompatability({ parts: [] }); + + expect(results).toHaveLength(8); + expect(results.every((line) => line.startsWith("[UNKNOWN]"))).toBe(true); + expect(results.join("\n")).toContain("CPU selection"); + expect(results.join("\n")).toContain("CPUCooler selection"); + }); + + it("only reports one unknown result when a category has several parts", () => { + const results = checkCompatability({ + parts: [cpu(), cpu(), motherboard()], + }); + + expect(results).toHaveLength(1); + expect(results[0]).toMatch(/^\[UNKNOWN\] Build selection:/); + }); +});