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,43 @@
|
||||
import { describe, it, expect, beforeAll } from "vitest"
|
||||
|
||||
// Auth integration tests require a fully-functional PostgreSQL database with
|
||||
// the application schema (set via DATABASE_URL). If the env var is missing or
|
||||
// the DB is unreachable, every test is skipped rather than failing.
|
||||
let dbUsable = false
|
||||
let dbHelper: any
|
||||
|
||||
if (process.env.DATABASE_URL) {
|
||||
try {
|
||||
const { db } = await import("@/lib/db/index")
|
||||
await db.execute("SELECT 1")
|
||||
dbUsable = true
|
||||
} catch {
|
||||
dbUsable = false
|
||||
}
|
||||
}
|
||||
|
||||
describe(dbUsable ? "Better-Auth integration" : "Better-Auth integration (skipped: database not usable)", () => {
|
||||
beforeAll(async () => {
|
||||
if (!dbUsable) return
|
||||
const { getTestHelpers } = await import("@/lib/auth/test")
|
||||
dbHelper = await getTestHelpers()
|
||||
})
|
||||
|
||||
it("should create a user", async () => {
|
||||
if (!dbUsable || !dbHelper) return
|
||||
const user = await dbHelper.createUser({ email: "test@example.com" })
|
||||
expect(user.email).toBe("test@example.com")
|
||||
})
|
||||
|
||||
it("should create a session for a user", async () => {
|
||||
if (!dbUsable || !dbHelper) return
|
||||
const user = await dbHelper.createUser({ email: "session-test@example.com" })
|
||||
await dbHelper.saveUser(user)
|
||||
|
||||
const { session, headers } = await dbHelper.login({ userId: user.id })
|
||||
expect(session.userId).toBe(user.id)
|
||||
expect(headers.get("cookie")).toBeTruthy()
|
||||
|
||||
await dbHelper.deleteUser(user.id)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { isDeckyVaultEmail, DOMAIN_BLOCK_ERROR } from "../domain-block"
|
||||
|
||||
describe("isDeckyVaultEmail", () => {
|
||||
it("returns true for a @deckyvault.xyz email (lowercase)", () => {
|
||||
expect(isDeckyVaultEmail("user@deckyvault.xyz")).toBe(true)
|
||||
})
|
||||
|
||||
it("returns true for a @deckyvault.xyz email (mixed case)", () => {
|
||||
expect(isDeckyVaultEmail("User@DeckyVault.xyz")).toBe(true)
|
||||
})
|
||||
|
||||
it("returns true for a @deckyvault.xyz email with plus addressing", () => {
|
||||
expect(isDeckyVaultEmail("user+tag@deckyvault.xyz")).toBe(true)
|
||||
})
|
||||
|
||||
it("returns true for a @deckyvault.xyz email with surrounding whitespace", () => {
|
||||
expect(isDeckyVaultEmail(" admin@deckyvault.xyz ")).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false for a @gmail.com email", () => {
|
||||
expect(isDeckyVaultEmail("user@gmail.com")).toBe(false)
|
||||
})
|
||||
|
||||
it("returns false for a @example.com email", () => {
|
||||
expect(isDeckyVaultEmail("user@example.com")).toBe(false)
|
||||
})
|
||||
|
||||
it("returns false for a subdomain like @mail.deckyvault.xyz", () => {
|
||||
expect(isDeckyVaultEmail("user@mail.deckyvault.xyz")).toBe(false)
|
||||
})
|
||||
|
||||
it("returns false for an empty string", () => {
|
||||
expect(isDeckyVaultEmail("")).toBe(false)
|
||||
})
|
||||
|
||||
it("returns false for a string without @", () => {
|
||||
expect(isDeckyVaultEmail("deckyvault.xyz")).toBe(false)
|
||||
})
|
||||
|
||||
it("returns true for a .DECKYVAULT.XYZ email (uppercase domain)", () => {
|
||||
expect(isDeckyVaultEmail("admin@DECKYVAULT.XYZ")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("DOMAIN_BLOCK_ERROR", () => {
|
||||
it("is a non-empty string", () => {
|
||||
expect(DOMAIN_BLOCK_ERROR).toBeTruthy()
|
||||
expect(typeof DOMAIN_BLOCK_ERROR).toBe("string")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest"
|
||||
|
||||
// Mock the auth module — no real betterAuth initialisation should run.
|
||||
vi.mock("@/lib/auth", () => ({
|
||||
auth: {
|
||||
api: {
|
||||
getSession: vi.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
// Use dynamic imports so vi.mock is guaranteed to be registered before
|
||||
// any module evaluation in bun test's shared-module-cache multi-file mode.
|
||||
let requireAuth: typeof import("../guard")["requireAuth"]
|
||||
let requireRole: typeof import("../guard")["requireRole"]
|
||||
let requireAdmin: typeof import("../guard")["requireAdmin"]
|
||||
let requireContributorOrAdmin: typeof import("../guard")["requireContributorOrAdmin"]
|
||||
let requireModeratorOrAdmin: typeof import("../guard")["requireModeratorOrAdmin"]
|
||||
let getSession: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeAll(async () => {
|
||||
const guard = await import("../guard")
|
||||
requireAuth = guard.requireAuth
|
||||
requireRole = guard.requireRole
|
||||
requireAdmin = guard.requireAdmin
|
||||
requireContributorOrAdmin = guard.requireContributorOrAdmin
|
||||
requireModeratorOrAdmin = guard.requireModeratorOrAdmin
|
||||
|
||||
const mocked = await import("@/lib/auth")
|
||||
getSession = mocked.auth.api.getSession
|
||||
})
|
||||
|
||||
function mockSession(overrides: Record<string, unknown> = {}) {
|
||||
getSession.mockResolvedValue({
|
||||
user: {
|
||||
id: "user-1",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
role: "user",
|
||||
...overrides,
|
||||
},
|
||||
session: {
|
||||
id: "session-1",
|
||||
userId: "user-1",
|
||||
token: "token-abc",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function mockNoSession() {
|
||||
getSession.mockResolvedValue(null)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getSession.mockReset()
|
||||
})
|
||||
|
||||
describe("requireAuth", () => {
|
||||
it("returns ok with user and session when authenticated", async () => {
|
||||
mockSession()
|
||||
const result = await requireAuth(new Headers())
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.user.id).toBe("user-1")
|
||||
expect(result.session.id).toBe("session-1")
|
||||
}
|
||||
})
|
||||
|
||||
it("returns error 401 when not authenticated", async () => {
|
||||
mockNoSession()
|
||||
const result = await requireAuth(new Headers())
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.status).toBe(401)
|
||||
expect(result.error).toBe("Unauthorized")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("requireRole", () => {
|
||||
it("allows user with matching role", async () => {
|
||||
mockSession({ role: "admin" })
|
||||
const result = await requireRole(new Headers(), ["admin"])
|
||||
expect(result.ok).toBe(true)
|
||||
})
|
||||
|
||||
it("denies user with non-matching role (403)", async () => {
|
||||
mockSession({ role: "user" })
|
||||
const result = await requireRole(new Headers(), ["admin"])
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.status).toBe(403)
|
||||
expect(result.error).toBe("Forbidden")
|
||||
}
|
||||
})
|
||||
|
||||
it("returns 401 when not authenticated", async () => {
|
||||
mockNoSession()
|
||||
const result = await requireRole(new Headers(), ["admin"])
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.status).toBe(401)
|
||||
}
|
||||
})
|
||||
|
||||
it("defaults to 'user' role when role is null", async () => {
|
||||
mockSession({ role: null })
|
||||
const result = await requireRole(new Headers(), ["user"])
|
||||
expect(result.ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("requireAdmin", () => {
|
||||
it("allows admin user", async () => {
|
||||
mockSession({ role: "admin" })
|
||||
const result = await requireAdmin(new Headers())
|
||||
expect(result.ok).toBe(true)
|
||||
})
|
||||
|
||||
it("denies non-admin user (403)", async () => {
|
||||
mockSession({ role: "moderator" })
|
||||
const result = await requireAdmin(new Headers())
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("requireContributorOrAdmin", () => {
|
||||
it("allows contributor", async () => {
|
||||
mockSession({ role: "contributor" })
|
||||
const result = await requireContributorOrAdmin(new Headers())
|
||||
expect(result.ok).toBe(true)
|
||||
})
|
||||
|
||||
it("allows admin", async () => {
|
||||
mockSession({ role: "admin" })
|
||||
const result = await requireContributorOrAdmin(new Headers())
|
||||
expect(result.ok).toBe(true)
|
||||
})
|
||||
|
||||
it("denies regular user", async () => {
|
||||
mockSession({ role: "user" })
|
||||
const result = await requireContributorOrAdmin(new Headers())
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("requireModeratorOrAdmin", () => {
|
||||
it("allows moderator", async () => {
|
||||
mockSession({ role: "moderator" })
|
||||
const result = await requireModeratorOrAdmin(new Headers())
|
||||
expect(result.ok).toBe(true)
|
||||
})
|
||||
|
||||
it("allows admin", async () => {
|
||||
mockSession({ role: "admin" })
|
||||
const result = await requireModeratorOrAdmin(new Headers())
|
||||
expect(result.ok).toBe(true)
|
||||
})
|
||||
|
||||
it("denies contributor", async () => {
|
||||
mockSession({ role: "contributor" })
|
||||
const result = await requireModeratorOrAdmin(new Headers())
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { checkPasswordStrength } from "../password-strength"
|
||||
|
||||
describe("checkPasswordStrength", () => {
|
||||
it("returns weak (score 0) for empty string", () => {
|
||||
const result = checkPasswordStrength("")
|
||||
expect(result.level).toBe("weak")
|
||||
expect(result.score).toBe(0)
|
||||
expect(result.feedback).toContain("Enter a password")
|
||||
})
|
||||
|
||||
it("returns fair for 'password' (common pattern penalty)", () => {
|
||||
const result = checkPasswordStrength("password")
|
||||
expect(result.level).toBe("fair")
|
||||
expect(result.feedback).toContain("Avoid common passwords")
|
||||
})
|
||||
|
||||
it("returns fair for short password with only letters", () => {
|
||||
const result = checkPasswordStrength("abc")
|
||||
expect(result.level).toBe("fair")
|
||||
})
|
||||
|
||||
it("returns good for 10-char lowercase-only password", () => {
|
||||
const result = checkPasswordStrength("abcdefghij")
|
||||
expect(result.level).toBe("good")
|
||||
})
|
||||
|
||||
it("returns good for 10-char mixed case + numbers", () => {
|
||||
const result = checkPasswordStrength("AbcDef1234")
|
||||
expect(["good", "strong", "excellent"]).toContain(result.level)
|
||||
})
|
||||
|
||||
it("returns strong for 14-char with all character types", () => {
|
||||
const result = checkPasswordStrength("MyP@ssw0rd!2024")
|
||||
expect(["strong", "excellent"]).toContain(result.level)
|
||||
})
|
||||
|
||||
it("returns excellent for 18+ char complex password", () => {
|
||||
const result = checkPasswordStrength("C0mpl3x!P@ssw0rd#2024")
|
||||
expect(result.level).toBe("excellent")
|
||||
})
|
||||
|
||||
it("flags repeated characters", () => {
|
||||
const result = checkPasswordStrength("aaaBBB111@@@")
|
||||
expect(result.feedback).toContain("Avoid repeated characters")
|
||||
})
|
||||
|
||||
it("flags common patterns like 'letmein'", () => {
|
||||
const result = checkPasswordStrength("letmein1234")
|
||||
expect(result.feedback).toContain("Avoid common passwords")
|
||||
})
|
||||
|
||||
it("gives positive feedback for excellent passwords", () => {
|
||||
const result = checkPasswordStrength("Tr0ub4dor&3X7r3m3ly!")
|
||||
expect(result.feedback).toContain("Great password!")
|
||||
})
|
||||
|
||||
it("handles unicode characters", () => {
|
||||
const result = checkPasswordStrength("パスワード1234Abc!")
|
||||
expect(result.level).toBeDefined()
|
||||
expect(result.score).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,236 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import {
|
||||
loginEmailSchema,
|
||||
loginSchema,
|
||||
signupSchema,
|
||||
otpSchema,
|
||||
forgotPasswordSchema,
|
||||
resetPasswordSchema,
|
||||
} from "../validation"
|
||||
|
||||
describe("loginEmailSchema", () => {
|
||||
it("accepts a valid email", () => {
|
||||
const result = loginEmailSchema.safeParse({ email: "user@example.com" })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects empty string", () => {
|
||||
const result = loginEmailSchema.safeParse({ email: "" })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects invalid email format", () => {
|
||||
const result = loginEmailSchema.safeParse({ email: "not-an-email" })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects missing email field", () => {
|
||||
const result = loginEmailSchema.safeParse({})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("loginSchema", () => {
|
||||
it("accepts valid email and password", () => {
|
||||
const result = loginSchema.safeParse({
|
||||
email: "user@example.com",
|
||||
password: "password123",
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects empty password", () => {
|
||||
const result = loginSchema.safeParse({
|
||||
email: "user@example.com",
|
||||
password: "",
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].message).toBe("Password is required")
|
||||
}
|
||||
})
|
||||
|
||||
it("rejects missing password field", () => {
|
||||
const result = loginSchema.safeParse({ email: "user@example.com" })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("signupSchema", () => {
|
||||
it("accepts valid name, email, and password (≥10 chars)", () => {
|
||||
const result = signupSchema.safeParse({
|
||||
name: "Test User",
|
||||
email: "user@example.com",
|
||||
password: "abcdefghij",
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects short password (<10 chars)", () => {
|
||||
const result = signupSchema.safeParse({
|
||||
name: "Test User",
|
||||
email: "user@example.com",
|
||||
password: "short",
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].message).toContain("at least 10 characters")
|
||||
}
|
||||
})
|
||||
|
||||
it("rejects empty name", () => {
|
||||
const result = signupSchema.safeParse({
|
||||
name: "",
|
||||
email: "user@example.com",
|
||||
password: "abcdefghij",
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects name longer than 100 chars", () => {
|
||||
const result = signupSchema.safeParse({
|
||||
name: "a".repeat(101),
|
||||
email: "user@example.com",
|
||||
password: "abcdefghij",
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("otpSchema", () => {
|
||||
it("accepts exactly 6 digits", () => {
|
||||
const result = otpSchema.safeParse({ otp: "123456" })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects 5 digits", () => {
|
||||
const result = otpSchema.safeParse({ otp: "12345" })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects 7 digits", () => {
|
||||
const result = otpSchema.safeParse({ otp: "1234567" })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("accepts any 6-char string (schema only validates length)", () => {
|
||||
const result = otpSchema.safeParse({ otp: "abc123" })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("forgotPasswordSchema", () => {
|
||||
it("accepts valid email", () => {
|
||||
const result = forgotPasswordSchema.safeParse({ email: "user@example.com" })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects invalid email", () => {
|
||||
const result = forgotPasswordSchema.safeParse({ email: "bad" })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("resetPasswordSchema", () => {
|
||||
it("accepts matching passwords with valid OTP", () => {
|
||||
const result = resetPasswordSchema.safeParse({
|
||||
otp: "123456",
|
||||
newPassword: "abcdefghij",
|
||||
confirmPassword: "abcdefghij",
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects mismatched passwords", () => {
|
||||
const result = resetPasswordSchema.safeParse({
|
||||
otp: "123456",
|
||||
newPassword: "abcdefghij",
|
||||
confirmPassword: "different",
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].message).toBe("Passwords do not match")
|
||||
}
|
||||
})
|
||||
|
||||
it("rejects short new password", () => {
|
||||
const result = resetPasswordSchema.safeParse({
|
||||
otp: "123456",
|
||||
newPassword: "short",
|
||||
confirmPassword: "short",
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects empty confirm password", () => {
|
||||
const result = resetPasswordSchema.safeParse({
|
||||
otp: "123456",
|
||||
newPassword: "abcdefghij",
|
||||
confirmPassword: "",
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects short OTP", () => {
|
||||
const result = resetPasswordSchema.safeParse({
|
||||
otp: "12345",
|
||||
newPassword: "abcdefghij",
|
||||
confirmPassword: "abcdefghij",
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("signupSchema — domain block", () => {
|
||||
it("rejects @deckyvault.xyz email when NODE_ENV is not development", () => {
|
||||
const result = signupSchema.safeParse({
|
||||
name: "Test User",
|
||||
email: "user@deckyvault.xyz",
|
||||
password: "abcdefghij",
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
const emailIssues = result.error.issues.filter(
|
||||
(i) => i.path[0] === "email"
|
||||
)
|
||||
expect(emailIssues.length).toBeGreaterThan(0)
|
||||
expect(emailIssues[0].message).toContain("deckyvault.xyz")
|
||||
}
|
||||
})
|
||||
|
||||
it("rejects @DECKYVAULT.XYZ email (uppercase) when not in dev", () => {
|
||||
const result = signupSchema.safeParse({
|
||||
name: "Test User",
|
||||
email: "admin@DECKYVAULT.XYZ",
|
||||
password: "abcdefghij",
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects @deckyvault.xyz with plus addressing when not in dev", () => {
|
||||
const result = signupSchema.safeParse({
|
||||
name: "Test User",
|
||||
email: "user+spam@deckyvault.xyz",
|
||||
password: "abcdefghij",
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("accepts @gmail.com email (non-deckyvault domain)", () => {
|
||||
const result = signupSchema.safeParse({
|
||||
name: "Test User",
|
||||
email: "user@gmail.com",
|
||||
password: "abcdefghij",
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("accepts valid email from any non-deckyvault domain", () => {
|
||||
const result = signupSchema.safeParse({
|
||||
name: "Test User",
|
||||
email: "hello@outlook.com",
|
||||
password: "abcdefghij",
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user