feat: add registration and authentication
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { Pool, PoolConfig, QueryResult, QueryResultRow } from "pg";
|
||||
import crypto from "crypto";
|
||||
import { config } from "./config";
|
||||
import type { RaceRow } from "./mappers/race";
|
||||
|
||||
@@ -19,6 +20,8 @@ function mockRowFromInsert(sql: string, params: unknown[]): RaceRow {
|
||||
if (!match) {
|
||||
return {
|
||||
id: String(params[0] ?? ""),
|
||||
slug: String(params[0] ?? ""),
|
||||
owner_user_id: null,
|
||||
race_date: "",
|
||||
title: "",
|
||||
distance_km: "0",
|
||||
@@ -42,7 +45,9 @@ function mockRowFromInsert(sql: string, params: unknown[]): RaceRow {
|
||||
row[col] = params[i];
|
||||
});
|
||||
return {
|
||||
id: String(row.id ?? ""),
|
||||
id: String(row.id ?? crypto.randomUUID()),
|
||||
slug: String(row.slug ?? row.id ?? ""),
|
||||
owner_user_id: row.owner_user_id != null ? String(row.owner_user_id) : null,
|
||||
race_date: String(row.race_date ?? ""),
|
||||
title: String(row.title ?? ""),
|
||||
distance_km: String(row.distance_km ?? "0"),
|
||||
@@ -72,6 +77,14 @@ function createMockPool(): Pool {
|
||||
}) as QueryResult<T>;
|
||||
|
||||
const store = new Map<string, RaceRow>();
|
||||
const users = new Map<string, any>();
|
||||
const sessions = new Map<string, any>();
|
||||
const verificationTokens = new Map<string, any>();
|
||||
const resetTokens = new Map<string, any>();
|
||||
const appSettings = new Map<string, string>();
|
||||
|
||||
const result = <T extends QueryResultRow>(rows: T[], command = "SELECT"): QueryResult<T> =>
|
||||
({ rows, rowCount: rows.length, command, oid: 0, fields: [] }) as QueryResult<T>;
|
||||
|
||||
const mockQuery = async <T extends QueryResultRow>(
|
||||
text: string,
|
||||
@@ -80,8 +93,217 @@ function createMockPool(): Pool {
|
||||
const sql = text.replace(/\s+/g, " ").trim();
|
||||
const p = params ?? [];
|
||||
|
||||
if (sql === "BEGIN" || sql === "COMMIT" || sql === "ROLLBACK" || sql.includes("pg_advisory_xact_lock")) {
|
||||
return result<T>([]);
|
||||
}
|
||||
|
||||
if (sql.includes("SELECT COUNT(*)::text AS count FROM users")) {
|
||||
return result([{ count: String(users.size) } as unknown as T]);
|
||||
}
|
||||
|
||||
if (sql.includes("SELECT id FROM users WHERE LOWER(BTRIM(email))")) {
|
||||
const email = String(p[0] ?? "");
|
||||
const user = Array.from(users.values()).find((item) => item.email.trim().toLowerCase() === email);
|
||||
return user ? result([user as T]) : emptyResult();
|
||||
}
|
||||
|
||||
if (sql.includes("SELECT id, email, password_hash, email_verified_at FROM users WHERE LOWER(BTRIM(email))")) {
|
||||
const email = String(p[0] ?? "");
|
||||
const user = Array.from(users.values()).find((item) => item.email.trim().toLowerCase() === email);
|
||||
return user ? result([user as T]) : emptyResult();
|
||||
}
|
||||
|
||||
if (sql.includes("INSERT INTO users")) {
|
||||
const id = crypto.randomUUID();
|
||||
const row = {
|
||||
id,
|
||||
email: String(p[0] ?? ""),
|
||||
password_hash: String(p[1] ?? ""),
|
||||
email_verified_at: null,
|
||||
created_at: new Date(),
|
||||
updated_at: null,
|
||||
};
|
||||
users.set(id, row);
|
||||
return result([row as unknown as T], "INSERT");
|
||||
}
|
||||
|
||||
if (sql.includes("INSERT INTO email_verification_tokens")) {
|
||||
const id = crypto.randomUUID();
|
||||
const row = {
|
||||
id,
|
||||
user_id: String(p[0] ?? ""),
|
||||
token_hash: String(p[1] ?? ""),
|
||||
expires_at: p[2] ?? new Date(),
|
||||
used_at: null,
|
||||
created_at: new Date(),
|
||||
};
|
||||
verificationTokens.set(id, row);
|
||||
return result([row as unknown as T], "INSERT");
|
||||
}
|
||||
|
||||
if (sql.includes("UPDATE email_verification_tokens SET used_at = NOW() WHERE user_id")) {
|
||||
const userId = String(p[0] ?? "");
|
||||
for (const row of verificationTokens.values()) {
|
||||
if (row.user_id === userId && row.used_at == null) {
|
||||
row.used_at = new Date();
|
||||
}
|
||||
}
|
||||
return result<T>([], "UPDATE");
|
||||
}
|
||||
|
||||
if (sql.includes("FROM email_verification_tokens") && sql.includes("WHERE used_at IS NULL")) {
|
||||
const now = Date.now();
|
||||
return result(
|
||||
Array.from(verificationTokens.values()).filter((row) => !row.used_at && new Date(row.expires_at).getTime() > now) as T[],
|
||||
);
|
||||
}
|
||||
|
||||
if (sql.includes("INSERT INTO password_reset_tokens")) {
|
||||
const id = crypto.randomUUID();
|
||||
const row = {
|
||||
id,
|
||||
user_id: String(p[0] ?? ""),
|
||||
token_hash: String(p[1] ?? ""),
|
||||
expires_at: p[2] ?? new Date(),
|
||||
used_at: null,
|
||||
created_at: new Date(),
|
||||
};
|
||||
resetTokens.set(id, row);
|
||||
return result([row as unknown as T], "INSERT");
|
||||
}
|
||||
|
||||
if (sql.includes("UPDATE password_reset_tokens SET used_at = NOW() WHERE user_id")) {
|
||||
const userId = String(p[0] ?? "");
|
||||
for (const row of resetTokens.values()) {
|
||||
if (row.user_id === userId && row.used_at == null) {
|
||||
row.used_at = new Date();
|
||||
}
|
||||
}
|
||||
return result<T>([], "UPDATE");
|
||||
}
|
||||
|
||||
if (sql.includes("FROM password_reset_tokens") && sql.includes("WHERE used_at IS NULL")) {
|
||||
const now = Date.now();
|
||||
return result(
|
||||
Array.from(resetTokens.values()).filter((row) => !row.used_at && new Date(row.expires_at).getTime() > now) as T[],
|
||||
);
|
||||
}
|
||||
|
||||
if (sql.includes("UPDATE users SET email_verified_at")) {
|
||||
const user = users.get(String(p[0] ?? ""));
|
||||
if (user) {
|
||||
user.email_verified_at = user.email_verified_at ?? new Date();
|
||||
user.updated_at = new Date();
|
||||
}
|
||||
return result<T>([], "UPDATE");
|
||||
}
|
||||
|
||||
if (sql.includes("UPDATE users SET password_hash")) {
|
||||
const user = users.get(String(p[0] ?? ""));
|
||||
if (user) {
|
||||
user.password_hash = String(p[1] ?? "");
|
||||
user.updated_at = new Date();
|
||||
}
|
||||
return result<T>([], "UPDATE");
|
||||
}
|
||||
|
||||
if (sql.includes("INSERT INTO sessions")) {
|
||||
const id = crypto.randomUUID();
|
||||
const user = users.get(String(p[0] ?? ""));
|
||||
const row = {
|
||||
id,
|
||||
user_id: String(p[0] ?? ""),
|
||||
token_hash: String(p[1] ?? ""),
|
||||
csrf_token_hash: String(p[2] ?? ""),
|
||||
expires_at: p[3] ?? new Date(),
|
||||
email: user?.email ?? "",
|
||||
email_verified_at: user?.email_verified_at ?? null,
|
||||
revoked_at: null,
|
||||
created_at: new Date(),
|
||||
last_seen_at: new Date(),
|
||||
};
|
||||
sessions.set(id, row);
|
||||
return result([row as unknown as T], "INSERT");
|
||||
}
|
||||
|
||||
if (sql.includes("FROM sessions s JOIN users u")) {
|
||||
const tokenHash = String(p[0] ?? "");
|
||||
const now = Date.now();
|
||||
const row = Array.from(sessions.values()).find(
|
||||
(item) => item.token_hash === tokenHash && !item.revoked_at && new Date(item.expires_at).getTime() > now,
|
||||
);
|
||||
if (!row) {
|
||||
return emptyResult();
|
||||
}
|
||||
const user = users.get(row.user_id);
|
||||
return result([{ ...row, email: user?.email ?? "", email_verified_at: user?.email_verified_at ?? null } as unknown as T]);
|
||||
}
|
||||
|
||||
if (sql.includes("UPDATE sessions SET csrf_token_hash")) {
|
||||
const tokenHash = String(p[0] ?? "");
|
||||
const row = Array.from(sessions.values()).find((item) => item.token_hash === tokenHash && !item.revoked_at);
|
||||
if (row) {
|
||||
row.csrf_token_hash = String(p[1] ?? "");
|
||||
row.last_seen_at = new Date();
|
||||
}
|
||||
return result<T>(row ? ([{} as unknown as T]) : [], "UPDATE");
|
||||
}
|
||||
|
||||
if (sql.includes("UPDATE sessions SET last_seen_at")) {
|
||||
return result<T>([], "UPDATE");
|
||||
}
|
||||
|
||||
if (sql.includes("UPDATE sessions SET revoked_at = NOW() WHERE token_hash")) {
|
||||
const tokenHash = String(p[0] ?? "");
|
||||
for (const row of sessions.values()) {
|
||||
if (row.token_hash === tokenHash) {
|
||||
row.revoked_at = new Date();
|
||||
}
|
||||
}
|
||||
return result<T>([], "UPDATE");
|
||||
}
|
||||
|
||||
if (sql.includes("UPDATE sessions SET revoked_at = NOW() WHERE user_id")) {
|
||||
const userId = String(p[0] ?? "");
|
||||
for (const row of sessions.values()) {
|
||||
if (row.user_id === userId && !row.revoked_at) {
|
||||
row.revoked_at = new Date();
|
||||
}
|
||||
}
|
||||
return result<T>([], "UPDATE");
|
||||
}
|
||||
|
||||
if (sql.includes("SELECT value FROM app_settings")) {
|
||||
const value = appSettings.get("orphan_races_claimed_by_user_id");
|
||||
return value ? result([{ value } as unknown as T]) : emptyResult();
|
||||
}
|
||||
|
||||
if (sql.includes("INSERT INTO app_settings")) {
|
||||
appSettings.set("orphan_races_claimed_by_user_id", String(p[0] ?? ""));
|
||||
return result<T>([], "INSERT");
|
||||
}
|
||||
|
||||
if (sql.includes("UPDATE races SET owner_user_id")) {
|
||||
const userId = String(p[0] ?? "");
|
||||
for (const race of store.values()) {
|
||||
if (!race.owner_user_id) {
|
||||
race.owner_user_id = userId;
|
||||
race.updated_at = new Date();
|
||||
}
|
||||
}
|
||||
return result<T>([], "UPDATE");
|
||||
}
|
||||
|
||||
if (sql.includes("INSERT INTO races") && sql.includes("RETURNING")) {
|
||||
const row = mockRowFromInsert(text, p);
|
||||
const conflict = Array.from(store.values()).find(
|
||||
(item) => item.owner_user_id && item.owner_user_id === row.owner_user_id && item.slug === row.slug,
|
||||
);
|
||||
if (conflict) {
|
||||
const err = new Error("duplicate key") as Error & { code?: string };
|
||||
err.code = "23505";
|
||||
throw err;
|
||||
}
|
||||
store.set(row.id, row);
|
||||
return {
|
||||
rows: [row as unknown as T],
|
||||
@@ -94,7 +316,12 @@ function createMockPool(): Pool {
|
||||
|
||||
if (sql.includes("DELETE FROM races")) {
|
||||
const id = String(p[0] ?? "");
|
||||
const existed = store.delete(id);
|
||||
const ownerId = p[1] != null ? String(p[1]) : null;
|
||||
const existing = store.get(id);
|
||||
const existed = Boolean(existing && (!ownerId || existing.owner_user_id === ownerId));
|
||||
if (existed) {
|
||||
store.delete(id);
|
||||
}
|
||||
return {
|
||||
rows: [],
|
||||
rowCount: existed ? 1 : 0,
|
||||
@@ -105,9 +332,10 @@ function createMockPool(): Pool {
|
||||
}
|
||||
|
||||
if (sql.includes("UPDATE races") && sql.includes("RETURNING")) {
|
||||
const id = String(p[p.length - 1] ?? "");
|
||||
const id = String(p[p.length - 2] ?? p[p.length - 1] ?? "");
|
||||
const ownerId = p[p.length - 1] != null ? String(p[p.length - 1]) : null;
|
||||
const existing = store.get(id);
|
||||
if (!existing) {
|
||||
if (!existing || (ownerId && existing.owner_user_id !== ownerId)) {
|
||||
return emptyResult();
|
||||
}
|
||||
const setMatch = sql.match(/UPDATE races SET (.+) WHERE id =/);
|
||||
@@ -135,14 +363,18 @@ function createMockPool(): Pool {
|
||||
|
||||
if (sql.includes("SELECT * FROM races WHERE id =")) {
|
||||
const id = String(p[0] ?? "");
|
||||
const ownerId = p[1] != null ? String(p[1]) : null;
|
||||
const row = store.get(id);
|
||||
return row
|
||||
return row && (!ownerId || row.owner_user_id === ownerId)
|
||||
? { rows: [row as unknown as T], rowCount: 1, command: "SELECT", oid: 0, fields: [] } as QueryResult<T>
|
||||
: emptyResult();
|
||||
}
|
||||
|
||||
if (sql.includes("SELECT * FROM races")) {
|
||||
const rows = Array.from(store.values());
|
||||
const ownerParam = p.find((value) => typeof value === "string" && /^[0-9a-f-]{36}$/i.test(value));
|
||||
const rows = ownerParam
|
||||
? Array.from(store.values()).filter((row) => row.owner_user_id === ownerParam)
|
||||
: Array.from(store.values());
|
||||
return { rows: rows as unknown as T[], rowCount: rows.length, command: "SELECT", oid: 0, fields: [] } as QueryResult<T>;
|
||||
}
|
||||
|
||||
@@ -152,9 +384,10 @@ function createMockPool(): Pool {
|
||||
const mockPool = {
|
||||
query: mockQuery,
|
||||
connect: async () => {
|
||||
throw new Error(
|
||||
"CALENDAR_RUN_MOCK_DB is enabled: migrate/seed require a real database; unset CALENDAR_RUN_MOCK_DB and configure DB_*.",
|
||||
);
|
||||
return {
|
||||
query: mockQuery,
|
||||
release() {},
|
||||
};
|
||||
},
|
||||
end: async () => {},
|
||||
on() {
|
||||
|
||||
Reference in New Issue
Block a user