From e9fb5862f7a0c79a56c803318cee89c5dc0d903f Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Fri, 19 Dec 2025 19:25:21 +0100 Subject: [PATCH] Controllers tests (#187) * test: backups service * refactor: create hono app in a separate file To avoid side effects like db migration or startup scripts when testing test(backups): add security tests to the backups controller * ci: run typechecks, build and tests on PR * test: controllers security tests * chore: update lock file * refactor: pr feedbacks --- .env.test | 1 + .../actions/install-dependencies/action.yml | 15 ++ .github/workflows/ci.yml | 23 +-- AGENTS.md | 10 ++ app/server/app.ts | 87 ++++++++++ app/server/core/constants.ts | 2 +- app/server/db/schema.ts | 3 + app/server/index.ts | 79 +-------- app/server/jobs/backup-execution.ts | 5 +- .../__tests__/backups.controller.test.ts | 126 +++++++++++++++ .../backups/__tests__/backups.service.test.ts | 151 ++++++++++++++++++ .../modules/backups/backups.controller.ts | 4 +- app/server/modules/backups/backups.service.ts | 2 - .../__tests__/events.controller.test.ts | 53 ++++++ .../notifications.controller.test.ts | 110 +++++++++++++ .../__tests__/repositories.controller.test.ts | 105 ++++++++++++ .../__tests__/system.controller.test.ts | 89 +++++++++++ .../__tests__/volumes.controller.test.ts | 104 ++++++++++++ app/server/utils/restic.ts | 3 +- app/server/utils/spawn.ts | 4 +- app/test/helpers/auth.ts | 24 +++ app/test/helpers/backup.ts | 16 ++ app/test/helpers/repository.ts | 20 +++ app/test/helpers/restic.ts | 18 +++ app/test/helpers/volume.ts | 21 +++ app/test/setup.ts | 19 +++ bun.lock | 72 +++++++-- package.json | 5 +- 28 files changed, 1063 insertions(+), 108 deletions(-) create mode 100644 .env.test create mode 100644 .github/actions/install-dependencies/action.yml create mode 100644 app/server/app.ts create mode 100644 app/server/modules/backups/__tests__/backups.controller.test.ts create mode 100644 app/server/modules/backups/__tests__/backups.service.test.ts create mode 100644 app/server/modules/events/__tests__/events.controller.test.ts create mode 100644 app/server/modules/notifications/__tests__/notifications.controller.test.ts create mode 100644 app/server/modules/repositories/__tests__/repositories.controller.test.ts create mode 100644 app/server/modules/system/__tests__/system.controller.test.ts create mode 100644 app/server/modules/volumes/__tests__/volumes.controller.test.ts create mode 100644 app/test/helpers/auth.ts create mode 100644 app/test/helpers/backup.ts create mode 100644 app/test/helpers/repository.ts create mode 100644 app/test/helpers/restic.ts create mode 100644 app/test/helpers/volume.ts create mode 100644 app/test/setup.ts diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..a8c805e --- /dev/null +++ b/.env.test @@ -0,0 +1 @@ +DATABASE_URL=:memory: diff --git a/.github/actions/install-dependencies/action.yml b/.github/actions/install-dependencies/action.yml new file mode 100644 index 0000000..36b4abe --- /dev/null +++ b/.github/actions/install-dependencies/action.yml @@ -0,0 +1,15 @@ +name: Install dependencies + +description: Install dependencies + +runs: + using: "composite" + steps: + - uses: oven-sh/setup-bun@v2 + name: Install Bun + with: + bun-version: "1.3.5" + + - name: Install dependencies + shell: bash + run: bun install --frozen-lockfile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index acb4c33..ad28862 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,15 +21,18 @@ jobs: uses: actions/checkout@v5 with: fetch-depth: 0 - ref: ${{ github.ref }} - - name: Scan current project - id: scan - uses: anchore/scan-action@v7 - with: - path: "." + - name: Install dependencies + uses: "./.github/actions/install-dependencies" - - name: upload Anchore scan SARIF report - uses: github/codeql-action/upload-sarif@v4 - with: - sarif_file: ${{ steps.scan.outputs.sarif }} + - name: Run type checks + shell: bash + run: bun run tsc + + - name: Run tests + shell: bash + run: bun run test --ci --coverage + + - name: Build project + shell: bash + run: bun run build diff --git a/AGENTS.md b/AGENTS.md index 6ba3fcd..92f7ff1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,16 @@ This is a unified application with the following structure: bun run tsc ``` +### Testing + +```bash +# Run all tests +bun run test + +# Run a specific test file +bunx dotenv-cli -e .env.test -- bun test --preload ./app/test/setup.ts path/to/test.ts +``` + ### Building ```bash diff --git a/app/server/app.ts b/app/server/app.ts new file mode 100644 index 0000000..b2d12d0 --- /dev/null +++ b/app/server/app.ts @@ -0,0 +1,87 @@ +import { Scalar } from "@scalar/hono-api-reference"; +import { Hono } from "hono"; +import { logger as honoLogger } from "hono/logger"; +import { secureHeaders } from "hono/secure-headers"; +import { rateLimiter } from "hono-rate-limiter"; +import { openAPIRouteHandler } from "hono-openapi"; +import { authController } from "./modules/auth/auth.controller"; +import { requireAuth } from "./modules/auth/auth.middleware"; +import { repositoriesController } from "./modules/repositories/repositories.controller"; +import { systemController } from "./modules/system/system.controller"; +import { volumeController } from "./modules/volumes/volume.controller"; +import { backupScheduleController } from "./modules/backups/backups.controller"; +import { eventsController } from "./modules/events/events.controller"; +import { notificationsController } from "./modules/notifications/notifications.controller"; +import { handleServiceError } from "./utils/errors"; +import { logger } from "./utils/logger"; +import { config } from "./core/config"; + +export const generalDescriptor = (app: Hono) => + openAPIRouteHandler(app, { + documentation: { + info: { + title: "Zerobyte API", + version: "1.0.0", + description: "API for managing volumes", + }, + servers: [{ url: `http://${config.serverIp}:4096`, description: "Development Server" }], + }, + }); + +export const scalarDescriptor = Scalar({ + title: "Zerobyte API Docs", + pageTitle: "Zerobyte API Docs", + url: "/api/v1/openapi.json", +}); + +export const createApp = () => { + const app = new Hono(); + + if (config.environment !== "test") { + app.use(honoLogger()); + } + + app + .use(secureHeaders()) + .use( + rateLimiter({ + windowMs: 60 * 5 * 1000, + limit: 1000, + keyGenerator: (c) => c.req.header("x-forwarded-for") ?? "", + skip: () => { + return config.__prod__ === false; + }, + }), + ) + .get("healthcheck", (c) => c.json({ status: "ok" })) + .route("/api/v1/auth", authController) + .use("/api/v1/volumes/*", requireAuth) + .use("/api/v1/repositories/*", requireAuth) + .use("/api/v1/backups/*", requireAuth) + .use("/api/v1/notifications/*", requireAuth) + .use("/api/v1/system/*", requireAuth) + .use("/api/v1/events/*", requireAuth) + .route("/api/v1/volumes", volumeController) + .route("/api/v1/repositories", repositoriesController) + .route("/api/v1/backups", backupScheduleController) + .route("/api/v1/notifications", notificationsController) + .route("/api/v1/system", systemController) + .route("/api/v1/events", eventsController); + + app.get("/api/v1/openapi.json", generalDescriptor(app)); + app.get("/api/v1/docs", requireAuth, scalarDescriptor); + + app.onError((err, c) => { + logger.error(`${c.req.url}: ${err.message}`); + + if (err.cause instanceof Error) { + logger.error(err.cause.message); + } + + const { status, message } = handleServiceError(err); + + return c.json({ message }, status); + }); + + return app; +}; diff --git a/app/server/core/constants.ts b/app/server/core/constants.ts index a59f558..98a3872 100644 --- a/app/server/core/constants.ts +++ b/app/server/core/constants.ts @@ -1,7 +1,7 @@ export const OPERATION_TIMEOUT = 5000; export const VOLUME_MOUNT_BASE = "/var/lib/zerobyte/volumes"; export const REPOSITORY_BASE = "/var/lib/zerobyte/repositories"; -export const DATABASE_URL = "/var/lib/zerobyte/data/ironmount.db"; +export const DATABASE_URL = process.env.DATABASE_URL || "/var/lib/zerobyte/data/ironmount.db"; export const RESTIC_PASS_FILE = "/var/lib/zerobyte/data/restic.pass"; export const DEFAULT_EXCLUDES = [DATABASE_URL, RESTIC_PASS_FILE, REPOSITORY_BASE]; diff --git a/app/server/db/schema.ts b/app/server/db/schema.ts index cb94d29..e35ca82 100644 --- a/app/server/db/schema.ts +++ b/app/server/db/schema.ts @@ -21,6 +21,7 @@ export const volumesTable = sqliteTable("volumes_table", { autoRemount: int("auto_remount", { mode: "boolean" }).notNull().default(true), }); export type Volume = typeof volumesTable.$inferSelect; +export type VolumeInsert = typeof volumesTable.$inferInsert; /** * Users Table @@ -61,6 +62,7 @@ export const repositoriesTable = sqliteTable("repositories_table", { updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`), }); export type Repository = typeof repositoriesTable.$inferSelect; +export type RepositoryInsert = typeof repositoriesTable.$inferInsert; /** * Backup Schedules Table @@ -96,6 +98,7 @@ export const backupSchedulesTable = sqliteTable("backup_schedules_table", { createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`), updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`), }); +export type BackupScheduleInsert = typeof backupSchedulesTable.$inferInsert; export const backupScheduleRelations = relations(backupSchedulesTable, ({ one, many }) => ({ volume: one(volumesTable, { diff --git a/app/server/index.ts b/app/server/index.ts index 20139a1..d1c3209 100644 --- a/app/server/index.ts +++ b/app/server/index.ts @@ -1,89 +1,14 @@ import { createHonoServer } from "react-router-hono-server/bun"; -import { Scalar } from "@scalar/hono-api-reference"; -import { Hono } from "hono"; -import { logger as honoLogger } from "hono/logger"; -import { secureHeaders } from "hono/secure-headers"; -import { rateLimiter } from "hono-rate-limiter"; -import { openAPIRouteHandler } from "hono-openapi"; import { runDbMigrations } from "./db/db"; -import { authController } from "./modules/auth/auth.controller"; -import { requireAuth } from "./modules/auth/auth.middleware"; import { startup } from "./modules/lifecycle/startup"; import { migrateToShortIds } from "./modules/lifecycle/migration"; -import { repositoriesController } from "./modules/repositories/repositories.controller"; -import { systemController } from "./modules/system/system.controller"; -import { volumeController } from "./modules/volumes/volume.controller"; -import { backupScheduleController } from "./modules/backups/backups.controller"; -import { eventsController } from "./modules/events/events.controller"; -import { notificationsController } from "./modules/notifications/notifications.controller"; -import { handleServiceError } from "./utils/errors"; import { logger } from "./utils/logger"; import { shutdown } from "./modules/lifecycle/shutdown"; import { REQUIRED_MIGRATIONS } from "./core/constants"; import { validateRequiredMigrations } from "./modules/lifecycle/checkpoint"; -import { config } from "./core/config"; +import { createApp } from "./app"; -export const generalDescriptor = (app: Hono) => - openAPIRouteHandler(app, { - documentation: { - info: { - title: "Zerobyte API", - version: "1.0.0", - description: "API for managing volumes", - }, - servers: [{ url: `http://${config.serverIp}:4096`, description: "Development Server" }], - }, - }); - -export const scalarDescriptor = Scalar({ - title: "Zerobyte API Docs", - pageTitle: "Zerobyte API Docs", - url: "/api/v1/openapi.json", -}); - -const app = new Hono() - .use(honoLogger()) - .use(secureHeaders()) - .use( - rateLimiter({ - windowMs: 60 * 5 * 1000, - limit: 1000, - keyGenerator: (c) => c.req.header("x-forwarded-for") ?? "", - skip: () => { - return config.__prod__ === false; - }, - }), - ) - .get("healthcheck", (c) => c.json({ status: "ok" })) - .route("/api/v1/auth", authController) - .use("/api/v1/volumes/*", requireAuth) - .use("/api/v1/repositories/*", requireAuth) - .use("/api/v1/backups/*", requireAuth) - .use("/api/v1/notifications/*", requireAuth) - .use("/api/v1/system/*", requireAuth) - .use("/api/v1/events/*", requireAuth) - .route("/api/v1/volumes", volumeController) - .route("/api/v1/repositories", repositoriesController) - .route("/api/v1/backups", backupScheduleController) - .route("/api/v1/notifications", notificationsController) - .route("/api/v1/system", systemController) - .route("/api/v1/events", eventsController); - -// API documentation endpoints require authentication -app.get("/api/v1/openapi.json", requireAuth, generalDescriptor(app)); -app.get("/api/v1/docs", requireAuth, scalarDescriptor); - -app.onError((err, c) => { - logger.error(`${c.req.url}: ${err.message}`); - - if (err.cause instanceof Error) { - logger.error(err.cause.message); - } - - const { status, message } = handleServiceError(err); - - return c.json({ message }, status); -}); +const app = createApp(); runDbMigrations(); diff --git a/app/server/jobs/backup-execution.ts b/app/server/jobs/backup-execution.ts index 821333e..c38947a 100644 --- a/app/server/jobs/backup-execution.ts +++ b/app/server/jobs/backup-execution.ts @@ -1,6 +1,5 @@ import { Job } from "../core/scheduler"; import { backupsService } from "../modules/backups/backups.service"; -import { toMessage } from "../utils/errors"; import { logger } from "../utils/logger"; export class BackupExecutionJob extends Job { @@ -17,8 +16,8 @@ export class BackupExecutionJob extends Job { logger.info(`Found ${scheduleIds.length} backup schedule(s) to execute`); for (const scheduleId of scheduleIds) { - backupsService.executeBackup(scheduleId).catch((error) => { - logger.error(`Failed to execute backup for schedule ${scheduleId}: ${toMessage(error)}`); + backupsService.executeBackup(scheduleId).catch((err) => { + logger.error(`Error executing backup for schedule ${scheduleId}:`, err); }); } diff --git a/app/server/modules/backups/__tests__/backups.controller.test.ts b/app/server/modules/backups/__tests__/backups.controller.test.ts new file mode 100644 index 0000000..3c41500 --- /dev/null +++ b/app/server/modules/backups/__tests__/backups.controller.test.ts @@ -0,0 +1,126 @@ +import { test, describe, expect } from "bun:test"; +import { createApp } from "~/server/app"; +import { createTestSession } from "~/test/helpers/auth"; + +const app = createApp(); + +describe("backups security", () => { + test("should return 401 if no session cookie is provided", async () => { + const res = await app.request("/api/v1/backups"); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Authentication required"); + }); + + test("should return 401 if session is invalid", async () => { + const res = await app.request("/api/v1/backups", { + headers: { + Cookie: "session_id=invalid-session", + }, + }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Invalid or expired session"); + + expect(res.headers.get("Set-Cookie")).toContain("session_id=;"); + }); + + test("should return 200 if session is valid", async () => { + const { sessionId } = await createTestSession(); + + const res = await app.request("/api/v1/backups", { + headers: { + Cookie: `session_id=${sessionId}`, + }, + }); + + expect(res.status).toBe(200); + }); + + describe("unauthenticated access", () => { + const endpoints: { method: string; path: string }[] = [ + { method: "GET", path: "/api/v1/backups" }, + { method: "GET", path: "/api/v1/backups/1" }, + { method: "GET", path: "/api/v1/backups/volume/1" }, + { method: "POST", path: "/api/v1/backups" }, + { method: "PATCH", path: "/api/v1/backups/1" }, + { method: "DELETE", path: "/api/v1/backups/1" }, + { method: "POST", path: "/api/v1/backups/1/run" }, + { method: "POST", path: "/api/v1/backups/1/stop" }, + { method: "POST", path: "/api/v1/backups/1/forget" }, + { method: "GET", path: "/api/v1/backups/1/notifications" }, + { method: "PUT", path: "/api/v1/backups/1/notifications" }, + { method: "GET", path: "/api/v1/backups/1/mirrors" }, + { method: "PUT", path: "/api/v1/backups/1/mirrors" }, + { method: "GET", path: "/api/v1/backups/1/mirrors/compatibility" }, + { method: "POST", path: "/api/v1/backups/reorder" }, + ]; + + for (const { method, path } of endpoints) { + test(`${method} ${path} should return 401`, async () => { + const res = await app.request(path, { method }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Authentication required"); + }); + } + }); + + describe("information disclosure", () => { + test("should not disclose if a schedule exists when unauthenticated", async () => { + const res = await app.request("/api/v1/backups/999999"); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Authentication required"); + }); + + test("should not disclose if a volume exists when unauthenticated", async () => { + const res = await app.request("/api/v1/backups/volume/999999"); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Authentication required"); + }); + }); + + describe("input validation", () => { + test("should return 404 for malformed schedule ID", async () => { + const { sessionId } = await createTestSession(); + const res = await app.request("/api/v1/backups/not-a-number", { + headers: { + Cookie: `session_id=${sessionId}`, + }, + }); + + expect(res.status).toBe(404); + }); + + test("should return 404 for non-existent schedule ID", async () => { + const { sessionId } = await createTestSession(); + const res = await app.request("/api/v1/backups/999999", { + headers: { + Cookie: `session_id=${sessionId}`, + }, + }); + + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.message).toBe("Backup schedule not found"); + }); + + test("should return 400 for invalid payload on create", async () => { + const { sessionId } = await createTestSession(); + const res = await app.request("/api/v1/backups", { + method: "POST", + headers: { + Cookie: `session_id=${sessionId}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: "Test", + }), + }); + + expect(res.status).toBe(400); + }); + }); +}); diff --git a/app/server/modules/backups/__tests__/backups.service.test.ts b/app/server/modules/backups/__tests__/backups.service.test.ts new file mode 100644 index 0000000..8fa5ecd --- /dev/null +++ b/app/server/modules/backups/__tests__/backups.service.test.ts @@ -0,0 +1,151 @@ +import { test, describe, mock, expect } from "bun:test"; +import { backupsService } from "../backups.service"; +import { createTestVolume } from "~/test/helpers/volume"; +import { createTestBackupSchedule } from "~/test/helpers/backup"; +import { createTestRepository } from "~/test/helpers/repository"; +import { generateBackupOutput } from "~/test/helpers/restic"; +import { beforeEach } from "bun:test"; + +const resticBackupMock = mock(() => Promise.resolve({ exitCode: 0 })); + +mock.module("~/server/utils/spawn", () => ({ + safeSpawn: resticBackupMock, +})); + +beforeEach(() => { + resticBackupMock.mockClear(); +}); + +describe("execute backup", () => { + test("should correctly set next backup time", async () => { + // arrange + const volume = await createTestVolume(); + const repository = await createTestRepository(); + const schedule = await createTestBackupSchedule({ + volumeId: volume.id, + repositoryId: repository.id, + cronExpression: "*/5 * * * *", + }); + expect(schedule.nextBackupAt).toBeNull(); + + resticBackupMock.mockImplementationOnce(() => + Promise.resolve({ exitCode: 0, stdout: generateBackupOutput(), stderr: "" }), + ); + + // act + await backupsService.executeBackup(schedule.id); + + // assert + const updatedSchedule = await backupsService.getSchedule(schedule.id); + expect(updatedSchedule.nextBackupAt).not.toBeNull(); + + const nextBackupAt = new Date(updatedSchedule.nextBackupAt ?? 0); + const now = new Date(); + + expect(nextBackupAt.getTime()).toBeGreaterThanOrEqual(now.getTime()); + expect(nextBackupAt.getTime() - now.getTime()).toBeLessThanOrEqual(5 * 60 * 1000); + }); + + test("should skip backup if schedule is disabled", async () => { + // arrange + const volume = await createTestVolume(); + const repository = await createTestRepository(); + const schedule = await createTestBackupSchedule({ + volumeId: volume.id, + repositoryId: repository.id, + enabled: false, + }); + + // act + await backupsService.executeBackup(schedule.id); + + // assert + expect(resticBackupMock).not.toHaveBeenCalled(); + }); + + test("should execute backup if schedule is disabled but the run is manual", async () => { + // arrange + const volume = await createTestVolume(); + const repository = await createTestRepository(); + const schedule = await createTestBackupSchedule({ + volumeId: volume.id, + repositoryId: repository.id, + enabled: false, + }); + + resticBackupMock.mockImplementationOnce(() => + Promise.resolve({ exitCode: 0, stdout: generateBackupOutput(), stderr: "" }), + ); + + // act + await backupsService.executeBackup(schedule.id, true); + + // assert + expect(resticBackupMock).toHaveBeenCalled(); + }); + + test("should skip the backup if the previous one is still running", async () => { + // arrange + const volume = await createTestVolume(); + const repository = await createTestRepository(); + const schedule = await createTestBackupSchedule({ + volumeId: volume.id, + repositoryId: repository.id, + }); + + resticBackupMock.mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + return Promise.resolve({ exitCode: 0, stdout: generateBackupOutput(), stderr: "" }); + }); + + // act + backupsService.executeBackup(schedule.id); + await new Promise((resolve) => setTimeout(resolve, 10)); + await backupsService.executeBackup(schedule.id); + + // assert + expect(resticBackupMock).toHaveBeenCalledTimes(1); + }); + + test("should set the backup status to failed if restic returns a 3 exit code", async () => { + // arrange + const volume = await createTestVolume(); + const repository = await createTestRepository(); + const schedule = await createTestBackupSchedule({ + volumeId: volume.id, + repositoryId: repository.id, + }); + + resticBackupMock.mockImplementationOnce(() => + Promise.resolve({ exitCode: 3, stdout: generateBackupOutput(), stderr: "Some error occurred" }), + ); + + // act + await backupsService.executeBackup(schedule.id); + + // assert + const updatedSchedule = await backupsService.getSchedule(schedule.id); + expect(updatedSchedule.lastBackupStatus).toBe("warning"); + }); + + test("should set the backup status to failed if restic returns a non zero exit code", async () => { + // arrange + const volume = await createTestVolume(); + const repository = await createTestRepository(); + const schedule = await createTestBackupSchedule({ + volumeId: volume.id, + repositoryId: repository.id, + }); + + resticBackupMock.mockImplementationOnce(() => + Promise.resolve({ exitCode: 1, stdout: generateBackupOutput(), stderr: "Some error occurred" }), + ); + + // act + await backupsService.executeBackup(schedule.id); + + // assert + const updatedSchedule = await backupsService.getSchedule(schedule.id); + expect(updatedSchedule.lastBackupStatus).toBe("error"); + }); +}); diff --git a/app/server/modules/backups/backups.controller.ts b/app/server/modules/backups/backups.controller.ts index 653cd65..e9fa9f7 100644 --- a/app/server/modules/backups/backups.controller.ts +++ b/app/server/modules/backups/backups.controller.ts @@ -86,8 +86,8 @@ export const backupScheduleController = new Hono() .post("/:scheduleId/run", runBackupNowDto, async (c) => { const scheduleId = c.req.param("scheduleId"); - backupsService.executeBackup(Number(scheduleId), true).catch((error) => { - console.error("Backup execution failed:", error); + backupsService.executeBackup(Number(scheduleId), true).catch((err) => { + console.error(`Error executing manual backup for schedule ${scheduleId}:`, err); }); return c.json({ success: true }, 200); diff --git a/app/server/modules/backups/backups.service.ts b/app/server/modules/backups/backups.service.ts index 584fada..bb28f29 100644 --- a/app/server/modules/backups/backups.service.ts +++ b/app/server/modules/backups/backups.service.ts @@ -363,8 +363,6 @@ const executeBackup = async (scheduleId: number, manual = false) => { .catch((notifError) => { logger.error(`Failed to send backup failure notification: ${toMessage(notifError)}`); }); - - throw error; } finally { runningBackups.delete(scheduleId); } diff --git a/app/server/modules/events/__tests__/events.controller.test.ts b/app/server/modules/events/__tests__/events.controller.test.ts new file mode 100644 index 0000000..c40793f --- /dev/null +++ b/app/server/modules/events/__tests__/events.controller.test.ts @@ -0,0 +1,53 @@ +import { test, describe, expect } from "bun:test"; +import { createApp } from "~/server/app"; +import { createTestSession } from "~/test/helpers/auth"; + +const app = createApp(); + +describe("events security", () => { + test("should return 401 if no session cookie is provided", async () => { + const res = await app.request("/api/v1/events"); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Authentication required"); + }); + + test("should return 401 if session is invalid", async () => { + const res = await app.request("/api/v1/events", { + headers: { + Cookie: "session_id=invalid-session", + }, + }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Invalid or expired session"); + + expect(res.headers.get("Set-Cookie")).toContain("session_id=;"); + }); + + test("should return 200 if session is valid", async () => { + const { sessionId } = await createTestSession(); + + const res = await app.request("/api/v1/events", { + headers: { + Cookie: `session_id=${sessionId}`, + }, + }); + + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe("text/event-stream"); + }); + + describe("unauthenticated access", () => { + const endpoints: { method: string; path: string }[] = [{ method: "GET", path: "/api/v1/events" }]; + + for (const { method, path } of endpoints) { + test(`${method} ${path} should return 401`, async () => { + const res = await app.request(path, { method }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Authentication required"); + }); + } + }); +}); diff --git a/app/server/modules/notifications/__tests__/notifications.controller.test.ts b/app/server/modules/notifications/__tests__/notifications.controller.test.ts new file mode 100644 index 0000000..0283426 --- /dev/null +++ b/app/server/modules/notifications/__tests__/notifications.controller.test.ts @@ -0,0 +1,110 @@ +import { test, describe, expect } from "bun:test"; +import { createApp } from "~/server/app"; +import { createTestSession } from "~/test/helpers/auth"; + +const app = createApp(); + +describe("notifications security", () => { + test("should return 401 if no session cookie is provided", async () => { + const res = await app.request("/api/v1/notifications/destinations"); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Authentication required"); + }); + + test("should return 401 if session is invalid", async () => { + const res = await app.request("/api/v1/notifications/destinations", { + headers: { + Cookie: "session_id=invalid-session", + }, + }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Invalid or expired session"); + + expect(res.headers.get("Set-Cookie")).toContain("session_id=;"); + }); + + test("should return 200 if session is valid", async () => { + const { sessionId } = await createTestSession(); + + const res = await app.request("/api/v1/notifications/destinations", { + headers: { + Cookie: `session_id=${sessionId}`, + }, + }); + + expect(res.status).toBe(200); + }); + + describe("unauthenticated access", () => { + const endpoints: { method: string; path: string }[] = [ + { method: "GET", path: "/api/v1/notifications/destinations" }, + { method: "POST", path: "/api/v1/notifications/destinations" }, + { method: "GET", path: "/api/v1/notifications/destinations/1" }, + { method: "PATCH", path: "/api/v1/notifications/destinations/1" }, + { method: "DELETE", path: "/api/v1/notifications/destinations/1" }, + { method: "POST", path: "/api/v1/notifications/destinations/1/test" }, + ]; + + for (const { method, path } of endpoints) { + test(`${method} ${path} should return 401`, async () => { + const res = await app.request(path, { method }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Authentication required"); + }); + } + }); + + describe("information disclosure", () => { + test("should not disclose if a destination exists when unauthenticated", async () => { + const res = await app.request("/api/v1/notifications/destinations/999999"); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Authentication required"); + }); + }); + + describe("input validation", () => { + test("should return 404 for malformed destination ID", async () => { + const { sessionId } = await createTestSession(); + const res = await app.request("/api/v1/notifications/destinations/not-a-number", { + headers: { + Cookie: `session_id=${sessionId}`, + }, + }); + + expect(res.status).toBe(404); + }); + + test("should return 404 for non-existent destination ID", async () => { + const { sessionId } = await createTestSession(); + const res = await app.request("/api/v1/notifications/destinations/999999", { + headers: { + Cookie: `session_id=${sessionId}`, + }, + }); + + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.message).toBe("Notification destination not found"); + }); + + test("should return 400 for invalid payload on create", async () => { + const { sessionId } = await createTestSession(); + const res = await app.request("/api/v1/notifications/destinations", { + method: "POST", + headers: { + Cookie: `session_id=${sessionId}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: "Test", + }), + }); + + expect(res.status).toBe(400); + }); + }); +}); diff --git a/app/server/modules/repositories/__tests__/repositories.controller.test.ts b/app/server/modules/repositories/__tests__/repositories.controller.test.ts new file mode 100644 index 0000000..d7afc68 --- /dev/null +++ b/app/server/modules/repositories/__tests__/repositories.controller.test.ts @@ -0,0 +1,105 @@ +import { test, describe, expect } from "bun:test"; +import { createApp } from "~/server/app"; +import { createTestSession } from "~/test/helpers/auth"; + +const app = createApp(); + +describe("repositories security", () => { + test("should return 401 if no session cookie is provided", async () => { + const res = await app.request("/api/v1/repositories"); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Authentication required"); + }); + + test("should return 401 if session is invalid", async () => { + const res = await app.request("/api/v1/repositories", { + headers: { + Cookie: "session_id=invalid-session", + }, + }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Invalid or expired session"); + + expect(res.headers.get("Set-Cookie")).toContain("session_id=;"); + }); + + test("should return 200 if session is valid", async () => { + const { sessionId } = await createTestSession(); + + const res = await app.request("/api/v1/repositories", { + headers: { + Cookie: `session_id=${sessionId}`, + }, + }); + + expect(res.status).toBe(200); + }); + + describe("unauthenticated access", () => { + const endpoints: { method: string; path: string }[] = [ + { method: "GET", path: "/api/v1/repositories" }, + { method: "POST", path: "/api/v1/repositories" }, + { method: "GET", path: "/api/v1/repositories/rclone-remotes" }, + { method: "GET", path: "/api/v1/repositories/test-repo" }, + { method: "DELETE", path: "/api/v1/repositories/test-repo" }, + { method: "GET", path: "/api/v1/repositories/test-repo/snapshots" }, + { method: "GET", path: "/api/v1/repositories/test-repo/snapshots/test-snapshot" }, + { method: "GET", path: "/api/v1/repositories/test-repo/snapshots/test-snapshot/files" }, + { method: "POST", path: "/api/v1/repositories/test-repo/restore" }, + { method: "POST", path: "/api/v1/repositories/test-repo/doctor" }, + { method: "DELETE", path: "/api/v1/repositories/test-repo/snapshots/test-snapshot" }, + { method: "PATCH", path: "/api/v1/repositories/test-repo" }, + ]; + + for (const { method, path } of endpoints) { + test(`${method} ${path} should return 401`, async () => { + const res = await app.request(path, { method }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Authentication required"); + }); + } + }); + + describe("information disclosure", () => { + test("should not disclose if a repository exists when unauthenticated", async () => { + const res = await app.request("/api/v1/repositories/non-existent-repo"); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Authentication required"); + }); + }); + + describe("input validation", () => { + test("should return 404 for non-existent repository", async () => { + const { sessionId } = await createTestSession(); + const res = await app.request("/api/v1/repositories/non-existent-repo", { + headers: { + Cookie: `session_id=${sessionId}`, + }, + }); + + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.message).toBe("Repository not found"); + }); + + test("should return 400 for invalid payload on create", async () => { + const { sessionId } = await createTestSession(); + const res = await app.request("/api/v1/repositories", { + method: "POST", + headers: { + Cookie: `session_id=${sessionId}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: "Test", + }), + }); + + expect(res.status).toBe(400); + }); + }); +}); diff --git a/app/server/modules/system/__tests__/system.controller.test.ts b/app/server/modules/system/__tests__/system.controller.test.ts new file mode 100644 index 0000000..5ce3d51 --- /dev/null +++ b/app/server/modules/system/__tests__/system.controller.test.ts @@ -0,0 +1,89 @@ +import { test, describe, expect } from "bun:test"; +import { createApp } from "~/server/app"; +import { createTestSession } from "~/test/helpers/auth"; + +const app = createApp(); + +describe("system security", () => { + test("should return 401 if no session cookie is provided", async () => { + const res = await app.request("/api/v1/system/info"); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Authentication required"); + }); + + test("should return 401 if session is invalid", async () => { + const res = await app.request("/api/v1/system/info", { + headers: { + Cookie: "session_id=invalid-session", + }, + }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Invalid or expired session"); + + expect(res.headers.get("Set-Cookie")).toContain("session_id=;"); + }); + + test("should return 200 if session is valid", async () => { + const { sessionId } = await createTestSession(); + + const res = await app.request("/api/v1/system/info", { + headers: { + Cookie: `session_id=${sessionId}`, + }, + }); + + expect(res.status).toBe(200); + }); + + describe("unauthenticated access", () => { + const endpoints: { method: string; path: string }[] = [ + { method: "GET", path: "/api/v1/system/info" }, + { method: "POST", path: "/api/v1/system/restic-password" }, + ]; + + for (const { method, path } of endpoints) { + test(`${method} ${path} should return 401`, async () => { + const res = await app.request(path, { method }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Authentication required"); + }); + } + }); + + describe("input validation", () => { + test("should return 400 for invalid payload on restic-password", async () => { + const { sessionId } = await createTestSession(); + const res = await app.request("/api/v1/system/restic-password", { + method: "POST", + headers: { + Cookie: `session_id=${sessionId}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }); + + expect(res.status).toBe(400); + }); + + test("should return 401 for incorrect password on restic-password", async () => { + const { sessionId } = await createTestSession(); + const res = await app.request("/api/v1/system/restic-password", { + method: "POST", + headers: { + Cookie: `session_id=${sessionId}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + password: "wrong-password", + }), + }); + + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Incorrect password"); + }); + }); +}); diff --git a/app/server/modules/volumes/__tests__/volumes.controller.test.ts b/app/server/modules/volumes/__tests__/volumes.controller.test.ts new file mode 100644 index 0000000..04b0577 --- /dev/null +++ b/app/server/modules/volumes/__tests__/volumes.controller.test.ts @@ -0,0 +1,104 @@ +import { test, describe, expect } from "bun:test"; +import { createApp } from "~/server/app"; +import { createTestSession } from "~/test/helpers/auth"; + +const app = createApp(); + +describe("volumes security", () => { + test("should return 401 if no session cookie is provided", async () => { + const res = await app.request("/api/v1/volumes"); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Authentication required"); + }); + + test("should return 401 if session is invalid", async () => { + const res = await app.request("/api/v1/volumes", { + headers: { + Cookie: "session_id=invalid-session", + }, + }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Invalid or expired session"); + + expect(res.headers.get("Set-Cookie")).toContain("session_id=;"); + }); + + test("should return 200 if session is valid", async () => { + const { sessionId } = await createTestSession(); + + const res = await app.request("/api/v1/volumes", { + headers: { + Cookie: `session_id=${sessionId}`, + }, + }); + + expect(res.status).toBe(200); + }); + + describe("unauthenticated access", () => { + const endpoints: { method: string; path: string }[] = [ + { method: "GET", path: "/api/v1/volumes" }, + { method: "POST", path: "/api/v1/volumes" }, + { method: "POST", path: "/api/v1/volumes/test-connection" }, + { method: "DELETE", path: "/api/v1/volumes/test-volume" }, + { method: "GET", path: "/api/v1/volumes/test-volume" }, + { method: "PUT", path: "/api/v1/volumes/test-volume" }, + { method: "POST", path: "/api/v1/volumes/test-volume/mount" }, + { method: "POST", path: "/api/v1/volumes/test-volume/unmount" }, + { method: "POST", path: "/api/v1/volumes/test-volume/health-check" }, + { method: "GET", path: "/api/v1/volumes/test-volume/files" }, + { method: "GET", path: "/api/v1/volumes/filesystem/browse" }, + ]; + + for (const { method, path } of endpoints) { + test(`${method} ${path} should return 401`, async () => { + const res = await app.request(path, { method }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Authentication required"); + }); + } + }); + + describe("information disclosure", () => { + test("should not disclose if a volume exists when unauthenticated", async () => { + const res = await app.request("/api/v1/volumes/non-existent-volume"); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toBe("Authentication required"); + }); + }); + + describe("input validation", () => { + test("should return 404 for non-existent volume", async () => { + const { sessionId } = await createTestSession(); + const res = await app.request("/api/v1/volumes/non-existent-volume", { + headers: { + Cookie: `session_id=${sessionId}`, + }, + }); + + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.message).toBe("Volume not found"); + }); + + test("should return 400 for invalid payload on create", async () => { + const { sessionId } = await createTestSession(); + const res = await app.request("/api/v1/volumes", { + method: "POST", + headers: { + Cookie: `session_id=${sessionId}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: "Test", + }), + }); + + expect(res.status).toBe(400); + }); + }); +}); diff --git a/app/server/utils/restic.ts b/app/server/utils/restic.ts index ebd52e2..0e1a733 100644 --- a/app/server/utils/restic.ts +++ b/app/server/utils/restic.ts @@ -29,6 +29,7 @@ const backupOutputSchema = type({ total_duration: "number", snapshot_id: "string", }); +export type BackupOutput = typeof backupOutputSchema.infer; const snapshotInfoSchema = type({ gid: "number?", @@ -344,7 +345,7 @@ const backup = async ( throw new ResticError(res.exitCode, res.stderr.toString()); } - const lastLine = stdout.trim(); + const lastLine = (stdout || res.stdout).trim(); let summaryLine = ""; try { const resSummary = JSON.parse(lastLine ?? "{}"); diff --git a/app/server/utils/spawn.ts b/app/server/utils/spawn.ts index 6ef86cb..c2b9246 100644 --- a/app/server/utils/spawn.ts +++ b/app/server/utils/spawn.ts @@ -1,6 +1,6 @@ import { spawn } from "node:child_process"; -interface Params { +export interface SafeSpawnParams { command: string; args: string[]; env?: NodeJS.ProcessEnv; @@ -18,7 +18,7 @@ type SpawnResult = { stderr: string; }; -export const safeSpawn = (params: Params) => { +export const safeSpawn = (params: SafeSpawnParams) => { const { command, args, env = {}, signal, ...callbacks } = params; return new Promise((resolve) => { diff --git a/app/test/helpers/auth.ts b/app/test/helpers/auth.ts new file mode 100644 index 0000000..fba04cc --- /dev/null +++ b/app/test/helpers/auth.ts @@ -0,0 +1,24 @@ +import { authService } from "~/server/modules/auth/auth.service"; +import { db } from "~/server/db/db"; +import { usersTable, sessionsTable } from "~/server/db/schema"; + +export async function createTestSession() { + const [existingUser] = await db.select().from(usersTable); + + if (!existingUser) { + await authService.register("testadmin", "testpassword"); + } + + const [user] = await db.select().from(usersTable); + + const sessionId = crypto.randomUUID(); + const expiresAt = Date.now() + 1000 * 60 * 60 * 24; // 24 hours + + await db.insert(sessionsTable).values({ + id: sessionId, + userId: user.id, + expiresAt, + }); + + return { sessionId, user }; +} diff --git a/app/test/helpers/backup.ts b/app/test/helpers/backup.ts new file mode 100644 index 0000000..4e8be3f --- /dev/null +++ b/app/test/helpers/backup.ts @@ -0,0 +1,16 @@ +import { db } from "~/server/db/db"; +import { faker } from "@faker-js/faker"; +import { backupSchedulesTable, type BackupScheduleInsert } from "~/server/db/schema"; + +export const createTestBackupSchedule = async (overrides: Partial = {}) => { + const backup: BackupScheduleInsert = { + name: faker.system.fileName(), + cronExpression: "0 0 * * *", + repositoryId: "repo_123", + volumeId: 1, + ...overrides, + }; + + const data = await db.insert(backupSchedulesTable).values(backup).returning(); + return data[0]; +}; diff --git a/app/test/helpers/repository.ts b/app/test/helpers/repository.ts new file mode 100644 index 0000000..0a15c59 --- /dev/null +++ b/app/test/helpers/repository.ts @@ -0,0 +1,20 @@ +import { db } from "~/server/db/db"; +import { faker } from "@faker-js/faker"; +import { repositoriesTable, type RepositoryInsert } from "~/server/db/schema"; + +export const createTestRepository = async (overrides: Partial = {}) => { + const repository: RepositoryInsert = { + id: faker.string.alphanumeric(6), + name: faker.string.alphanumeric(10), + shortId: faker.string.alphanumeric(6), + config: { + name: "test-repo", + backend: "local", + }, + type: "local", + ...overrides, + }; + + const data = await db.insert(repositoriesTable).values(repository).returning(); + return data[0]; +}; diff --git a/app/test/helpers/restic.ts b/app/test/helpers/restic.ts new file mode 100644 index 0000000..b573201 --- /dev/null +++ b/app/test/helpers/restic.ts @@ -0,0 +1,18 @@ +export const generateBackupOutput = () => { + return JSON.stringify({ + message_type: "summary", + files_new: 10, + files_changed: 5, + files_unmodified: 85, + dirs_new: 2, + dirs_changed: 1, + dirs_unmodified: 17, + data_blobs: 20, + tree_blobs: 5, + data_added: 1048576, + total_files_processed: 100, + total_bytes_processed: 2097152, + total_duration: 12.34, + snapshot_id: "abcd1234", + }); +}; diff --git a/app/test/helpers/volume.ts b/app/test/helpers/volume.ts new file mode 100644 index 0000000..0d63740 --- /dev/null +++ b/app/test/helpers/volume.ts @@ -0,0 +1,21 @@ +import { db } from "~/server/db/db"; +import { faker } from "@faker-js/faker"; +import { volumesTable, type VolumeInsert } from "~/server/db/schema"; + +export const createTestVolume = async (overrides: Partial = {}) => { + const volume: VolumeInsert = { + name: faker.system.fileName(), + config: { + backend: "directory", + path: `/mnt/volumes/${faker.system.fileName()}`, + }, + status: "mounted", + autoRemount: true, + shortId: faker.string.alphanumeric(6), + type: "directory", + ...overrides, + }; + + const data = await db.insert(volumesTable).values(volume).returning(); + return data[0]; +}; diff --git a/app/test/setup.ts b/app/test/setup.ts new file mode 100644 index 0000000..8791d42 --- /dev/null +++ b/app/test/setup.ts @@ -0,0 +1,19 @@ +import { beforeAll, mock } from "bun:test"; +import { migrate } from "drizzle-orm/bun-sqlite/migrator"; +import path from "node:path"; +import { cwd } from "node:process"; +import { db } from "~/server/db/db"; + +mock.module("~/server/utils/logger", () => ({ + logger: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + }, +})); + +beforeAll(async () => { + const migrationsFolder = path.join(cwd(), "app", "drizzle"); + migrate(db, { migrationsFolder }); +}); diff --git a/bun.lock b/bun.lock index b430f4d..82e3a37 100644 --- a/bun.lock +++ b/bun.lock @@ -58,6 +58,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.3.8", + "@faker-js/faker": "^10.1.0", "@hey-api/openapi-ts": "^0.88.0", "@react-router/dev": "^7.10.0", "@tailwindcss/vite": "^4.1.17", @@ -66,6 +67,7 @@ "@types/node": "^24.10.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", + "dotenv-cli": "^11.0.0", "drizzle-kit": "^0.31.7", "lightningcss": "^1.30.2", "tailwindcss": "^4.1.17", @@ -230,6 +232,8 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="], + "@faker-js/faker": ["@faker-js/faker@10.1.0", "", {}, "sha512-C3mrr3b5dRVlKPJdfrAXS8+dq+rq8Qm5SNRazca0JKgw1HQERFmrVb0towvMmw5uu8hHKNiQasMaR/tydf3Zsg=="], + "@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="], "@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="], @@ -400,11 +404,13 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.53.5", "", { "os": "win32", "cpu": "x64" }, "sha512-JRpZUhCfhZ4keB5v0fe02gQJy05GqboPOaxvjugW04RLSYYoB/9t2lx2u/tMs/Na/1NXfY8QYjgRljRpN+MjTQ=="], - "@scalar/core": ["@scalar/core@0.3.26", "", { "dependencies": { "@scalar/types": "0.5.2" } }, "sha512-CTwhU0zteyhFvlGbiErUC/nt7o4VMraXC4E52x2Cz+s/rgGrmS00pTYtnjO3NVQXXqjScq8BqMTqBZrHQEJhWw=="], + "@scalar/core": ["@scalar/core@0.3.28", "", { "dependencies": { "@scalar/types": "0.5.4" } }, "sha512-Ka+g5P3Fe4f9lsJcBxfI+XAgwMYeZRgzIBWw1/HBrDoRmH3rV/N//410MBKEYXUw7pWpS+dZPJANZRvU5jtxhw=="], - "@scalar/hono-api-reference": ["@scalar/hono-api-reference@0.9.28", "", { "dependencies": { "@scalar/core": "0.3.26" }, "peerDependencies": { "hono": "^4.10.3" } }, "sha512-RVY55Rpcy9/irv0SMSxuSlQ6wDyuP+iTDmTz/d5tGv/qqo8vEOJMdDRNftMUqdtqiZUAE8fXJnuDCTJ80ZztAQ=="], + "@scalar/helpers": ["@scalar/helpers@0.2.4", "", {}, "sha512-G7oGybO2QXM+MIxa4OZLXaYsS9mxKygFgOcY4UOXO6xpVoY5+8rahdak9cPk7HNj8RZSt4m/BveoT8g5BtnXxg=="], - "@scalar/types": ["@scalar/types@0.5.2", "", { "dependencies": { "nanoid": "5.1.5", "type-fest": "5.0.0", "zod": "^4.1.11" } }, "sha512-F5wyb/B/Mu56PpNqhgSfuKwiwVnmhNhzTOo+k5b++HvYhjwAnqnw8BzbDzwXhhn172IPw8kSkupA/vphw61IRA=="], + "@scalar/hono-api-reference": ["@scalar/hono-api-reference@0.9.30", "", { "dependencies": { "@scalar/core": "0.3.28" }, "peerDependencies": { "hono": "^4.10.3" } }, "sha512-a9cPluqfi1bgX2p7PJl/2O4jgPcoAl/ecSAe74TbPYIi27A0O0bkUBscO7WNRJhWJ1GVVxX8NvJTNlDxUNBlpg=="], + + "@scalar/types": ["@scalar/types@0.5.4", "", { "dependencies": { "@scalar/helpers": "0.2.4", "nanoid": "5.1.5", "type-fest": "5.0.0", "zod": "^4.1.11" } }, "sha512-5FNQH/zx3tnERzxfpErscPHfRxLCuhncmhFYiaSz196Xi2iG1YI08BtxTV2slfT6of52epJ/MrKerarplKf9eg=="], "@so-ric/colorspace": ["@so-ric/colorspace@1.1.6", "", { "dependencies": { "color": "^5.0.2", "text-hex": "1.0.x" } }, "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw=="], @@ -454,7 +460,7 @@ "@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.91.1", "", { "dependencies": { "@tanstack/query-devtools": "5.91.1" }, "peerDependencies": { "@tanstack/react-query": "^5.90.10", "react": "^18 || ^19" } }, "sha512-tRnJYwEbH0kAOuToy8Ew7bJw1lX3AjkkgSlf/vzb+NpnqmHPdWM+lA2DSdGQSLi1SU0PDRrrCI1vnZnci96CsQ=="], - "@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="], + "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], @@ -492,6 +498,8 @@ "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], + "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], @@ -510,7 +518,7 @@ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.9.9", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-V8fbOCSeOFvlDj7LLChUcqbZrdKD9RU/VR260piF1790vT0mfLSwGc/Qzxv3IqiTukOpNtItePa0HBpMAj7MDg=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.9.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ=="], "basic-auth": ["basic-auth@2.0.1", "", { "dependencies": { "safe-buffer": "5.1.2" } }, "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg=="], @@ -522,7 +530,7 @@ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], - "bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="], + "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], @@ -536,7 +544,7 @@ "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], - "caniuse-lite": ["caniuse-lite@1.0.30001760", "", {}, "sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw=="], + "caniuse-lite": ["caniuse-lite@1.0.30001761", "", {}, "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g=="], "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], @@ -574,10 +582,16 @@ "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + "cookie-es": ["cookie-es@1.2.2", "", {}, "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="], + "cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="], "cron-parser": ["cron-parser@5.4.0", "", { "dependencies": { "luxon": "^3.7.1" } }, "sha512-HxYB8vTvnQFx4dLsZpGRa0uHp6X3qIzS3ZJgJ9v6l/5TJMgeWQbLkR5yiJ5hOxGbc9+jCADDnydIe15ReLZnJA=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], @@ -632,6 +646,10 @@ "dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="], + "dotenv-cli": ["dotenv-cli@11.0.0", "", { "dependencies": { "cross-spawn": "^7.0.6", "dotenv": "^17.1.0", "dotenv-expand": "^12.0.0", "minimist": "^1.2.6" }, "bin": { "dotenv": "cli.js" } }, "sha512-r5pA8idbk7GFWuHEU7trSTflWcdBpQEK+Aw17UrSHjS6CReuhrrPcyC3zcQBPQvhArRHnBo/h6eLH1fkCvNlww=="], + + "dotenv-expand": ["dotenv-expand@12.0.3", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA=="], + "drizzle-kit": ["drizzle-kit@0.31.8", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg=="], "drizzle-orm": ["drizzle-orm@0.44.7", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ=="], @@ -712,6 +730,8 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "h3": ["h3@1.15.4", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": "^0.3.5", "defu": "^6.1.4", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.2", "radix3": "^1.1.2", "ufo": "^1.6.1", "uncrypto": "^0.1.3" } }, "sha512-z5cFQWDffyOe4vQ9xIqNfCZdV4p//vy6fBnr8Q1AWnVZ0teurKMG66rLj++TKwKPUP3u7iMUvrvKaEUiQw2QWQ=="], + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], @@ -720,7 +740,7 @@ "hono-openapi": ["hono-openapi@1.1.2", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.8.3", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-toUcO60MftRBxqcVyxsHNYs2m4vf4xkQaiARAucQx3TiBPDtMNNkoh+C4I1vAretQZiGyaLOZNWn1YxfSyUA5g=="], - "hono-rate-limiter": ["hono-rate-limiter@0.5.0", "", { "peerDependencies": { "hono": "^4.10.8", "unstorage": "^1.17.3" }, "optionalPeers": ["unstorage"] }, "sha512-Cps4udhDdPQ3O1Dm1fOzunI1iN1fW3TcVj1YvPdIjxHiHRitTsEz05q+BjgnLtcVDaDGbyuYyBaAxIy1DD1bMw=="], + "hono-rate-limiter": ["hono-rate-limiter@0.5.1", "", { "peerDependencies": { "hono": "^4.10.8", "unstorage": "^1.17.3" } }, "sha512-c3bUn6IRgFKjlouvRNBy+ZIPZ2CTyTt3fc0uat2bv3GiHmLM4jI0QJ6fHd3Tf4R6dO2sX2Uvl9Gtp+kny4KdXg=="], "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], @@ -736,6 +756,8 @@ "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="], + "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], @@ -748,6 +770,8 @@ "isbot": ["isbot@5.1.32", "", {}, "sha512-VNfjM73zz2IBZmdShMfAUg10prm6t7HFUQmNAEOAVS4YH92ZrZcvkMcGX6cIgBJAzWDzPent/EeAtYEHNPNPBQ=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -788,7 +812,7 @@ "logform": ["logform@2.7.0", "", { "dependencies": { "@colors/colors": "1.6.0", "@types/triple-beam": "^1.3.2", "fecha": "^4.2.0", "ms": "^2.1.1", "safe-stable-stringify": "^2.3.1", "triple-beam": "^1.3.0" } }, "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ=="], - "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "lucide-react": ["lucide-react@0.555.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA=="], @@ -812,6 +836,8 @@ "minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "morgan": ["morgan@1.10.1", "", { "dependencies": { "basic-auth": "~2.0.1", "debug": "2.6.9", "depd": "~2.0.0", "on-finished": "~2.3.0", "on-headers": "~1.1.0" } }, "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -826,12 +852,18 @@ "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], + "node-mock-http": ["node-mock-http@1.0.4", "", {}, "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ=="], + "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + "nypm": ["nypm@0.6.2", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.2", "pathe": "^2.0.3", "pkg-types": "^2.3.0", "tinyexec": "^1.0.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + "ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="], + "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], @@ -848,6 +880,8 @@ "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "path-to-regexp": ["path-to-regexp@0.1.12", "", {}, "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="], "pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], @@ -872,6 +906,8 @@ "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], + "radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="], + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], "raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="], @@ -936,6 +972,10 @@ "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], @@ -994,10 +1034,16 @@ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "ufo": ["ufo@1.6.1", "", {}, "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA=="], + + "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "unstorage": ["unstorage@1.17.3", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^4.0.3", "destr": "^2.0.5", "h3": "^1.15.4", "lru-cache": "^10.4.3", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.1" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6.0.3 || ^7.0.0", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1.0.1", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-i+JYyy0DoKmQ3FximTHbGadmIYb8JEpq7lxUjnjeB702bCPum0vzo6oy5Mfu0lpqISw7hCyMW2yj4nWC8bqJ3Q=="], + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], @@ -1024,6 +1070,8 @@ "vite-tsconfig-paths": ["vite-tsconfig-paths@5.1.4", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" }, "optionalPeers": ["vite"] }, "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "winston": ["winston@3.19.0", "", { "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", "async": "^3.2.3", "is-stream": "^2.0.0", "logform": "^2.7.0", "one-time": "^1.0.0", "readable-stream": "^3.4.0", "safe-stable-stringify": "^2.3.1", "stack-trace": "0.0.x", "triple-beam": "^1.3.0", "winston-transport": "^4.9.0" } }, "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA=="], "winston-transport": ["winston-transport@4.9.0", "", { "dependencies": { "logform": "^2.7.0", "readable-stream": "^3.6.2", "triple-beam": "^1.3.0" } }, "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A=="], @@ -1040,6 +1088,8 @@ "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -1082,6 +1132,8 @@ "accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], + "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "basic-auth/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], @@ -1090,6 +1142,8 @@ "compression/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + "dotenv-expand/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], diff --git a/package.json b/package.json index 2dc931f..c6e73e6 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "start:prod": "docker compose down && docker compose up --build zerobyte-prod", "gen:api-client": "openapi-ts", "gen:migrations": "drizzle-kit generate", - "studio": "drizzle-kit studio" + "studio": "drizzle-kit studio", + "test": "dotenv -e .env.test -- bun test --preload ./app/test/setup.ts" }, "overrides": { "esbuild": "^0.27.2" @@ -73,6 +74,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.3.8", + "@faker-js/faker": "^10.1.0", "@hey-api/openapi-ts": "^0.88.0", "@react-router/dev": "^7.10.0", "@tailwindcss/vite": "^4.1.17", @@ -81,6 +83,7 @@ "@types/node": "^24.10.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", + "dotenv-cli": "^11.0.0", "drizzle-kit": "^0.31.7", "lightningcss": "^1.30.2", "tailwindcss": "^4.1.17",