First Commit. Duplicate bunext-sqlite preset
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* # App Error
|
||||
* An error whose message is safe to show to the client. Anything else is
|
||||
* treated as internal and replaced with a generic message at the API edge.
|
||||
*/
|
||||
export default class AppError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "AppError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
export default async function bufferFromBase64(
|
||||
base_64: string,
|
||||
): Promise<{ buffer: Buffer; ext: string }> {
|
||||
let mimeType = "";
|
||||
let data = base_64;
|
||||
|
||||
const dataUrlMatch = base_64.match(
|
||||
/^data:([^;]+);base64,([\s\S]+)$/i,
|
||||
);
|
||||
|
||||
if (dataUrlMatch?.[1] && dataUrlMatch[2]) {
|
||||
mimeType = dataUrlMatch[1].trim().toLowerCase();
|
||||
data = dataUrlMatch[2];
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(data, "base64");
|
||||
|
||||
let ext = "bin";
|
||||
if (mimeType.includes("image/jpeg") || mimeType.includes("image/jpg"))
|
||||
ext = "jpeg";
|
||||
else if (mimeType.includes("image/png")) ext = "png";
|
||||
else if (mimeType.includes("image/webp")) ext = "webp";
|
||||
else if (mimeType.includes("image/gif")) ext = "gif";
|
||||
else if (mimeType.includes("image/svg")) ext = "svg";
|
||||
else if (mimeType.includes("application/pdf")) ext = "pdf";
|
||||
else if (
|
||||
mimeType.includes(
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml",
|
||||
)
|
||||
)
|
||||
ext = "docx";
|
||||
else if (
|
||||
mimeType.includes(
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml",
|
||||
)
|
||||
)
|
||||
ext = "xlsx";
|
||||
else if (mimeType.includes("text/csv")) ext = "csv";
|
||||
else if (mimeType.includes("text/markdown")) ext = "md";
|
||||
else if (mimeType.includes("text/plain")) ext = "txt";
|
||||
else if (mimeType.includes("/")) {
|
||||
ext = mimeType.split("/").pop()?.split("+")[0] || "bin";
|
||||
}
|
||||
|
||||
return { buffer, ext };
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
export default async function bufferFromUrl(
|
||||
url: string,
|
||||
): Promise<{ buffer: Buffer; ext: string }> {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status}`);
|
||||
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
const arrayBuffer = await res.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
// Derive ext from content-type first, fall back to URL path
|
||||
let ext = "txt";
|
||||
if (contentType.includes("application/pdf")) ext = "pdf";
|
||||
else if (
|
||||
contentType.includes(
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml",
|
||||
)
|
||||
)
|
||||
ext = "docx";
|
||||
else if (
|
||||
contentType.includes(
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml",
|
||||
)
|
||||
)
|
||||
ext = "xlsx";
|
||||
else if (contentType.includes("text/csv")) ext = "csv";
|
||||
else if (contentType.includes("text/markdown")) ext = "md";
|
||||
else {
|
||||
const urlPath = new URL(url).pathname;
|
||||
ext = urlPath.split(".").pop()?.toLowerCase() ?? "txt";
|
||||
}
|
||||
|
||||
return { buffer, ext };
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { CookieOptions } from "./cookies";
|
||||
import {
|
||||
deleteCookies,
|
||||
getCookie,
|
||||
setCookies,
|
||||
} from "./cookies";
|
||||
|
||||
describe("getCookie", () => {
|
||||
test("extracts a single cookie", () => {
|
||||
const req = new Request("http://localhost", {
|
||||
headers: { Cookie: `bmp_auth_key=abc123` },
|
||||
});
|
||||
expect(getCookie(req, "bmp_auth_key")).toBe("abc123");
|
||||
});
|
||||
|
||||
test("extracts a cookie from multiple", () => {
|
||||
const req = new Request("http://localhost", {
|
||||
headers: { Cookie: `foo=1; bmp_auth_key=abc; bar=2` },
|
||||
});
|
||||
expect(getCookie(req, "bmp_auth_key")).toBe("abc");
|
||||
});
|
||||
|
||||
test("handles values with equals signs", () => {
|
||||
const req = new Request("http://localhost", {
|
||||
headers: { Cookie: `bmp_auth_key=a=b=c` },
|
||||
});
|
||||
expect(getCookie(req, "bmp_auth_key")).toBe("a=b=c");
|
||||
});
|
||||
|
||||
test("returns undefined on empty or missing header", () => {
|
||||
const no_header = new Request("http://localhost");
|
||||
expect(getCookie(no_header, "bmp_auth_key")).toBeUndefined();
|
||||
|
||||
const empty = new Request("http://localhost", {
|
||||
headers: { Cookie: "" },
|
||||
});
|
||||
expect(getCookie(empty, "bmp_auth_key")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined for a missing cookie name", () => {
|
||||
const req = new Request("http://localhost", {
|
||||
headers: { Cookie: `foo=1` },
|
||||
});
|
||||
expect(getCookie(req, "bmp_auth_key")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("setCookies", () => {
|
||||
test("applies secure defaults", () => {
|
||||
const res = new Response();
|
||||
setCookies(res, [{ name: "bmp_auth_key", value: "abc" }]);
|
||||
|
||||
const [header] = res.headers.getSetCookie();
|
||||
expect(header).toContain(`bmp_auth_key=abc`);
|
||||
expect(header).toContain("HttpOnly");
|
||||
expect(header).toContain("Secure");
|
||||
expect(header).toContain("SameSite=Lax");
|
||||
expect(header).toContain("Path=/");
|
||||
});
|
||||
|
||||
test("appends multiple cookies without overwriting", () => {
|
||||
const res = new Response();
|
||||
setCookies(res, [
|
||||
{ name: "bmp_auth_key", value: "abc" },
|
||||
{ name: "bmp_auth_csrf", value: "xyz" },
|
||||
]);
|
||||
expect(res.headers.getSetCookie()).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("honors maxAge and custom options", () => {
|
||||
const res = new Response();
|
||||
setCookies(res, [
|
||||
{
|
||||
name: "bmp_auth_key",
|
||||
value: "abc",
|
||||
maxAge: 3600,
|
||||
sameSite: "Strict",
|
||||
path: "/admin",
|
||||
httpOnly: false,
|
||||
secure: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const [header] = res.headers.getSetCookie();
|
||||
expect(header).toContain("Max-Age=3600");
|
||||
expect(header).toContain("SameSite=Strict");
|
||||
expect(header).toContain("Path=/admin");
|
||||
expect(header).not.toContain("HttpOnly");
|
||||
expect(header).not.toContain("Secure");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteCookies", () => {
|
||||
test("expires the named cookies", () => {
|
||||
const res = new Response();
|
||||
deleteCookies(res, [
|
||||
{ name: "bmp_auth_key", httpOnly: true },
|
||||
{ name: "bmp_auth_csrf", httpOnly: true },
|
||||
]);
|
||||
|
||||
const headers = res.headers.getSetCookie();
|
||||
expect(headers).toHaveLength(2);
|
||||
for (const header of headers) {
|
||||
expect(header).toContain("Max-Age=0");
|
||||
expect(header).toContain("bmp_");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Extracts a specific cookie value from a Request's Cookie header.
|
||||
* Returns undefined if the cookie is not found.
|
||||
*/
|
||||
export function getCookie(request: Request, name: string): string | undefined {
|
||||
const header = request.headers.get("Cookie");
|
||||
if (!header) return undefined;
|
||||
|
||||
const cookies = header.split(";");
|
||||
|
||||
for (const cookie of cookies) {
|
||||
const [key, ...rest] = cookie.split("=");
|
||||
if (!key) continue;
|
||||
if (key.trim() === name) {
|
||||
return rest.join("=").trim();
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export type CookieOptions = {
|
||||
/** Cookie name */
|
||||
name: string;
|
||||
/** Cookie value */
|
||||
value: string;
|
||||
/** Prevents client-side JS access (default: true) */
|
||||
httpOnly?: boolean;
|
||||
/** Only send over HTTPS (default: true) */
|
||||
secure?: boolean;
|
||||
/** CSRF protection strategy (default: "Lax") */
|
||||
sameSite?: "Strict" | "Lax" | "None";
|
||||
/** URL path scope (default: "/") */
|
||||
path?: string;
|
||||
/** Lifetime in seconds. Omit for a session cookie. */
|
||||
maxAge?: number;
|
||||
/** Restrict to a specific domain */
|
||||
domain?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Appends one or more Set-Cookie headers to a Response.
|
||||
* Uses `append` so multiple cookies don't overwrite each other.
|
||||
*/
|
||||
export function setCookies(response: Response, cookies: CookieOptions[]): void {
|
||||
for (const cookie of cookies) {
|
||||
const {
|
||||
name,
|
||||
value,
|
||||
httpOnly = true,
|
||||
secure = true,
|
||||
sameSite = "Lax",
|
||||
path = "/",
|
||||
maxAge,
|
||||
domain,
|
||||
} = cookie;
|
||||
|
||||
let header = `${name}=${value}; Path=${path}; SameSite=${sameSite}`;
|
||||
|
||||
if (httpOnly) header += "; HttpOnly";
|
||||
if (secure) header += "; Secure";
|
||||
if (maxAge !== undefined) header += `; Max-Age=${maxAge}`;
|
||||
if (domain) header += `; Domain=${domain}`;
|
||||
|
||||
response.headers.append("Set-Cookie", header);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a Set-Cookie header that expires the named cookie immediately.
|
||||
* Sets Max-Age=0 and an empty value to instruct the browser to remove it.
|
||||
* Path and domain must match the original cookie for deletion to work.
|
||||
*/
|
||||
export function deleteCookies(
|
||||
response: Response,
|
||||
cookies: Pick<CookieOptions, "name" | "path" | "domain" | "httpOnly">[],
|
||||
): void {
|
||||
for (const { name, path = "/", domain, httpOnly } of cookies) {
|
||||
let header = `${name}=; Path=${path}; Max-Age=0`;
|
||||
|
||||
if (httpOnly) header += "; HttpOnly";
|
||||
if (domain) header += `; Domain=${domain}`;
|
||||
|
||||
response.headers.append("Set-Cookie", header);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { decrypt, encrypt } from "./crypt";
|
||||
|
||||
const original_key = process.env.ENCRYPTION_KEY;
|
||||
const original_salt = process.env.ENCRYPTION_SALT;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.ENCRYPTION_KEY = "test-key-1234567890";
|
||||
process.env.ENCRYPTION_SALT = "test-salt-1234567890";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (original_key) process.env.ENCRYPTION_KEY = original_key;
|
||||
else delete process.env.ENCRYPTION_KEY;
|
||||
if (original_salt) process.env.ENCRYPTION_SALT = original_salt;
|
||||
else delete process.env.ENCRYPTION_SALT;
|
||||
});
|
||||
|
||||
describe("encrypt/decrypt", () => {
|
||||
test("round-trips plain text", async () => {
|
||||
const text = "hello world";
|
||||
const encrypted = await encrypt(text);
|
||||
expect(encrypted).not.toBe(text);
|
||||
expect(await decrypt(encrypted)).toBe(text);
|
||||
});
|
||||
|
||||
test("round-trips unicode and long strings", async () => {
|
||||
const text = "hello 世界 🎉 " + "x".repeat(5000);
|
||||
expect(await decrypt(await encrypt(text))).toBe(text);
|
||||
});
|
||||
|
||||
test("round-trips empty strings", async () => {
|
||||
expect(await decrypt(await encrypt(""))).toBe("");
|
||||
});
|
||||
|
||||
test("produces unique ciphertext per call", async () => {
|
||||
const text = "same plaintext";
|
||||
const a = await encrypt(text);
|
||||
const b = await encrypt(text);
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
test("decrypt fails on tampered input", async () => {
|
||||
const encrypted = await encrypt("secret");
|
||||
const tampered = "A" + encrypted.slice(1);
|
||||
expect(async () => await decrypt(tampered)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
const key = process.env.ENCRYPTION_KEY;
|
||||
const salt = process.env.ENCRYPTION_SALT;
|
||||
|
||||
async function getKey() {
|
||||
const material = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
new TextEncoder().encode(key),
|
||||
"PBKDF2",
|
||||
false,
|
||||
["deriveKey"],
|
||||
);
|
||||
return crypto.subtle.deriveKey(
|
||||
{
|
||||
name: "PBKDF2",
|
||||
salt: new TextEncoder().encode(salt),
|
||||
iterations: 100000,
|
||||
hash: "SHA-256",
|
||||
},
|
||||
material,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["encrypt", "decrypt"],
|
||||
);
|
||||
}
|
||||
|
||||
export async function encrypt(text: string): Promise<string> {
|
||||
const derivedKey = await getKey();
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv },
|
||||
derivedKey,
|
||||
new TextEncoder().encode(text),
|
||||
);
|
||||
const combined = new Uint8Array(
|
||||
iv.length + new Uint8Array(ciphertext).length,
|
||||
);
|
||||
combined.set(iv);
|
||||
combined.set(new Uint8Array(ciphertext), iv.length);
|
||||
return btoa(String.fromCharCode(...combined));
|
||||
}
|
||||
|
||||
export async function decrypt(encoded: string): Promise<string> {
|
||||
const derivedKey = await getKey();
|
||||
const combined = Uint8Array.from(atob(encoded), (c) => c.charCodeAt(0));
|
||||
const iv = combined.slice(0, 12);
|
||||
const ciphertext = combined.slice(12);
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv },
|
||||
derivedKey,
|
||||
ciphertext,
|
||||
);
|
||||
return new TextDecoder().decode(decrypted);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* # EJSON parse string
|
||||
*/
|
||||
function parse(
|
||||
string: string | null | number,
|
||||
reviver?: (this: any, key: string, value: any) => any
|
||||
): { [s: string]: any } | { [s: string]: any }[] | undefined {
|
||||
if (!string) return undefined;
|
||||
if (typeof string == "object") return string;
|
||||
if (typeof string !== "string") return undefined;
|
||||
try {
|
||||
return JSON.parse(string, reviver);
|
||||
} catch (error) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* # EJSON stringify object
|
||||
*/
|
||||
function stringify(
|
||||
value: any,
|
||||
replacer?: ((this: any, key: string, value: any) => any) | null,
|
||||
space?: string | number
|
||||
): string | undefined {
|
||||
try {
|
||||
return JSON.stringify(value, replacer || undefined, space);
|
||||
} catch (error) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const EJSON = {
|
||||
parse,
|
||||
stringify,
|
||||
};
|
||||
|
||||
export default EJSON;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import formatBytes from "./format-bytes";
|
||||
|
||||
test("formatBytes formats sizes", () => {
|
||||
expect(formatBytes({ bytes: 0 })).toBe("0 B");
|
||||
expect(formatBytes({ bytes: 512 })).toBe("512 B");
|
||||
expect(formatBytes({ bytes: 12449 })).toBe("12.2 KB");
|
||||
expect(formatBytes({ bytes: 5 * 1024 * 1024 })).toBe("5.0 MB");
|
||||
expect(formatBytes({ bytes: NaN })).toBe("0 B");
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
const Units = ["B", "KB", "MB", "GB", "TB"];
|
||||
|
||||
type Params = {
|
||||
bytes: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Human readable file size, e.g. `12.2 KB`.
|
||||
*/
|
||||
export default function formatBytes({ bytes }: Params) {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) {
|
||||
return "0 B";
|
||||
}
|
||||
|
||||
const unit_index = Math.min(
|
||||
Math.floor(Math.log(bytes) / Math.log(1024)),
|
||||
Units.length - 1,
|
||||
);
|
||||
|
||||
const value = bytes / 1024 ** unit_index;
|
||||
|
||||
return `${unit_index == 0 ? value : value.toFixed(1)} ${Units[unit_index]}`;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import path from "path";
|
||||
import type { User } from "../types";
|
||||
import type { BUN_SQLITE_BSQLITE_USERS } from "@/db/types/db";
|
||||
|
||||
type Params = {
|
||||
user?: BUN_SQLITE_BSQLITE_USERS | User;
|
||||
};
|
||||
|
||||
export default function grabDirNames(params?: Params) {
|
||||
const DIRNAME = __dirname;
|
||||
const ROOT_DIR = DIRNAME.includes(`/.bunext`)
|
||||
? process.cwd()
|
||||
: path.resolve(__dirname, "../..");
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(ROOT_DIR, ".data");
|
||||
const MEDIA_RELATIVE_DIR = "media";
|
||||
const MEDIA_DIR = path.join(DATA_DIR, MEDIA_RELATIVE_DIR);
|
||||
|
||||
const USER_MEDIA_PUBLIC_DIR = params?.user?.id
|
||||
? path.join(MEDIA_DIR, String(params.user.id), "public")
|
||||
: undefined;
|
||||
const USER_MEDIA_PUBLIC_RELATIVE_DIR = params?.user?.id
|
||||
? path.join(MEDIA_RELATIVE_DIR, String(params.user.id), "public")
|
||||
: undefined;
|
||||
const USER_MEDIA_PRIVATE_DIR = params?.user?.id
|
||||
? path.join(MEDIA_DIR, String(params.user.id), "private")
|
||||
: undefined;
|
||||
const USER_MEDIA_PRIVATE_RELATIVE_DIR = params?.user?.id
|
||||
? path.join(MEDIA_RELATIVE_DIR, String(params.user.id), "private")
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
ROOT_DIR,
|
||||
MEDIA_DIR,
|
||||
USER_MEDIA_PUBLIC_DIR,
|
||||
USER_MEDIA_PRIVATE_DIR,
|
||||
DATA_DIR,
|
||||
USER_MEDIA_PUBLIC_RELATIVE_DIR,
|
||||
USER_MEDIA_PRIVATE_RELATIVE_DIR,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import hexToRgbFractions from "./hex-to-rgb-fractions";
|
||||
|
||||
describe("hexToRgbFractions", () => {
|
||||
test("converts 6-digit hex", () => {
|
||||
expect(hexToRgbFractions({ hex: "#ff0000" })).toEqual({
|
||||
r: 1,
|
||||
g: 0,
|
||||
b: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("expands 3-digit hex", () => {
|
||||
expect(hexToRgbFractions({ hex: "#fff" })).toEqual({
|
||||
r: 1,
|
||||
g: 1,
|
||||
b: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("ignores alpha", () => {
|
||||
expect(hexToRgbFractions({ hex: "#00000080" })).toEqual({
|
||||
r: 0,
|
||||
g: 0,
|
||||
b: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Converts `#rgb` / `#rrggbb` hex into `0-1` channel fractions (pdf-lib `rgb()`
|
||||
* input). Alpha in `#rrggbbaa` is ignored.
|
||||
*/
|
||||
export default function hexToRgbFractions({ hex }: { hex: string }): {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
} {
|
||||
let clean = hex.replace("#", "");
|
||||
|
||||
if (clean.length == 3 || clean.length == 4) {
|
||||
clean = clean
|
||||
.slice(0, 3)
|
||||
.split("")
|
||||
.map((char) => char + char)
|
||||
.join("");
|
||||
}
|
||||
|
||||
const channel = (offset: number) =>
|
||||
(parseInt(clean.slice(offset, offset + 2), 16) || 0) / 255;
|
||||
|
||||
return { r: channel(0), g: channel(2), b: channel(4) };
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
formatNaira,
|
||||
nairaToKobo,
|
||||
sumInvoiceItemsKobo,
|
||||
} from "./money";
|
||||
|
||||
test("formatNaira formats kobo amounts", () => {
|
||||
expect(formatNaira(150050)).toBe("₦1,500.50");
|
||||
expect(formatNaira(100)).toBe("₦1.00");
|
||||
expect(formatNaira(0)).toBe("₦0.00");
|
||||
expect(formatNaira(500000000)).toBe("₦5,000,000.00");
|
||||
expect(formatNaira(1)).toBe("₦0.01");
|
||||
expect(formatNaira("250000")).toBe("₦2,500.00");
|
||||
});
|
||||
|
||||
test("formatNaira handles strings, undefined and negative", () => {
|
||||
expect(formatNaira(undefined)).toBe("₦0.00");
|
||||
expect(formatNaira(null)).toBe("₦0.00");
|
||||
expect(formatNaira(1000)).toBe("₦10.00");
|
||||
expect(formatNaira(-5050)).toBe("-₦50.50");
|
||||
});
|
||||
|
||||
test("nairaToKobo parses naira strings", () => {
|
||||
expect(nairaToKobo("₦1,500.50")).toBe(150050);
|
||||
expect(nairaToKobo("1500")).toBe(150000);
|
||||
expect(nairaToKobo("1,234.56")).toBe(123456);
|
||||
expect(nairaToKobo(undefined)).toBe(0);
|
||||
expect(nairaToKobo("")).toBe(0);
|
||||
expect(nairaToKobo("abc")).toBe(0);
|
||||
});
|
||||
|
||||
test("sumInvoiceItemsKobo totals line items", () => {
|
||||
const items = [
|
||||
{ quantity: 2, unit_price_kobo: 5000 }, // ₦50.00 x2
|
||||
{ quantity: 1, unit_price_kobo: 150000 }, // ₦1,500.00
|
||||
{ quantity: "", unit_price_kobo: "" },
|
||||
];
|
||||
|
||||
expect(sumInvoiceItemsKobo(items)).toBe(160000);
|
||||
expect(sumInvoiceItemsKobo([])).toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* # Format a kobo (₦/100) amount as a Naira currency string.
|
||||
* @example formatNaira(150050) // "₦1,500.50"
|
||||
*/
|
||||
export function formatNaira(kobo: number | string | undefined | null): string {
|
||||
const amount = Math.round(Number(kobo || 0));
|
||||
const negative = amount < 0;
|
||||
const absolute = Math.abs(amount);
|
||||
|
||||
const whole = Math.floor(absolute / 100);
|
||||
const fraction = String(absolute % 100).padStart(2, "0");
|
||||
|
||||
const digits = String(whole);
|
||||
let grouped = "";
|
||||
|
||||
for (let i = digits.length - 1, c = 0; i >= 0; i--, c++) {
|
||||
if (c > 0 && c % 3 == 0) {
|
||||
grouped = `,${grouped}`;
|
||||
}
|
||||
grouped = digits[i] + grouped;
|
||||
}
|
||||
|
||||
return `${negative ? "-" : ""}₦${grouped}.${fraction}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* # Format a kobo amount with an ISO currency prefix.
|
||||
* Standard PDF fonts (WinAnsi) cannot encode the ₦ glyph,
|
||||
* so PDFs use "NGN" instead of "₦".
|
||||
* @example formatNairaPdf(150050) // "NGN 1,500.50"
|
||||
*/
|
||||
export function formatNairaPdf(
|
||||
kobo: number | string | undefined | null,
|
||||
): string {
|
||||
const amount = Math.round(Number(kobo || 0));
|
||||
const negative = amount < 0;
|
||||
const absolute = Math.abs(amount);
|
||||
|
||||
const whole = Math.floor(absolute / 100);
|
||||
const fraction = String(absolute % 100).padStart(2, "0");
|
||||
|
||||
const digits = String(whole);
|
||||
let grouped = "";
|
||||
|
||||
for (let i = digits.length - 1, c = 0; i >= 0; i--, c++) {
|
||||
if (c > 0 && c % 3 == 0) {
|
||||
grouped = `,${grouped}`;
|
||||
}
|
||||
grouped = digits[i] + grouped;
|
||||
}
|
||||
|
||||
return `${negative ? "-" : ""}NGN ${grouped}.${fraction}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* # Convert a Naira string/number to kobo (smallest unit).
|
||||
* @example nairaToKobo("₦1,500.50") // 150050
|
||||
*/
|
||||
export function nairaToKobo(value: string | number | undefined): number {
|
||||
const cleaned = String(value || 0).replace(/[^0-9.]/g, "");
|
||||
return Math.round((Number(cleaned) || 0) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* # Convert a kobo amount to a plain Naira string for form inputs.
|
||||
* @example koboToNairaString(150050) // "1500.5"
|
||||
*/
|
||||
export function koboToNairaString(
|
||||
kobo: number | string | undefined | null,
|
||||
): string {
|
||||
const amount = Math.abs(Number(kobo || 0));
|
||||
const naira = amount / 100;
|
||||
return String(Math.round(naira * 100) / 100);
|
||||
}
|
||||
|
||||
export type InvoiceItemAmountInput = {
|
||||
quantity?: number | string | "";
|
||||
unit_price_kobo?: number | string | "";
|
||||
};
|
||||
|
||||
/**
|
||||
* # Sum line items into a kobo total.
|
||||
*/
|
||||
export function sumInvoiceItemsKobo(
|
||||
items: InvoiceItemAmountInput[],
|
||||
): number {
|
||||
return items.reduce((acc, item) => {
|
||||
const quantity = Number(item.quantity) || 0;
|
||||
const unit_price = Number(item.unit_price_kobo) || 0;
|
||||
return acc + quantity * unit_price;
|
||||
}, 0);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import parseLoginMethod from "./parse-login-method";
|
||||
|
||||
describe("parseLoginMethod", () => {
|
||||
test("reads password", () => {
|
||||
expect(parseLoginMethod({ value: "password" })).toBe("password");
|
||||
expect(parseLoginMethod({ value: " Password " })).toBe("password");
|
||||
});
|
||||
|
||||
test("reads code", () => {
|
||||
expect(parseLoginMethod({ value: "code" })).toBe("code");
|
||||
});
|
||||
|
||||
test("falls back to code when unset or unknown", () => {
|
||||
expect(parseLoginMethod({})).toBe("code");
|
||||
expect(parseLoginMethod({ value: "" })).toBe("code");
|
||||
expect(parseLoginMethod({ value: "passwrd" })).toBe("code");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { LoginMethod } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
value?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads the site-wide login method (`AUTH_LOGIN_METHOD`). Anything other
|
||||
* than `password` falls back to passwordless `code` login, so a typo can
|
||||
* never lock out accounts that have no password.
|
||||
*/
|
||||
export default function parseLoginMethod({ value }: Params): LoginMethod {
|
||||
return value?.trim().toLowerCase() == "password" ? "password" : "code";
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import parseThemeColors from "./parse-theme-colors";
|
||||
|
||||
describe("parseThemeColors", () => {
|
||||
test("extracts color tokens from the @theme inline block", () => {
|
||||
const css = `
|
||||
@import "x.css";
|
||||
|
||||
@theme inline {
|
||||
--font-sans: "Geist";
|
||||
--color-primary: #101828;
|
||||
--color-slate-500: #667085;
|
||||
--color-accent:#5B5BD6;
|
||||
--radius-default: 6px;
|
||||
}
|
||||
|
||||
.foo { --color-primary: #ff0000; }
|
||||
`;
|
||||
|
||||
expect(parseThemeColors({ css })).toEqual({
|
||||
primary: "#101828",
|
||||
"slate-500": "#667085",
|
||||
accent: "#5b5bd6",
|
||||
});
|
||||
});
|
||||
|
||||
test("returns an empty object without a theme block", () => {
|
||||
expect(parseThemeColors({ css: ".a { color: red; }" })).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Extracts `--color-*: #hex;` tokens from the `@theme inline` block of a CSS
|
||||
* string. Keys are returned without the `--color-` prefix (e.g. `primary`,
|
||||
* `slate-500`).
|
||||
*/
|
||||
export default function parseThemeColors({
|
||||
css,
|
||||
}: {
|
||||
css: string;
|
||||
}): Record<string, string> {
|
||||
const theme_block = css.match(/@theme\s+inline\s*\{([\s\S]*?)\n\}/)?.[1];
|
||||
|
||||
if (!theme_block) return {};
|
||||
|
||||
const colors: Record<string, string> = {};
|
||||
const color_regex = /--color-([a-z0-9-]+)\s*:\s*(#[0-9a-fA-F]{3,8})\s*;/g;
|
||||
|
||||
for (const match of theme_block.matchAll(color_regex)) {
|
||||
const [, name, value] = match;
|
||||
if (name && value) colors[name] = value.toLowerCase();
|
||||
}
|
||||
|
||||
return colors;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { z } from "zod";
|
||||
import AppError from "./app-error";
|
||||
import parseWithSchema from "./parse-with-schema";
|
||||
|
||||
const Schema = z.object({
|
||||
email: z.email("Please enter a valid email address"),
|
||||
age: z.number().min(18, "You must be at least 18"),
|
||||
});
|
||||
|
||||
describe("parseWithSchema", () => {
|
||||
test("returns the parsed data for valid input", () => {
|
||||
expect(
|
||||
parseWithSchema({
|
||||
schema: Schema,
|
||||
data: { email: "[email protected]", age: 30 },
|
||||
}),
|
||||
).toEqual({ email: "[email protected]", age: 30 });
|
||||
});
|
||||
|
||||
test("throws an AppError with the first issue message", () => {
|
||||
expect(() =>
|
||||
parseWithSchema({
|
||||
schema: Schema,
|
||||
data: { email: "not-an-email", age: 30 },
|
||||
}),
|
||||
).toThrow(AppError);
|
||||
});
|
||||
|
||||
test("throws for non-object input", () => {
|
||||
expect(() =>
|
||||
parseWithSchema({ schema: Schema, data: null }),
|
||||
).toThrow(AppError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ZodType } from "zod";
|
||||
import AppError from "./app-error";
|
||||
|
||||
type Params<T> = {
|
||||
schema: ZodType<T>;
|
||||
data: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Parse With Schema
|
||||
* Validates untrusted input and throws a user-facing `AppError` with the
|
||||
* first issue message when validation fails.
|
||||
*/
|
||||
export default function parseWithSchema<T>({ schema, data }: Params<T>): T {
|
||||
const result = schema.safeParse(data);
|
||||
|
||||
if (!result.success) {
|
||||
throw new AppError(
|
||||
result.error.issues[0]?.message || "Invalid request",
|
||||
);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import rateLimit, { resetRateLimit } from "./rate-limit";
|
||||
|
||||
describe("rateLimit", () => {
|
||||
test("allows hits up to the limit then blocks", () => {
|
||||
const key = `test:${crypto.randomUUID()}`;
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
expect(rateLimit({ key, limit: 3, window_ms: 60_000 }).allowed).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
expect(rateLimit({ key, limit: 3, window_ms: 60_000 }).allowed).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("resets after the window expires", async () => {
|
||||
const key = `test:${crypto.randomUUID()}`;
|
||||
|
||||
rateLimit({ key, limit: 1, window_ms: 5 });
|
||||
expect(rateLimit({ key, limit: 1, window_ms: 5 }).allowed).toBe(false);
|
||||
|
||||
await Bun.sleep(10);
|
||||
|
||||
expect(rateLimit({ key, limit: 1, window_ms: 5 }).allowed).toBe(true);
|
||||
});
|
||||
|
||||
test("resetRateLimit clears the bucket", () => {
|
||||
const key = `test:${crypto.randomUUID()}`;
|
||||
|
||||
rateLimit({ key, limit: 1, window_ms: 60_000 });
|
||||
resetRateLimit({ key });
|
||||
|
||||
expect(rateLimit({ key, limit: 1, window_ms: 60_000 }).allowed).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps buckets independent", () => {
|
||||
const a = `test:${crypto.randomUUID()}`;
|
||||
const b = `test:${crypto.randomUUID()}`;
|
||||
|
||||
rateLimit({ key: a, limit: 1, window_ms: 60_000 });
|
||||
|
||||
expect(rateLimit({ key: a, limit: 1, window_ms: 60_000 }).allowed).toBe(
|
||||
false,
|
||||
);
|
||||
expect(rateLimit({ key: b, limit: 1, window_ms: 60_000 }).allowed).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
type RateLimitEntry = {
|
||||
count: number;
|
||||
reset_at: number;
|
||||
};
|
||||
|
||||
const store = new Map<string, RateLimitEntry>();
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* Unique bucket key, e.g. `sso-login:<user_id>` or `contact:<ip>`
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* Max hits allowed inside the window
|
||||
*/
|
||||
limit: number;
|
||||
window_ms: number;
|
||||
};
|
||||
|
||||
export type RateLimitReturn = {
|
||||
allowed: boolean;
|
||||
remaining: number;
|
||||
reset_at: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* In-memory fixed-window rate limiter. Each call counts as one hit.
|
||||
* State is per-process, which is enough for single-container deploys.
|
||||
*/
|
||||
export default function rateLimit({
|
||||
key,
|
||||
limit,
|
||||
window_ms,
|
||||
}: Params): RateLimitReturn {
|
||||
const now = Date.now();
|
||||
|
||||
if (store.size > 10000) {
|
||||
for (const [k, entry] of store) {
|
||||
if (entry.reset_at <= now) store.delete(k);
|
||||
}
|
||||
}
|
||||
|
||||
const existing = store.get(key);
|
||||
|
||||
const entry: RateLimitEntry =
|
||||
existing && existing.reset_at > now
|
||||
? existing
|
||||
: { count: 0, reset_at: now + window_ms };
|
||||
|
||||
entry.count += 1;
|
||||
store.set(key, entry);
|
||||
|
||||
return {
|
||||
allowed: entry.count <= limit,
|
||||
remaining: Math.max(0, limit - entry.count),
|
||||
reset_at: entry.reset_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear a bucket, e.g. after a successful login.
|
||||
*/
|
||||
export function resetRateLimit({ key }: { key: string }) {
|
||||
store.delete(key);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { BUN_SQLITE_BSQLITE_USER_TYPES } from "@/db/types/db";
|
||||
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
|
||||
import isPassThroughUser from "./is-pass-through-user";
|
||||
import { UserManagementUserTypes } from "@/src/dict/user-types-dict";
|
||||
|
||||
type Params = {
|
||||
user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the actor may create new user records. User type assignment on
|
||||
* the new record is still limited by the assignment matrix.
|
||||
*/
|
||||
export default function canCreateUser({ user_types }: Params): boolean {
|
||||
if (isPassThroughUser({ user_types })) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return checkUserAccess({
|
||||
user_types,
|
||||
includes: UserManagementUserTypes,
|
||||
}).success;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { BUN_SQLITE_BSQLITE_USER_TYPES } from "@/db/types/db";
|
||||
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
|
||||
import isPassThroughUser from "./is-pass-through-user";
|
||||
import { UserManagementUserTypes } from "@/src/dict/user-types-dict";
|
||||
|
||||
type Params = {
|
||||
user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the actor may list (view) all user records, not just their
|
||||
* own. Pass-through actors always pass.
|
||||
*/
|
||||
export default function canListUsers({ user_types }: Params): boolean {
|
||||
if (isPassThroughUser({ user_types })) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return checkUserAccess({
|
||||
user_types,
|
||||
includes: UserManagementUserTypes,
|
||||
}).success;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { BUN_SQLITE_BSQLITE_USER_TYPES } from "@/db/types/db";
|
||||
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
|
||||
import isPassThroughUser from "./is-pass-through-user";
|
||||
import { UserManagementUserTypes } from "@/src/dict/user-types-dict";
|
||||
|
||||
type Params = {
|
||||
user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the actor may manage user type records (insert or delete rows
|
||||
* in the `user_types` table). Which individual types they may grant or
|
||||
* remove is still enforced per type by the assignment matrix.
|
||||
*/
|
||||
export default function canManageUserTypes({
|
||||
user_types,
|
||||
}: Params): boolean {
|
||||
if (isPassThroughUser({ user_types })) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return checkUserAccess({
|
||||
user_types,
|
||||
includes: UserManagementUserTypes,
|
||||
}).success;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { BUN_SQLITE_BSQLITE_USER_TYPES } from "@/db/types/db";
|
||||
import type { UserType } from "@/src/types";
|
||||
import userTypeAssignment from "./user-type-assignment";
|
||||
|
||||
type Params = {
|
||||
user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
|
||||
target_user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the actor may revoke a user (i.e. add the `revoked` user type).
|
||||
*
|
||||
* - A user holding the `super_admin` type can never be revoked.
|
||||
* - Otherwise governed by the assignment matrix for the `revoked` type:
|
||||
* pass-through actors (`super_admin`) may revoke anyone, and `admin`
|
||||
* may revoke users who do not hold an excluded protected type.
|
||||
*/
|
||||
export default function canRevokeUser({
|
||||
user_types,
|
||||
target_user_types,
|
||||
}: Params): boolean {
|
||||
if (target_user_types?.some((ut) => ut.user_type === "super_admin")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const target_user_type_values: UserType[] | undefined = target_user_types
|
||||
?.map((ut) => ut.user_type)
|
||||
.filter((ut) => Boolean(ut)) as UserType[] | undefined;
|
||||
|
||||
return userTypeAssignment({
|
||||
actor_user_types: user_types,
|
||||
target_user_type: "revoked",
|
||||
target_user_types: target_user_type_values,
|
||||
action: "assign",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { BUN_SQLITE_BSQLITE_USER_TYPES } from "@/db/types/db";
|
||||
import type { UserType } from "@/src/types";
|
||||
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
|
||||
import isPassThroughUser from "./is-pass-through-user";
|
||||
|
||||
type Params = {
|
||||
actor_user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
|
||||
actor_user_id?: number | string;
|
||||
target_user_id?: number | string;
|
||||
target_user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
|
||||
/**
|
||||
* The keys of the fields being updated (e.g. ["first_name", "email"]).
|
||||
* Used to restrict contact fields on protected users.
|
||||
*/
|
||||
update_fields?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Fields treated as contact information.
|
||||
*/
|
||||
export const ContactUpdateFields = ["email"];
|
||||
|
||||
/**
|
||||
* User types whose contact fields (email) are protected from changes by
|
||||
* non-pass-through actors.
|
||||
*/
|
||||
export const ContactProtectedUserTypes: UserType[] = ["super_admin"];
|
||||
|
||||
/**
|
||||
* Whether the actor may update a user record.
|
||||
*
|
||||
* - Any user may update their own record (profile fields only; user type
|
||||
* changes are still governed by the assignment matrix).
|
||||
* - Pass-through actors (`super_admin`) may update anyone.
|
||||
* - `admin` may edit the basic info of any user but cannot change the
|
||||
* email of protected users (`super_admin`). User type changes are
|
||||
* still governed by the assignment matrix.
|
||||
* - Everyone else can only update their own record.
|
||||
*/
|
||||
export default function canUpdateUser({
|
||||
actor_user_types,
|
||||
actor_user_id,
|
||||
target_user_id,
|
||||
target_user_types,
|
||||
update_fields,
|
||||
}: Params): boolean {
|
||||
if (
|
||||
target_user_id &&
|
||||
actor_user_id &&
|
||||
String(target_user_id) === String(actor_user_id)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isPassThroughUser({ user_types: actor_user_types })) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const can_edit_basic_info = checkUserAccess({
|
||||
user_types: actor_user_types,
|
||||
includes: ["admin"],
|
||||
});
|
||||
|
||||
if (!can_edit_basic_info.success) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const touches_contacts = update_fields?.some((field) =>
|
||||
ContactUpdateFields.includes(field),
|
||||
);
|
||||
|
||||
if (!touches_contacts) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
target_user_types?.some((ut) =>
|
||||
ContactProtectedUserTypes.includes(ut.user_type as UserType),
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { BUN_SQLITE_BSQLITE_USER_TYPES } from "@/db/types/db";
|
||||
import type { UserType } from "@/src/types";
|
||||
import userTypeAssignment from "./user-type-assignment";
|
||||
|
||||
type Params = {
|
||||
actor_user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
|
||||
user_types: UserType[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Filters a list of user types down to the ones the actor may assign
|
||||
* (grant or remove), per the assignment matrix.
|
||||
*/
|
||||
export default function filterAssignableUserTypes({
|
||||
actor_user_types,
|
||||
user_types,
|
||||
}: Params): UserType[] {
|
||||
return user_types.filter((target_user_type) =>
|
||||
userTypeAssignment({
|
||||
actor_user_types,
|
||||
target_user_type,
|
||||
action: "assign",
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
canCreateUser,
|
||||
canListUsers,
|
||||
canManageUserTypes,
|
||||
canRevokeUser,
|
||||
canUpdateUser,
|
||||
filterAssignableUserTypes,
|
||||
isPassThroughUser,
|
||||
} from "./";
|
||||
import {
|
||||
ALL_USER_TYPES,
|
||||
makeUserTypes,
|
||||
} from "./user-type-assignment.test-fixtures";
|
||||
|
||||
describe("isPassThroughUser", () => {
|
||||
test("super_admin is pass-through", () => {
|
||||
expect(isPassThroughUser({ user_types: makeUserTypes("super_admin") })).toBe(true);
|
||||
});
|
||||
|
||||
test("regular types are not pass-through", () => {
|
||||
for (const type of ["admin", "president", "secretary"]) {
|
||||
expect(
|
||||
isPassThroughUser({ user_types: makeUserTypes(type) }),
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("canListUsers / canCreateUser / canManageUserTypes", () => {
|
||||
test("pass-through always passes", () => {
|
||||
for (const fn of [canListUsers, canCreateUser, canManageUserTypes]) {
|
||||
expect(
|
||||
fn({ user_types: makeUserTypes("super_admin") }),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("admin passes", () => {
|
||||
expect(canListUsers({ user_types: makeUserTypes("admin") })).toBe(true);
|
||||
expect(canCreateUser({ user_types: makeUserTypes("admin") })).toBe(true);
|
||||
expect(
|
||||
canManageUserTypes({ user_types: makeUserTypes("admin") }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("non-admin types fail", () => {
|
||||
for (const type of ["auditor", "president", "secretary", "read_only"]) {
|
||||
expect(
|
||||
canListUsers({ user_types: makeUserTypes(type) }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canCreateUser({ user_types: makeUserTypes(type) }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canManageUserTypes({ user_types: makeUserTypes(type) }),
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("canUpdateUser", () => {
|
||||
test("actors may update their own record", () => {
|
||||
expect(
|
||||
canUpdateUser({
|
||||
actor_user_types: makeUserTypes("auditor"),
|
||||
actor_user_id: 5,
|
||||
target_user_id: 5,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("pass-through may update anyone including contacts", () => {
|
||||
expect(
|
||||
canUpdateUser({
|
||||
actor_user_types: makeUserTypes("super_admin"),
|
||||
actor_user_id: 1,
|
||||
target_user_id: 2,
|
||||
target_user_types: makeUserTypes("super_admin"),
|
||||
update_fields: ["email"],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("admin may edit basic info of others", () => {
|
||||
expect(
|
||||
canUpdateUser({
|
||||
actor_user_types: makeUserTypes("admin"),
|
||||
actor_user_id: 1,
|
||||
target_user_id: 2,
|
||||
update_fields: ["first_name"],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("admin cannot change contacts of protected users", () => {
|
||||
expect(
|
||||
canUpdateUser({
|
||||
actor_user_types: makeUserTypes("admin"),
|
||||
actor_user_id: 1,
|
||||
target_user_id: 2,
|
||||
target_user_types: makeUserTypes("super_admin"),
|
||||
update_fields: ["email"],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("non-admin actors cannot update others", () => {
|
||||
expect(
|
||||
canUpdateUser({
|
||||
actor_user_types: makeUserTypes("auditor"),
|
||||
actor_user_id: 1,
|
||||
target_user_id: 2,
|
||||
update_fields: ["first_name"],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canRevokeUser", () => {
|
||||
test("super_admin targets can never be revoked", () => {
|
||||
expect(
|
||||
canRevokeUser({
|
||||
user_types: makeUserTypes("super_admin"),
|
||||
target_user_types: makeUserTypes("super_admin"),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("pass-through may revoke anyone else", () => {
|
||||
expect(
|
||||
canRevokeUser({
|
||||
user_types: makeUserTypes("super_admin"),
|
||||
target_user_types: makeUserTypes("admin"),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("admin may revoke revocable targets", () => {
|
||||
expect(
|
||||
canRevokeUser({
|
||||
user_types: makeUserTypes("admin"),
|
||||
target_user_types: makeUserTypes("read_only"),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("admin cannot revoke protected targets", () => {
|
||||
expect(
|
||||
canRevokeUser({
|
||||
user_types: makeUserTypes("admin"),
|
||||
target_user_types: makeUserTypes("admin"),
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canRevokeUser({
|
||||
user_types: makeUserTypes("admin"),
|
||||
target_user_types: makeUserTypes("president"),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterAssignableUserTypes", () => {
|
||||
test("super_admin can assign everything", () => {
|
||||
const assignable = filterAssignableUserTypes({
|
||||
actor_user_types: makeUserTypes("super_admin"),
|
||||
user_types: ALL_USER_TYPES,
|
||||
});
|
||||
expect(assignable).toHaveLength(ALL_USER_TYPES.length);
|
||||
});
|
||||
|
||||
test("admin sees only assignable types", () => {
|
||||
const assignable = filterAssignableUserTypes({
|
||||
actor_user_types: makeUserTypes("admin"),
|
||||
user_types: ALL_USER_TYPES,
|
||||
});
|
||||
expect(assignable).toContain("auditor");
|
||||
expect(assignable).toContain("read_only");
|
||||
expect(assignable).not.toContain("admin");
|
||||
expect(assignable).not.toContain("super_admin");
|
||||
expect(assignable).not.toContain("president");
|
||||
expect(assignable).not.toContain("secretary");
|
||||
});
|
||||
|
||||
test("secretary can assign nothing", () => {
|
||||
const assignable = filterAssignableUserTypes({
|
||||
actor_user_types: makeUserTypes("secretary"),
|
||||
user_types: ALL_USER_TYPES,
|
||||
});
|
||||
expect(assignable).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
export { default as userTypeAssignment } from "./user-type-assignment";
|
||||
export { default as canCreateUser } from "./can-create-user";
|
||||
export { default as canListUsers } from "./can-list-users";
|
||||
export { default as canManageUserTypes } from "./can-manage-user-types";
|
||||
export { default as canRevokeUser } from "./can-revoke-user";
|
||||
export { default as canUpdateUser } from "./can-update-user";
|
||||
export { default as filterAssignableUserTypes } from "./filter-assignable-user-types";
|
||||
export { default as isPassThroughUser } from "./is-pass-through-user";
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { BUN_SQLITE_BSQLITE_USER_TYPES } from "@/db/types/db";
|
||||
|
||||
type Params = {
|
||||
user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pass-through actors that bypass the assignment matrix: `super_admin`.
|
||||
*/
|
||||
export default function isPassThroughUser({ user_types }: Params): boolean {
|
||||
return Boolean(
|
||||
user_types?.find((ut) => ut.user_type === "super_admin"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { BUN_SQLITE_BSQLITE_USER_TYPES } from "@/db/types/db";
|
||||
|
||||
type Params = {
|
||||
user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether this is a `read_only` user
|
||||
*/
|
||||
export default function isReadOnlyUser({ user_types }: Params): boolean {
|
||||
return Boolean(user_types?.find((ty) => ty.user_type == "read_only"));
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type {
|
||||
UserType,
|
||||
UserTypeAssignmentMatrixType,
|
||||
} from "@/src/types";
|
||||
|
||||
/**
|
||||
* Synthetic types (`orphan`, `board_member`) exercise the matrix interpreter
|
||||
* independently of the preset's real `UserTypes`.
|
||||
*/
|
||||
export const ALL_USER_TYPES = [
|
||||
"super_admin",
|
||||
"admin",
|
||||
"auditor",
|
||||
"president",
|
||||
"vice_president",
|
||||
"secretary",
|
||||
"orphan",
|
||||
"revoked",
|
||||
"read_only",
|
||||
"board_member",
|
||||
] as UserType[];
|
||||
|
||||
/**
|
||||
* Matrix keyed by any type name, cast to the real matrix type for the
|
||||
* interpreter.
|
||||
*/
|
||||
type FixtureMatrix = Record<string, any>;
|
||||
|
||||
type UserTypesInput = string[];
|
||||
|
||||
export function makeUserTypes(...types: UserTypesInput): { user_type: string }[] {
|
||||
return types.map((user_type) => ({ user_type }));
|
||||
}
|
||||
|
||||
export function makeMatrix(
|
||||
overrides?: FixtureMatrix,
|
||||
): UserTypeAssignmentMatrixType {
|
||||
const base: FixtureMatrix = {
|
||||
super_admin: {},
|
||||
admin: { allowed_actors: [] },
|
||||
president: { allowed_actors: [] },
|
||||
vice_president: { allowed_actors: [] },
|
||||
secretary: { allowed_actors: [] },
|
||||
board_member: { allowed_actors: ["admin"] },
|
||||
auditor: { allowed_actors: ["admin"] },
|
||||
orphan: { allowed_actors: ["admin"] },
|
||||
read_only: {
|
||||
allowed_actors: {
|
||||
admin: { excluded_targets: ["super_admin"] },
|
||||
},
|
||||
},
|
||||
revoked: {
|
||||
allowed_actors: {
|
||||
admin: {
|
||||
allowed_targets: ["board_member", "auditor", "orphan"],
|
||||
excluded_targets: [
|
||||
"super_admin",
|
||||
"admin",
|
||||
"president",
|
||||
"vice_president",
|
||||
"secretary",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...base,
|
||||
...overrides,
|
||||
} as UserTypeAssignmentMatrixType;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { UserType } from "@/src/types";
|
||||
import userTypeAssignment from "./user-type-assignment";
|
||||
import {
|
||||
ALL_USER_TYPES,
|
||||
makeMatrix,
|
||||
makeUserTypes,
|
||||
} from "./user-type-assignment.test-fixtures";
|
||||
|
||||
const ASSIGNMENT_MATRIX = makeMatrix();
|
||||
const DELETION_MATRIX = makeMatrix();
|
||||
|
||||
function assign(params: {
|
||||
actor: string[];
|
||||
target_type: string;
|
||||
target?: string[];
|
||||
action?: "assign" | "delete";
|
||||
}) {
|
||||
return userTypeAssignment({
|
||||
actor_user_types: makeUserTypes(...params.actor),
|
||||
target_user_type: params.target_type as UserType,
|
||||
target_user_types: params.target as UserType[],
|
||||
action: params.action || "assign",
|
||||
assignment_matrix: ASSIGNMENT_MATRIX,
|
||||
deletion_matrix: DELETION_MATRIX,
|
||||
});
|
||||
}
|
||||
|
||||
describe("userTypeAssignment", () => {
|
||||
test("pass-through (super_admin) can assign anything", () => {
|
||||
for (const target of ALL_USER_TYPES) {
|
||||
expect(
|
||||
assign({ actor: ["super_admin"], target_type: target }),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("pass-through (super_admin) can delete anything", () => {
|
||||
for (const target of ALL_USER_TYPES) {
|
||||
expect(
|
||||
assign({
|
||||
actor: ["super_admin"],
|
||||
target_type: target,
|
||||
action: "delete",
|
||||
}),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("no actors allowed for super_admin", () => {
|
||||
expect(
|
||||
assign({ actor: ["admin"], target_type: "super_admin" }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
assign({
|
||||
actor: ["admin"],
|
||||
target_type: "super_admin",
|
||||
action: "delete",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("empty allowed_actors means pass-through only", () => {
|
||||
for (const target of ["admin", "president", "vice_president", "secretary"]) {
|
||||
expect(assign({ actor: ["admin"], target_type: target })).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("array allowed_actors matches any actor", () => {
|
||||
expect(assign({ actor: ["admin"], target_type: "board_member" })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(assign({ actor: ["auditor"], target_type: "board_member" })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(assign({ actor: ["admin"], target_type: "auditor" })).toBe(true);
|
||||
});
|
||||
|
||||
test("per-actor rules respect excluded targets", () => {
|
||||
expect(
|
||||
assign({
|
||||
actor: ["admin"],
|
||||
target_type: "read_only",
|
||||
target: ["admin"],
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
assign({
|
||||
actor: ["admin"],
|
||||
target_type: "read_only",
|
||||
target: ["super_admin"],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("per-actor rules respect allowed targets", () => {
|
||||
expect(
|
||||
assign({
|
||||
actor: ["admin"],
|
||||
target_type: "revoked",
|
||||
target: ["board_member"],
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
assign({
|
||||
actor: ["admin"],
|
||||
target_type: "revoked",
|
||||
target: ["admin"],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("missing target user types fail allowed_targets checks", () => {
|
||||
expect(
|
||||
assign({
|
||||
actor: ["admin"],
|
||||
target_type: "revoked",
|
||||
target: [],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("same_as clones rules of another actor", () => {
|
||||
const matrix = makeMatrix({
|
||||
read_only: {
|
||||
allowed_actors: {
|
||||
admin: { excluded_targets: ["super_admin"] },
|
||||
auditor: { same_as: "admin" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
userTypeAssignment({
|
||||
actor_user_types: makeUserTypes("auditor"),
|
||||
target_user_type: "read_only",
|
||||
target_user_types: ["admin"] as UserType[],
|
||||
action: "assign",
|
||||
assignment_matrix: matrix,
|
||||
deletion_matrix: matrix,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
userTypeAssignment({
|
||||
actor_user_types: makeUserTypes("auditor"),
|
||||
target_user_type: "read_only",
|
||||
target_user_types: ["super_admin"] as UserType[],
|
||||
action: "assign",
|
||||
assignment_matrix: matrix,
|
||||
deletion_matrix: matrix,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("all flag grants unconditional access", () => {
|
||||
const matrix = makeMatrix({
|
||||
board_member: {
|
||||
allowed_actors: {
|
||||
admin: { all: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
userTypeAssignment({
|
||||
actor_user_types: makeUserTypes("admin"),
|
||||
target_user_type: "board_member" as UserType,
|
||||
target_user_types: ["super_admin"] as UserType[],
|
||||
action: "assign",
|
||||
assignment_matrix: matrix,
|
||||
deletion_matrix: matrix,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
describe("unknown user types", () => {
|
||||
test("types missing from the matrix are denied instead of throwing", () => {
|
||||
expect(
|
||||
userTypeAssignment({
|
||||
actor_user_types: makeUserTypes("admin"),
|
||||
target_user_type: "not_in_matrix" as UserType,
|
||||
action: "assign",
|
||||
assignment_matrix: makeMatrix(),
|
||||
deletion_matrix: makeMatrix(),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import type {
|
||||
UserAssignmentFnParams,
|
||||
UserTypeAssignmentRules,
|
||||
} from "@/src/types";
|
||||
import {
|
||||
UserTypeAssignmentMatrix,
|
||||
UserTypeDeletionMatrix,
|
||||
} from "@/src/dict/user-types-dict";
|
||||
import isPassThroughUser from "./is-pass-through-user";
|
||||
|
||||
/**
|
||||
* Whether the actor may assign or remove `target_user_type` on a user.
|
||||
* Governed by `UserTypeAssignmentMatrix` and `UserTypeDeletionMatrix` and
|
||||
* applied symmetrically to granting and removing. Pass-through actors
|
||||
* (`super_admin`) can always assign.
|
||||
*/
|
||||
export default function userTypeAssignment({
|
||||
actor_user_types,
|
||||
target_user_type,
|
||||
target_user_types,
|
||||
action,
|
||||
assignment_matrix = UserTypeAssignmentMatrix,
|
||||
deletion_matrix = UserTypeDeletionMatrix,
|
||||
debug,
|
||||
}: UserAssignmentFnParams): boolean {
|
||||
if (isPassThroughUser({ user_types: actor_user_types })) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[RBAC] Pass-through actor detected for [${target_user_type}], access granted.`,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const matrix = action == "assign" ? assignment_matrix : deletion_matrix;
|
||||
|
||||
const assignable_by = matrix[target_user_type];
|
||||
|
||||
if (!assignable_by?.allowed_actors) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[RBAC] No allowed actors for [${target_user_type}] in the ${action} matrix.`,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const is_target_user_type_excluded =
|
||||
target_user_types && assignable_by.excluded_targets
|
||||
? target_user_types.find((tut) =>
|
||||
assignable_by.excluded_targets?.find((ex) => ex == tut),
|
||||
)
|
||||
: false;
|
||||
|
||||
if (is_target_user_type_excluded) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[RBAC] Target user type [${target_user_types?.join(",")}] excluded for [${target_user_type}].`,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const actor_type_set = new Set<string>(
|
||||
actor_user_types
|
||||
?.map((ut) => ut.user_type)
|
||||
.filter((ut): ut is string => Boolean(ut)),
|
||||
);
|
||||
|
||||
if (Array.isArray(assignable_by.allowed_actors)) {
|
||||
if (!assignable_by.allowed_actors.length) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[RBAC] Actor not allowed for [${target_user_type}], allowed actors list is empty.`,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return assignable_by.allowed_actors.some((actor_type) =>
|
||||
actor_type_set.has(actor_type),
|
||||
);
|
||||
}
|
||||
|
||||
const allowed_actors_matrix = assignable_by.allowed_actors as Record<
|
||||
string,
|
||||
UserTypeAssignmentRules | undefined
|
||||
>;
|
||||
|
||||
if (!Object.keys(allowed_actors_matrix).length) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[RBAC] No allowed actors matrix for [${target_user_type}].`,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return Array.from(actor_type_set).some((actor_type) => {
|
||||
let actor_rules = allowed_actors_matrix[actor_type];
|
||||
|
||||
if (
|
||||
actor_rules?.same_as &&
|
||||
allowed_actors_matrix[actor_rules.same_as]
|
||||
) {
|
||||
actor_rules = {
|
||||
...actor_rules,
|
||||
...allowed_actors_matrix[actor_rules.same_as],
|
||||
};
|
||||
}
|
||||
|
||||
if (actor_rules?.all) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[RBAC] Actor [${actor_type}] has all access for [${target_user_type}].`,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!actor_rules?.allowed_targets && !actor_rules?.excluded_targets) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[RBAC] No actor rules for [${actor_type}] on [${target_user_type}].`,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
actor_rules.allowed_targets &&
|
||||
!actor_rules.allowed_targets.find((t) =>
|
||||
target_user_types?.includes(t),
|
||||
)
|
||||
) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[RBAC] Target user types [${target_user_types?.join(",")}] not included for [${actor_type}] -> [${target_user_type}].`,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
actor_rules.excluded_targets &&
|
||||
actor_rules.excluded_targets.find((t) =>
|
||||
target_user_types?.includes(t),
|
||||
)
|
||||
) {
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[RBAC] Target user types [${target_user_types?.join(",")}] excluded for [${actor_type}] -> [${target_user_type}].`,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
console.log(
|
||||
`[RBAC] Actor [${actor_type}] granted [${action}] of [${target_user_type}].`,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { safeCompare } from "./safe-compare";
|
||||
|
||||
describe("safeCompare", () => {
|
||||
test("returns true for equal strings", () => {
|
||||
expect(safeCompare("abc", "abc")).toBe(true);
|
||||
expect(safeCompare("", "")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for different strings", () => {
|
||||
expect(safeCompare("abc", "abd")).toBe(false);
|
||||
expect(safeCompare("abc", "abc ")).toBe(false);
|
||||
expect(safeCompare("abc", "")).toBe(false);
|
||||
});
|
||||
|
||||
test("is length-independent", () => {
|
||||
expect(safeCompare("a", "b")).toBe(false);
|
||||
expect(safeCompare("short", "longer-than-short")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createHash, timingSafeEqual } from "node:crypto";
|
||||
|
||||
export function safeCompare(a: string, b: string): boolean {
|
||||
const ah = createHash("sha256").update(a).digest();
|
||||
const bh = createHash("sha256").update(b).digest();
|
||||
return timingSafeEqual(ah, bh);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import AppError from "./app-error";
|
||||
import safeErrorMessage from "./safe-error-message";
|
||||
|
||||
describe("safeErrorMessage", () => {
|
||||
test("returns the message of an AppError", () => {
|
||||
expect(
|
||||
safeErrorMessage({ error: new AppError("Please try again") }),
|
||||
).toBe("Please try again");
|
||||
});
|
||||
|
||||
test("hides the message of a plain Error", () => {
|
||||
expect(
|
||||
safeErrorMessage({
|
||||
error: new Error("SQLITE_CONSTRAINT: users.email"),
|
||||
}),
|
||||
).toBe("Something went wrong. Please try again.");
|
||||
});
|
||||
|
||||
test("hides non-Error values", () => {
|
||||
expect(safeErrorMessage({ error: "boom" })).toBe(
|
||||
"Something went wrong. Please try again.",
|
||||
);
|
||||
});
|
||||
|
||||
test("supports a custom fallback", () => {
|
||||
expect(
|
||||
safeErrorMessage({
|
||||
error: new Error("internal"),
|
||||
fallback: "Request failed",
|
||||
}),
|
||||
).toBe("Request failed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import AppError from "./app-error";
|
||||
|
||||
type Params = {
|
||||
error: unknown;
|
||||
fallback?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Safe Error Message
|
||||
* Returns the message of an `AppError` (deliberate, user-facing) and a
|
||||
* generic fallback for everything else, so internal details (SQL errors,
|
||||
* file paths, stack messages) never reach the client.
|
||||
*/
|
||||
export default function safeErrorMessage({
|
||||
error,
|
||||
fallback = "Something went wrong. Please try again.",
|
||||
}: Params): string {
|
||||
if (error instanceof AppError) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import sanitizeRichTextHtml from "./sanitize-rich-text-html";
|
||||
|
||||
describe("sanitizeRichTextHtml", () => {
|
||||
test("returns an empty string for empty input", () => {
|
||||
expect(sanitizeRichTextHtml({ html: "" })).toBe("");
|
||||
expect(sanitizeRichTextHtml({ html: null })).toBe("");
|
||||
expect(sanitizeRichTextHtml({})).toBe("");
|
||||
});
|
||||
|
||||
test("keeps safe formatting", () => {
|
||||
expect(
|
||||
sanitizeRichTextHtml({
|
||||
html: "<p>Hello <strong>world</strong></p>",
|
||||
}),
|
||||
).toBe("<p>Hello <strong>world</strong></p>");
|
||||
});
|
||||
|
||||
test("strips script tags and their contents", () => {
|
||||
const clean = sanitizeRichTextHtml({
|
||||
html: `<p>Hi</p><script>alert("xss")</script>`,
|
||||
});
|
||||
|
||||
expect(clean).toBe("<p>Hi</p>");
|
||||
expect(clean).not.toContain("script");
|
||||
});
|
||||
|
||||
test("strips event handler attributes", () => {
|
||||
const clean = sanitizeRichTextHtml({
|
||||
html: `<p onclick="alert(1)">Hi</p><img src="x" onerror="alert(1)">`,
|
||||
});
|
||||
|
||||
expect(clean).not.toContain("onclick");
|
||||
expect(clean).not.toContain("onerror");
|
||||
});
|
||||
|
||||
test("strips unsafe URL schemes, including encoded ones", () => {
|
||||
const clean = sanitizeRichTextHtml({
|
||||
html: [
|
||||
`<a href="javascript:alert(1)">a</a>`,
|
||||
`<a href="javascript:alert(1)">b</a>`,
|
||||
`<a href="data:text/html,<script>alert(1)</script>">c</a>`,
|
||||
].join(""),
|
||||
});
|
||||
|
||||
expect(clean).not.toContain("javascript");
|
||||
expect(clean).not.toContain("data:");
|
||||
});
|
||||
|
||||
test("strips iframes and forms", () => {
|
||||
const clean = sanitizeRichTextHtml({
|
||||
html: `<iframe src="https://example.com"></iframe><form><input></form><p>ok</p>`,
|
||||
});
|
||||
|
||||
expect(clean).toBe("<p>ok</p>");
|
||||
});
|
||||
|
||||
test("adds rel=noopener to links", () => {
|
||||
const clean = sanitizeRichTextHtml({
|
||||
html: `<a href="https://example.com" target="_blank">x</a>`,
|
||||
});
|
||||
|
||||
expect(clean).toContain(`rel="noopener noreferrer"`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
|
||||
/**
|
||||
* Tags allowed in admin-authored rich text (TinyMCE output).
|
||||
*/
|
||||
const AllowedTags = [
|
||||
"p",
|
||||
"br",
|
||||
"hr",
|
||||
"strong",
|
||||
"b",
|
||||
"em",
|
||||
"i",
|
||||
"u",
|
||||
"s",
|
||||
"sub",
|
||||
"sup",
|
||||
"span",
|
||||
"ul",
|
||||
"ol",
|
||||
"li",
|
||||
"blockquote",
|
||||
"code",
|
||||
"pre",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"a",
|
||||
"img",
|
||||
"table",
|
||||
"thead",
|
||||
"tbody",
|
||||
"tfoot",
|
||||
"tr",
|
||||
"th",
|
||||
"td",
|
||||
"caption",
|
||||
"colgroup",
|
||||
"col",
|
||||
];
|
||||
|
||||
const AllowedAttributes: sanitizeHtml.IOptions["allowedAttributes"] = {
|
||||
a: ["href", "target", "rel", "title"],
|
||||
img: ["src", "alt", "title", "width", "height"],
|
||||
"*": ["class"],
|
||||
};
|
||||
|
||||
type Params = {
|
||||
html?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Sanitize rich text HTML.
|
||||
* Strips scripts, event handlers, embedded frames and unsafe URL schemes
|
||||
* while preserving the formatting TinyMCE produces. Use it on every write
|
||||
* to an `html: true` column that accepts user input.
|
||||
*/
|
||||
export default function sanitizeRichTextHtml({ html }: Params): string {
|
||||
if (!html) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return sanitizeHtml(html, {
|
||||
allowedTags: AllowedTags,
|
||||
allowedAttributes: AllowedAttributes,
|
||||
allowedSchemes: ["http", "https", "mailto"],
|
||||
// TinyMCE can embed pasted images as data URLs.
|
||||
allowedSchemesByTag: {
|
||||
img: ["http", "https", "data"],
|
||||
},
|
||||
transformTags: {
|
||||
a: sanitizeHtml.simpleTransform("a", {
|
||||
rel: "noopener noreferrer",
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { ApiReqParams } from "../types";
|
||||
import type { BUN_SQLITE_BSQLITE_USERS_JOIN } from "../types/sql-joins";
|
||||
|
||||
export default function searchableFieldToQueryField({
|
||||
field,
|
||||
}: {
|
||||
field: keyof BUN_SQLITE_BSQLITE_USERS_JOIN;
|
||||
}): keyof ApiReqParams {
|
||||
switch (field) {
|
||||
case "email":
|
||||
return "search_term_email";
|
||||
case "last_name":
|
||||
return "search_term_last_name";
|
||||
case "user_types":
|
||||
return "search_term_user_type";
|
||||
case "first_name":
|
||||
default:
|
||||
return "search_term_first_name";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import textToSafeHtml from "./text-to-html";
|
||||
|
||||
test("textToSafeHtml passes HTML through unchanged", () => {
|
||||
const html = `<p>Payment may be split: <strong>30% deposit</strong>.</p>`;
|
||||
expect(textToSafeHtml(html)).toBe(html);
|
||||
});
|
||||
|
||||
test("textToSafeHtml converts legacy plain-text line breaks", () => {
|
||||
expect(textToSafeHtml("Line one\nLine two")).toBe(
|
||||
"Line one<br />Line two",
|
||||
);
|
||||
expect(textToSafeHtml("A\r\nB\r\nC")).toBe("A<br />B<br />C");
|
||||
});
|
||||
|
||||
test("textToSafeHtml handles empty and plain single-line input", () => {
|
||||
expect(textToSafeHtml("")).toBe("");
|
||||
expect(textToSafeHtml("Just a note")).toBe("Just a note");
|
||||
expect(textToSafeHtml(undefined)).toBe("");
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* # Convert a stored text field to an HTML fragment for rendering.
|
||||
* Documents that switched from plain text to rich HTML (e.g. invoice/quote
|
||||
* notes) may still hold legacy plain-text rows — those get their line breaks
|
||||
* converted to `<br />` so they render like they used to.
|
||||
*/
|
||||
export default function textToSafeHtml(text?: string): string {
|
||||
const value = text || "";
|
||||
|
||||
if (/<[a-z][^>]*>/i.test(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return value.replace(/\r?\n/g, "<br />");
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function uniqueStringArr(arr: string[]): string[] {
|
||||
return [...new Set(arr)];
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import validatePassword from "./validate-password";
|
||||
|
||||
describe("validatePassword", () => {
|
||||
test("rejects an empty password", () => {
|
||||
expect(validatePassword({ password: "" })).toBeString();
|
||||
expect(validatePassword({})).toBeString();
|
||||
});
|
||||
|
||||
test("rejects passwords shorter than the minimum", () => {
|
||||
const password = "a".repeat(AppData["PasswordMinLength"] - 1);
|
||||
expect(validatePassword({ password })).toContain("at least");
|
||||
});
|
||||
|
||||
test("rejects passwords longer than the maximum", () => {
|
||||
const password = "a".repeat(AppData["PasswordMaxLength"] + 1);
|
||||
expect(validatePassword({ password })).toContain("longer than");
|
||||
});
|
||||
|
||||
test("rejects whitespace-only passwords", () => {
|
||||
const password = " ".repeat(AppData["PasswordMinLength"]);
|
||||
expect(validatePassword({ password })).toContain("spaces");
|
||||
});
|
||||
|
||||
test("accepts a password at the minimum length", () => {
|
||||
const password = "a".repeat(AppData["PasswordMinLength"]);
|
||||
expect(validatePassword({ password })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
|
||||
type Params = {
|
||||
password?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a user-facing error message, or `undefined` when the
|
||||
* password is acceptable.
|
||||
*/
|
||||
export default function validatePassword({ password }: Params) {
|
||||
if (!password) {
|
||||
return `Please enter a password`;
|
||||
}
|
||||
|
||||
if (password.length < AppData["PasswordMinLength"]) {
|
||||
return `Passwords must be at least ${AppData["PasswordMinLength"]} characters`;
|
||||
}
|
||||
|
||||
if (password.length > AppData["PasswordMaxLength"]) {
|
||||
return `Passwords can't be longer than ${AppData["PasswordMaxLength"]} characters`;
|
||||
}
|
||||
|
||||
if (!password.trim()) {
|
||||
return `Passwords can't be only spaces`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user