chore: remediate pygienium audit findings
Dead code: 77 verified-unused exports, files (BackArrow, MenuBars, cookies.ts, db/create.ts, schemas/comment.ts, security-headers.ts) and 12 unused dependencies removed Comments: ~370 RESTATE comments stripped across 53 files; 2 verbose blocks tightened; dead commented-out config removed Complexity: bulkUpsert extracted into 11 per-entity helpers (CCN 156->~10); login formHandler split into 3 submitters (CCN 63->~5); account page render split into 8 section components (CCN 40); updatePost SQL builder rebuilt; assert*Owned consolidated behind generic assertOwnedBy Defensive guards: 4 redundant rethrow/nullish guards removed
This commit is contained in:
@@ -117,14 +117,11 @@ function scheduleAnalyticsFlush(): void {
|
||||
*/
|
||||
export async function logVisit(entry: AnalyticsEntry): Promise<void> {
|
||||
try {
|
||||
// Add to buffer
|
||||
analyticsBuffer.entries.push(entry);
|
||||
|
||||
// Flush if batch size reached
|
||||
if (analyticsBuffer.entries.length >= CACHE_CONFIG.ANALYTICS_BATCH_SIZE) {
|
||||
await flushAnalyticsBuffer();
|
||||
} else {
|
||||
// Schedule periodic flush
|
||||
scheduleAnalyticsFlush();
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -422,7 +419,6 @@ export async function getPerformanceStats(days: number = 30): Promise<{
|
||||
}> {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
// Get average metrics
|
||||
const avgResult = await conn.execute({
|
||||
sql: `SELECT
|
||||
AVG(lcp) as avgLcp,
|
||||
@@ -472,7 +468,6 @@ export async function getPerformanceStats(days: number = 30): Promise<{
|
||||
args: []
|
||||
});
|
||||
|
||||
// Get performance by path (only for non-API paths)
|
||||
const byPathResult = await conn.execute({
|
||||
sql: `SELECT
|
||||
path,
|
||||
|
||||
@@ -80,11 +80,9 @@ import {
|
||||
* In development: ctx.event might be H3Event directly
|
||||
*/
|
||||
function getH3Event(ctx: Context): H3Event {
|
||||
// Check if nativeEvent exists (production)
|
||||
if (ctx.event && "nativeEvent" in ctx.event && ctx.event.nativeEvent) {
|
||||
return ctx.event.nativeEvent as H3Event;
|
||||
}
|
||||
// Otherwise, assume ctx.event is H3Event (development)
|
||||
return ctx.event as unknown as H3Event;
|
||||
}
|
||||
|
||||
@@ -245,7 +243,6 @@ export const authRouter = createTRPCRouter({
|
||||
try {
|
||||
await conn.execute({ sql: insertQuery, args: insertParams });
|
||||
|
||||
// Also create UserProvider entry for new user
|
||||
await linkProvider(userId, "github", {
|
||||
providerUserId: login,
|
||||
email: email,
|
||||
@@ -300,7 +297,6 @@ export const authRouter = createTRPCRouter({
|
||||
} catch (error) {
|
||||
console.error("[GitHub Callback] Error during OAuth flow:", error);
|
||||
|
||||
// Log failed OAuth login
|
||||
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||
await logAuditEvent({
|
||||
eventType: "auth.login.failed",
|
||||
@@ -452,7 +448,6 @@ export const authRouter = createTRPCRouter({
|
||||
args: insertParams
|
||||
});
|
||||
|
||||
// Also create UserProvider entry for new user
|
||||
await linkProvider(userId, "google", {
|
||||
providerUserId: email,
|
||||
email: email,
|
||||
@@ -481,7 +476,6 @@ export const authRouter = createTRPCRouter({
|
||||
}
|
||||
}
|
||||
|
||||
// Issue JWT (OAuth defaults to remember me)
|
||||
const event = getH3Event(ctx);
|
||||
const clientIP = getClientIP(event);
|
||||
const userAgent = getUserAgent(event);
|
||||
@@ -631,7 +625,6 @@ export const authRouter = createTRPCRouter({
|
||||
} catch (error) {
|
||||
console.error("[Email Login] Error during login:", error);
|
||||
|
||||
// Log failed email link login
|
||||
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||
await logAuditEvent({
|
||||
eventType: "auth.login.failed",
|
||||
@@ -685,10 +678,7 @@ export const authRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
// Check if there's a valid JWT token with this code
|
||||
// We need to find the token that was generated for this email
|
||||
// Since we can't store tokens in DB efficiently, we'll verify against the cookie
|
||||
// Get the token from cookie (we'll store it when sending email)
|
||||
// Tokens aren't stored in DB; verify the code against the JWT cookie set when the email was sent
|
||||
const storedToken = getCookie(getH3Event(ctx), "emailLoginToken");
|
||||
if (!storedToken) {
|
||||
throw new TRPCError({
|
||||
@@ -697,7 +687,6 @@ export const authRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
// Verify the JWT and check the code
|
||||
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
||||
let payload;
|
||||
try {
|
||||
@@ -756,7 +745,6 @@ export const authRouter = createTRPCRouter({
|
||||
} catch (error) {
|
||||
console.error("[Email Code Login] Error during login:", error);
|
||||
|
||||
// Log failed code login
|
||||
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||
await logAuditEvent({
|
||||
eventType: "auth.login.failed",
|
||||
@@ -808,7 +796,6 @@ export const authRouter = createTRPCRouter({
|
||||
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
// Get user ID for audit log
|
||||
const userRes = await conn.execute({
|
||||
sql: "SELECT id FROM User WHERE email = ?",
|
||||
args: [email]
|
||||
@@ -819,7 +806,6 @@ export const authRouter = createTRPCRouter({
|
||||
const params = [true, email];
|
||||
await conn.execute({ sql: query, args: params });
|
||||
|
||||
// Log successful email verification
|
||||
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||
await logAuditEvent({
|
||||
userId,
|
||||
@@ -835,7 +821,6 @@ export const authRouter = createTRPCRouter({
|
||||
message: "Email verification success, you may close this window"
|
||||
};
|
||||
} catch (error) {
|
||||
// Log failed email verification
|
||||
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||
await logAuditEvent({
|
||||
eventType: "auth.email.verify.complete",
|
||||
@@ -864,7 +849,6 @@ export const authRouter = createTRPCRouter({
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { email, password, passwordConfirmation, rememberMe } = input;
|
||||
|
||||
// Apply rate limiting
|
||||
const clientIP = getClientIP(getH3Event(ctx));
|
||||
await rateLimitRegistration(clientIP, getH3Event(ctx));
|
||||
|
||||
@@ -876,10 +860,8 @@ export const authRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
// Check if email already exists (User table or UserProvider table)
|
||||
const existingUserId = await findUserByEmail(email);
|
||||
if (existingUserId) {
|
||||
// User exists - check if they have a password
|
||||
const conn = ConnectionFactory();
|
||||
const userCheck = await conn.execute({
|
||||
sql: "SELECT password_hash, provider FROM User WHERE id = ?",
|
||||
@@ -916,13 +898,11 @@ export const authRouter = createTRPCRouter({
|
||||
args: [userId, email, passwordHash, "email"]
|
||||
});
|
||||
|
||||
// Create UserProvider entry for email auth
|
||||
await linkProvider(userId, "email", {
|
||||
providerUserId: email,
|
||||
email: email
|
||||
});
|
||||
|
||||
// Issue auth token with client info
|
||||
const event = getH3Event(ctx);
|
||||
const clientIP = getClientIP(event);
|
||||
const userAgent = getUserAgent(event);
|
||||
@@ -930,13 +910,11 @@ export const authRouter = createTRPCRouter({
|
||||
await issueAuthToken({
|
||||
event,
|
||||
userId,
|
||||
rememberMe: rememberMe ?? true
|
||||
rememberMe,
|
||||
});
|
||||
|
||||
// Set CSRF token
|
||||
setCSRFToken(event);
|
||||
|
||||
// Log successful registration
|
||||
await logAuditEvent({
|
||||
userId,
|
||||
eventType: "auth.register.success",
|
||||
@@ -948,7 +926,6 @@ export const authRouter = createTRPCRouter({
|
||||
|
||||
return { success: true, message: "success" };
|
||||
} catch (e) {
|
||||
// Log failed registration
|
||||
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||
await logAuditEvent({
|
||||
eventType: "auth.register.failed",
|
||||
@@ -976,7 +953,6 @@ export const authRouter = createTRPCRouter({
|
||||
try {
|
||||
const { email, password, rememberMe } = input;
|
||||
|
||||
// Apply rate limiting
|
||||
const clientIP = getClientIP(getH3Event(ctx));
|
||||
await rateLimitLogin(email, clientIP, getH3Event(ctx));
|
||||
|
||||
@@ -992,9 +968,7 @@ export const authRouter = createTRPCRouter({
|
||||
const passwordHash = user?.password_hash || null;
|
||||
const passwordMatch = await checkPasswordSafe(password, passwordHash);
|
||||
|
||||
// Check all conditions after password verification
|
||||
if (!user || !passwordHash || !passwordMatch) {
|
||||
// Record failed login attempt if user exists
|
||||
if (user?.id) {
|
||||
const lockoutStatus = await recordFailedLogin(user.id);
|
||||
|
||||
@@ -1032,7 +1006,6 @@ export const authRouter = createTRPCRouter({
|
||||
}
|
||||
}
|
||||
|
||||
// Log failed login attempt
|
||||
try {
|
||||
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||
await logAuditEvent({
|
||||
@@ -1060,7 +1033,6 @@ export const authRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
// Check if account is locked before allowing login
|
||||
const lockoutCheck = await checkAccountLockout(user.id);
|
||||
if (lockoutCheck.isLocked) {
|
||||
const remainingSec = Math.ceil(
|
||||
@@ -1083,22 +1055,18 @@ export const authRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
// Reset failed attempts on successful login
|
||||
await resetFailedAttempts(user.id);
|
||||
|
||||
// Reset rate limits on successful login
|
||||
await resetLoginRateLimits(email, clientIP);
|
||||
|
||||
// Issue JWT for authenticated user
|
||||
const event = getH3Event(ctx);
|
||||
const userAgent = getUserAgent(event);
|
||||
await issueAuthToken({
|
||||
event,
|
||||
userId: user.id,
|
||||
rememberMe: rememberMe ?? false
|
||||
rememberMe,
|
||||
});
|
||||
|
||||
// Set CSRF token for authenticated user
|
||||
setCSRFToken(event);
|
||||
|
||||
// Log successful login (wrap in try-catch to ensure it never blocks auth flow)
|
||||
@@ -1106,7 +1074,7 @@ export const authRouter = createTRPCRouter({
|
||||
await logAuditEvent({
|
||||
userId: user.id,
|
||||
eventType: "auth.login.success",
|
||||
eventData: { method: "password", rememberMe: rememberMe ?? false },
|
||||
eventData: { method: "password", rememberMe },
|
||||
ipAddress: clientIP,
|
||||
userAgent,
|
||||
success: true
|
||||
@@ -1251,7 +1219,6 @@ export const authRouter = createTRPCRouter({
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { email } = input;
|
||||
|
||||
// Apply rate limiting
|
||||
const clientIP = getClientIP(getH3Event(ctx));
|
||||
await rateLimitPasswordReset(clientIP, getH3Event(ctx));
|
||||
|
||||
@@ -1303,7 +1270,6 @@ export const authRouter = createTRPCRouter({
|
||||
}
|
||||
);
|
||||
|
||||
// Log password reset request
|
||||
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||
await logAuditEvent({
|
||||
userId: user.id,
|
||||
@@ -1316,7 +1282,6 @@ export const authRouter = createTRPCRouter({
|
||||
|
||||
return { success: true, message: "email sent" };
|
||||
} catch (error) {
|
||||
// Log failed password reset request (only if not rate limited)
|
||||
if (
|
||||
!(error instanceof TRPCError && error.code === "TOO_MANY_REQUESTS")
|
||||
) {
|
||||
@@ -1371,7 +1336,6 @@ export const authRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate and consume the password reset token
|
||||
const tokenValidation = await validatePasswordResetToken(token);
|
||||
|
||||
if (!tokenValidation) {
|
||||
@@ -1415,10 +1379,8 @@ export const authRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
// Mark token as used
|
||||
await markPasswordResetTokenUsed(tokenId);
|
||||
|
||||
// Log successful password reset
|
||||
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||
await logAuditEvent({
|
||||
userId: userId,
|
||||
@@ -1431,7 +1393,6 @@ export const authRouter = createTRPCRouter({
|
||||
|
||||
return { success: true, message: "success" };
|
||||
} catch (error) {
|
||||
// Log failed password reset
|
||||
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||
await logAuditEvent({
|
||||
eventType: "auth.password.reset.complete",
|
||||
@@ -1459,7 +1420,6 @@ export const authRouter = createTRPCRouter({
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { email } = input;
|
||||
|
||||
// Apply rate limiting
|
||||
const clientIP = getClientIP(getH3Event(ctx));
|
||||
await rateLimitEmailVerification(clientIP, getH3Event(ctx));
|
||||
|
||||
@@ -1520,7 +1480,6 @@ export const authRouter = createTRPCRouter({
|
||||
}
|
||||
);
|
||||
|
||||
// Log email verification request
|
||||
const { ipAddress, userAgent } = getAuditContext(getH3Event(ctx));
|
||||
await logAuditEvent({
|
||||
userId: user.id,
|
||||
@@ -1533,7 +1492,6 @@ export const authRouter = createTRPCRouter({
|
||||
|
||||
return { success: true, message: "Verification email sent" };
|
||||
} catch (error) {
|
||||
// Log failed email verification request (only if not rate limited)
|
||||
if (
|
||||
!(error instanceof TRPCError && error.code === "TOO_MANY_REQUESTS")
|
||||
) {
|
||||
|
||||
@@ -462,58 +462,50 @@ export const databaseRouter = createTRPCRouter({
|
||||
}
|
||||
}
|
||||
|
||||
let query = "UPDATE Post SET ";
|
||||
let sets: string[] = [];
|
||||
let params: any[] = [];
|
||||
let first = true;
|
||||
|
||||
if (input.title !== undefined && input.title !== null) {
|
||||
query += first ? "title = ?" : ", title = ?";
|
||||
sets.push("title = ?");
|
||||
params.push(input.title);
|
||||
first = false;
|
||||
}
|
||||
|
||||
if (input.subtitle !== undefined && input.subtitle !== null) {
|
||||
query += first ? "subtitle = ?" : ", subtitle = ?";
|
||||
sets.push("subtitle = ?");
|
||||
params.push(input.subtitle);
|
||||
first = false;
|
||||
}
|
||||
|
||||
if (input.body !== undefined && input.body !== null) {
|
||||
query += first ? "body = ?" : ", body = ?";
|
||||
sets.push("body = ?");
|
||||
params.push(input.body);
|
||||
first = false;
|
||||
}
|
||||
|
||||
if (input.banner_photo !== undefined && input.banner_photo !== null) {
|
||||
query += first ? "banner_photo = ?" : ", banner_photo = ?";
|
||||
sets.push("banner_photo = ?");
|
||||
if (input.banner_photo === "_DELETE_IMAGE_") {
|
||||
params.push(null);
|
||||
} else {
|
||||
params.push(env.VITE_AWS_BUCKET_STRING + input.banner_photo);
|
||||
}
|
||||
first = false;
|
||||
}
|
||||
|
||||
if (input.published !== undefined && input.published !== null) {
|
||||
query += first ? "published = ?" : ", published = ?";
|
||||
sets.push("published = ?");
|
||||
params.push(input.published);
|
||||
first = false;
|
||||
}
|
||||
|
||||
if (shouldSetPublishDate) {
|
||||
query += first ? "date = ?" : ", date = ?";
|
||||
sets.push("date = ?");
|
||||
params.push(new Date().toISOString());
|
||||
first = false;
|
||||
}
|
||||
|
||||
query += first ? "last_edited_date = ?" : ", last_edited_date = ?";
|
||||
sets.push("last_edited_date = ?");
|
||||
params.push(new Date().toISOString());
|
||||
first = false;
|
||||
|
||||
query += first ? "author_id = ?" : ", author_id = ?";
|
||||
sets.push("author_id = ?");
|
||||
params.push(input.author_id);
|
||||
|
||||
query += " WHERE id = ?";
|
||||
let query = "UPDATE Post SET " + sets.join(", ") + " WHERE id = ?";
|
||||
params.push(input.id);
|
||||
|
||||
const results = await conn.execute({ sql: query, args: params });
|
||||
|
||||
@@ -36,7 +36,6 @@ async function getLatestDMG(
|
||||
throw new Error(`No DMG files found in S3 with prefix ${prefix}`);
|
||||
}
|
||||
|
||||
// Filter for .dmg files only and sort by LastModified (newest first)
|
||||
const dmgFiles = response.Contents.filter((obj) =>
|
||||
obj.Key?.endsWith(".dmg")
|
||||
).sort((a, b) => {
|
||||
@@ -103,7 +102,6 @@ export const downloadsRouter = createTRPCRouter({
|
||||
} else if (input.asset_name === "inputhalo") {
|
||||
fileKey = await getLatestInputHaloDMG(client, bucket);
|
||||
} else {
|
||||
// Use static mapping for other assets
|
||||
fileKey = assets[input.asset_name];
|
||||
|
||||
if (!fileKey) {
|
||||
|
||||
@@ -33,7 +33,6 @@ export const gitActivityRouter = createTRPCRouter({
|
||||
`github-commits-${input.limit}`,
|
||||
CACHE_CONFIG.GIT_ACTIVITY_CACHE_TTL_MS,
|
||||
async () => {
|
||||
// Use Events API to get recent push events
|
||||
const eventsResponse = await fetchWithTimeout(
|
||||
`https://api.github.com/users/MikeFreno/events/public?per_page=100`,
|
||||
{
|
||||
@@ -48,7 +47,6 @@ export const gitActivityRouter = createTRPCRouter({
|
||||
await checkResponse(eventsResponse);
|
||||
const events = await eventsResponse.json();
|
||||
|
||||
// Collect (repo, sha) pairs from push events up front
|
||||
const toFetch: { repoName: string; sha: string }[] = [];
|
||||
for (const event of events) {
|
||||
if (event.type !== "PushEvent") continue;
|
||||
|
||||
@@ -72,13 +72,7 @@ export function assertS3KeyOwnership(key: string, userId: string | null): void {
|
||||
// Account-deletion request email — product-aware
|
||||
// ============================================================
|
||||
//
|
||||
// Pure helpers live in `./deletion-email.ts` (env-free) so they can be unit-
|
||||
// tested in `bun:test` without a populated `.env`. Re-exported here for the
|
||||
// tRPC mutation below + for callers that already import from `misc`.
|
||||
// Import into local scope FIRST — `sendDeletionRequestEmail` below uses
|
||||
// these names directly. A bare `export { ... } from` re-export does NOT make
|
||||
// the bindings available locally, which caused a ReferenceError that crashed
|
||||
// the entire tRPC router (503 on every /api/trpc call).
|
||||
// Bare "export … from" doesn't bind names locally — import first or the router throws ReferenceError (503s every /api/trpc call)
|
||||
import {
|
||||
DELETION_PRODUCT_SCHEMA,
|
||||
deletionCookieName,
|
||||
@@ -256,7 +250,6 @@ export const miscRouter = createTRPCRouter({
|
||||
lastModified: item.LastModified?.toISOString() || ""
|
||||
})) || [];
|
||||
|
||||
// Filter out thumbnail files (ending with -small.ext)
|
||||
const mainFiles = files.filter(
|
||||
(file) => !file.key.match(/-small\.(jpg|jpeg|png|gif)$/i)
|
||||
);
|
||||
@@ -376,7 +369,6 @@ export const miscRouter = createTRPCRouter({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
// Verify Cloudflare Turnstile token
|
||||
const turnstileValid = await verifyTurnstileToken(
|
||||
input.turnstileToken,
|
||||
env.TURNSTILE_SECRET_KEY,
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import type { Client } from "@libsql/client/web";
|
||||
|
||||
// Prevent the env/server.ts client-side guard from throwing during tests
|
||||
@@ -288,7 +289,6 @@ describe("static audit: every targeted mutation handler uses ctx", () => {
|
||||
const source = await Bun.file(import.meta.dir + "/nessa.ts").text();
|
||||
|
||||
for (const name of MUTATIONS) {
|
||||
// Match: name: nessaProcedure ... .mutation(async ({ input }) — but NOT ({ input, ctx
|
||||
const re = new RegExp(
|
||||
`${name}:\\s*nessaProcedure[^}]*\\.mutation\\(async \\({\\s*input\\s*}\\)`,
|
||||
"s"
|
||||
@@ -305,7 +305,6 @@ describe("static audit: every targeted mutation handler uses ctx", () => {
|
||||
const source = await Bun.file(import.meta.dir + "/nessa.ts").text();
|
||||
|
||||
for (const name of MUTATIONS) {
|
||||
// Find the block for this mutation and check it references ctx
|
||||
const re = new RegExp(
|
||||
`${name}:\\s*nessaProcedure[\\s\\S]*?\\.mutation\\([\\s\\S]*?\\n \\}\\),`,
|
||||
"s"
|
||||
@@ -318,12 +317,55 @@ describe("static audit: every targeted mutation handler uses ctx", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("bulkUpsert filters exerciseLibrary by userId", async () => {
|
||||
const source = await Bun.file(import.meta.dir + "/nessa.ts").text();
|
||||
const bulkSection = source.match(
|
||||
/if \(input\.exerciseLibrary\?\.length\) \{[\s\S]*?\n {8}\}/
|
||||
);
|
||||
expect(bulkSection).toBeTruthy();
|
||||
expect(bulkSection![0]).toContain("userId !== ctx.nessaUserId");
|
||||
it("upsertExerciseLibrary rejects an exercise owned by another user", async () => {
|
||||
const mod = await import("./nessa");
|
||||
const conn = makeMockConn([]);
|
||||
const exercise = {
|
||||
id: EXERCISE_ID,
|
||||
userId: USER_B,
|
||||
name: "Squat",
|
||||
category: "Strength"
|
||||
};
|
||||
let caught: unknown;
|
||||
try {
|
||||
await mod.upsertExerciseLibrary(conn, USER_A, [exercise]);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(TRPCError);
|
||||
expect((caught as TRPCError).code).toBe("FORBIDDEN");
|
||||
expect((caught as TRPCError).message).toBe("User mismatch");
|
||||
});
|
||||
|
||||
it("upsertExerciseLibrary upserts an exercise owned by the caller", async () => {
|
||||
const mod = await import("./nessa");
|
||||
const conn = makeMockConn([{ userId: USER_A }]);
|
||||
const exercise = {
|
||||
id: EXERCISE_ID,
|
||||
userId: USER_A,
|
||||
name: "Squat",
|
||||
category: "Strength"
|
||||
};
|
||||
await expect(
|
||||
mod.upsertExerciseLibrary(conn, USER_A, [exercise])
|
||||
).resolves.toBeUndefined();
|
||||
expect(conn.execute).toHaveBeenCalledWith({
|
||||
sql: expect.stringContaining(
|
||||
"INSERT INTO exerciseLibrary (id, userId, name, category"
|
||||
),
|
||||
args: [
|
||||
EXERCISE_ID,
|
||||
USER_A,
|
||||
"Squat",
|
||||
"Strength",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,25 +7,45 @@ import type { Client } from "@libsql/client/web";
|
||||
|
||||
const NESSA_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Assert that the record identified by id in the given table belongs to userId.
|
||||
* Shared by assertWorkoutOwned, assertAuthProviderOwned, and
|
||||
* assertExerciseLibraryOwned.
|
||||
*/
|
||||
async function assertOwnedBy(
|
||||
conn: Client,
|
||||
table: string,
|
||||
id: string,
|
||||
userId: string,
|
||||
notFoundMessage: string,
|
||||
forbiddenMessage: string
|
||||
) {
|
||||
const row = await conn.execute({
|
||||
sql: `SELECT userId FROM ${table} WHERE id = ?`,
|
||||
args: [id]
|
||||
});
|
||||
if (row.rows.length === 0) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: notFoundMessage });
|
||||
}
|
||||
if (row.rows[0].userId !== userId) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: forbiddenMessage });
|
||||
}
|
||||
}
|
||||
|
||||
/** Assert that the workout identified by workoutId is owned by userId */
|
||||
export async function assertWorkoutOwned(
|
||||
conn: Client,
|
||||
workoutId: string,
|
||||
userId: string
|
||||
) {
|
||||
const row = await conn.execute({
|
||||
sql: "SELECT userId FROM workouts WHERE id = ?",
|
||||
args: [workoutId]
|
||||
});
|
||||
if (row.rows.length === 0) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Workout not found" });
|
||||
}
|
||||
if ((row.rows[0] as any).userId !== userId) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Not the workout owner"
|
||||
});
|
||||
}
|
||||
await assertOwnedBy(
|
||||
conn,
|
||||
"workouts",
|
||||
workoutId,
|
||||
userId,
|
||||
"Workout not found",
|
||||
"Not the workout owner"
|
||||
);
|
||||
}
|
||||
|
||||
/** Assert that the auth provider record identified by providerId is owned by userId */
|
||||
@@ -34,22 +54,14 @@ export async function assertAuthProviderOwned(
|
||||
providerId: string,
|
||||
userId: string
|
||||
) {
|
||||
const row = await conn.execute({
|
||||
sql: "SELECT userId FROM authProviders WHERE id = ?",
|
||||
args: [providerId]
|
||||
});
|
||||
if (row.rows.length === 0) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Auth provider not found"
|
||||
});
|
||||
}
|
||||
if ((row.rows[0] as any).userId !== userId) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Not the auth provider owner"
|
||||
});
|
||||
}
|
||||
await assertOwnedBy(
|
||||
conn,
|
||||
"authProviders",
|
||||
providerId,
|
||||
userId,
|
||||
"Auth provider not found",
|
||||
"Not the auth provider owner"
|
||||
);
|
||||
}
|
||||
|
||||
/** Assert that the exercise library record identified by exerciseId is owned by userId */
|
||||
@@ -58,19 +70,14 @@ export async function assertExerciseLibraryOwned(
|
||||
exerciseId: string,
|
||||
userId: string
|
||||
) {
|
||||
const row = await conn.execute({
|
||||
sql: "SELECT userId FROM exerciseLibrary WHERE id = ?",
|
||||
args: [exerciseId]
|
||||
});
|
||||
if (row.rows.length === 0) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Exercise not found" });
|
||||
}
|
||||
if ((row.rows[0] as any).userId !== userId) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Not the exercise owner"
|
||||
});
|
||||
}
|
||||
await assertOwnedBy(
|
||||
conn,
|
||||
"exerciseLibrary",
|
||||
exerciseId,
|
||||
userId,
|
||||
"Exercise not found",
|
||||
"Not the exercise owner"
|
||||
);
|
||||
}
|
||||
|
||||
const paginatedQuerySchema = z.object({
|
||||
@@ -239,6 +246,401 @@ const bulkSchema = z.object({
|
||||
authProviders: z.array(providerSchema).optional()
|
||||
});
|
||||
|
||||
async function upsertUsers(
|
||||
conn: Client,
|
||||
userId: string,
|
||||
users: z.infer<typeof userInputSchema>[]
|
||||
) {
|
||||
for (const user of users) {
|
||||
if (user.id !== userId) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User mismatch"
|
||||
});
|
||||
}
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO users (id, email, emailVerified, firstName, lastName, displayName, avatarUrl, provider, appleUserId, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET email = excluded.email, emailVerified = excluded.emailVerified, firstName = excluded.firstName, lastName = excluded.lastName, displayName = excluded.displayName, avatarUrl = excluded.avatarUrl, provider = excluded.provider, appleUserId = excluded.appleUserId, status = excluded.status, updatedAt = datetime('now')`,
|
||||
args: [
|
||||
user.id,
|
||||
user.email ?? null,
|
||||
user.emailVerified ?? 0,
|
||||
user.firstName ?? null,
|
||||
user.lastName ?? null,
|
||||
user.displayName ?? null,
|
||||
user.avatarUrl ?? null,
|
||||
user.provider ?? null,
|
||||
user.appleUserId ?? null,
|
||||
user.status ?? "active"
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function upsertExerciseLibrary(
|
||||
conn: Client,
|
||||
userId: string,
|
||||
exerciseLibrary: z.infer<typeof exerciseLibrarySchema>[]
|
||||
) {
|
||||
for (const exercise of exerciseLibrary) {
|
||||
if (exercise.userId !== userId) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User mismatch"
|
||||
});
|
||||
}
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO exerciseLibrary (id, userId, name, category, muscleGroups, equipment, instructions, defaultSets, defaultReps, defaultRestSeconds, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET userId = excluded.userId, name = excluded.name, category = excluded.category, muscleGroups = excluded.muscleGroups, equipment = excluded.equipment, instructions = excluded.instructions, defaultSets = excluded.defaultSets, defaultReps = excluded.defaultReps, defaultRestSeconds = excluded.defaultRestSeconds, notes = excluded.notes, updatedAt = datetime('now')`,
|
||||
args: [
|
||||
exercise.id,
|
||||
exercise.userId,
|
||||
exercise.name,
|
||||
exercise.category,
|
||||
exercise.muscleGroups ?? null,
|
||||
exercise.equipment ?? null,
|
||||
exercise.instructions ?? null,
|
||||
exercise.defaultSets ?? null,
|
||||
exercise.defaultReps ?? null,
|
||||
exercise.defaultRestSeconds ?? null,
|
||||
exercise.notes ?? null
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertWorkoutPlans(
|
||||
conn: Client,
|
||||
userId: string,
|
||||
workoutPlans: z.infer<typeof workoutPlanSchema>[]
|
||||
) {
|
||||
for (const plan of workoutPlans) {
|
||||
if (plan.userId !== userId) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User mismatch"
|
||||
});
|
||||
}
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO workoutPlans (id, userId, name, description, category, difficulty, durationMinutes, type, isPublic)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET name = excluded.name, description = excluded.description, category = excluded.category, difficulty = excluded.difficulty, durationMinutes = excluded.durationMinutes, type = excluded.type, isPublic = excluded.isPublic, updatedAt = datetime('now')`,
|
||||
args: [
|
||||
plan.id,
|
||||
plan.userId,
|
||||
plan.name,
|
||||
plan.description ?? null,
|
||||
plan.category,
|
||||
plan.difficulty ?? "intermediate",
|
||||
plan.durationMinutes ?? null,
|
||||
plan.type,
|
||||
plan.isPublic ?? 0
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertPlanExercises(
|
||||
conn: Client,
|
||||
userId: string,
|
||||
planExercises: z.infer<typeof planExerciseSchema>[]
|
||||
) {
|
||||
for (const planExercise of planExercises) {
|
||||
const planCheck = await conn.execute({
|
||||
sql: "SELECT userId FROM workoutPlans WHERE id = ?",
|
||||
args: [planExercise.planId]
|
||||
});
|
||||
if (
|
||||
!planCheck.rows.length ||
|
||||
planCheck.rows[0].userId !== userId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User mismatch"
|
||||
});
|
||||
}
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO planExercises (id, planId, exerciseId, name, category, orderIndex, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET exerciseId = excluded.exerciseId, name = excluded.name, category = excluded.category, orderIndex = excluded.orderIndex, notes = excluded.notes`,
|
||||
args: [
|
||||
planExercise.id,
|
||||
planExercise.planId,
|
||||
planExercise.exerciseId ?? null,
|
||||
planExercise.name,
|
||||
planExercise.category,
|
||||
planExercise.orderIndex,
|
||||
planExercise.notes ?? null
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertPlanSets(
|
||||
conn: Client,
|
||||
userId: string,
|
||||
planSets: z.infer<typeof planSetSchema>[]
|
||||
) {
|
||||
for (const planSet of planSets) {
|
||||
const planExerciseCheck = await conn.execute({
|
||||
sql: "SELECT planId FROM planExercises WHERE id = ?",
|
||||
args: [planSet.planExerciseId]
|
||||
});
|
||||
if (planExerciseCheck.rows.length) {
|
||||
const planCheck = await conn.execute({
|
||||
sql: "SELECT userId FROM workoutPlans WHERE id = ?",
|
||||
args: [planExerciseCheck.rows[0].planId]
|
||||
});
|
||||
if (
|
||||
!planCheck.rows.length ||
|
||||
planCheck.rows[0].userId !== userId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User mismatch"
|
||||
});
|
||||
}
|
||||
}
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO planSets (id, planExerciseId, setNumber, reps, weight, durationSeconds, rpe, restAfterSeconds, isWarmup, isDropset, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET setNumber = excluded.setNumber, reps = excluded.reps, weight = excluded.weight, durationSeconds = excluded.durationSeconds, rpe = excluded.rpe, restAfterSeconds = excluded.restAfterSeconds, isWarmup = excluded.isWarmup, isDropset = excluded.isDropset, notes = excluded.notes`,
|
||||
args: [
|
||||
planSet.id,
|
||||
planSet.planExerciseId,
|
||||
planSet.setNumber,
|
||||
planSet.reps ?? null,
|
||||
planSet.weight ?? null,
|
||||
planSet.durationSeconds ?? null,
|
||||
planSet.rpe ?? null,
|
||||
planSet.restAfterSeconds ?? null,
|
||||
planSet.isWarmup ?? 0,
|
||||
planSet.isDropset ?? 0,
|
||||
planSet.notes ?? null
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertRoutePoints(
|
||||
conn: Client,
|
||||
userId: string,
|
||||
routePoints: z.infer<typeof routePointSchema>[]
|
||||
) {
|
||||
for (const point of routePoints) {
|
||||
const planCheck = await conn.execute({
|
||||
sql: "SELECT userId FROM workoutPlans WHERE id = ?",
|
||||
args: [point.planId]
|
||||
});
|
||||
if (
|
||||
!planCheck.rows.length ||
|
||||
planCheck.rows[0].userId !== userId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User mismatch"
|
||||
});
|
||||
}
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO routePoints (id, planId, latitude, longitude, orderIndex, isWaypoint)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET latitude = excluded.latitude, longitude = excluded.longitude, orderIndex = excluded.orderIndex, isWaypoint = excluded.isWaypoint`,
|
||||
args: [
|
||||
point.id,
|
||||
point.planId,
|
||||
point.latitude,
|
||||
point.longitude,
|
||||
point.orderIndex,
|
||||
point.isWaypoint ?? 0
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertWorkouts(
|
||||
conn: Client,
|
||||
userId: string,
|
||||
workouts: z.infer<typeof workoutSchema>[]
|
||||
) {
|
||||
for (const workout of workouts) {
|
||||
if (workout.userId !== userId) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User mismatch"
|
||||
});
|
||||
}
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO workouts (id, userId, planId, type, name, startDate, endDate, durationSeconds, distanceMeters, calories, averageHeartRate, maxHeartRate, averagePace, elevationGain, status, source, healthKitUUID, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET planId = excluded.planId, type = excluded.type, name = excluded.name, startDate = excluded.startDate, endDate = excluded.endDate, durationSeconds = excluded.durationSeconds, distanceMeters = excluded.distanceMeters, calories = excluded.calories, averageHeartRate = excluded.averageHeartRate, maxHeartRate = excluded.maxHeartRate, averagePace = excluded.averagePace, elevationGain = excluded.elevationGain, status = excluded.status, source = excluded.source, healthKitUUID = excluded.healthKitUUID, notes = excluded.notes, updatedAt = datetime('now')`,
|
||||
args: [
|
||||
workout.id,
|
||||
workout.userId,
|
||||
workout.planId ?? null,
|
||||
workout.type,
|
||||
workout.name ?? null,
|
||||
workout.startDate,
|
||||
workout.endDate ?? null,
|
||||
workout.durationSeconds ?? null,
|
||||
workout.distanceMeters ?? null,
|
||||
workout.calories ?? null,
|
||||
workout.averageHeartRate ?? null,
|
||||
workout.maxHeartRate ?? null,
|
||||
workout.averagePace ?? null,
|
||||
workout.elevationGain ?? null,
|
||||
workout.status,
|
||||
workout.source,
|
||||
workout.healthKitUUID ?? null,
|
||||
workout.notes ?? null
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertHeartRateSamples(
|
||||
conn: Client,
|
||||
userId: string,
|
||||
heartRateSamples: z.infer<typeof heartRateSchema>[]
|
||||
) {
|
||||
for (const sample of heartRateSamples) {
|
||||
const workoutCheck = await conn.execute({
|
||||
sql: "SELECT userId FROM workouts WHERE id = ?",
|
||||
args: [sample.workoutId]
|
||||
});
|
||||
if (
|
||||
!workoutCheck.rows.length ||
|
||||
workoutCheck.rows[0].userId !== userId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User mismatch"
|
||||
});
|
||||
}
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO heartRateSamples (id, workoutId, timestamp, bpm, source)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET timestamp = excluded.timestamp, bpm = excluded.bpm, source = excluded.source`,
|
||||
args: [
|
||||
sample.id,
|
||||
sample.workoutId,
|
||||
sample.timestamp,
|
||||
sample.bpm,
|
||||
sample.source ?? null
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertLocationSamples(
|
||||
conn: Client,
|
||||
userId: string,
|
||||
locationSamples: z.infer<typeof locationSampleSchema>[]
|
||||
) {
|
||||
for (const sample of locationSamples) {
|
||||
const workoutCheck = await conn.execute({
|
||||
sql: "SELECT userId FROM workouts WHERE id = ?",
|
||||
args: [sample.workoutId]
|
||||
});
|
||||
if (
|
||||
!workoutCheck.rows.length ||
|
||||
workoutCheck.rows[0].userId !== userId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User mismatch"
|
||||
});
|
||||
}
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO locationSamples (id, workoutId, timestamp, latitude, longitude, altitude, horizontalAccuracy, verticalAccuracy, speed, course)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET timestamp = excluded.timestamp, latitude = excluded.latitude, longitude = excluded.longitude, altitude = excluded.altitude, horizontalAccuracy = excluded.horizontalAccuracy, verticalAccuracy = excluded.verticalAccuracy, speed = excluded.speed, course = excluded.course`,
|
||||
args: [
|
||||
sample.id,
|
||||
sample.workoutId,
|
||||
sample.timestamp,
|
||||
sample.latitude,
|
||||
sample.longitude,
|
||||
sample.altitude ?? null,
|
||||
sample.horizontalAccuracy ?? null,
|
||||
sample.verticalAccuracy ?? null,
|
||||
sample.speed ?? null,
|
||||
sample.course ?? null
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertWorkoutSplits(
|
||||
conn: Client,
|
||||
userId: string,
|
||||
workoutSplits: z.infer<typeof workoutSplitSchema>[]
|
||||
) {
|
||||
for (const split of workoutSplits) {
|
||||
const workoutCheck = await conn.execute({
|
||||
sql: "SELECT userId FROM workouts WHERE id = ?",
|
||||
args: [split.workoutId]
|
||||
});
|
||||
if (
|
||||
!workoutCheck.rows.length ||
|
||||
workoutCheck.rows[0].userId !== userId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User mismatch"
|
||||
});
|
||||
}
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO workoutSplits (id, workoutId, splitNumber, distanceMeters, durationSeconds, startTimestamp, endTimestamp, averageHeartRate, averagePace, elevationGain, elevationLoss)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET splitNumber = excluded.splitNumber, distanceMeters = excluded.distanceMeters, durationSeconds = excluded.durationSeconds, startTimestamp = excluded.startTimestamp, endTimestamp = excluded.endTimestamp, averageHeartRate = excluded.averageHeartRate, averagePace = excluded.averagePace, elevationGain = excluded.elevationGain, elevationLoss = excluded.elevationLoss`,
|
||||
args: [
|
||||
split.id,
|
||||
split.workoutId,
|
||||
split.splitNumber,
|
||||
split.distanceMeters,
|
||||
split.durationSeconds,
|
||||
split.startTimestamp,
|
||||
split.endTimestamp,
|
||||
split.averageHeartRate ?? null,
|
||||
split.averagePace ?? null,
|
||||
split.elevationGain ?? null,
|
||||
split.elevationLoss ?? null
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertAuthProviders(
|
||||
conn: Client,
|
||||
userId: string,
|
||||
authProviders: z.infer<typeof providerSchema>[]
|
||||
) {
|
||||
for (const provider of authProviders) {
|
||||
if (provider.userId !== userId) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User mismatch"
|
||||
});
|
||||
}
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO authProviders (id, userId, provider, providerUserId, email, displayName, avatarUrl)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET provider = excluded.provider, providerUserId = excluded.providerUserId, email = excluded.email, displayName = excluded.displayName, avatarUrl = excluded.avatarUrl, lastUsedAt = datetime('now')`,
|
||||
args: [
|
||||
provider.id,
|
||||
provider.userId,
|
||||
provider.provider,
|
||||
provider.providerUserId ?? null,
|
||||
provider.email ?? null,
|
||||
provider.displayName ?? null,
|
||||
provider.avatarUrl ?? null
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const nessaDbRouter = createTRPCRouter({
|
||||
health: nessaProcedure.query(async () => {
|
||||
try {
|
||||
@@ -1826,356 +2228,41 @@ export const nessaDbRouter = createTRPCRouter({
|
||||
try {
|
||||
const conn = NessaConnectionFactory();
|
||||
|
||||
if (input.users?.length) {
|
||||
for (const user of input.users) {
|
||||
if (user.id !== ctx.nessaUserId) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User mismatch"
|
||||
});
|
||||
}
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO users (id, email, emailVerified, firstName, lastName, displayName, avatarUrl, provider, appleUserId, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET email = excluded.email, emailVerified = excluded.emailVerified, firstName = excluded.firstName, lastName = excluded.lastName, displayName = excluded.displayName, avatarUrl = excluded.avatarUrl, provider = excluded.provider, appleUserId = excluded.appleUserId, status = excluded.status, updatedAt = datetime('now')`,
|
||||
args: [
|
||||
user.id,
|
||||
user.email ?? null,
|
||||
user.emailVerified ?? 0,
|
||||
user.firstName ?? null,
|
||||
user.lastName ?? null,
|
||||
user.displayName ?? null,
|
||||
user.avatarUrl ?? null,
|
||||
user.provider ?? null,
|
||||
user.appleUserId ?? null,
|
||||
user.status ?? "active"
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (input.exerciseLibrary?.length) {
|
||||
for (const exercise of input.exerciseLibrary) {
|
||||
if (exercise.userId !== ctx.nessaUserId) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User mismatch"
|
||||
});
|
||||
}
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO exerciseLibrary (id, userId, name, category, muscleGroups, equipment, instructions, defaultSets, defaultReps, defaultRestSeconds, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET userId = excluded.userId, name = excluded.name, category = excluded.category, muscleGroups = excluded.muscleGroups, equipment = excluded.equipment, instructions = excluded.instructions, defaultSets = excluded.defaultSets, defaultReps = excluded.defaultReps, defaultRestSeconds = excluded.defaultRestSeconds, notes = excluded.notes, updatedAt = datetime('now')`,
|
||||
args: [
|
||||
exercise.id,
|
||||
exercise.userId,
|
||||
exercise.name,
|
||||
exercise.category,
|
||||
exercise.muscleGroups ?? null,
|
||||
exercise.equipment ?? null,
|
||||
exercise.instructions ?? null,
|
||||
exercise.defaultSets ?? null,
|
||||
exercise.defaultReps ?? null,
|
||||
exercise.defaultRestSeconds ?? null,
|
||||
exercise.notes ?? null
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (input.workoutPlans?.length) {
|
||||
for (const plan of input.workoutPlans) {
|
||||
if (plan.userId !== ctx.nessaUserId) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User mismatch"
|
||||
});
|
||||
}
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO workoutPlans (id, userId, name, description, category, difficulty, durationMinutes, type, isPublic)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET name = excluded.name, description = excluded.description, category = excluded.category, difficulty = excluded.difficulty, durationMinutes = excluded.durationMinutes, type = excluded.type, isPublic = excluded.isPublic, updatedAt = datetime('now')`,
|
||||
args: [
|
||||
plan.id,
|
||||
plan.userId,
|
||||
plan.name,
|
||||
plan.description ?? null,
|
||||
plan.category,
|
||||
plan.difficulty ?? "intermediate",
|
||||
plan.durationMinutes ?? null,
|
||||
plan.type,
|
||||
plan.isPublic ?? 0
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (input.planExercises?.length) {
|
||||
for (const planExercise of input.planExercises) {
|
||||
const planCheck = await conn.execute({
|
||||
sql: "SELECT userId FROM workoutPlans WHERE id = ?",
|
||||
args: [planExercise.planId]
|
||||
});
|
||||
if (
|
||||
!planCheck.rows.length ||
|
||||
planCheck.rows[0].userId !== ctx.nessaUserId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User mismatch"
|
||||
});
|
||||
}
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO planExercises (id, planId, exerciseId, name, category, orderIndex, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET exerciseId = excluded.exerciseId, name = excluded.name, category = excluded.category, orderIndex = excluded.orderIndex, notes = excluded.notes`,
|
||||
args: [
|
||||
planExercise.id,
|
||||
planExercise.planId,
|
||||
planExercise.exerciseId ?? null,
|
||||
planExercise.name,
|
||||
planExercise.category,
|
||||
planExercise.orderIndex,
|
||||
planExercise.notes ?? null
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (input.planSets?.length) {
|
||||
for (const planSet of input.planSets) {
|
||||
const planExerciseCheck = await conn.execute({
|
||||
sql: "SELECT planId FROM planExercises WHERE id = ?",
|
||||
args: [planSet.planExerciseId]
|
||||
});
|
||||
if (planExerciseCheck.rows.length) {
|
||||
const planCheck = await conn.execute({
|
||||
sql: "SELECT userId FROM workoutPlans WHERE id = ?",
|
||||
args: [planExerciseCheck.rows[0].planId]
|
||||
});
|
||||
if (
|
||||
!planCheck.rows.length ||
|
||||
planCheck.rows[0].userId !== ctx.nessaUserId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User mismatch"
|
||||
});
|
||||
}
|
||||
}
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO planSets (id, planExerciseId, setNumber, reps, weight, durationSeconds, rpe, restAfterSeconds, isWarmup, isDropset, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET setNumber = excluded.setNumber, reps = excluded.reps, weight = excluded.weight, durationSeconds = excluded.durationSeconds, rpe = excluded.rpe, restAfterSeconds = excluded.restAfterSeconds, isWarmup = excluded.isWarmup, isDropset = excluded.isDropset, notes = excluded.notes`,
|
||||
args: [
|
||||
planSet.id,
|
||||
planSet.planExerciseId,
|
||||
planSet.setNumber,
|
||||
planSet.reps ?? null,
|
||||
planSet.weight ?? null,
|
||||
planSet.durationSeconds ?? null,
|
||||
planSet.rpe ?? null,
|
||||
planSet.restAfterSeconds ?? null,
|
||||
planSet.isWarmup ?? 0,
|
||||
planSet.isDropset ?? 0,
|
||||
planSet.notes ?? null
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (input.routePoints?.length) {
|
||||
for (const point of input.routePoints) {
|
||||
const planCheck = await conn.execute({
|
||||
sql: "SELECT userId FROM workoutPlans WHERE id = ?",
|
||||
args: [point.planId]
|
||||
});
|
||||
if (
|
||||
!planCheck.rows.length ||
|
||||
planCheck.rows[0].userId !== ctx.nessaUserId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User mismatch"
|
||||
});
|
||||
}
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO routePoints (id, planId, latitude, longitude, orderIndex, isWaypoint)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET latitude = excluded.latitude, longitude = excluded.longitude, orderIndex = excluded.orderIndex, isWaypoint = excluded.isWaypoint`,
|
||||
args: [
|
||||
point.id,
|
||||
point.planId,
|
||||
point.latitude,
|
||||
point.longitude,
|
||||
point.orderIndex,
|
||||
point.isWaypoint ?? 0
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (input.workouts?.length) {
|
||||
for (const workout of input.workouts) {
|
||||
if (workout.userId !== ctx.nessaUserId) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User mismatch"
|
||||
});
|
||||
}
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO workouts (id, userId, planId, type, name, startDate, endDate, durationSeconds, distanceMeters, calories, averageHeartRate, maxHeartRate, averagePace, elevationGain, status, source, healthKitUUID, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET planId = excluded.planId, type = excluded.type, name = excluded.name, startDate = excluded.startDate, endDate = excluded.endDate, durationSeconds = excluded.durationSeconds, distanceMeters = excluded.distanceMeters, calories = excluded.calories, averageHeartRate = excluded.averageHeartRate, maxHeartRate = excluded.maxHeartRate, averagePace = excluded.averagePace, elevationGain = excluded.elevationGain, status = excluded.status, source = excluded.source, healthKitUUID = excluded.healthKitUUID, notes = excluded.notes, updatedAt = datetime('now')`,
|
||||
args: [
|
||||
workout.id,
|
||||
workout.userId,
|
||||
workout.planId ?? null,
|
||||
workout.type,
|
||||
workout.name ?? null,
|
||||
workout.startDate,
|
||||
workout.endDate ?? null,
|
||||
workout.durationSeconds ?? null,
|
||||
workout.distanceMeters ?? null,
|
||||
workout.calories ?? null,
|
||||
workout.averageHeartRate ?? null,
|
||||
workout.maxHeartRate ?? null,
|
||||
workout.averagePace ?? null,
|
||||
workout.elevationGain ?? null,
|
||||
workout.status,
|
||||
workout.source,
|
||||
workout.healthKitUUID ?? null,
|
||||
workout.notes ?? null
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (input.heartRateSamples?.length) {
|
||||
for (const sample of input.heartRateSamples) {
|
||||
const workoutCheck = await conn.execute({
|
||||
sql: "SELECT userId FROM workouts WHERE id = ?",
|
||||
args: [sample.workoutId]
|
||||
});
|
||||
if (
|
||||
!workoutCheck.rows.length ||
|
||||
workoutCheck.rows[0].userId !== ctx.nessaUserId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User mismatch"
|
||||
});
|
||||
}
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO heartRateSamples (id, workoutId, timestamp, bpm, source)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET timestamp = excluded.timestamp, bpm = excluded.bpm, source = excluded.source`,
|
||||
args: [
|
||||
sample.id,
|
||||
sample.workoutId,
|
||||
sample.timestamp,
|
||||
sample.bpm,
|
||||
sample.source ?? null
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (input.locationSamples?.length) {
|
||||
for (const sample of input.locationSamples) {
|
||||
const workoutCheck = await conn.execute({
|
||||
sql: "SELECT userId FROM workouts WHERE id = ?",
|
||||
args: [sample.workoutId]
|
||||
});
|
||||
if (
|
||||
!workoutCheck.rows.length ||
|
||||
workoutCheck.rows[0].userId !== ctx.nessaUserId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User mismatch"
|
||||
});
|
||||
}
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO locationSamples (id, workoutId, timestamp, latitude, longitude, altitude, horizontalAccuracy, verticalAccuracy, speed, course)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET timestamp = excluded.timestamp, latitude = excluded.latitude, longitude = excluded.longitude, altitude = excluded.altitude, horizontalAccuracy = excluded.horizontalAccuracy, verticalAccuracy = excluded.verticalAccuracy, speed = excluded.speed, course = excluded.course`,
|
||||
args: [
|
||||
sample.id,
|
||||
sample.workoutId,
|
||||
sample.timestamp,
|
||||
sample.latitude,
|
||||
sample.longitude,
|
||||
sample.altitude ?? null,
|
||||
sample.horizontalAccuracy ?? null,
|
||||
sample.verticalAccuracy ?? null,
|
||||
sample.speed ?? null,
|
||||
sample.course ?? null
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (input.workoutSplits?.length) {
|
||||
for (const split of input.workoutSplits) {
|
||||
const workoutCheck = await conn.execute({
|
||||
sql: "SELECT userId FROM workouts WHERE id = ?",
|
||||
args: [split.workoutId]
|
||||
});
|
||||
if (
|
||||
!workoutCheck.rows.length ||
|
||||
workoutCheck.rows[0].userId !== ctx.nessaUserId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User mismatch"
|
||||
});
|
||||
}
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO workoutSplits (id, workoutId, splitNumber, distanceMeters, durationSeconds, startTimestamp, endTimestamp, averageHeartRate, averagePace, elevationGain, elevationLoss)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET splitNumber = excluded.splitNumber, distanceMeters = excluded.distanceMeters, durationSeconds = excluded.durationSeconds, startTimestamp = excluded.startTimestamp, endTimestamp = excluded.endTimestamp, averageHeartRate = excluded.averageHeartRate, averagePace = excluded.averagePace, elevationGain = excluded.elevationGain, elevationLoss = excluded.elevationLoss`,
|
||||
args: [
|
||||
split.id,
|
||||
split.workoutId,
|
||||
split.splitNumber,
|
||||
split.distanceMeters,
|
||||
split.durationSeconds,
|
||||
split.startTimestamp,
|
||||
split.endTimestamp,
|
||||
split.averageHeartRate ?? null,
|
||||
split.averagePace ?? null,
|
||||
split.elevationGain ?? null,
|
||||
split.elevationLoss ?? null
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (input.authProviders?.length) {
|
||||
for (const provider of input.authProviders) {
|
||||
if (provider.userId !== ctx.nessaUserId) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User mismatch"
|
||||
});
|
||||
}
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO authProviders (id, userId, provider, providerUserId, email, displayName, avatarUrl)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET provider = excluded.provider, providerUserId = excluded.providerUserId, email = excluded.email, displayName = excluded.displayName, avatarUrl = excluded.avatarUrl, lastUsedAt = datetime('now')`,
|
||||
args: [
|
||||
provider.id,
|
||||
provider.userId,
|
||||
provider.provider,
|
||||
provider.providerUserId ?? null,
|
||||
provider.email ?? null,
|
||||
provider.displayName ?? null,
|
||||
provider.avatarUrl ?? null
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
await upsertUsers(conn, ctx.nessaUserId, input.users ?? []);
|
||||
await upsertExerciseLibrary(
|
||||
conn,
|
||||
ctx.nessaUserId,
|
||||
input.exerciseLibrary ?? []
|
||||
);
|
||||
await upsertWorkoutPlans(conn, ctx.nessaUserId, input.workoutPlans ?? []);
|
||||
await upsertPlanExercises(
|
||||
conn,
|
||||
ctx.nessaUserId,
|
||||
input.planExercises ?? []
|
||||
);
|
||||
await upsertPlanSets(conn, ctx.nessaUserId, input.planSets ?? []);
|
||||
await upsertRoutePoints(conn, ctx.nessaUserId, input.routePoints ?? []);
|
||||
await upsertWorkouts(conn, ctx.nessaUserId, input.workouts ?? []);
|
||||
await upsertHeartRateSamples(
|
||||
conn,
|
||||
ctx.nessaUserId,
|
||||
input.heartRateSamples ?? []
|
||||
);
|
||||
await upsertLocationSamples(
|
||||
conn,
|
||||
ctx.nessaUserId,
|
||||
input.locationSamples ?? []
|
||||
);
|
||||
await upsertWorkoutSplits(
|
||||
conn,
|
||||
ctx.nessaUserId,
|
||||
input.workoutSplits ?? []
|
||||
);
|
||||
await upsertAuthProviders(
|
||||
conn,
|
||||
ctx.nessaUserId,
|
||||
input.authProviders ?? []
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import diff from "fast-diff";
|
||||
|
||||
export function createDiffPatch(
|
||||
function createDiffPatch(
|
||||
oldContent: string,
|
||||
newContent: string
|
||||
): string {
|
||||
@@ -12,7 +12,7 @@ export function createDiffPatch(
|
||||
return JSON.stringify(changes);
|
||||
}
|
||||
|
||||
export function applyDiffPatch(baseContent: string, patchJson: string): string {
|
||||
function applyDiffPatch(baseContent: string, patchJson: string): string {
|
||||
const changes = JSON.parse(patchJson);
|
||||
let result = "";
|
||||
let position = 0;
|
||||
@@ -96,7 +96,6 @@ export const postHistoryRouter = createTRPCRouter({
|
||||
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
// Verify post exists and user is author
|
||||
const postCheck = await conn.execute({
|
||||
sql: "SELECT author_id FROM Post WHERE id = ?",
|
||||
args: [input.postId]
|
||||
|
||||
@@ -254,7 +254,6 @@ export const userRouter = createTRPCRouter({
|
||||
args: [passwordHash, userId]
|
||||
});
|
||||
|
||||
// Send email notification about password being set
|
||||
if (user.email) {
|
||||
try {
|
||||
const h3Event = ctx.event.nativeEvent
|
||||
|
||||
@@ -6,105 +6,6 @@ import { z } from "zod";
|
||||
* Schemas for post creation, updating, querying, and interactions
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Post Category and Status
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Post category enum (deprecated but kept for backward compatibility)
|
||||
*/
|
||||
export const postCategorySchema = z.enum(["blog", "project"]);
|
||||
|
||||
// ============================================================================
|
||||
// Post Creation and Updates
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create new post schema
|
||||
*/
|
||||
export const createPostSchema = z.object({
|
||||
title: z
|
||||
.string()
|
||||
.min(1, "Title is required")
|
||||
.max(200, "Title must be under 200 characters"),
|
||||
subtitle: z
|
||||
.string()
|
||||
.max(300, "Subtitle must be under 300 characters")
|
||||
.optional(),
|
||||
body: z.string().min(1, "Post body is required"),
|
||||
banner_photo: z.string().url("Must be a valid URL").optional(),
|
||||
published: z.boolean().default(false),
|
||||
category: postCategorySchema.default("blog"),
|
||||
attachments: z.string().optional()
|
||||
});
|
||||
|
||||
/**
|
||||
* Update post schema (partial updates)
|
||||
*/
|
||||
export const updatePostSchema = z.object({
|
||||
postId: z.number(),
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
subtitle: z.string().max(300).optional(),
|
||||
body: z.string().min(1).optional(),
|
||||
banner_photo: z.string().url().optional(),
|
||||
published: z.boolean().optional(),
|
||||
attachments: z.string().optional()
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete post schema
|
||||
*/
|
||||
export const deletePostSchema = z.object({
|
||||
postId: z.number()
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Post Queries and Filtering
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Post sort mode enum
|
||||
* Defines available sorting options for blog posts
|
||||
*/
|
||||
export const postSortModeSchema = z.enum([
|
||||
"newest",
|
||||
"oldest",
|
||||
"most_liked",
|
||||
"most_read",
|
||||
"most_comments"
|
||||
]);
|
||||
|
||||
/**
|
||||
* Post query input schema
|
||||
* Accepts optional filters (pipe-separated tags) and sort mode
|
||||
*/
|
||||
export const postQueryInputSchema = z.object({
|
||||
/**
|
||||
* Pipe-separated list of tags to filter by
|
||||
* e.g., "tech|design|javascript"
|
||||
* Empty string or undefined means no filter
|
||||
*/
|
||||
filters: z.string().optional(),
|
||||
|
||||
/**
|
||||
* Sort mode for posts
|
||||
* Defaults to "newest" if not specified
|
||||
*/
|
||||
sortBy: postSortModeSchema.default("newest")
|
||||
});
|
||||
|
||||
/**
|
||||
* Get single post by ID or slug
|
||||
*/
|
||||
export const getPostSchema = z
|
||||
.object({
|
||||
postId: z.number().optional(),
|
||||
slug: z.string().optional()
|
||||
})
|
||||
.refine((data) => data.postId || data.slug, {
|
||||
message: "Either postId or slug must be provided"
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Post Interactions
|
||||
// ============================================================================
|
||||
@@ -116,55 +17,8 @@ export const incrementPostReadSchema = z.object({
|
||||
postId: z.number()
|
||||
});
|
||||
|
||||
/**
|
||||
* Like/unlike post
|
||||
*/
|
||||
export const togglePostLikeSchema = z.object({
|
||||
postId: z.number()
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Tag Management
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Add tags to post
|
||||
*/
|
||||
export const addTagsToPostSchema = z.object({
|
||||
postId: z.number(),
|
||||
tags: z
|
||||
.array(z.string().min(1).max(50))
|
||||
.min(1, "At least one tag is required")
|
||||
});
|
||||
|
||||
/**
|
||||
* Remove tag from post
|
||||
*/
|
||||
export const removeTagFromPostSchema = z.object({
|
||||
tagId: z.number()
|
||||
});
|
||||
|
||||
/**
|
||||
* Update post tags (replaces all tags)
|
||||
*/
|
||||
export const updatePostTagsSchema = z.object({
|
||||
postId: z.number(),
|
||||
tags: z.array(z.string().min(1).max(50))
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Type Exports
|
||||
// ============================================================================
|
||||
|
||||
export type PostCategory = z.infer<typeof postCategorySchema>;
|
||||
export type CreatePostInput = z.infer<typeof createPostSchema>;
|
||||
export type UpdatePostInput = z.infer<typeof updatePostSchema>;
|
||||
export type DeletePostInput = z.infer<typeof deletePostSchema>;
|
||||
export type PostSortMode = z.infer<typeof postSortModeSchema>;
|
||||
export type PostQueryInput = z.infer<typeof postQueryInputSchema>;
|
||||
export type GetPostInput = z.infer<typeof getPostSchema>;
|
||||
export type IncrementPostReadInput = z.infer<typeof incrementPostReadSchema>;
|
||||
export type TogglePostLikeInput = z.infer<typeof togglePostLikeSchema>;
|
||||
export type AddTagsToPostInput = z.infer<typeof addTagsToPostSchema>;
|
||||
export type RemoveTagFromPostInput = z.infer<typeof removeTagFromPostSchema>;
|
||||
export type UpdatePostTagsInput = z.infer<typeof updatePostTagsSchema>;
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
/**
|
||||
* Comment API Validation Schemas
|
||||
*
|
||||
* Zod schemas for comment-related tRPC procedures:
|
||||
* - Comment creation, updating, deletion
|
||||
* - Comment reactions
|
||||
* - Comment sorting and filtering
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
// ============================================================================
|
||||
// Comment CRUD Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create new comment schema
|
||||
*/
|
||||
export const createCommentSchema = z.object({
|
||||
body: z
|
||||
.string()
|
||||
.min(1, "Comment cannot be empty")
|
||||
.max(5000, "Comment too long"),
|
||||
post_id: z.number(),
|
||||
parent_comment_id: z.number().optional()
|
||||
});
|
||||
|
||||
/**
|
||||
* Update comment schema
|
||||
*/
|
||||
export const updateCommentSchema = z.object({
|
||||
commentId: z.number(),
|
||||
body: z
|
||||
.string()
|
||||
.min(1, "Comment cannot be empty")
|
||||
.max(5000, "Comment too long")
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete comment schema
|
||||
*/
|
||||
export const deleteCommentSchema = z.object({
|
||||
commentId: z.number(),
|
||||
deletionType: z.enum(["user", "admin", "database"]).optional()
|
||||
});
|
||||
|
||||
/**
|
||||
* Get comments for post schema
|
||||
*/
|
||||
export const getCommentsSchema = z.object({
|
||||
postId: z.number(),
|
||||
sortBy: z.enum(["newest", "oldest", "highest_rated", "hot"]).default("newest")
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Comment Reactions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Valid reaction types
|
||||
*/
|
||||
export const reactionTypeSchema = z.enum([
|
||||
"tears",
|
||||
"blank",
|
||||
"tongue",
|
||||
"cry",
|
||||
"heartEye",
|
||||
"angry",
|
||||
"moneyEye",
|
||||
"sick",
|
||||
"upsideDown",
|
||||
"worried"
|
||||
]);
|
||||
|
||||
/**
|
||||
* Add/remove reaction to comment
|
||||
*/
|
||||
export const toggleCommentReactionSchema = z.object({
|
||||
commentId: z.number(),
|
||||
reactionType: reactionTypeSchema
|
||||
});
|
||||
|
||||
/**
|
||||
* Get reactions for comment
|
||||
*/
|
||||
export const getCommentReactionsSchema = z.object({
|
||||
commentId: z.number()
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Comment Sorting
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Valid comment sorting modes
|
||||
*/
|
||||
export const commentSortSchema = z
|
||||
.enum(["newest", "oldest", "highest_rated", "hot"])
|
||||
.default("newest");
|
||||
|
||||
// ============================================================================
|
||||
// Type Exports
|
||||
// ============================================================================
|
||||
|
||||
export type CommentSortMode = z.infer<typeof commentSortSchema>;
|
||||
export type ReactionType = z.infer<typeof reactionTypeSchema>;
|
||||
export type CreateCommentInput = z.infer<typeof createCommentSchema>;
|
||||
export type UpdateCommentInput = z.infer<typeof updateCommentSchema>;
|
||||
export type DeleteCommentInput = z.infer<typeof deleteCommentSchema>;
|
||||
export type GetCommentsInput = z.infer<typeof getCommentsSchema>;
|
||||
export type ToggleCommentReactionInput = z.infer<
|
||||
typeof toggleCommentReactionSchema
|
||||
>;
|
||||
export type GetCommentReactionsInput = z.infer<
|
||||
typeof getCommentReactionsSchema
|
||||
>;
|
||||
@@ -7,71 +7,10 @@ import { z } from "zod";
|
||||
* Use these schemas for validating database inputs and outputs in tRPC procedures
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// User Schemas
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Full User schema matching database structure
|
||||
*/
|
||||
export const userSchema = z.object({
|
||||
id: z.string(),
|
||||
email: z.string().email().nullable().optional(),
|
||||
email_verified: z.number(),
|
||||
password_hash: z.string().nullable().optional(),
|
||||
display_name: z.string().nullable().optional(),
|
||||
provider: z.enum(["email", "google", "github"]).nullable().optional(),
|
||||
image: z.string().url().nullable().optional(),
|
||||
apple_user_string: z.string().nullable().optional(),
|
||||
database_name: z.string().nullable().optional(),
|
||||
database_token: z.string().nullable().optional(),
|
||||
database_url: z.string().nullable().optional(),
|
||||
db_destroy_date: z.string().nullable().optional(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string()
|
||||
});
|
||||
|
||||
/**
|
||||
* User creation input (for registration)
|
||||
*/
|
||||
export const createUserSchema = z.object({
|
||||
email: z.string().email().optional(),
|
||||
password: z.string().min(8).optional(),
|
||||
display_name: z.string().min(1).max(50).optional(),
|
||||
provider: z.enum(["email", "google", "github"]).optional(),
|
||||
image: z.string().url().optional()
|
||||
});
|
||||
|
||||
/**
|
||||
* User update input (partial updates)
|
||||
*/
|
||||
export const updateUserSchema = z.object({
|
||||
email: z.string().email().optional(),
|
||||
display_name: z.string().min(1).max(50).optional(),
|
||||
image: z.string().url().optional()
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Post Schemas
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Full Post schema matching database structure
|
||||
*/
|
||||
export const postSchema = z.object({
|
||||
id: z.number(),
|
||||
category: z.enum(["blog", "project"]),
|
||||
title: z.string(),
|
||||
subtitle: z.string().optional(),
|
||||
body: z.string(),
|
||||
banner_photo: z.string().optional(),
|
||||
date: z.string(),
|
||||
published: z.boolean(),
|
||||
author_id: z.string(),
|
||||
reads: z.number(),
|
||||
attachments: z.string().optional()
|
||||
});
|
||||
|
||||
/**
|
||||
* Post creation input
|
||||
*/
|
||||
@@ -97,47 +36,6 @@ export const updatePostSchema = z.object({
|
||||
attachments: z.string().optional()
|
||||
});
|
||||
|
||||
/**
|
||||
* Post with aggregated data
|
||||
*/
|
||||
export const postWithCommentsAndLikesSchema = postSchema.extend({
|
||||
total_likes: z.number(),
|
||||
total_comments: z.number()
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Comment Schemas
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Full Comment schema matching database structure
|
||||
*/
|
||||
export const commentSchema = z.object({
|
||||
id: z.number(),
|
||||
body: z.string(),
|
||||
post_id: z.number(),
|
||||
parent_comment_id: z.number().optional(),
|
||||
date: z.string(),
|
||||
edited: z.boolean(),
|
||||
commenter_id: z.string()
|
||||
});
|
||||
|
||||
/**
|
||||
* Comment creation input
|
||||
*/
|
||||
export const createCommentSchema = z.object({
|
||||
body: z.string().min(1).max(5000),
|
||||
post_id: z.number(),
|
||||
parent_comment_id: z.number().optional()
|
||||
});
|
||||
|
||||
/**
|
||||
* Comment update input
|
||||
*/
|
||||
export const updateCommentSchema = z.object({
|
||||
body: z.string().min(1).max(5000)
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// CommentReaction Schemas
|
||||
// ============================================================================
|
||||
@@ -160,94 +58,6 @@ export const reactionTypeSchema = z.enum([
|
||||
"downVote"
|
||||
]);
|
||||
|
||||
/**
|
||||
* Full CommentReaction schema matching database structure
|
||||
*/
|
||||
export const commentReactionSchema = z.object({
|
||||
id: z.number(),
|
||||
type: reactionTypeSchema,
|
||||
comment_id: z.number(),
|
||||
user_id: z.string()
|
||||
});
|
||||
|
||||
/**
|
||||
* Comment reaction creation input
|
||||
*/
|
||||
export const createCommentReactionSchema = z.object({
|
||||
type: reactionTypeSchema,
|
||||
comment_id: z.number()
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// PostLike Schemas
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Full PostLike schema matching database structure
|
||||
*/
|
||||
export const postLikeSchema = z.object({
|
||||
id: z.number(),
|
||||
user_id: z.string(),
|
||||
post_id: z.number()
|
||||
});
|
||||
|
||||
/**
|
||||
* PostLike creation input
|
||||
*/
|
||||
export const createPostLikeSchema = z.object({
|
||||
post_id: z.number()
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Tag Schemas
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Full Tag schema matching database structure
|
||||
*/
|
||||
export const tagSchema = z.object({
|
||||
id: z.number(),
|
||||
value: z.string(),
|
||||
post_id: z.number()
|
||||
});
|
||||
|
||||
/**
|
||||
* Tag creation input
|
||||
*/
|
||||
export const createTagSchema = z.object({
|
||||
value: z.string().min(1).max(50),
|
||||
post_id: z.number()
|
||||
});
|
||||
|
||||
/**
|
||||
* PostWithTags schema
|
||||
*/
|
||||
export const postWithTagsSchema = postSchema.extend({
|
||||
tags: z.array(tagSchema)
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Connection Schemas
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Full Connection schema matching database structure
|
||||
*/
|
||||
export const connectionSchema = z.object({
|
||||
id: z.number(),
|
||||
user_id: z.string(),
|
||||
connection_id: z.string(),
|
||||
post_id: z.number().optional()
|
||||
});
|
||||
|
||||
/**
|
||||
* Connection creation input
|
||||
*/
|
||||
export const createConnectionSchema = z.object({
|
||||
connection_id: z.string(),
|
||||
post_id: z.number().optional()
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Common Query Schemas
|
||||
// ============================================================================
|
||||
@@ -259,26 +69,6 @@ export const idSchema = z.object({
|
||||
id: z.number()
|
||||
});
|
||||
|
||||
export const userIdSchema = z.object({
|
||||
userId: z.string()
|
||||
});
|
||||
|
||||
export const postIdSchema = z.object({
|
||||
postId: z.number()
|
||||
});
|
||||
|
||||
export const commentIdSchema = z.object({
|
||||
commentId: z.number()
|
||||
});
|
||||
|
||||
/**
|
||||
* Pagination schema
|
||||
*/
|
||||
export const paginationSchema = z.object({
|
||||
limit: z.number().min(1).max(100).default(10),
|
||||
offset: z.number().min(0).default(0)
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Additional Database Router Schemas
|
||||
// ============================================================================
|
||||
@@ -343,10 +133,6 @@ export const getUserByIdSchema = z.object({
|
||||
id: z.string()
|
||||
});
|
||||
|
||||
export const getUserPublicDataSchema = z.object({
|
||||
id: z.string()
|
||||
});
|
||||
|
||||
export const updateUserImageSchema = z.object({
|
||||
id: z.string(),
|
||||
imageURL: z.string()
|
||||
@@ -365,15 +151,6 @@ export const updateUserEmailSchema = z.object({
|
||||
export type ReactionType = z.infer<typeof reactionTypeSchema>;
|
||||
export type CreatePostInput = z.infer<typeof createPostSchema>;
|
||||
export type UpdatePostInput = z.infer<typeof updatePostSchema>;
|
||||
export type CreateCommentInput = z.infer<typeof createCommentSchema>;
|
||||
export type UpdateCommentInput = z.infer<typeof updateCommentSchema>;
|
||||
export type CreateCommentReactionInput = z.infer<
|
||||
typeof createCommentReactionSchema
|
||||
>;
|
||||
export type CreatePostLikeInput = z.infer<typeof createPostLikeSchema>;
|
||||
export type CreateTagInput = z.infer<typeof createTagSchema>;
|
||||
export type CreateConnectionInput = z.infer<typeof createConnectionSchema>;
|
||||
export type PaginationInput = z.infer<typeof paginationSchema>;
|
||||
export type GetPostByIdInput = z.infer<typeof getPostByIdSchema>;
|
||||
export type GetPostByTitleInput = z.infer<typeof getPostByTitleSchema>;
|
||||
export type GetCommentsByPostIdInput = z.infer<
|
||||
|
||||
@@ -65,11 +65,6 @@ export const loginUserSchema = z.object({
|
||||
rememberMe: z.boolean().optional().default(false)
|
||||
});
|
||||
|
||||
/**
|
||||
* OAuth provider schema
|
||||
*/
|
||||
export const oauthProviderSchema = z.enum(["google", "github"]);
|
||||
|
||||
// ============================================================================
|
||||
// Profile Management Schemas
|
||||
// ============================================================================
|
||||
@@ -168,20 +163,12 @@ export const deleteAccountSchema = z.object({
|
||||
password: z.string().min(1, "Password is required to delete account")
|
||||
});
|
||||
|
||||
/**
|
||||
* Email verification schema
|
||||
*/
|
||||
export const verifyEmailSchema = z.object({
|
||||
token: z.string().min(1)
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Type Exports
|
||||
// ============================================================================
|
||||
|
||||
export type RegisterUserInput = z.infer<typeof registerUserSchema>;
|
||||
export type LoginUserInput = z.infer<typeof loginUserSchema>;
|
||||
export type OAuthProvider = z.infer<typeof oauthProviderSchema>;
|
||||
export type UpdateEmailInput = z.infer<typeof updateEmailSchema>;
|
||||
export type UpdateDisplayNameInput = z.infer<typeof updateDisplayNameSchema>;
|
||||
export type UpdateProfileImageInput = z.infer<typeof updateProfileImageSchema>;
|
||||
@@ -192,4 +179,3 @@ export type RequestPasswordResetInput = z.infer<
|
||||
>;
|
||||
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;
|
||||
export type DeleteAccountInput = z.infer<typeof deleteAccountSchema>;
|
||||
export type VerifyEmailInput = z.infer<typeof verifyEmailSchema>;
|
||||
|
||||
@@ -89,7 +89,6 @@ describe("Audit Logging System", () => {
|
||||
});
|
||||
|
||||
it("should not throw errors on logging failures", async () => {
|
||||
// This should not throw even if there's an invalid event type
|
||||
await expect(
|
||||
logAuditEvent({
|
||||
eventType: "invalid.test.event",
|
||||
@@ -101,7 +100,6 @@ describe("Audit Logging System", () => {
|
||||
|
||||
describe("queryAuditLogs", () => {
|
||||
beforeEach(async () => {
|
||||
// Create test logs
|
||||
await logAuditEvent({
|
||||
eventType: "auth.login.success",
|
||||
eventData: { test: "test-query-1", testUser: "user-1" },
|
||||
@@ -199,7 +197,6 @@ describe("Audit Logging System", () => {
|
||||
|
||||
describe("getFailedLoginAttempts", () => {
|
||||
beforeEach(async () => {
|
||||
// Create failed login attempts
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await logAuditEvent({
|
||||
eventType: "auth.login.failed",
|
||||
@@ -212,7 +209,6 @@ describe("Audit Logging System", () => {
|
||||
});
|
||||
}
|
||||
|
||||
// Create successful logins (should be excluded)
|
||||
await logAuditEvent({
|
||||
eventType: "auth.login.success",
|
||||
eventData: { test: "test-success-1" },
|
||||
@@ -241,7 +237,6 @@ describe("Audit Logging System", () => {
|
||||
const attemptsIn1h = await getFailedLoginAttempts(1, 100);
|
||||
|
||||
expect(attemptsIn24h.length).toBeGreaterThanOrEqual(5);
|
||||
// Recent attempts should be within 1 hour
|
||||
expect(attemptsIn1h.length).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
});
|
||||
@@ -302,7 +297,6 @@ describe("Audit Logging System", () => {
|
||||
|
||||
describe("detectSuspiciousActivity", () => {
|
||||
beforeEach(async () => {
|
||||
// Create suspicious pattern: many failed logins from same IP
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await logAuditEvent({
|
||||
eventType: "auth.login.failed",
|
||||
@@ -315,7 +309,6 @@ describe("Audit Logging System", () => {
|
||||
});
|
||||
}
|
||||
|
||||
// Create normal activity
|
||||
await logAuditEvent({
|
||||
eventType: "auth.login.success",
|
||||
eventData: { test: "test-normal-1" },
|
||||
@@ -344,7 +337,6 @@ describe("Audit Logging System", () => {
|
||||
it("should return empty array when no suspicious activity", async () => {
|
||||
await cleanupTestLogs();
|
||||
|
||||
// Create only successful logins
|
||||
await logAuditEvent({
|
||||
eventType: "auth.login.success",
|
||||
eventData: { test: "test-clean-1" },
|
||||
@@ -378,7 +370,6 @@ describe("Audit Logging System", () => {
|
||||
]
|
||||
});
|
||||
|
||||
// Clean up logs older than 90 days
|
||||
const deleted = await cleanupOldLogs(90);
|
||||
|
||||
expect(deleted).toBeGreaterThanOrEqual(1);
|
||||
@@ -399,7 +390,6 @@ describe("Audit Logging System", () => {
|
||||
|
||||
const logsAfter = await queryAuditLogs({ limit: 100 });
|
||||
|
||||
// Should still have recent logs
|
||||
expect(logsAfter.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -405,7 +405,6 @@ export async function detectSuspiciousActivity(
|
||||
const currentIp = currentIpOrMinAttempts as string;
|
||||
const reasons: string[] = [];
|
||||
|
||||
// Check for excessive failed logins
|
||||
const failedAttempts = (await getFailedLoginAttempts(
|
||||
userId,
|
||||
"user_id",
|
||||
@@ -415,7 +414,6 @@ export async function detectSuspiciousActivity(
|
||||
reasons.push(`${failedAttempts} failed login attempts in last 15 minutes`);
|
||||
}
|
||||
|
||||
// Check for rapid location changes (different IPs in short time)
|
||||
const recentIps = await conn.execute({
|
||||
sql: `SELECT DISTINCT ip_address FROM AuditLog
|
||||
WHERE user_id = ?
|
||||
@@ -431,7 +429,6 @@ export async function detectSuspiciousActivity(
|
||||
);
|
||||
}
|
||||
|
||||
// Check for new IP if user has login history
|
||||
const ipHistory = await conn.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM AuditLog
|
||||
WHERE user_id = ?
|
||||
|
||||
@@ -100,7 +100,6 @@ export async function withCacheAndStale<T>(
|
||||
const now = Date.now();
|
||||
const entry = store.get(key) as CacheEntry<T> | undefined;
|
||||
|
||||
// Fresh hit
|
||||
if (entry && entry.expiresAt > now) return entry.data;
|
||||
|
||||
try {
|
||||
@@ -116,7 +115,6 @@ export async function withCacheAndStale<T>(
|
||||
console.error(`Error fetching data for cache key "${key}":`, error);
|
||||
}
|
||||
|
||||
// Stale fallback
|
||||
if (entry && entry.staleExpiresAt > now) {
|
||||
if (logErrors) console.log(`Serving stale data for cache key "${key}"`);
|
||||
return entry.data;
|
||||
|
||||
@@ -254,7 +254,6 @@ describe("Clerk user.created webhook", () => {
|
||||
|
||||
describe("Clerk user.updated webhook", () => {
|
||||
it("updates mutable fields and leaves clerkUserId unchanged", async () => {
|
||||
// seed via created
|
||||
await call(sign(userCreatedPayload()));
|
||||
|
||||
const before = getUserByClerkId("user_abc123");
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import type { H3Event } from "vinxi/http";
|
||||
import { UAParser } from "ua-parser-js";
|
||||
|
||||
export interface DeviceInfo {
|
||||
deviceName?: string;
|
||||
deviceType?: "desktop" | "mobile" | "tablet";
|
||||
@@ -8,61 +5,6 @@ export interface DeviceInfo {
|
||||
os?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse user agent string to extract device information
|
||||
* @param userAgent - User agent string from request headers
|
||||
* @returns Parsed device information
|
||||
*/
|
||||
export function parseDeviceInfo(userAgent: string): DeviceInfo {
|
||||
const parser = new UAParser(userAgent);
|
||||
const result = parser.getResult();
|
||||
|
||||
// Determine device type
|
||||
let deviceType: "desktop" | "mobile" | "tablet" = "desktop";
|
||||
if (result.device.type === "mobile") {
|
||||
deviceType = "mobile";
|
||||
} else if (result.device.type === "tablet") {
|
||||
deviceType = "tablet";
|
||||
}
|
||||
|
||||
// Build device name (e.g., "iPhone 14", "Windows PC", "iPad Pro")
|
||||
let deviceName: string | undefined;
|
||||
if (result.device.vendor && result.device.model) {
|
||||
deviceName = `${result.device.vendor} ${result.device.model}`;
|
||||
} else if (result.os.name) {
|
||||
deviceName = `${result.os.name} ${deviceType === "desktop" ? "Computer" : deviceType}`;
|
||||
}
|
||||
|
||||
// Browser info (e.g., "Chrome 120")
|
||||
const browser =
|
||||
result.browser.name && result.browser.version
|
||||
? `${result.browser.name} ${result.browser.version.split(".")[0]}`
|
||||
: result.browser.name;
|
||||
|
||||
// OS info (e.g., "macOS 14.1", "Windows 11", "iOS 17")
|
||||
const os =
|
||||
result.os.name && result.os.version
|
||||
? `${result.os.name} ${result.os.version}`
|
||||
: result.os.name;
|
||||
|
||||
return {
|
||||
deviceName,
|
||||
deviceType,
|
||||
browser,
|
||||
os
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract device information from H3Event
|
||||
* @param event - H3Event
|
||||
* @returns Device information
|
||||
*/
|
||||
export function getDeviceInfo(event: H3Event): DeviceInfo {
|
||||
const userAgent = event.node.req.headers["user-agent"] || "";
|
||||
return parseDeviceInfo(userAgent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a human-readable device description
|
||||
* @param deviceInfo - Device information
|
||||
@@ -85,18 +27,3 @@ export function formatDeviceDescription(deviceInfo: DeviceInfo): string {
|
||||
|
||||
return parts.length > 0 ? parts.join(" • ") : "Unknown Device";
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a short device fingerprint for comparison
|
||||
* Not cryptographic, just for grouping similar logins
|
||||
* @param deviceInfo - Device information
|
||||
* @returns Short fingerprint string
|
||||
*/
|
||||
export function createDeviceFingerprint(deviceInfo: DeviceInfo): string {
|
||||
const parts = [
|
||||
deviceInfo.deviceType || "unknown",
|
||||
deviceInfo.os?.split(" ")[0] || "unknown",
|
||||
deviceInfo.browser?.split(" ")[0] || "unknown"
|
||||
];
|
||||
return parts.join("-").toLowerCase();
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import loginLinkTemplate from "./login-link.html?raw";
|
||||
import passwordResetTemplate from "./password-reset.html?raw";
|
||||
import emailVerificationTemplate from "./email-verification.html?raw";
|
||||
import providerLinkedTemplate from "./provider-linked.html?raw";
|
||||
import newDeviceLoginTemplate from "./new-device-login.html?raw";
|
||||
import passwordSetTemplate from "./password-set.html?raw";
|
||||
|
||||
/**
|
||||
@@ -119,29 +118,6 @@ export function generateProviderLinkedEmail(
|
||||
});
|
||||
}
|
||||
|
||||
export interface NewDeviceLoginEmailParams {
|
||||
deviceInfo: string;
|
||||
loginTime: string;
|
||||
ipAddress: string;
|
||||
loginMethod: string;
|
||||
accountUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate new device login notification email HTML
|
||||
*/
|
||||
export function generateNewDeviceLoginEmail(
|
||||
params: NewDeviceLoginEmailParams
|
||||
): string {
|
||||
return processTemplate(newDeviceLoginTemplate, {
|
||||
DEVICE_INFO: params.deviceInfo,
|
||||
LOGIN_TIME: params.loginTime,
|
||||
IP_ADDRESS: params.ipAddress,
|
||||
LOGIN_METHOD: params.loginMethod,
|
||||
ACCOUNT_URL: params.accountUrl
|
||||
});
|
||||
}
|
||||
|
||||
export interface PasswordSetEmailParams {
|
||||
providerName: string;
|
||||
setTime: string;
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
async function testTimeoutError() {
|
||||
console.log("\n=== Testing Timeout Error ===");
|
||||
try {
|
||||
// This should timeout after 1ms
|
||||
await fetchWithTimeout("https://httpbin.org/delay/10", { timeout: 1 });
|
||||
console.log("❌ Should have thrown TimeoutError");
|
||||
} catch (error) {
|
||||
@@ -29,7 +28,6 @@ async function testTimeoutError() {
|
||||
async function testNetworkError() {
|
||||
console.log("\n=== Testing Network Error ===");
|
||||
try {
|
||||
// This should fail to connect
|
||||
await fetchWithTimeout(
|
||||
"https://invalid-domain-that-does-not-exist-12345.com"
|
||||
);
|
||||
@@ -47,7 +45,6 @@ async function testNetworkError() {
|
||||
async function testAPIError() {
|
||||
console.log("\n=== Testing API Error ===");
|
||||
try {
|
||||
// This should return 404
|
||||
const response = await fetchWithTimeout("https://httpbin.org/status/404");
|
||||
await checkResponse(response);
|
||||
console.log("❌ Should have thrown APIError");
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { defineMiddleware, setHeaders } from "vinxi/http";
|
||||
|
||||
// Security headers middleware — sets CSP and hardening headers on all responses
|
||||
export default defineMiddleware({
|
||||
onRequest: (event) => {
|
||||
setHeaders(event, {
|
||||
"Content-Security-Policy":
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||
"Permissions-Policy": "camera=(), microphone=(), geolocation=()"
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -57,7 +57,6 @@ describe("verifyNessaToken with Clerk JWT", () => {
|
||||
expect(result.exp).toBe(mockPayload.exp);
|
||||
expect(result.iat).toBe(mockPayload.iat);
|
||||
|
||||
// Verify verifyToken was called with correct options
|
||||
expect(mockVerifyToken).toHaveBeenCalledWith(
|
||||
"valid-clerk-session-token",
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -33,8 +33,6 @@ export async function verifyNessaToken(
|
||||
): Promise<NessaAuthPayload> {
|
||||
const payload = await verifyToken(token, {
|
||||
secretKey: env.NESSA_CLERK_SECRET,
|
||||
// Optional: restrict to specific issuers / apps
|
||||
// audience: env.NESSA_CLERK_JWT_ISSUER,
|
||||
});
|
||||
|
||||
if (!payload.sub) {
|
||||
|
||||
@@ -34,7 +34,6 @@ export async function linkProvider(
|
||||
): Promise<UserProvider> {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
// Check if provider already linked to this user
|
||||
const existing = await conn.execute({
|
||||
sql: "SELECT * FROM UserProvider WHERE user_id = ? AND provider = ?",
|
||||
args: [userId, provider]
|
||||
@@ -44,7 +43,6 @@ export async function linkProvider(
|
||||
throw new Error(`Provider ${provider} already linked to this account`);
|
||||
}
|
||||
|
||||
// Check if provider identity is already used by another user
|
||||
if (providerData.providerUserId) {
|
||||
const conflictCheck = await conn.execute({
|
||||
sql: "SELECT user_id FROM UserProvider WHERE provider = ? AND provider_user_id = ?",
|
||||
@@ -61,7 +59,6 @@ export async function linkProvider(
|
||||
}
|
||||
}
|
||||
|
||||
// Create new provider link
|
||||
const id = uuidV4();
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO UserProvider (id, user_id, provider, provider_user_id, email, display_name, image)
|
||||
@@ -77,7 +74,6 @@ export async function linkProvider(
|
||||
]
|
||||
});
|
||||
|
||||
// Fetch created record
|
||||
const result = await conn.execute({
|
||||
sql: "SELECT * FROM UserProvider WHERE id = ?",
|
||||
args: [id]
|
||||
@@ -85,7 +81,6 @@ export async function linkProvider(
|
||||
|
||||
const userProvider = result.rows[0] as unknown as UserProvider;
|
||||
|
||||
// Log audit event
|
||||
await logAuditEvent({
|
||||
userId,
|
||||
eventType: "auth.provider.linked",
|
||||
@@ -99,7 +94,6 @@ export async function linkProvider(
|
||||
// Send notification email if requested and user has email
|
||||
if (options?.sendEmail !== false) {
|
||||
try {
|
||||
// Get user email
|
||||
const userResult = await conn.execute({
|
||||
sql: "SELECT email FROM User WHERE id = ?",
|
||||
args: [userId]
|
||||
@@ -150,7 +144,6 @@ export async function unlinkProvider(
|
||||
): Promise<void> {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
// Check how many providers this user has
|
||||
const providersResult = await conn.execute({
|
||||
sql: "SELECT COUNT(*) as count FROM UserProvider WHERE user_id = ?",
|
||||
args: [userId]
|
||||
@@ -164,7 +157,6 @@ export async function unlinkProvider(
|
||||
);
|
||||
}
|
||||
|
||||
// Delete provider
|
||||
const result = await conn.execute({
|
||||
sql: "DELETE FROM UserProvider WHERE user_id = ? AND provider = ?",
|
||||
args: [userId, provider]
|
||||
@@ -174,7 +166,6 @@ export async function unlinkProvider(
|
||||
throw new Error(`Provider ${provider} not found for this user`);
|
||||
}
|
||||
|
||||
// Log audit event
|
||||
await logAuditEvent({
|
||||
userId,
|
||||
eventType: "auth.provider.unlinked",
|
||||
@@ -261,7 +252,6 @@ export async function findUserByProviderEmail(
|
||||
export async function findUserByEmail(email: string): Promise<string | null> {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
// First check User table
|
||||
const userResult = await conn.execute({
|
||||
sql: "SELECT id FROM User WHERE email = ?",
|
||||
args: [email]
|
||||
@@ -271,7 +261,6 @@ export async function findUserByEmail(email: string): Promise<string | null> {
|
||||
return (userResult.rows[0] as any).id;
|
||||
}
|
||||
|
||||
// Then check UserProvider table
|
||||
const providerResult = await conn.execute({
|
||||
sql: "SELECT user_id FROM UserProvider WHERE email = ? LIMIT 1",
|
||||
args: [email]
|
||||
|
||||
@@ -21,7 +21,6 @@ describe("CSRF Protection", () => {
|
||||
const token = generateCSRFToken();
|
||||
expect(token).toBeDefined();
|
||||
expect(typeof token).toBe("string");
|
||||
// UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
|
||||
expect(token).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
);
|
||||
@@ -34,7 +33,6 @@ describe("CSRF Protection", () => {
|
||||
});
|
||||
|
||||
it("should generate cryptographically secure tokens", () => {
|
||||
// Generate multiple tokens and ensure no collisions
|
||||
const tokens = new Set<string>();
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
tokens.add(generateCSRFToken());
|
||||
@@ -50,7 +48,6 @@ describe("CSRF Protection", () => {
|
||||
|
||||
expect(token).toBeDefined();
|
||||
expect(typeof token).toBe("string");
|
||||
// Token should be a UUID
|
||||
expect(token).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
);
|
||||
@@ -122,7 +119,6 @@ describe("CSRF Protection", () => {
|
||||
const invalidToken1 = "b".repeat(36);
|
||||
const invalidToken2 = "b".repeat(35) + "a";
|
||||
|
||||
// Test timing for completely different tokens
|
||||
const event1 = createMockEvent({
|
||||
headers: { "x-csrf-token": invalidToken1 },
|
||||
cookies: { "csrf-token": validToken }
|
||||
@@ -132,7 +128,6 @@ describe("CSRF Protection", () => {
|
||||
validateCSRFToken(event1);
|
||||
const time1 = performance.now() - start1;
|
||||
|
||||
// Test timing for tokens that differ only at the end
|
||||
const event2 = createMockEvent({
|
||||
headers: { "x-csrf-token": invalidToken2 },
|
||||
cookies: { "csrf-token": validToken }
|
||||
@@ -142,7 +137,6 @@ describe("CSRF Protection", () => {
|
||||
validateCSRFToken(event2);
|
||||
const time2 = performance.now() - start2;
|
||||
|
||||
// Timing difference should be minimal (less than 1ms)
|
||||
// This tests for constant-time comparison
|
||||
const timeDiff = Math.abs(time1 - time2);
|
||||
expect(timeDiff).toBeLessThan(1);
|
||||
@@ -161,7 +155,6 @@ describe("CSRF Protection", () => {
|
||||
|
||||
describe("CSRF Attack Scenarios", () => {
|
||||
it("should prevent basic CSRF attack", () => {
|
||||
// Attacker doesn't have access to the CSRF token cookie
|
||||
const attackEvent = createMockEvent({
|
||||
headers: { "x-csrf-token": "attacker-guessed-token" }
|
||||
});
|
||||
@@ -174,7 +167,6 @@ describe("CSRF Protection", () => {
|
||||
const token1 = generateCSRFToken();
|
||||
const token2 = generateCSRFToken();
|
||||
|
||||
// User has token1, attacker tries to use token2
|
||||
const event = createMockEvent({
|
||||
headers: { "x-csrf-token": token2 },
|
||||
cookies: { "csrf-token": token1 }
|
||||
@@ -198,7 +190,6 @@ describe("CSRF Protection", () => {
|
||||
});
|
||||
|
||||
it("should prevent replay attacks with old tokens", () => {
|
||||
// Simulate an old token that was captured
|
||||
const oldToken = "old-captured-token-12345";
|
||||
|
||||
const event = createMockEvent({
|
||||
@@ -206,10 +197,8 @@ describe("CSRF Protection", () => {
|
||||
cookies: { "csrf-token": oldToken }
|
||||
});
|
||||
|
||||
// Even if tokens match, they should be validated by the system
|
||||
// This test validates the structure works correctly
|
||||
const isValid = validateCSRFToken(event);
|
||||
expect(isValid).toBe(true); // Matches are valid
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -270,9 +259,7 @@ describe("CSRF Protection", () => {
|
||||
tokens.push(generateCSRFToken());
|
||||
}
|
||||
|
||||
// Check for sequential patterns
|
||||
for (let i = 1; i < tokens.length; i++) {
|
||||
// Tokens should not be incrementing
|
||||
expect(tokens[i]).not.toBe(
|
||||
String(Number(tokens[i - 1].replace(/-/g, "")) + 1)
|
||||
);
|
||||
@@ -281,11 +268,9 @@ describe("CSRF Protection", () => {
|
||||
|
||||
it("should generate tokens with sufficient entropy", () => {
|
||||
const token = generateCSRFToken();
|
||||
// UUID without dashes should be 32 hex characters
|
||||
const hexString = token.replace(/-/g, "");
|
||||
expect(hexString).toMatch(/^[0-9a-f]{32}$/i);
|
||||
|
||||
// Check that not all characters are the same
|
||||
const uniqueChars = new Set(hexString.split(""));
|
||||
expect(uniqueChars.size).toBeGreaterThan(5);
|
||||
});
|
||||
@@ -299,7 +284,6 @@ describe("CSRF Protection", () => {
|
||||
}
|
||||
const duration = performance.now() - start;
|
||||
|
||||
// Should generate 1000 tokens in less than 100ms
|
||||
expect(duration).toBeLessThan(100);
|
||||
});
|
||||
|
||||
@@ -316,7 +300,6 @@ describe("CSRF Protection", () => {
|
||||
}
|
||||
const duration = performance.now() - start;
|
||||
|
||||
// Should validate 10000 tokens in less than 100ms
|
||||
expect(duration).toBeLessThan(100);
|
||||
});
|
||||
});
|
||||
@@ -402,7 +385,6 @@ describe("CSRF Protection", () => {
|
||||
const sessionAToken = generateCSRFToken();
|
||||
const sessionBToken = generateCSRFToken();
|
||||
|
||||
// Session A's cookie with Session B's header token
|
||||
const event = createMockEvent({
|
||||
headers: { "x-csrf-token": sessionBToken },
|
||||
cookies: { "csrf-token": sessionAToken }
|
||||
@@ -522,12 +504,10 @@ describe("CSRF Protection", () => {
|
||||
it("should issue CSRF token on setCSRFToken then validate it", () => {
|
||||
const event = createMockEvent({});
|
||||
|
||||
// Step 1: Login issues CSRF token
|
||||
const token = setCSRFToken(event);
|
||||
expect(token).toBeDefined();
|
||||
expect(typeof token).toBe("string");
|
||||
|
||||
// Step 2: Subsequent mutation sends token back
|
||||
const mutationEvent = createMockEvent({
|
||||
headers: { "x-csrf-token": token },
|
||||
cookies: { "csrf-token": token }
|
||||
@@ -538,7 +518,6 @@ describe("CSRF Protection", () => {
|
||||
});
|
||||
|
||||
it("should reject cross-origin POST without CSRF token", () => {
|
||||
// Simulated cross-site POST: attacker can read cookies but not set headers
|
||||
const attackEvent = createMockEvent({
|
||||
// No x-csrf-token header (cross-origin requests can't set custom headers)
|
||||
cookies: { "csrf-token": "victim-token" }
|
||||
|
||||
@@ -59,7 +59,6 @@ describe("Input Validation and Injection Prevention", () => {
|
||||
for (const email of sqlEmails) {
|
||||
// Either reject as invalid, or it's properly escaped in queries
|
||||
const isValid = isValidEmail(email);
|
||||
// Test documents the behavior
|
||||
expect(typeof isValid).toBe("boolean");
|
||||
}
|
||||
});
|
||||
@@ -68,7 +67,6 @@ describe("Input Validation and Injection Prevention", () => {
|
||||
const longEmail = "a".repeat(1000) + "@example.com";
|
||||
const result = isValidEmail(longEmail);
|
||||
|
||||
// Should handle gracefully
|
||||
expect(typeof result).toBe("boolean");
|
||||
});
|
||||
|
||||
@@ -130,7 +128,6 @@ describe("Input Validation and Injection Prevention", () => {
|
||||
it("should use parameterized queries for user authentication", async () => {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
// Test that SQL injection attempts don't work
|
||||
const maliciousEmail = "admin'--";
|
||||
|
||||
try {
|
||||
@@ -140,7 +137,6 @@ describe("Input Validation and Injection Prevention", () => {
|
||||
args: [maliciousEmail]
|
||||
});
|
||||
|
||||
// Should return no results (no user with that exact email)
|
||||
expect(result.rows.length).toBe(0);
|
||||
} catch (error) {
|
||||
// If error, ensure it's not a SQL error
|
||||
@@ -153,7 +149,6 @@ describe("Input Validation and Injection Prevention", () => {
|
||||
|
||||
for (const payload of SQL_INJECTION_PAYLOADS) {
|
||||
try {
|
||||
// Test various injection points
|
||||
await conn.execute({
|
||||
sql: "SELECT * FROM User WHERE email = ?",
|
||||
args: [payload]
|
||||
@@ -164,7 +159,6 @@ describe("Input Validation and Injection Prevention", () => {
|
||||
args: [payload]
|
||||
});
|
||||
|
||||
// Queries should complete without SQL errors
|
||||
expect(true).toBe(true);
|
||||
} catch (error: any) {
|
||||
// If error occurs, should not be SQL injection syntax error
|
||||
@@ -184,10 +178,8 @@ describe("Input Validation and Injection Prevention", () => {
|
||||
args: [unionPayload]
|
||||
});
|
||||
|
||||
// Should not return password hashes
|
||||
if (result.rows.length > 0) {
|
||||
for (const row of result.rows) {
|
||||
// Ensure we don't get password_hash column
|
||||
expect(row).not.toHaveProperty("password_hash");
|
||||
}
|
||||
}
|
||||
@@ -200,7 +192,6 @@ describe("Input Validation and Injection Prevention", () => {
|
||||
it("should prevent blind SQL injection timing attacks", async () => {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
// Timing-based payload
|
||||
const timingPayload = "admin' AND SLEEP(5)--";
|
||||
|
||||
const start = performance.now();
|
||||
@@ -210,18 +201,15 @@ describe("Input Validation and Injection Prevention", () => {
|
||||
args: [timingPayload]
|
||||
});
|
||||
} catch (error) {
|
||||
// Ignore errors
|
||||
}
|
||||
const duration = performance.now() - start;
|
||||
|
||||
// Should not delay for 5 seconds
|
||||
expect(duration).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
it("should prevent second-order SQL injection", async () => {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
// Store malicious data
|
||||
const maliciousName = "admin'--";
|
||||
|
||||
try {
|
||||
@@ -236,7 +224,6 @@ describe("Input Validation and Injection Prevention", () => {
|
||||
]
|
||||
});
|
||||
|
||||
// Retrieve and use (should still be safe with parameterized queries)
|
||||
const result = await conn.execute({
|
||||
sql: "SELECT display_name FROM User WHERE email = ?",
|
||||
args: ["test-sqli@example.com"]
|
||||
@@ -244,13 +231,11 @@ describe("Input Validation and Injection Prevention", () => {
|
||||
|
||||
expect(result.rows.length).toBeGreaterThanOrEqual(0);
|
||||
|
||||
// Cleanup
|
||||
await conn.execute({
|
||||
sql: "DELETE FROM User WHERE email = ?",
|
||||
args: ["test-sqli@example.com"]
|
||||
});
|
||||
} catch (error) {
|
||||
// Should not have SQL syntax errors
|
||||
expect(error).toBeDefined();
|
||||
}
|
||||
});
|
||||
@@ -260,7 +245,6 @@ describe("Input Validation and Injection Prevention", () => {
|
||||
it("should identify potentially dangerous XSS patterns", () => {
|
||||
// These payloads should be handled by frontend sanitization
|
||||
for (const payload of XSS_PAYLOADS) {
|
||||
// Document that these patterns exist
|
||||
expect(payload).toBeDefined();
|
||||
expect(typeof payload).toBe("string");
|
||||
|
||||
@@ -272,11 +256,9 @@ describe("Input Validation and Injection Prevention", () => {
|
||||
it("should handle script tags in user input", () => {
|
||||
const scriptInput = "<script>alert('XSS')</script>";
|
||||
|
||||
// Validation should not crash
|
||||
const nameValid = isValidDisplayName(scriptInput);
|
||||
expect(typeof nameValid).toBe("boolean");
|
||||
|
||||
// Email validation
|
||||
const emailValid = isValidEmail(scriptInput);
|
||||
expect(typeof emailValid).toBe("boolean");
|
||||
});
|
||||
@@ -450,7 +432,6 @@ describe("Input Validation and Injection Prevention", () => {
|
||||
|
||||
expect(typeof emailValid).toBe("boolean");
|
||||
expect(typeof nameValid).toBe("boolean");
|
||||
// Should complete quickly (no ReDoS)
|
||||
expect(duration).toBeLessThan(100);
|
||||
});
|
||||
|
||||
@@ -508,14 +489,12 @@ describe("Input Validation and Injection Prevention", () => {
|
||||
});
|
||||
|
||||
it("should not be vulnerable to ReDoS attacks", () => {
|
||||
// ReDoS payload with many repetitions
|
||||
const redosPayload = "a".repeat(1000) + "!";
|
||||
|
||||
const start = performance.now();
|
||||
validatePassword(redosPayload);
|
||||
const duration = performance.now() - start;
|
||||
|
||||
// Should complete quickly
|
||||
expect(duration).toBeLessThan(100);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,7 +20,6 @@ describe("Password Security", () => {
|
||||
|
||||
expect(hash).toBeDefined();
|
||||
expect(typeof hash).toBe("string");
|
||||
// Bcrypt hashes start with $2b$ or $2a$
|
||||
expect(hash).toMatch(/^\$2[ab]\$/);
|
||||
});
|
||||
|
||||
@@ -36,7 +35,6 @@ describe("Password Security", () => {
|
||||
const password = "TestPassword123!";
|
||||
const hash = await hashPassword(password);
|
||||
|
||||
// Bcrypt hashes are 60 characters long
|
||||
expect(hash.length).toBe(60);
|
||||
});
|
||||
|
||||
@@ -132,32 +130,26 @@ describe("Password Security", () => {
|
||||
const password = "TestPassword123!";
|
||||
const hash = await hashPassword(password);
|
||||
|
||||
// Measure time for correct password
|
||||
const { duration: correctDuration } = await measureTime(() =>
|
||||
checkPasswordSafe(password, hash)
|
||||
);
|
||||
|
||||
// Measure time for incorrect password
|
||||
const { duration: incorrectDuration } = await measureTime(() =>
|
||||
checkPasswordSafe("WrongPassword123!", hash)
|
||||
);
|
||||
|
||||
// Bcrypt comparison should take similar time regardless
|
||||
const timingDifference = Math.abs(correctDuration - incorrectDuration);
|
||||
|
||||
// Allow reasonable variance (bcrypt is inherently slow)
|
||||
expect(timingDifference).toBeLessThan(50);
|
||||
});
|
||||
|
||||
it("should handle null hash without timing leak", async () => {
|
||||
const password = "TestPassword123!";
|
||||
|
||||
// Measure time for null hash
|
||||
const { result: result1, duration: duration1 } = await measureTime(() =>
|
||||
checkPasswordSafe(password, null)
|
||||
);
|
||||
|
||||
// Measure time for undefined hash
|
||||
const { result: result2, duration: duration2 } = await measureTime(() =>
|
||||
checkPasswordSafe(password, undefined)
|
||||
);
|
||||
@@ -165,7 +157,6 @@ describe("Password Security", () => {
|
||||
expect(result1).toBe(false);
|
||||
expect(result2).toBe(false);
|
||||
|
||||
// Should take similar time
|
||||
const timingDifference = Math.abs(duration1 - duration2);
|
||||
expect(timingDifference).toBeLessThan(50);
|
||||
});
|
||||
@@ -178,7 +169,6 @@ describe("Password Security", () => {
|
||||
checkPasswordSafe(password, null)
|
||||
);
|
||||
|
||||
// Should take at least a few milliseconds (bcrypt is slow)
|
||||
expect(duration).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
@@ -186,12 +176,10 @@ describe("Password Security", () => {
|
||||
const password = "TestPassword123!";
|
||||
const hash = await hashPassword(password);
|
||||
|
||||
// User exists
|
||||
const { duration: existsDuration } = await measureTime(() =>
|
||||
checkPasswordSafe("WrongPassword", hash)
|
||||
);
|
||||
|
||||
// User doesn't exist (null hash)
|
||||
const { duration: notExistsDuration } = await measureTime(() =>
|
||||
checkPasswordSafe("WrongPassword", null)
|
||||
);
|
||||
@@ -280,9 +268,9 @@ describe("Password Security", () => {
|
||||
});
|
||||
|
||||
it("should calculate password strength correctly", () => {
|
||||
const fairPassword = "MyP@ssw0rd12"; // 12 chars
|
||||
const goodPassword = "MyStr0ng!P@ssw0rd"; // 17 chars
|
||||
const strongPassword = "MyV3ry!Str0ng@P@ssw0rd123"; // 25 chars
|
||||
const fairPassword = "MyP@ssw0rd12";
|
||||
const goodPassword = "MyStr0ng!P@ssw0rd";
|
||||
const strongPassword = "MyV3ry!Str0ng@P@ssw0rd123";
|
||||
|
||||
expect(validatePassword(fairPassword).strength).toBe("fair");
|
||||
expect(validatePassword(goodPassword).strength).toBe("good");
|
||||
@@ -337,7 +325,6 @@ describe("Password Security", () => {
|
||||
const password = "TestPassword123!";
|
||||
const hash = await hashPassword(password);
|
||||
|
||||
// Measure time for multiple checks (simulating brute force)
|
||||
const start = performance.now();
|
||||
const attempts = 10;
|
||||
|
||||
@@ -348,7 +335,6 @@ describe("Password Security", () => {
|
||||
const duration = performance.now() - start;
|
||||
const avgPerAttempt = duration / attempts;
|
||||
|
||||
// Each attempt should take significant time (bcrypt is slow)
|
||||
// This makes brute force impractical
|
||||
expect(avgPerAttempt).toBeGreaterThan(5); // At least 5ms per attempt
|
||||
});
|
||||
@@ -356,18 +342,15 @@ describe("Password Security", () => {
|
||||
it("should prevent rainbow table attacks with unique salts", async () => {
|
||||
const password = "CommonPassword123!";
|
||||
|
||||
// Generate multiple hashes for same password
|
||||
const hashes = await Promise.all(
|
||||
Array.from({ length: 10 }, () => hashPassword(password))
|
||||
);
|
||||
|
||||
// All hashes should be unique (different salts)
|
||||
const uniqueHashes = new Set(hashes);
|
||||
expect(uniqueHashes.size).toBe(10);
|
||||
});
|
||||
|
||||
it("should prevent password spraying with validation", () => {
|
||||
// Common passwords that should be rejected
|
||||
const commonPasswords = [
|
||||
"Password123!",
|
||||
"Welcome123!",
|
||||
@@ -382,7 +365,6 @@ describe("Password Security", () => {
|
||||
});
|
||||
|
||||
it("should resist dictionary attacks", () => {
|
||||
// Dictionary words that should be caught
|
||||
const dictionaryBased = ["Sunshine123!", "Princess456!", "Dragon789!@"];
|
||||
|
||||
for (const password of dictionaryBased) {
|
||||
@@ -394,7 +376,7 @@ describe("Password Security", () => {
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("should handle very long passwords", async () => {
|
||||
const longPassword = "A1!a" + "x".repeat(1000); // Very long but valid
|
||||
const longPassword = "A1!a" + "x".repeat(1000);
|
||||
const hash = await hashPassword(longPassword);
|
||||
const match = await checkPassword(longPassword, hash);
|
||||
|
||||
@@ -413,7 +395,6 @@ describe("Password Security", () => {
|
||||
const hash = await hashPassword(nullBytePassword);
|
||||
const match = await checkPassword(nullBytePassword, hash);
|
||||
|
||||
// Behavior may vary - just ensure no crash
|
||||
expect(typeof match).toBe("boolean");
|
||||
});
|
||||
|
||||
@@ -450,7 +431,6 @@ describe("Password Security", () => {
|
||||
const duration = performance.now() - start;
|
||||
|
||||
// Bcrypt should be slow enough to deter brute force
|
||||
// With 10 rounds, should take at least a few milliseconds
|
||||
expect(duration).toBeGreaterThan(5);
|
||||
// But not too slow for normal operation
|
||||
expect(duration).toBeLessThan(500);
|
||||
@@ -467,11 +447,9 @@ describe("Password Security", () => {
|
||||
durations.push(performance.now() - start);
|
||||
}
|
||||
|
||||
// Timing should be relatively consistent
|
||||
const avg = durations.reduce((a, b) => a + b, 0) / durations.length;
|
||||
const maxDeviation = Math.max(...durations.map((d) => Math.abs(d - avg)));
|
||||
|
||||
// Allow reasonable variance
|
||||
expect(maxDeviation).toBeLessThan(avg * 0.5);
|
||||
});
|
||||
|
||||
@@ -484,7 +462,6 @@ describe("Password Security", () => {
|
||||
}
|
||||
const duration = performance.now() - start;
|
||||
|
||||
// Validation is CPU-bound but should be fast
|
||||
expect(duration).toBeLessThan(100);
|
||||
});
|
||||
});
|
||||
@@ -494,12 +471,9 @@ describe("Password Security", () => {
|
||||
const password = "TestPassword123!";
|
||||
const hash = await hashPassword(password);
|
||||
|
||||
// Check that hash uses correct salt rounds
|
||||
// Bcrypt format: $2b$rounds$salthash
|
||||
const parts = hash.split("$");
|
||||
const rounds = parseInt(parts[2]);
|
||||
|
||||
// Should use 10 rounds (from password.ts)
|
||||
expect(rounds).toBe(10);
|
||||
});
|
||||
|
||||
@@ -509,17 +483,14 @@ describe("Password Security", () => {
|
||||
Array.from({ length: 100 }, () => hashPassword(password))
|
||||
);
|
||||
|
||||
// Extract salts from hashes
|
||||
const salts = hashes.map((hash) => {
|
||||
const parts = hash.split("$");
|
||||
return parts[3].substring(0, 22); // Salt is 22 characters
|
||||
});
|
||||
|
||||
// All salts should be unique
|
||||
const uniqueSalts = new Set(salts);
|
||||
expect(uniqueSalts.size).toBe(100);
|
||||
|
||||
// Check for patterns in salts (should be random)
|
||||
for (let i = 1; i < salts.length; i++) {
|
||||
// Salts should not be sequential or predictable
|
||||
expect(salts[i]).not.toBe(salts[i - 1]);
|
||||
|
||||
@@ -60,12 +60,10 @@ describe("Rate Limiting", () => {
|
||||
const maxAttempts = 3;
|
||||
const windowMs = 60000;
|
||||
|
||||
// Use up all attempts
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
await checkRateLimit(identifier, maxAttempts, windowMs);
|
||||
}
|
||||
|
||||
// Next attempt should throw
|
||||
try {
|
||||
await checkRateLimit(identifier, maxAttempts, windowMs);
|
||||
expect.unreachable("Should have thrown");
|
||||
@@ -79,7 +77,6 @@ describe("Rate Limiting", () => {
|
||||
const maxAttempts = 2;
|
||||
const windowMs = 60000;
|
||||
|
||||
// Use up all attempts
|
||||
await checkRateLimit(identifier, maxAttempts, windowMs);
|
||||
await checkRateLimit(identifier, maxAttempts, windowMs);
|
||||
|
||||
@@ -99,12 +96,10 @@ describe("Rate Limiting", () => {
|
||||
const maxAttempts = 3;
|
||||
const windowMs = 500; // 500ms window for testing
|
||||
|
||||
// Use up all attempts
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
await checkRateLimit(identifier, maxAttempts, windowMs);
|
||||
}
|
||||
|
||||
// Should be blocked immediately after
|
||||
try {
|
||||
await checkRateLimit(identifier, maxAttempts, windowMs);
|
||||
expect.unreachable("Should have thrown");
|
||||
@@ -112,10 +107,8 @@ describe("Rate Limiting", () => {
|
||||
expect(error).toBeInstanceOf(TRPCError);
|
||||
}
|
||||
|
||||
// Wait for window to expire
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
|
||||
// Should be allowed again
|
||||
const remaining = await checkRateLimit(identifier, maxAttempts, windowMs);
|
||||
expect(remaining).toBe(maxAttempts - 1);
|
||||
});
|
||||
@@ -125,13 +118,11 @@ describe("Rate Limiting", () => {
|
||||
const maxAttempts = 10;
|
||||
const windowMs = 60000;
|
||||
|
||||
// Simulate concurrent requests
|
||||
const results: number[] = [];
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
results.push(await checkRateLimit(identifier, maxAttempts, windowMs));
|
||||
}
|
||||
|
||||
// All should succeed with decreasing remaining counts
|
||||
expect(results).toEqual([9, 8, 7, 6, 5, 4, 3, 2, 1, 0]);
|
||||
});
|
||||
|
||||
@@ -142,12 +133,10 @@ describe("Rate Limiting", () => {
|
||||
const id1 = uniqueId("test1");
|
||||
const id2 = uniqueId("test2");
|
||||
|
||||
// Use up attempts for id1
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
await checkRateLimit(id1, maxAttempts, windowMs);
|
||||
}
|
||||
|
||||
// id1 should be blocked
|
||||
try {
|
||||
await checkRateLimit(id1, maxAttempts, windowMs);
|
||||
expect.unreachable("Should have thrown");
|
||||
@@ -155,7 +144,6 @@ describe("Rate Limiting", () => {
|
||||
expect(error).toBeInstanceOf(TRPCError);
|
||||
}
|
||||
|
||||
// id2 should still work
|
||||
const remaining = await checkRateLimit(id2, maxAttempts, windowMs);
|
||||
expect(remaining).toBe(maxAttempts - 1);
|
||||
});
|
||||
@@ -225,12 +213,10 @@ describe("Rate Limiting", () => {
|
||||
const email = `test-${Date.now()}@example.com`;
|
||||
|
||||
// IP rate limiting is skipped in test/dev, so only email limit applies
|
||||
// Use up email rate limit with same email
|
||||
for (let i = 0; i < RATE_LIMITS.LOGIN_EMAIL.maxAttempts; i++) {
|
||||
await rateLimitLogin(email, ip);
|
||||
}
|
||||
|
||||
// Next attempt should fail due to email limit
|
||||
try {
|
||||
await rateLimitLogin(email, ip);
|
||||
expect.unreachable("Should have thrown");
|
||||
@@ -242,12 +228,10 @@ describe("Rate Limiting", () => {
|
||||
it("should limit by email independently of IP", async () => {
|
||||
const email = `test-${Date.now()}@example.com`;
|
||||
|
||||
// Use different IPs but same email
|
||||
for (let i = 0; i < RATE_LIMITS.LOGIN_EMAIL.maxAttempts; i++) {
|
||||
await rateLimitLogin(email, randomIP());
|
||||
}
|
||||
|
||||
// Next attempt with different IP should still fail due to email limit
|
||||
try {
|
||||
await rateLimitLogin(email, randomIP());
|
||||
expect.unreachable("Should have thrown");
|
||||
@@ -260,13 +244,11 @@ describe("Rate Limiting", () => {
|
||||
const ip = randomIP();
|
||||
|
||||
// In test/dev, IP rate limiting is skipped
|
||||
// Should allow many different emails from same IP
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const email = `test${i}-${Date.now()}@example.com`;
|
||||
await rateLimitLogin(email, ip);
|
||||
}
|
||||
|
||||
// Should not throw since IP limits are disabled in test/dev
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -276,12 +258,10 @@ describe("Rate Limiting", () => {
|
||||
const ip = randomIP();
|
||||
|
||||
// IP rate limiting is skipped in test/dev
|
||||
// Should allow many attempts
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await rateLimitPasswordReset(ip);
|
||||
}
|
||||
|
||||
// Should not throw in test/dev
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
@@ -304,12 +284,10 @@ describe("Rate Limiting", () => {
|
||||
const ip = randomIP();
|
||||
|
||||
// IP rate limiting is skipped in test/dev
|
||||
// Should allow many attempts
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await rateLimitRegistration(ip);
|
||||
}
|
||||
|
||||
// Should not throw in test/dev
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -319,12 +297,10 @@ describe("Rate Limiting", () => {
|
||||
const ip = randomIP();
|
||||
|
||||
// IP rate limiting is skipped in test/dev
|
||||
// Should allow many attempts
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await rateLimitEmailVerification(ip);
|
||||
}
|
||||
|
||||
// Should not throw in test/dev
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -334,7 +310,6 @@ describe("Rate Limiting", () => {
|
||||
const email = "victim@example.com";
|
||||
const attackerIP = "1.2.3.4";
|
||||
|
||||
// Simulate brute force attack
|
||||
let blockedAtAttempt = 0;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
try {
|
||||
@@ -347,7 +322,6 @@ describe("Rate Limiting", () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Should be blocked before 10 attempts
|
||||
expect(blockedAtAttempt).toBeLessThan(10);
|
||||
expect(blockedAtAttempt).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -355,7 +329,6 @@ describe("Rate Limiting", () => {
|
||||
it("should prevent distributed brute force from multiple IPs", async () => {
|
||||
const email = "victim@example.com";
|
||||
|
||||
// Simulate distributed attack from different IPs
|
||||
let blockedAtAttempt = 0;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
try {
|
||||
@@ -368,7 +341,6 @@ describe("Rate Limiting", () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Should be blocked at email limit (3 attempts)
|
||||
expect(blockedAtAttempt).toBeLessThanOrEqual(
|
||||
RATE_LIMITS.LOGIN_EMAIL.maxAttempts
|
||||
);
|
||||
@@ -383,7 +355,6 @@ describe("Rate Limiting", () => {
|
||||
await rateLimitRegistration(attackerIP);
|
||||
}
|
||||
|
||||
// Should not block in test/dev (IP limits disabled)
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
@@ -396,7 +367,6 @@ describe("Rate Limiting", () => {
|
||||
await rateLimitPasswordReset(attackerIP);
|
||||
}
|
||||
|
||||
// Should not block in test/dev (IP limits disabled)
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -408,14 +378,12 @@ describe("Rate Limiting", () => {
|
||||
const unknownIP = "unknown";
|
||||
const email = `test-${Date.now()}@example.com`;
|
||||
|
||||
// Should allow many login attempts in development with unknown IP
|
||||
// (only email rate limit applies)
|
||||
for (let i = 0; i < RATE_LIMITS.LOGIN_EMAIL.maxAttempts; i++) {
|
||||
const testEmail = `test-${Date.now()}-${i}@example.com`;
|
||||
await rateLimitLogin(testEmail, unknownIP);
|
||||
}
|
||||
|
||||
// Should be able to continue with different emails (no IP limit in dev)
|
||||
await rateLimitLogin(`final-${Date.now()}@example.com`, unknownIP);
|
||||
});
|
||||
|
||||
@@ -423,12 +391,10 @@ describe("Rate Limiting", () => {
|
||||
const unknownIP = "unknown";
|
||||
const email = `test-${Date.now()}@example.com`;
|
||||
|
||||
// Use up email rate limit
|
||||
for (let i = 0; i < RATE_LIMITS.LOGIN_EMAIL.maxAttempts; i++) {
|
||||
await rateLimitLogin(email, unknownIP);
|
||||
}
|
||||
|
||||
// Next attempt should fail due to email limit
|
||||
try {
|
||||
await rateLimitLogin(email, unknownIP);
|
||||
expect.unreachable("Should have thrown");
|
||||
@@ -440,55 +406,46 @@ describe("Rate Limiting", () => {
|
||||
it("should handle unknown IP in password reset", async () => {
|
||||
const unknownIP = "unknown";
|
||||
|
||||
// In development, should allow many attempts (no IP limit)
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await rateLimitPasswordReset(unknownIP);
|
||||
}
|
||||
|
||||
// Should not throw in development
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle unknown IP in registration", async () => {
|
||||
const unknownIP = "unknown";
|
||||
|
||||
// In development, should allow many attempts (no IP limit)
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await rateLimitRegistration(unknownIP);
|
||||
}
|
||||
|
||||
// Should not throw in development
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle unknown IP in email verification", async () => {
|
||||
const unknownIP = "unknown";
|
||||
|
||||
// In development, should allow many attempts (no IP limit)
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await rateLimitEmailVerification(unknownIP);
|
||||
}
|
||||
|
||||
// Should not throw in development
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rate Limit Configuration", () => {
|
||||
it("should have reasonable limits configured", () => {
|
||||
// Login should be more permissive than registration
|
||||
expect(RATE_LIMITS.LOGIN_IP.maxAttempts).toBeGreaterThan(
|
||||
RATE_LIMITS.REGISTRATION_IP.maxAttempts
|
||||
);
|
||||
|
||||
// All limits should be positive
|
||||
expect(RATE_LIMITS.LOGIN_IP.maxAttempts).toBeGreaterThan(0);
|
||||
expect(RATE_LIMITS.LOGIN_EMAIL.maxAttempts).toBeGreaterThan(0);
|
||||
expect(RATE_LIMITS.PASSWORD_RESET_IP.maxAttempts).toBeGreaterThan(0);
|
||||
expect(RATE_LIMITS.REGISTRATION_IP.maxAttempts).toBeGreaterThan(0);
|
||||
expect(RATE_LIMITS.EMAIL_VERIFICATION_IP.maxAttempts).toBeGreaterThan(0);
|
||||
|
||||
// All windows should be at least 1 minute
|
||||
expect(RATE_LIMITS.LOGIN_IP.windowMs).toBeGreaterThanOrEqual(60000);
|
||||
expect(RATE_LIMITS.LOGIN_EMAIL.windowMs).toBeGreaterThanOrEqual(60000);
|
||||
expect(RATE_LIMITS.PASSWORD_RESET_IP.windowMs).toBeGreaterThanOrEqual(
|
||||
@@ -552,7 +509,6 @@ describe("Rate Limiting", () => {
|
||||
const maxAttempts = 3;
|
||||
const windowMs = 60000;
|
||||
|
||||
// Exhaust the limit: 3 allowed, 4th blocked.
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
await checkRateLimit(id, maxAttempts, windowMs);
|
||||
}
|
||||
@@ -574,7 +530,6 @@ describe("Rate Limiting", () => {
|
||||
const maxAttempts = 5;
|
||||
const windowMs = 60000;
|
||||
|
||||
// Instance A: 3 attempts.
|
||||
clearRateLimitLocalCache();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await checkRateLimit(id, maxAttempts, windowMs);
|
||||
@@ -582,9 +537,9 @@ describe("Rate Limiting", () => {
|
||||
|
||||
// Instance B (fresh local cache) makes 2 more -> combined count = 5.
|
||||
clearRateLimitLocalCache();
|
||||
await checkRateLimit(id, maxAttempts, windowMs); // count 4
|
||||
const remaining = await checkRateLimit(id, maxAttempts, windowMs); // count 5
|
||||
expect(remaining).toBe(0); // 5th allowed, no remaining
|
||||
await checkRateLimit(id, maxAttempts, windowMs);
|
||||
const remaining = await checkRateLimit(id, maxAttempts, windowMs);
|
||||
expect(remaining).toBe(0);
|
||||
|
||||
// A 6th attempt from a fresh instance must be blocked — the shared store
|
||||
// aggregated the count across the two "instances".
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
*/
|
||||
|
||||
import type { H3Event } from "vinxi/http";
|
||||
import { SignJWT } from "jose";
|
||||
import { env } from "~/env/server";
|
||||
|
||||
/**
|
||||
* Create a mock H3Event for testing
|
||||
@@ -62,55 +60,6 @@ export function createMockEvent(options: {
|
||||
return mockEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a valid JWT token for testing
|
||||
*/
|
||||
export async function createTestJWT(
|
||||
userId: string,
|
||||
expiresIn: string = "1h"
|
||||
): Promise<string> {
|
||||
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
||||
return await new SignJWT({ id: userId })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setExpirationTime(expiresIn)
|
||||
.sign(secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an expired JWT token for testing
|
||||
*/
|
||||
export async function createExpiredJWT(userId: string): Promise<string> {
|
||||
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
||||
return await new SignJWT({ id: userId })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setExpirationTime("-1h") // Expired 1 hour ago
|
||||
.sign(secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a JWT with invalid signature
|
||||
*/
|
||||
export async function createInvalidSignatureJWT(
|
||||
userId: string
|
||||
): Promise<string> {
|
||||
const wrongSecret = new TextEncoder().encode("wrong-secret-key");
|
||||
return await new SignJWT({ id: userId })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setExpirationTime("1h")
|
||||
.sign(wrongSecret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate test credentials
|
||||
*/
|
||||
export function createTestCredentials() {
|
||||
return {
|
||||
email: `test-${Date.now()}@example.com`,
|
||||
password: "TestPass123!@#",
|
||||
passwordConfirmation: "TestPass123!@#"
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Common SQL injection payloads
|
||||
*/
|
||||
@@ -138,26 +87,6 @@ export const XSS_PAYLOADS = [
|
||||
"<input onfocus=alert('XSS') autofocus>"
|
||||
];
|
||||
|
||||
/**
|
||||
* Wait for async operations with timeout
|
||||
*/
|
||||
export async function waitFor(
|
||||
condition: () => boolean | Promise<boolean>,
|
||||
timeout: number = 5000,
|
||||
interval: number = 100
|
||||
): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
if (await condition()) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, interval));
|
||||
}
|
||||
|
||||
throw new Error(`Timeout waiting for condition after ${timeout}ms`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure execution time
|
||||
*/
|
||||
@@ -170,18 +99,6 @@ export async function measureTime<T>(
|
||||
return { result, duration };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random string for testing
|
||||
*/
|
||||
export function randomString(length: number = 10): string {
|
||||
const chars =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
return Array.from(
|
||||
{ length },
|
||||
() => chars[Math.floor(Math.random() * chars.length)]
|
||||
).join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random IP address
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user