mirror of
https://github.com/nicotsx/zerobyte.git
synced 2025-12-23 21:47:47 -05:00
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
This commit is contained in:
committed by
Nicolas Meienberger
parent
11b588ce20
commit
e9fb5862f7
15
.github/actions/install-dependencies/action.yml
vendored
Normal file
15
.github/actions/install-dependencies/action.yml
vendored
Normal file
@@ -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
|
||||
23
.github/workflows/ci.yml
vendored
23
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
10
AGENTS.md
10
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
|
||||
|
||||
87
app/server/app.ts
Normal file
87
app/server/app.ts
Normal file
@@ -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;
|
||||
};
|
||||
@@ -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];
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
126
app/server/modules/backups/__tests__/backups.controller.test.ts
Normal file
126
app/server/modules/backups/__tests__/backups.controller.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
151
app/server/modules/backups/__tests__/backups.service.test.ts
Normal file
151
app/server/modules/backups/__tests__/backups.service.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<RunBackupNowDto>({ success: true }, 200);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
104
app/server/modules/volumes/__tests__/volumes.controller.test.ts
Normal file
104
app/server/modules/volumes/__tests__/volumes.controller.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 ?? "{}");
|
||||
|
||||
@@ -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<SpawnResult>((resolve) => {
|
||||
|
||||
24
app/test/helpers/auth.ts
Normal file
24
app/test/helpers/auth.ts
Normal file
@@ -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 };
|
||||
}
|
||||
16
app/test/helpers/backup.ts
Normal file
16
app/test/helpers/backup.ts
Normal file
@@ -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<BackupScheduleInsert> = {}) => {
|
||||
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];
|
||||
};
|
||||
20
app/test/helpers/repository.ts
Normal file
20
app/test/helpers/repository.ts
Normal file
@@ -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<RepositoryInsert> = {}) => {
|
||||
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];
|
||||
};
|
||||
18
app/test/helpers/restic.ts
Normal file
18
app/test/helpers/restic.ts
Normal file
@@ -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",
|
||||
});
|
||||
};
|
||||
21
app/test/helpers/volume.ts
Normal file
21
app/test/helpers/volume.ts
Normal file
@@ -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<VolumeInsert> = {}) => {
|
||||
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];
|
||||
};
|
||||
19
app/test/setup.ts
Normal file
19
app/test/setup.ts
Normal file
@@ -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 });
|
||||
});
|
||||
72
bun.lock
72
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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user