refactor: convert to bun workspaces monorepo
- Move web app into apps/web/ - Create packages/shared/ with shared types - Create plugins/decky-vault/ scaffold - Root package.json manages workspaces only
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { checkAndAutoPin } from "@/lib/api/auto-pin"
|
||||
|
||||
// Mock drizzle-orm to avoid ESM named-export resolution errors between test files
|
||||
vi.mock("drizzle-orm", () => ({
|
||||
eq: vi.fn((col, val) => ({ col, val })),
|
||||
}))
|
||||
vi.mock("drizzle-orm/pg-core", () => ({
|
||||
pgTable: vi.fn((name, columns, indexes) => ({ name, columns, indexes })),
|
||||
pgEnum: vi.fn((name, values) => ({ name, values })),
|
||||
text: vi.fn((name) => name),
|
||||
integer: vi.fn((name) => name),
|
||||
real: vi.fn((name) => name),
|
||||
boolean: vi.fn((name) => name),
|
||||
timestamp: vi.fn((name) => name),
|
||||
jsonb: vi.fn((name) => name),
|
||||
index: vi.fn((name) => ({ on: vi.fn() })),
|
||||
}))
|
||||
|
||||
// Mock db schema — only the symbols the module under test references
|
||||
vi.mock("@/lib/db/schema", () => ({
|
||||
performanceEntries: {
|
||||
id: "id",
|
||||
isPinned: "is_pinned",
|
||||
isRemoved: "is_removed",
|
||||
upvotes: "upvotes",
|
||||
downvotes: "downvotes",
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock the database
|
||||
vi.mock("@/lib/db/index", () => ({
|
||||
db: {
|
||||
select: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
function setupMockEntry(overrides: {
|
||||
id?: string
|
||||
isPinned?: boolean
|
||||
isRemoved?: boolean
|
||||
upvotes?: number
|
||||
downvotes?: number
|
||||
}) {
|
||||
const entry = {
|
||||
id: overrides.id ?? "test-entry-1",
|
||||
isPinned: overrides.isPinned ?? false,
|
||||
isRemoved: overrides.isRemoved ?? false,
|
||||
upvotes: overrides.upvotes ?? 0,
|
||||
downvotes: overrides.downvotes ?? 0,
|
||||
}
|
||||
|
||||
// Mock select chain: .select(...).from(...).where(...).limit(...)
|
||||
const limitMock = vi.fn().mockResolvedValue([entry])
|
||||
const whereMock = vi.fn().mockReturnValue({ limit: limitMock })
|
||||
const fromMock = vi.fn().mockReturnValue({ where: whereMock })
|
||||
const selectMock = vi.fn().mockReturnValue({ from: fromMock })
|
||||
;(db.select as any).mockReturnValue({ from: fromMock })
|
||||
|
||||
// Mock update chain: .update(...).set(...).where(...)
|
||||
const updateWhereMock = vi.fn().mockResolvedValue(undefined)
|
||||
const updateSetMock = vi.fn().mockReturnValue({ where: updateWhereMock })
|
||||
const updateMock = vi.fn().mockReturnValue({ set: updateSetMock })
|
||||
;(db.update as any).mockImplementation(updateMock)
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
describe("checkAndAutoPin", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("pins an entry with 8 upvotes and 2 downvotes (80% approval, 10 total)", async () => {
|
||||
setupMockEntry({ upvotes: 8, downvotes: 2 })
|
||||
|
||||
const result = await checkAndAutoPin("test-entry-1")
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("does NOT pin an entry with 7 upvotes and 1 downvote (< 8 absolute upvotes)", async () => {
|
||||
setupMockEntry({ upvotes: 7, downvotes: 1 })
|
||||
|
||||
const result = await checkAndAutoPin("test-entry-1")
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("does NOT pin an entry with 8 upvotes and 0 downvotes (< 10 total votes)", async () => {
|
||||
setupMockEntry({ upvotes: 8, downvotes: 0 })
|
||||
|
||||
const result = await checkAndAutoPin("test-entry-1")
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("does NOT pin an entry with 6 upvotes and 4 downvotes (60% approval, below 80%)", async () => {
|
||||
setupMockEntry({ upvotes: 6, downvotes: 4 })
|
||||
|
||||
const result = await checkAndAutoPin("test-entry-1")
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("does NOT pin an already-pinned entry", async () => {
|
||||
setupMockEntry({ isPinned: true, upvotes: 20, downvotes: 0 })
|
||||
|
||||
const result = await checkAndAutoPin("test-entry-1")
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("does NOT pin a removed entry", async () => {
|
||||
setupMockEntry({ isRemoved: true, upvotes: 20, downvotes: 0 })
|
||||
|
||||
const result = await checkAndAutoPin("test-entry-1")
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("pins an entry with 20 upvotes and 5 downvotes (80% approval exactly)", async () => {
|
||||
setupMockEntry({ upvotes: 20, downvotes: 5 })
|
||||
|
||||
const result = await checkAndAutoPin("test-entry-1")
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("does NOT pin when entry is not found", async () => {
|
||||
// Mock select returning empty array
|
||||
const limitMock = vi.fn().mockResolvedValue([])
|
||||
const whereMock = vi.fn().mockReturnValue({ limit: limitMock })
|
||||
const fromMock = vi.fn().mockReturnValue({ where: whereMock })
|
||||
const selectMock = vi.fn().mockReturnValue({ from: fromMock })
|
||||
;(db.select as any).mockReturnValue({ from: fromMock })
|
||||
;(db.update as any) = vi.fn()
|
||||
|
||||
const result = await checkAndAutoPin("nonexistent")
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { loginEmailSchema } from "@/lib/auth/validation"
|
||||
|
||||
describe("check-email validation", () => {
|
||||
it("accepts valid email", () => {
|
||||
const result = loginEmailSchema.safeParse({ email: "user@example.com" })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects missing email", () => {
|
||||
const result = loginEmailSchema.safeParse({})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects invalid email format", () => {
|
||||
const result = loginEmailSchema.safeParse({ email: "not-email" })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects empty string email", () => {
|
||||
const result = loginEmailSchema.safeParse({ email: "" })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("check-email response contract", () => {
|
||||
it("returns { exists: boolean } on success", () => {
|
||||
const successShape = { exists: true }
|
||||
const failureShape = { exists: false }
|
||||
expect(successShape).toHaveProperty("exists")
|
||||
expect(failureShape).toHaveProperty("exists")
|
||||
expect(typeof successShape.exists).toBe("boolean")
|
||||
expect(typeof failureShape.exists).toBe("boolean")
|
||||
})
|
||||
|
||||
it("returns { error: string } on validation failure", () => {
|
||||
const errorShape = { error: "Invalid request body" }
|
||||
expect(errorShape).toHaveProperty("error")
|
||||
expect(typeof errorShape.error).toBe("string")
|
||||
})
|
||||
})
|
||||
|
||||
describe("check-email does NOT block deckyvault.xyz for login", () => {
|
||||
it("accepts a @deckyvault.xyz email through loginEmailSchema", () => {
|
||||
const result = loginEmailSchema.safeParse({
|
||||
email: "admin@deckyvault.xyz",
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("accepts a @DECKYVAULT.XYZ email (uppercase) through loginEmailSchema", () => {
|
||||
const result = loginEmailSchema.safeParse({
|
||||
email: "admin@DECKYVAULT.XYZ",
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("the response contract still matches { exists: boolean }", () => {
|
||||
const successShape = { exists: true }
|
||||
const failureShape = { exists: false }
|
||||
expect(typeof successShape.exists).toBe("boolean")
|
||||
expect(typeof failureShape.exists).toBe("boolean")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { Elysia } from "elysia"
|
||||
import { isDeckyVaultEmail, DOMAIN_BLOCK_ERROR } from "@/lib/auth/domain-block"
|
||||
|
||||
/**
|
||||
* The domain-block onBeforeHandle handler – mirrors the logic
|
||||
* wired into the auth group in lib/api/app.ts. Tested in isolation
|
||||
* here so we don't need to stand up the full app / DB.
|
||||
*
|
||||
* The handler is typed loosely to avoid Elysia's complex context type
|
||||
* in test environments — the actual type safety is verified against
|
||||
* the real app.ts implementation at integration test time.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const domainBlockOnBeforeHandle = async (context: any) => {
|
||||
const { request, set } = context as {
|
||||
request: Request
|
||||
set: { status: number }
|
||||
}
|
||||
const url = new URL(request.url)
|
||||
const isSignUp =
|
||||
url.pathname === "/api/auth/sign-up/email" &&
|
||||
request.method === "POST"
|
||||
|
||||
if (!isSignUp) return
|
||||
|
||||
if (process.env.NODE_ENV !== "development") {
|
||||
try {
|
||||
const cloned = request.clone()
|
||||
const body = await cloned.json()
|
||||
if (isDeckyVaultEmail(body.email)) {
|
||||
set.status = 400
|
||||
return { error: DOMAIN_BLOCK_ERROR }
|
||||
}
|
||||
} catch {
|
||||
// Malformed body — let Better Auth reject it downstream
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("domain block middleware", () => {
|
||||
it("blocks POST /api/auth/sign-up/email with @deckyvault.xyz email", async () => {
|
||||
const app = new Elysia()
|
||||
.onBeforeHandle(domainBlockOnBeforeHandle)
|
||||
.post("/api/auth/sign-up/email", () => ({
|
||||
success: "should not reach this",
|
||||
}))
|
||||
|
||||
const res = await app.handle(
|
||||
new Request("http://localhost:3000/api/auth/sign-up/email", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email: "bad@deckyvault.xyz",
|
||||
password: "abcdefghij",
|
||||
name: "Test",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error).toBe(DOMAIN_BLOCK_ERROR)
|
||||
})
|
||||
|
||||
it("allows POST /api/auth/sign-up/email with non-deckyvault email", async () => {
|
||||
const app = new Elysia()
|
||||
.onBeforeHandle(domainBlockOnBeforeHandle)
|
||||
.post("/api/auth/sign-up/email", () => ({
|
||||
success: true,
|
||||
}))
|
||||
|
||||
const res = await app.handle(
|
||||
new Request("http://localhost:3000/api/auth/sign-up/email", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email: "good@gmail.com",
|
||||
password: "abcdefghij",
|
||||
name: "Test",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
it("ignores non-sign-up routes (GET /api/auth/something)", async () => {
|
||||
const app = new Elysia()
|
||||
.onBeforeHandle(domainBlockOnBeforeHandle)
|
||||
.get("/api/auth/something", () => ({ ok: true }))
|
||||
|
||||
const res = await app.handle(
|
||||
new Request("http://localhost:3000/api/auth/something"),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.ok).toBe(true)
|
||||
})
|
||||
|
||||
it("handles malformed JSON body gracefully (passes through)", async () => {
|
||||
const app = new Elysia()
|
||||
.onBeforeHandle(domainBlockOnBeforeHandle)
|
||||
.post("/api/auth/sign-up/email", () => ({ success: true }))
|
||||
|
||||
const res = await app.handle(
|
||||
new Request("http://localhost:3000/api/auth/sign-up/email", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "not-valid-json",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
describe("API version", () => {
|
||||
it("matches package.json version", () => {
|
||||
const pkg = require("../../../package.json")
|
||||
const expectedVersion = pkg.version
|
||||
expect(expectedVersion).toBe("2026.2.2")
|
||||
})
|
||||
})
|
||||
|
||||
describe("set-password endpoint security", () => {
|
||||
it("only accepts JSON body for newPassword (not URL-encoded or form-data)", () => {
|
||||
const allowedContentTypes = ["application/json"]
|
||||
const forbiddenContentTypes = [
|
||||
"application/x-www-form-urlencoded",
|
||||
"multipart/form-data",
|
||||
]
|
||||
expect(allowedContentTypes).toContain("application/json")
|
||||
for (const ct of forbiddenContentTypes) {
|
||||
expect(allowedContentTypes).not.toContain(ct)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
|
||||
describe("rate limiting removal", () => {
|
||||
const cwd = process.cwd()
|
||||
|
||||
it("lib/auth/rate-limit.ts no longer exports the rateLimit function", () => {
|
||||
const filePath = path.join(cwd, "lib", "auth", "rate-limit.ts")
|
||||
expect(fs.existsSync(filePath)).toBe(false)
|
||||
})
|
||||
|
||||
it("lib/api/app.ts no longer imports or uses rateLimit", () => {
|
||||
const content = fs.readFileSync(
|
||||
path.join(cwd, "lib", "api", "app.ts"),
|
||||
"utf8",
|
||||
)
|
||||
expect(content).not.toContain('import { rateLimit }')
|
||||
expect(content).not.toContain('.use(rateLimit(')
|
||||
})
|
||||
|
||||
it("lib/auth.ts has Better Auth rateLimit disabled", () => {
|
||||
const content = fs.readFileSync(
|
||||
path.join(cwd, "lib", "auth.ts"),
|
||||
"utf8",
|
||||
)
|
||||
const hasRateLimitBlock = content.includes("rateLimit:")
|
||||
if (hasRateLimitBlock) {
|
||||
expect(content).toContain("enabled: false")
|
||||
}
|
||||
})
|
||||
|
||||
it("lib/api/contact.ts no longer calls checkContactRateLimit", () => {
|
||||
const content = fs.readFileSync(
|
||||
path.join(cwd, "lib", "api", "contact.ts"),
|
||||
"utf8",
|
||||
)
|
||||
expect(content).not.toContain("checkContactRateLimit")
|
||||
})
|
||||
|
||||
it("lib/api/comments.ts no longer enforces hourly cap", () => {
|
||||
const content = fs.readFileSync(
|
||||
path.join(cwd, "lib", "api", "comments.ts"),
|
||||
"utf8",
|
||||
)
|
||||
expect(content).not.toContain("hourly limit")
|
||||
expect(content).not.toContain('"Duplicate comment detected')
|
||||
})
|
||||
|
||||
it("lib/api/performance-submit.ts no longer enforces submission cooldown", () => {
|
||||
const content = fs.readFileSync(
|
||||
path.join(cwd, "lib", "api", "performance-submit.ts"),
|
||||
"utf8",
|
||||
)
|
||||
expect(content).not.toContain("Submission cooldown")
|
||||
expect(content).not.toContain("sixtySecondsAgo")
|
||||
expect(content).not.toContain("Please wait before submitting")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user