Remove dead signup router, unused verification functions, and legacy cleanup query
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 18m37s
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 18m37s
- Delete src/routes/signup.ts (dead code, 410 handler in index.ts remains) - Remove isEmailVerified() and getVerifiedApiKey() from verification.ts (only used by signup) - Remove stale-key cleanup from cleanupStaleData() that queried legacy verifications table - Update usage middleware message: 'Free tier limit' → 'Account limit' - TDD: 8 new tests, removed signup.test.ts (dead), net 556 tests passing
This commit is contained in:
parent
921562750f
commit
7206cb518d
11 changed files with 79 additions and 308 deletions
16
src/__tests__/cleanup-no-verifications-table.test.ts
Normal file
16
src/__tests__/cleanup-no-verifications-table.test.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
describe("cleanupStaleData should not reference legacy verifications table", () => {
|
||||
it("should not query verifications table (legacy, no longer written to)", () => {
|
||||
const dbSrc = readFileSync(join(__dirname, "../services/db.ts"), "utf8");
|
||||
// Extract just the cleanupStaleData function body
|
||||
const funcStart = dbSrc.indexOf("async function cleanupStaleData");
|
||||
const funcEnd = dbSrc.indexOf("export { pool }");
|
||||
const funcBody = dbSrc.slice(funcStart, funcEnd);
|
||||
// Should not reference 'verifications' table (only pending_verifications is active)
|
||||
// The old query checked: email NOT IN (SELECT ... FROM verifications WHERE verified_at IS NOT NULL)
|
||||
expect(funcBody).not.toContain("FROM verifications WHERE verified_at");
|
||||
});
|
||||
});
|
||||
44
src/__tests__/dead-signup-removal.test.ts
Normal file
44
src/__tests__/dead-signup-removal.test.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync, existsSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
describe("Dead Signup Router Removal", () => {
|
||||
describe("Signup router module removed", () => {
|
||||
it("should not have src/routes/signup.ts file", () => {
|
||||
const signupPath = join(__dirname, "../routes/signup.ts");
|
||||
expect(existsSync(signupPath)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Dead verification functions removed from source", () => {
|
||||
it("should not export isEmailVerified from verification.ts source", () => {
|
||||
const src = readFileSync(join(__dirname, "../services/verification.ts"), "utf8");
|
||||
expect(src).not.toContain("export async function isEmailVerified");
|
||||
});
|
||||
|
||||
it("should not export getVerifiedApiKey from verification.ts source", () => {
|
||||
const src = readFileSync(join(__dirname, "../services/verification.ts"), "utf8");
|
||||
expect(src).not.toContain("export async function getVerifiedApiKey");
|
||||
});
|
||||
|
||||
it("should still export createPendingVerification", () => {
|
||||
const src = readFileSync(join(__dirname, "../services/verification.ts"), "utf8");
|
||||
expect(src).toContain("export async function createPendingVerification");
|
||||
});
|
||||
|
||||
it("should still export verifyCode", () => {
|
||||
const src = readFileSync(join(__dirname, "../services/verification.ts"), "utf8");
|
||||
expect(src).toContain("export async function verifyCode");
|
||||
});
|
||||
});
|
||||
|
||||
describe("410 signup handler still works", () => {
|
||||
it("should still have signup 410 handler working", async () => {
|
||||
const request = (await import("supertest")).default;
|
||||
const { app } = await import("../index.js");
|
||||
const res = await request(app).post("/v1/signup/free");
|
||||
expect(res.status).toBe(410);
|
||||
expect(res.body.error).toContain("discontinued");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -80,17 +80,7 @@ describe("Dead Token Verification System Removal", () => {
|
|||
expect(typeof verification.verifyCode).toBe("function");
|
||||
});
|
||||
|
||||
it("should export isEmailVerified", async () => {
|
||||
const verification = await import("../services/verification.js");
|
||||
expect(verification).toHaveProperty("isEmailVerified");
|
||||
expect(typeof verification.isEmailVerified).toBe("function");
|
||||
});
|
||||
|
||||
it("should export getVerifiedApiKey", async () => {
|
||||
const verification = await import("../services/verification.js");
|
||||
expect(verification).toHaveProperty("getVerifiedApiKey");
|
||||
expect(typeof verification.getVerifiedApiKey).toBe("function");
|
||||
});
|
||||
// isEmailVerified and getVerifiedApiKey removed — only used by dead signup router
|
||||
|
||||
it("should export PendingVerification interface", async () => {
|
||||
// TypeScript interface test - if compilation passes, the interface exists
|
||||
|
|
|
|||
|
|
@ -74,8 +74,7 @@ vi.mock("../services/browser.js", () => ({
|
|||
vi.mock("../services/verification.js", () => ({
|
||||
createPendingVerification: vi.fn().mockResolvedValue({ email: "test@test.com", code: "123456" }),
|
||||
verifyCode: vi.fn().mockResolvedValue({ status: "ok" }),
|
||||
isEmailVerified: vi.fn().mockResolvedValue(false),
|
||||
getVerifiedApiKey: vi.fn().mockResolvedValue(null),
|
||||
|
||||
}));
|
||||
|
||||
// Mock email service
|
||||
|
|
|
|||
|
|
@ -1,99 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
|
||||
let app: express.Express;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
|
||||
const { isEmailVerified, createPendingVerification, verifyCode } = await import("../services/verification.js");
|
||||
const { sendVerificationEmail } = await import("../services/email.js");
|
||||
const { createFreeKey } = await import("../services/keys.js");
|
||||
|
||||
vi.mocked(isEmailVerified).mockResolvedValue(false);
|
||||
vi.mocked(createPendingVerification).mockResolvedValue({ email: "test@test.com", code: "123456", createdAt: "", expiresAt: "", attempts: 0 });
|
||||
vi.mocked(verifyCode).mockResolvedValue({ status: "ok" });
|
||||
vi.mocked(createFreeKey).mockResolvedValue({ key: "free-key-123", tier: "free", email: "test@test.com", createdAt: "" });
|
||||
|
||||
vi.mocked(sendVerificationEmail).mockResolvedValue(true);
|
||||
|
||||
const { signupRouter } = await import("../routes/signup.js");
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use("/signup", signupRouter);
|
||||
});
|
||||
|
||||
describe("POST /signup/free", () => {
|
||||
it("returns 400 for missing email", async () => {
|
||||
const res = await request(app).post("/signup/free").send({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 for invalid email format", async () => {
|
||||
const res = await request(app).post("/signup/free").send({ email: "not-email" });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 409 for already verified email", async () => {
|
||||
const { isEmailVerified } = await import("../services/verification.js");
|
||||
vi.mocked(isEmailVerified).mockResolvedValue(true);
|
||||
const res = await request(app).post("/signup/free").send({ email: "dup@test.com" });
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it("returns 200 with verification_required for valid email", async () => {
|
||||
const res = await request(app).post("/signup/free").send({ email: "new@test.com" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe("verification_required");
|
||||
});
|
||||
|
||||
it("sends verification email asynchronously", async () => {
|
||||
const { sendVerificationEmail } = await import("../services/email.js");
|
||||
await request(app).post("/signup/free").send({ email: "new@test.com" });
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
expect(sendVerificationEmail).toHaveBeenCalledWith("new@test.com", "123456");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /signup/verify", () => {
|
||||
it("returns 400 for missing email/code", async () => {
|
||||
const res = await request(app).post("/signup/verify").send({ email: "a@b.com" });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 409 for already verified email", async () => {
|
||||
const { isEmailVerified } = await import("../services/verification.js");
|
||||
vi.mocked(isEmailVerified).mockResolvedValue(true);
|
||||
const res = await request(app).post("/signup/verify").send({ email: "dup@test.com", code: "123456" });
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it("returns 410 for expired code", async () => {
|
||||
const { verifyCode } = await import("../services/verification.js");
|
||||
vi.mocked(verifyCode).mockResolvedValue({ status: "expired" });
|
||||
const res = await request(app).post("/signup/verify").send({ email: "a@b.com", code: "123456" });
|
||||
expect(res.status).toBe(410);
|
||||
});
|
||||
|
||||
it("returns 429 for max attempts", async () => {
|
||||
const { verifyCode } = await import("../services/verification.js");
|
||||
vi.mocked(verifyCode).mockResolvedValue({ status: "max_attempts" });
|
||||
const res = await request(app).post("/signup/verify").send({ email: "a@b.com", code: "123456" });
|
||||
expect(res.status).toBe(429);
|
||||
});
|
||||
|
||||
it("returns 400 for invalid code", async () => {
|
||||
const { verifyCode } = await import("../services/verification.js");
|
||||
vi.mocked(verifyCode).mockResolvedValue({ status: "invalid" });
|
||||
const res = await request(app).post("/signup/verify").send({ email: "a@b.com", code: "999999" });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 200 with apiKey for valid code", async () => {
|
||||
const res = await request(app).post("/signup/verify").send({ email: "a@b.com", code: "123456" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ status: "verified", apiKey: "free-key-123" });
|
||||
});
|
||||
});
|
||||
11
src/__tests__/usage-free-tier-message.test.ts
Normal file
11
src/__tests__/usage-free-tier-message.test.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
describe("Usage middleware messaging", () => {
|
||||
it("should not reference 'Free tier' in limit message", () => {
|
||||
const usageSrc = readFileSync(join(__dirname, "../middleware/usage.ts"), "utf8");
|
||||
// The rate limit message should say "Account limit" not "Free tier limit"
|
||||
expect(usageSrc).not.toContain("Free tier limit");
|
||||
});
|
||||
});
|
||||
|
|
@ -132,7 +132,7 @@ describe("usage middleware", () => {
|
|||
|
||||
expect(mockStatus).toHaveBeenCalledWith(429);
|
||||
expect(mockJson).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: expect.stringContaining("Free tier limit") })
|
||||
expect.objectContaining({ error: expect.stringContaining("Account limit") })
|
||||
);
|
||||
expect(mockNext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ export function usageMiddleware(req: any, res: any, next: any): void {
|
|||
|
||||
const record = usage.get(key);
|
||||
if (record && record.monthKey === monthKey && record.count >= FREE_TIER_LIMIT) {
|
||||
res.status(429).json({ error: "Free tier limit reached (100/month). Upgrade to Pro at https://docfast.dev/#pricing for 5,000 PDFs/month." });
|
||||
res.status(429).json({ error: "Account limit reached (100/month). Upgrade to Pro at https://docfast.dev/#pricing for 5,000 PDFs/month." });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,164 +0,0 @@
|
|||
import { Router, Request, Response } from "express";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import { createFreeKey } from "../services/keys.js";
|
||||
import { createPendingVerification, verifyCode, isEmailVerified, getVerifiedApiKey } from "../services/verification.js";
|
||||
import { sendVerificationEmail } from "../services/email.js";
|
||||
import logger from "../services/logger.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
const signupLimiter = rateLimit({
|
||||
windowMs: 60 * 60 * 1000,
|
||||
max: 5,
|
||||
message: { error: "Too many signup attempts. Please try again in 1 hour." },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
const verifyLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 15,
|
||||
message: { error: "Too many verification attempts. Please try again later." },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
async function rejectDuplicateEmail(req: Request, res: Response, next: Function) {
|
||||
const { email } = req.body || {};
|
||||
if (email && typeof email === "string") {
|
||||
const cleanEmail = email.trim().toLowerCase();
|
||||
if (await isEmailVerified(cleanEmail)) {
|
||||
res.status(409).json({ error: "Email already registered" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
// Step 1: Request signup — generates 6-digit code, sends via email
|
||||
router.post("/free", rejectDuplicateEmail, signupLimiter, async (req: Request, res: Response) => {
|
||||
const { email } = req.body || {};
|
||||
|
||||
if (!email || typeof email !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
res.status(400).json({ error: "A valid email address is required." });
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanEmail = email.trim().toLowerCase();
|
||||
|
||||
if (await isEmailVerified(cleanEmail)) {
|
||||
res.status(409).json({ error: "This email is already registered. Contact support if you need help." });
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = await createPendingVerification(cleanEmail);
|
||||
|
||||
sendVerificationEmail(cleanEmail, pending.code).catch(err => {
|
||||
logger.error({ err, email: cleanEmail }, "Failed to send verification email");
|
||||
});
|
||||
|
||||
res.json({
|
||||
status: "verification_required",
|
||||
message: "Check your email for the verification code.",
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /v1/signup/verify:
|
||||
* post:
|
||||
* tags: [Account]
|
||||
* summary: Verify email and get API key (discontinued)
|
||||
* deprecated: true
|
||||
* description: |
|
||||
* **Discontinued.** Free accounts are no longer available. Try the demo at POST /v1/demo/html or upgrade to Pro at https://docfast.dev.
|
||||
* Rate limited to 15 attempts per 15 minutes.
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [email, code]
|
||||
* properties:
|
||||
* email:
|
||||
* type: string
|
||||
* format: email
|
||||
* description: Email address used during signup
|
||||
* example: user@example.com
|
||||
* code:
|
||||
* type: string
|
||||
* description: 6-digit verification code from email
|
||||
* example: "123456"
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Email verified, API key issued
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* example: verified
|
||||
* message:
|
||||
* type: string
|
||||
* apiKey:
|
||||
* type: string
|
||||
* description: The provisioned API key
|
||||
* tier:
|
||||
* type: string
|
||||
* example: free
|
||||
* 400:
|
||||
* description: Missing fields or invalid verification code
|
||||
* 409:
|
||||
* description: Email already verified
|
||||
* 410:
|
||||
* description: Verification code expired
|
||||
* 429:
|
||||
* description: Too many failed attempts
|
||||
*/
|
||||
// Step 2: Verify code — creates API key
|
||||
router.post("/verify", verifyLimiter, async (req: Request, res: Response) => {
|
||||
const { email, code } = req.body || {};
|
||||
|
||||
if (!email || !code) {
|
||||
res.status(400).json({ error: "Email and code are required." });
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanEmail = email.trim().toLowerCase();
|
||||
const cleanCode = String(code).trim();
|
||||
|
||||
if (await isEmailVerified(cleanEmail)) {
|
||||
res.status(409).json({ error: "This email is already verified." });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await verifyCode(cleanEmail, cleanCode);
|
||||
|
||||
switch (result.status) {
|
||||
case "ok": {
|
||||
const keyInfo = await createFreeKey(cleanEmail);
|
||||
|
||||
res.json({
|
||||
status: "verified",
|
||||
message: "Email verified! Here's your API key.",
|
||||
apiKey: keyInfo.key,
|
||||
tier: keyInfo.tier,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "expired":
|
||||
res.status(410).json({ error: "Verification code has expired. Please sign up again." });
|
||||
break;
|
||||
case "max_attempts":
|
||||
res.status(429).json({ error: "Too many failed attempts. Please sign up again to get a new code." });
|
||||
break;
|
||||
case "invalid":
|
||||
res.status(400).json({ error: "Invalid verification code." });
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
export { router as signupRouter };
|
||||
|
|
@ -172,8 +172,8 @@ export async function initDatabase(): Promise<void> {
|
|||
* - Unverified free-tier API keys (never completed verification)
|
||||
* - Orphaned usage rows (key no longer exists)
|
||||
*/
|
||||
export async function cleanupStaleData(): Promise<{ expiredVerifications: number; staleKeys: number; orphanedUsage: number }> {
|
||||
const results = { expiredVerifications: 0, staleKeys: 0, orphanedUsage: 0 };
|
||||
export async function cleanupStaleData(): Promise<{ expiredVerifications: number; orphanedUsage: number }> {
|
||||
const results = { expiredVerifications: 0, orphanedUsage: 0 };
|
||||
|
||||
// 1. Delete expired pending verifications
|
||||
const pv = await queryWithRetry(
|
||||
|
|
@ -181,18 +181,7 @@ export async function cleanupStaleData(): Promise<{ expiredVerifications: number
|
|||
);
|
||||
results.expiredVerifications = pv.rowCount || 0;
|
||||
|
||||
// 2. Delete unverified free-tier keys (email not in verified verifications)
|
||||
const sk = await queryWithRetry(`
|
||||
DELETE FROM api_keys
|
||||
WHERE tier = 'free'
|
||||
AND email NOT IN (
|
||||
SELECT DISTINCT email FROM verifications WHERE verified_at IS NOT NULL
|
||||
)
|
||||
RETURNING key
|
||||
`);
|
||||
results.staleKeys = sk.rowCount || 0;
|
||||
|
||||
// 3. Delete orphaned usage rows
|
||||
// 2. Delete orphaned usage rows (key no longer exists in api_keys)
|
||||
const ou = await queryWithRetry(`
|
||||
DELETE FROM usage
|
||||
WHERE key NOT IN (SELECT key FROM api_keys)
|
||||
|
|
@ -202,7 +191,7 @@ export async function cleanupStaleData(): Promise<{ expiredVerifications: number
|
|||
|
||||
logger.info(
|
||||
{ ...results },
|
||||
`Database cleanup complete: ${results.expiredVerifications} expired verifications, ${results.staleKeys} stale keys, ${results.orphanedUsage} orphaned usage rows removed`
|
||||
`Database cleanup complete: ${results.expiredVerifications} expired verifications, ${results.orphanedUsage} orphaned usage rows removed`
|
||||
);
|
||||
|
||||
return results;
|
||||
|
|
|
|||
|
|
@ -60,18 +60,3 @@ export async function verifyCode(email: string, code: string): Promise<{ status:
|
|||
return { status: "ok" };
|
||||
}
|
||||
|
||||
export async function isEmailVerified(email: string): Promise<boolean> {
|
||||
const result = await queryWithRetry(
|
||||
"SELECT 1 FROM verifications WHERE email = $1 AND verified_at IS NOT NULL LIMIT 1",
|
||||
[email]
|
||||
);
|
||||
return result.rows.length > 0;
|
||||
}
|
||||
|
||||
export async function getVerifiedApiKey(email: string): Promise<string | null> {
|
||||
const result = await queryWithRetry(
|
||||
"SELECT api_key FROM verifications WHERE email = $1 AND verified_at IS NOT NULL LIMIT 1",
|
||||
[email]
|
||||
);
|
||||
return result.rows[0]?.api_key ?? null;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue