Item 5. GET /api/items/:id was the last route reading its id with a bare Number(), so an unreadable id reached Postgres and came back to the caller as a 500 for an item that cannot exist. It answers 404 now, like every other id-taking route since #207. It could not be fixed on its own, which is why it stayed. errorHandling.integration.test.ts used this route's looseness as its way of making a handler reject: tightening the parse would have left that test green while removing the thing it tests. So the test now fails a database call directly, with a spy on pool.query against a route that makes one. That is the failure the error middleware actually exists for, and it does not depend on any route declining to validate — the previous comment's own conclusion, that moving the trigger to cart.ts would only move the wart. Two things fixed along the way that the issue asked about but that switching to readId would not have delivered on its own. readId was not as strict as its name suggests. Number reads 5.0, 1e2, 0x10 and +5 as 5, 100, 16 and 5 — every one a positive integer, so every check readId made passed and the route fetched a real row for a URL nobody wrote. /items/5.0 answered with item 5. This never raised an error and so never announced itself; the issue noticed it only because #308 converted the comparison to a real integer. An id is a string of digits, so it is matched against digits before being parsed. readId is also now bounded at the top of a 32-bit serial. Above that Postgres raises 22003 rather than returning nothing, which is the same wrong answer to the caller as the 22P02 the function was written to prevent — a 500 for an id that identifies nothing. The leak assertion was passing for the wrong reason. It checks the response does not contain "syntax" or "items", and the error it was checking against happened to contain both only by accident of which route was used. The injected failure now contains both words deliberately, and the whole message is asserted against as well, so a future error format cannot slip through by wording itself differently. Verified: tsc clean, lint 0 errors with no new warnings, 485 unit tests passing across 33 suites — seven of them new, covering the inputs above. The integration suite cannot run on this machine, so whether the rewritten error test passes is for CI to say. Item 4, coverage, is not closed by this and cannot be closed yet: the SonarQube scan step has been skipped on every recent run because it is gated on the earlier steps succeeding, and those steps were failing. The dashboard is therefore stale. Reported on the issue rather than guessed at. Refs #307 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
159 lines
7.3 KiB
TypeScript
Executable File
159 lines
7.3 KiB
TypeScript
Executable File
export function toCents(price: string | number): number {
|
|
const n = typeof price === 'string' ? parseFloat(price) : price;
|
|
if (Number.isNaN(n) || n < 0) {
|
|
throw new Error('invalid price');
|
|
}
|
|
return Math.round(n * 100);
|
|
}
|
|
|
|
export function formatPrice(cents: number): string {
|
|
return `$${(cents / 100).toFixed(2)}`;
|
|
}
|
|
|
|
// RFC 5321 caps an address at 254 characters; reject anything longer up front so
|
|
// validation cost stays bounded regardless of what a client posts.
|
|
const MAX_EMAIL_LENGTH = 254;
|
|
|
|
// Both patterns are anchored single character classes with no overlapping
|
|
// alternatives, so they match in linear time. Splitting on '@' and '.' in code
|
|
// rather than in one combined pattern avoids the ambiguous (and backtracking)
|
|
// `[^\s@]+\.[^\s@]+` domain match.
|
|
const LOCAL_PART_RE = /^[^\s@]+$/;
|
|
const DOMAIN_LABEL_RE = /^[^\s@.]+$/;
|
|
|
|
export function isValidEmail(email: string): boolean {
|
|
const trimmed = email.trim();
|
|
if (trimmed.length === 0 || trimmed.length > MAX_EMAIL_LENGTH) {
|
|
return false;
|
|
}
|
|
|
|
const at = trimmed.indexOf('@');
|
|
if (at === -1 || at !== trimmed.lastIndexOf('@')) {
|
|
return false;
|
|
}
|
|
|
|
if (!LOCAL_PART_RE.test(trimmed.slice(0, at))) {
|
|
return false;
|
|
}
|
|
|
|
const labels = trimmed.slice(at + 1).split('.');
|
|
return labels.length >= 2 && labels.every((label) => DOMAIN_LABEL_RE.test(label));
|
|
}
|
|
|
|
// antd's preset Tag colours. Kept as the single source of truth for tag
|
|
// colours so the admin palette picker and the auto-assignment below can never
|
|
// drift apart — the frontend renders whatever string lands in tags.color.
|
|
export const TAG_COLORS: [string, ...string[]] = [
|
|
'magenta', 'red', 'volcano', 'orange', 'gold', 'lime',
|
|
'green', 'cyan', 'blue', 'geekblue', 'purple'
|
|
];
|
|
|
|
// Tags get a colour the moment they're created inline from the item form, with
|
|
// no prompt. Deriving it from the name (rather than picking at random or
|
|
// round-robining on insert order) means the same tag name always lands on the
|
|
// same colour, so a tag deleted and re-added doesn't silently change colour.
|
|
// The admin can still override it afterwards.
|
|
export function tagColorFor(name: string): string {
|
|
const normalized = name.trim().toLowerCase();
|
|
// djb2 — cheap, well-spread for short strings, and stable across Node
|
|
// versions. `| 0` keeps it in int32 range instead of drifting into float.
|
|
let hash = 5381;
|
|
for (let i = 0; i < normalized.length; i++) {
|
|
hash = ((hash << 5) + hash + normalized.charCodeAt(i)) | 0;
|
|
}
|
|
// The modulo keeps this in range, but an index signature cannot say so. The
|
|
// fallback is the first colour rather than a throw: a tag with an unexpected
|
|
// colour is not worth failing a request over.
|
|
// TAG_COLORS is typed as a non-empty tuple, so index 0 is known to exist —
|
|
// the annotation, rather than `as const`, because the elements must stay
|
|
// `string` for the callers that assign them. The modulo keeps the computed
|
|
// index in range; the fallback only exists because indexing cannot say so.
|
|
return TAG_COLORS[Math.abs(hash) % TAG_COLORS.length] ?? TAG_COLORS[0];
|
|
}
|
|
|
|
/**
|
|
* Email marketing only. Deliberately says nothing about tracking.
|
|
*
|
|
* This was briefly widened during #56 to cover analytics as well, and that was
|
|
* wrong: GDPR requires consent to be granular, and current EDPB guidance treats
|
|
* bundling tracking consent with subscription consent as invalid because the
|
|
* customer cannot accept one purpose and refuse the other. Quebec's Law 25 is
|
|
* stricter still. Analytics has its own sentence and its own column below.
|
|
*
|
|
* Left exactly as it was so that every existing consent record stays valid and
|
|
* untouched — nobody has to be re-asked for something they already agreed to.
|
|
*/
|
|
export const MARKETING_CONSENT_TEXT =
|
|
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs. I can unsubscribe at any time.';
|
|
|
|
/**
|
|
* Consent to the Brevo tracker (#56). Separate from marketing consent, and
|
|
* separately refusable, because they are two purposes with two recipients.
|
|
*
|
|
* Names Brevo rather than saying "our email provider": informed consent means
|
|
* the customer can tell who receives their data, and a description they cannot
|
|
* act on is not disclosure. Says what is shared and why, states that it is
|
|
* optional and independent of the emails, and states that it can be turned off
|
|
* — withdrawal has to be as easy as giving it.
|
|
*
|
|
* Stored verbatim in `analytics_consent_text` for the same reason the marketing
|
|
* sentence is: a record of consent that does not say what was consented to
|
|
* cannot be audited, and re-wording this later must not silently broaden
|
|
* anybody's agreement.
|
|
*/
|
|
export const ANALYTICS_CONSENT_TEXT =
|
|
'I agree that what I browse and buy on this site may be shared with Brevo, the service that sends our emails, so that what they contain is relevant to me. This is optional, separate from receiving the emails themselves, and I can turn it off at any time.';
|
|
|
|
/**
|
|
* Strips trailing slashes so a base URL can be joined with a stored path.
|
|
*
|
|
* A loop rather than `/\/+$/`, which backtracks: sonarjs flags that pattern as
|
|
* super-linear, and the input here is an environment variable rather than
|
|
* anything hostile, but the cheap version is no harder to read.
|
|
*
|
|
* Shared because two callers now need it — `/api/config` sends
|
|
* `uploadsBaseUrl` this way, and the upload-link routes build a submission URL
|
|
* from PUBLIC_URL. Stored paths always begin with a slash, so trimming the
|
|
* base is what stops the join producing a double.
|
|
*/
|
|
export function trimTrailingSlashes(value: string): string {
|
|
let trimmed = value;
|
|
while (trimmed.endsWith('/')) trimmed = trimmed.slice(0, -1);
|
|
return trimmed;
|
|
}
|
|
|
|
/**
|
|
* A route's `:id` as a positive integer, or null when it is not one.
|
|
*
|
|
* Guarding this is not cosmetic. `Number('abc')` is NaN, which the driver sends
|
|
* to Postgres as the text "NaN"; Postgres raises 22P02 for an integer column,
|
|
* the route's catch turns that into a 500, and a caller asking for an item that
|
|
* cannot exist is told the server broke. Returning null lets the route answer
|
|
* 404, which is what "/items/abc" actually means. See #207.
|
|
*
|
|
* Rejects 0 and negatives as well as fractions: every id in this schema is a
|
|
* positive serial, so anything else identifies nothing.
|
|
*
|
|
* Matched against decimal digits before parsing, because `Number` on its own is
|
|
* far more permissive than "is this an id" wants. It reads `5.0`, `1e2`, `0x10`
|
|
* and `+5` as 5, 100, 16 and 5 — each a positive integer, each passing the
|
|
* checks below, and each therefore fetching a real row for a URL nobody wrote.
|
|
* That is not a crash and so it never announced itself; #307 noticed it only
|
|
* because #308 converted the comparison to a real integer. An id is a string of
|
|
* digits, and anything else is a different request.
|
|
*
|
|
* Bounded at the top for the reason the whole function exists: the column is a
|
|
* 32-bit serial, so an id above that limit reaches Postgres as an out-of-range
|
|
* integer and raises 22003 — the same shape of failure as the 22P02 above, and
|
|
* the same wrong answer to the caller. Below the limit it is a 404.
|
|
*/
|
|
const MAX_SERIAL_ID = 2147483647;
|
|
|
|
export function readId(value: string | undefined): number | null {
|
|
if (value === undefined) return null;
|
|
const trimmed = value.trim();
|
|
if (!/^\d+$/.test(trimmed)) return null;
|
|
const parsed = Number(trimmed);
|
|
return Number.isInteger(parsed) && parsed > 0 && parsed <= MAX_SERIAL_ID ? parsed : null;
|
|
}
|